Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export function registerAnalyze(cli: CAC): void {

// JSON output mode — only JSON goes to stdout; exit code still applies
if (options.json) {
console.log(JSON.stringify(buildResultJson(file.name, result, scores), null, 2));
console.log(JSON.stringify(buildResultJson(file.name, result, scores, { fileKey: file.fileKey }), null, 2));
if (scores.overall.grade === "F") {
process.exitCode = 1;
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/implement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function registerImplement(cli: CAC): void {
// 2. Analysis
const result = analyzeFile(file);
const scores = calculateScores(result);
const resultJson = buildResultJson(file.name, result, scores);
const resultJson = buildResultJson(file.name, result, scores, { fileKey: file.fileKey });
await writeFile(resolve(outputDir, "analysis.json"), JSON.stringify(resultJson, null, 2), "utf-8");
console.log(` analysis.json: ${result.issues.length} issues, grade ${scores.overall.grade}`);

Expand Down
35 changes: 35 additions & 0 deletions src/core/engine/scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ describe("buildResultJson", () => {
expect(json.nodeCount).toBe(100);
expect(json.issueCount).toBe(3);
expect(json.version).toBeDefined();
expect(json.analyzedAt).toBeDefined();
expect(json.scores).toBeDefined();
expect(json.summary).toBeDefined();
expect(typeof json.summary).toBe("string");
Expand All @@ -373,4 +374,38 @@ describe("buildResultJson", () => {
expect(issuesByRule["no-auto-layout"]).toBe(2);
expect(issuesByRule["raw-color"]).toBe(1);
});

it("includes detailed issues list with severity and node info", () => {
const result = makeResult([
makeIssue({ ruleId: "no-auto-layout", category: "structure", severity: "blocking" }),
makeIssue({ ruleId: "raw-color", category: "token", severity: "missing-info" }),
]);
const scores = calculateScores(result);
const json = buildResultJson("TestFile", result, scores);
const issues = json.issues as Array<{ ruleId: string; severity: string; nodeId: string; nodePath: string; message: string }>;

expect(issues).toHaveLength(2);
expect(issues[0]).toMatchObject({
ruleId: "no-auto-layout",
severity: "blocking",
nodeId: expect.any(String),
nodePath: expect.any(String),
message: expect.any(String),
});
expect(issues[1]).toMatchObject({
ruleId: "raw-color",
severity: "missing-info",
});
});

it("includes fileKey when provided", () => {
const result = makeResult([]);
const scores = calculateScores(result);

const withKey = buildResultJson("TestFile", result, scores, { fileKey: "abc123" });
expect(withKey.fileKey).toBe("abc123");

const withoutKey = buildResultJson("TestFile", result, scores);
expect(withoutKey.fileKey).toBeUndefined();
});
});
12 changes: 12 additions & 0 deletions src/core/engine/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,15 +363,26 @@ export function buildResultJson(
fileName: string,
result: AnalysisResult,
scores: ScoreReport,
options?: { fileKey?: string },
): Record<string, unknown> {
const issuesByRule: Record<string, number> = {};
for (const issue of result.issues) {
const id = issue.violation.ruleId;
issuesByRule[id] = (issuesByRule[id] ?? 0) + 1;
}

const issues = result.issues.map((issue) => ({
ruleId: issue.violation.ruleId,
severity: issue.config.severity,
nodeId: issue.violation.nodeId,
nodePath: issue.violation.nodePath,
message: issue.violation.message,
}));

const json: Record<string, unknown> = {
version: VERSION,
analyzedAt: result.analyzedAt,
...(options?.fileKey && { fileKey: options.fileKey }),
fileName,
nodeCount: result.nodeCount,
maxDepth: result.maxDepth,
Expand All @@ -381,6 +392,7 @@ export function buildResultJson(
categories: scores.byCategory,
},
issuesByRule,
issues,
summary: formatScoreSummary(scores),
};

Expand Down
8 changes: 4 additions & 4 deletions src/core/rules/behavior/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const textTruncationUnhandledCheck: RuleCheckFn = (node, context) => {
ruleId: textTruncationUnhandledDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" may need text truncation handling`,
message: `"${node.name}" has long text (${node.characters!.length} chars) in narrow container (${width}px) — set text truncation (ellipsis) or use HUG sizing`,
};
}
}
Expand Down Expand Up @@ -132,7 +132,7 @@ const prototypeLinkInDesignCheck: RuleCheckFn = (node, context) => {
ruleId: prototypeLinkInDesignDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" looks interactive but has no prototype interactions defined`,
message: `"${node.name}" looks interactive but has no prototype interactions — add prototype interactions or rename to clarify non-interactive intent`,
};
};

Expand Down Expand Up @@ -182,7 +182,7 @@ const overflowBehaviorUnknownCheck: RuleCheckFn = (node, context) => {
ruleId: overflowBehaviorUnknownDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" has children overflowing bounds without explicit clip/scroll behavior — AI must guess overflow handling`,
message: `"${node.name}" has children overflowing bounds without explicit clip/scroll behavior — enable "Clip content" or set explicit scroll behavior`,
};
};

Expand Down Expand Up @@ -226,7 +226,7 @@ const wrapBehaviorUnknownCheck: RuleCheckFn = (node, context) => {
ruleId: wrapBehaviorUnknownDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" has ${visibleChildren.length} horizontal children exceeding container width without wrap behavior — AI cannot determine if content should wrap or scroll`,
message: `"${node.name}" has ${visibleChildren.length} horizontal children exceeding container width without wrap behavior — set layoutWrap to WRAP or add horizontal scroll behavior`,
};
};

Expand Down
14 changes: 7 additions & 7 deletions src/core/rules/component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ const missingComponentCheck: RuleCheckFn = (node, context, options) => {
ruleId: missingComponentDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `Component "${matchingComponent.name}" exists — use instances instead of repeated frames (${sameNameFrames.length} found)`,
message: `Component "${matchingComponent.name}" exists — use instances instead of repeated frames (${sameNameFrames.length} found) — replace frames with component instances`,
};
}
}
Expand All @@ -160,7 +160,7 @@ const missingComponentCheck: RuleCheckFn = (node, context, options) => {
ruleId: missingComponentDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" appears ${sameNameFrames.length} times — consider making it a component`,
message: `"${node.name}" appears ${sameNameFrames.length} times — extract as a reusable component`,
};
}
}
Expand Down Expand Up @@ -221,7 +221,7 @@ const missingComponentCheck: RuleCheckFn = (node, context, options) => {
ruleId: missingComponentDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" and ${count - 1} sibling frame(s) share the same internal structure — consider extracting a component`,
message: `"${node.name}" and ${count - 1} sibling frame(s) share the same internal structure — extract a shared component from the repeated structure`,
};
}
}
Expand Down Expand Up @@ -257,7 +257,7 @@ const missingComponentCheck: RuleCheckFn = (node, context, options) => {
ruleId: missingComponentDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${componentName}" instance has style overrides (${overrides.join(", ")}) — use a variant instead of direct style changes`,
message: `"${componentName}" instance has style overrides (${overrides.join(", ")}) — create a new variant for this style combination`,
};
}
return null;
Expand Down Expand Up @@ -301,7 +301,7 @@ const detachedInstanceCheck: RuleCheckFn = (node, context) => {
ruleId: detachedInstanceDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" may be a detached instance of component "${component.name}"`,
message: `"${node.name}" may be a detached instance of component "${component.name}" — restore as an instance of "${component.name}" or create a new variant`,
};
}
}
Expand Down Expand Up @@ -354,7 +354,7 @@ const missingComponentDescriptionCheck: RuleCheckFn = (node, context) => {
ruleId: missingComponentDescriptionDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `Component "${componentMeta.name}" has no description. Descriptions help developers understand purpose and usage.`,
message: `Component "${componentMeta.name}" has no description — add usage guidelines in the component's description field`,
};
};

Expand Down Expand Up @@ -401,7 +401,7 @@ const variantStructureMismatchCheck: RuleCheckFn = (node, context) => {
ruleId: variantStructureMismatchDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" has ${mismatchCount}/${totalVariants} variants with different child structures — AI cannot create a unified component template`,
message: `"${node.name}" has ${mismatchCount}/${totalVariants} variants with different child structures — unify variant structures using visibility toggles for optional elements`,
};
};

Expand Down
4 changes: 2 additions & 2 deletions src/core/rules/component/missing-component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ describe("missing-component — Stage 3: Structure-based repetition", () => {
expect(result!.ruleId).toBe("missing-component");
expect(result!.message).toContain("Card A");
expect(result!.message).toContain("1 sibling frame(s)");
expect(result!.message).toContain("consider extracting a component");
expect(result!.message).toContain("extract a shared component");
});

it("only flags first matching sibling", () => {
Expand Down Expand Up @@ -534,7 +534,7 @@ describe("missing-component — Stage 4: Instance style overrides", () => {
expect(result).not.toBeNull();
expect(result!.message).toContain("Button");
expect(result!.message).toContain("fills");
expect(result!.message).toContain("use a variant");
expect(result!.message).toContain("create a new variant");
});

it("flags when instance has different cornerRadius from master", () => {
Expand Down
10 changes: 5 additions & 5 deletions src/core/rules/naming/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const defaultNameCheck: RuleCheckFn = (node, context) => {
ruleId: defaultNameDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" is a default name - provide a meaningful name`,
message: `${node.type} "${node.name}" has a default name — rename to describe its purpose (e.g., "Header", "ProductCard")`,
};
};

Expand Down Expand Up @@ -116,7 +116,7 @@ const nonSemanticNameCheck: RuleCheckFn = (node, context) => {
ruleId: nonSemanticNameDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" is a non-semantic name - describe its purpose`,
message: `${node.type} "${node.name}" is a non-semantic name — rename to describe its role (e.g., "Divider", "Background")`,
};
};

Expand Down Expand Up @@ -172,7 +172,7 @@ const inconsistentNamingConventionCheck: RuleCheckFn = (node, context) => {
ruleId: inconsistentNamingConventionDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" uses ${nodeConvention} while siblings use ${dominantConvention}`,
message: `"${node.name}" uses ${nodeConvention} while siblings use ${dominantConvention} — rename to match ${dominantConvention} convention`,
};
}

Expand Down Expand Up @@ -207,7 +207,7 @@ const numericSuffixNameCheck: RuleCheckFn = (node, context) => {
ruleId: numericSuffixNameDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name}" has a numeric suffix - consider renaming`,
message: `"${node.name}" has a numeric suffix — remove suffix and extract as component, or rename to describe the difference`,
};
};

Expand Down Expand Up @@ -241,7 +241,7 @@ const tooLongNameCheck: RuleCheckFn = (node, context, options) => {
ruleId: tooLongNameDef.id,
nodeId: node.id,
nodePath: context.path.join(" > "),
message: `"${node.name.substring(0, 30)}..." is ${node.name.length} chars (max: ${maxLength})`,
message: `"${node.name.substring(0, 30)}..." is ${node.name.length} chars — shorten to under ${maxLength} characters`,
};
};

Expand Down
Loading
Loading