Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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