diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index 786cb7e5..0a135a5e 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -41,6 +41,8 @@ export interface RuleContext { componentDepth: number; maxDepth: number; path: string[]; + /** Ancestor node types from root to parent (excludes current node). */ + ancestorTypes: string[]; siblings?: AnalysisNode[] | undefined; /** Per-analysis shared state. Created fresh for each analysis run, eliminating module-level mutable state. */ analysisState: Map; diff --git a/src/core/engine/rule-engine.ts b/src/core/engine/rule-engine.ts index 1100a2d1..66c2694c 100644 --- a/src/core/engine/rule-engine.ts +++ b/src/core/engine/rule-engine.ts @@ -187,6 +187,7 @@ export class RuleEngine { failedRules, 0, [], + [], 0, analysisState, undefined, @@ -234,6 +235,7 @@ export class RuleEngine { failedRules: RuleFailure[], depth: number, path: string[], + ancestorTypes: string[], componentDepth: number, analysisState: Map, parent?: AnalysisNode, @@ -261,6 +263,7 @@ export class RuleEngine { componentDepth: currentComponentDepth, maxDepth, path: nodePath, + ancestorTypes, siblings, analysisState, }; @@ -308,6 +311,7 @@ export class RuleEngine { // Recurse into children if (node.children && node.children.length > 0) { + const childAncestorTypes = [...ancestorTypes, node.type]; for (const child of node.children) { this.traverseAndCheck( child, @@ -318,6 +322,7 @@ export class RuleEngine { failedRules, depth + 1, nodePath, + childAncestorTypes, currentComponentDepth + 1, analysisState, node, diff --git a/src/core/rules/component/index.ts b/src/core/rules/component/index.ts index 46c561d3..0e38f8d8 100644 --- a/src/core/rules/component/index.ts +++ b/src/core/rules/component/index.ts @@ -77,14 +77,12 @@ function buildFingerprint(node: AnalysisNode, depth: number): string { /** * Check if the node is inside an INSTANCE subtree. - * Currently checks immediate parent only — RuleContext does not expose the full - * ancestor type chain (context.path contains names, not types). - * TODO: When the engine exposes ancestor types, extend to full chain check. + * Walks the full ancestor type chain to detect INSTANCE at any level. */ function isInsideInstance(context: { - parent?: AnalysisNode | undefined; + ancestorTypes: string[]; }): boolean { - return context.parent?.type === "INSTANCE"; + return context.ancestorTypes.includes("INSTANCE"); } diff --git a/src/core/rules/component/missing-component.test.ts b/src/core/rules/component/missing-component.test.ts index e5867f89..2f393620 100644 --- a/src/core/rules/component/missing-component.test.ts +++ b/src/core/rules/component/missing-component.test.ts @@ -42,6 +42,7 @@ function makeContext(overrides?: Partial): RuleContext { componentDepth: 0, maxDepth: 10, path: ["Page", "Section"], + ancestorTypes: [], analysisState, ...overrides, }; @@ -321,15 +322,16 @@ describe("missing-component — Stage 3: Structure-based repetition", () => { const frameB = makeNode({ id: "f:2", children: [childB] }); const frameC = makeNode({ id: "f:3", children: [childC] }); - const instanceParent: AnalysisNode = { - id: "inst:1", - name: "MyInstance", - type: "INSTANCE", + const nestedFrameParent: AnalysisNode = { + id: "frame:1", + name: "NestedFrame", + type: "FRAME", visible: true, }; const ctx = makeContext({ - parent: instanceParent, + parent: nestedFrameParent, + ancestorTypes: ["DOCUMENT", "INSTANCE", "FRAME"], siblings: [frameA, frameB, frameC], }); diff --git a/src/core/rules/rule-exceptions.test.ts b/src/core/rules/rule-exceptions.test.ts index 70a79eff..134eae21 100644 --- a/src/core/rules/rule-exceptions.test.ts +++ b/src/core/rules/rule-exceptions.test.ts @@ -25,6 +25,7 @@ function makeContext(overrides: Partial = {}): RuleContext { componentDepth: 0, maxDepth: 10, path: ["Root", "Test"], + ancestorTypes: [], analysisState: new Map(), ...overrides, }; diff --git a/src/core/rules/test-helpers.ts b/src/core/rules/test-helpers.ts index 614b50b8..d2ea842d 100644 --- a/src/core/rules/test-helpers.ts +++ b/src/core/rules/test-helpers.ts @@ -25,6 +25,7 @@ export function makeContext(overrides?: Partial): RuleContext { componentDepth: 0, maxDepth: 10, path: ["Page", "Section"], + ancestorTypes: [], analysisState: new Map(), ...overrides, };