Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ Override score, severity, or enable/disable individual rules:
|---------|--------------|-----------------|
| `missing-component` | -7 | risk |
| `detached-instance` | -5 | risk |
| `nested-instance-override` | -3 | missing-info |
| `variant-not-used` | -3 | suggestion |
| `component-property-unused` | -2 | suggestion |
| `single-use-component` | -2 | suggestion |
| `missing-component-description` | -2 | missing-info |

**Naming (5 rules)**

Expand Down
1 change: 1 addition & 0 deletions src/core/contracts/figma-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const AnalysisNodeTypeSchema = z.enum([
"LINK_UNFURL",
"TABLE",
"TABLE_CELL",
"SLOT",
]);

export type AnalysisNodeType = z.infer<typeof AnalysisNodeTypeSchema>;
Expand Down
4 changes: 1 addition & 3 deletions src/core/contracts/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,13 @@ export type RuleId =
| "raw-shadow"
| "raw-opacity"
| "multiple-fill-colors"
// Component (8)
// Component (6)
| "missing-component"
| "detached-instance"
| "nested-instance-override"
| "variant-not-used"
| "component-property-unused"
| "single-use-component"
| "missing-component-description"
| "repeated-frame-structure"
// Naming (5)
| "default-name"
| "non-semantic-name"
Expand Down
61 changes: 61 additions & 0 deletions src/core/engine/design-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,67 @@ describe("generateDesignTree", () => {
});
});

describe("INSTANCE component annotation", () => {
it("annotates INSTANCE nodes with component name when available", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "Container",
type: "FRAME",
absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 },
children: [
makeNode({
id: "1:2",
name: "MyButton",
type: "INSTANCE",
componentId: "comp:1",
absoluteBoundingBox: { x: 0, y: 0, width: 120, height: 40 },
}),
],
})
);
file.components = {
"comp:1": { key: "abc", name: "Button", description: "" },
};

const output = generateDesignTree(file);

expect(output).toContain("MyButton (INSTANCE, 120x40) [component: Button]");
});

it("does not annotate INSTANCE when componentId has no match", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "MyButton",
type: "INSTANCE",
componentId: "comp:999",
absoluteBoundingBox: { x: 0, y: 0, width: 120, height: 40 },
})
);

const output = generateDesignTree(file);

expect(output).toContain("MyButton (INSTANCE, 120x40)");
expect(output).not.toContain("[component:");
});

it("does not annotate non-INSTANCE nodes", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "Card",
type: "FRAME",
absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 },
})
);

const output = generateDesignTree(file);

expect(output).not.toContain("[component:");
});
});

describe("TEXT nodes", () => {
it("TEXT nodes use color: not background: for fill", () => {
const file = makeFile(
Expand Down
22 changes: 17 additions & 5 deletions src/core/engine/design-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,29 @@ function mapAlign(figmaAlign: string): string {
}

/** Render a single node and its children as indented design-tree text. */
function renderNode(node: AnalysisNode, indent: number, vectorDir?: string): string {
function renderNode(
node: AnalysisNode,
indent: number,
vectorDir?: string,
components?: AnalysisFile["components"],
): string {
if (node.visible === false) return "";

const prefix = " ".repeat(indent);
const lines: string[] = [];

// Header
// Header — annotate INSTANCE nodes with component name
const bbox = node.absoluteBoundingBox;
const w = bbox ? Math.round(bbox.width) : "?";
const h = bbox ? Math.round(bbox.height) : "?";
lines.push(`${prefix}${node.name} (${node.type}, ${w}x${h})`);
let header = `${prefix}${node.name} (${node.type}, ${w}x${h})`;
if (node.type === "INSTANCE" && node.componentId && components) {
const comp = components[node.componentId];
if (comp) {
header += ` [component: ${comp.name}]`;
}
}
lines.push(header);

// Styles
const styles: string[] = [];
Expand Down Expand Up @@ -206,7 +218,7 @@ function renderNode(node: AnalysisNode, indent: number, vectorDir?: string): str
// Children
if (node.children) {
for (const child of node.children) {
const childOutput = renderNode(child, indent + 1, vectorDir);
const childOutput = renderNode(child, indent + 1, vectorDir, components);
if (childOutput) lines.push(childOutput);
}
}
Expand All @@ -227,7 +239,7 @@ export function generateDesignTree(file: AnalysisFile, options?: DesignTreeOptio
const w = root.absoluteBoundingBox ? Math.round(root.absoluteBoundingBox.width) : 0;
const h = root.absoluteBoundingBox ? Math.round(root.absoluteBoundingBox.height) : 0;

const tree = renderNode(root, 0, options?.vectorDir);
const tree = renderNode(root, 0, options?.vectorDir, file.components);

return [
"# Design Tree",
Expand Down
4 changes: 4 additions & 0 deletions src/core/engine/rule-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import { supportsDepthWeight } from "../contracts/rule.js";
import { ruleRegistry } from "../rules/rule-registry.js";
import { RULE_CONFIGS } from "../rules/rule-config.js";
import { resetMissingComponentState } from "../rules/component/index.js";

/**
* Analysis issue with calculated score and metadata
Expand Down Expand Up @@ -145,6 +146,9 @@ export class RuleEngine {
* Analyze a Figma file and return issues
*/
analyze(file: AnalysisFile): AnalysisResult {
// Reset module-level dedup state for rules that track seen patterns
resetMissingComponentState();

Comment on lines 148 to +152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing reset for missingComponentDescription state.

The resetMissingComponentState() call correctly clears dedup state for the missing-component rule. However, src/core/rules/component/index.ts also exports resetMissingComponentDescriptionState() (line 494) for the missing-component-description rule's seenMissingDescriptionComponentIds Set, which has the same module-level persistence issue.

For consistency and correctness in long-running processes, consider also resetting this state:

🛡️ Proposed fix
-import { resetMissingComponentState } from "../rules/component/index.js";
+import { resetMissingComponentState, resetMissingComponentDescriptionState } from "../rules/component/index.js";
   analyze(file: AnalysisFile): AnalysisResult {
     // Reset module-level dedup state for rules that track seen patterns
     resetMissingComponentState();
+    resetMissingComponentDescriptionState();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/engine/rule-engine.ts` around lines 148 - 151, The analyze method in
rule-engine.ts resets module-level dedup state via resetMissingComponentState()
but misses the companion reset for the `missing-component-description` rule;
call the exported resetMissingComponentDescriptionState() (from the component
rules module) alongside resetMissingComponentState() at the start of
analyze(file: AnalysisFile): AnalysisResult to clear the
seenMissingDescriptionComponentIds Set between runs, ensuring both module-level
caches are reset.

// Find target node if specified
let rootNode = file.document;
if (this.targetNodeId) {
Expand Down
Loading
Loading