Skip to content

Commit 54f45ec

Browse files
committed
feat: track property reads in graph flow
1 parent 62c25df commit 54f45ec

4 files changed

Lines changed: 212 additions & 23 deletions

File tree

.changeset/v1-2-property-flow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"any-map": minor
3+
---
4+
5+
Improve graph propagation through object property reads, string-literal index reads, and plain assignment statements.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ A single `any` in a utility can propagate through assignments, destructuring, an
2929

3030
`any-map scan`: `--format table|json|dot` (or legacy `--json`), `--dump-graph` (JSON graph snapshot), `--top N` (limits **both** the greedy fix-order table and the blast-ranked table, and the matching JSON arrays; `--fail-coverage` still uses the full greedy run), `--source-kinds`, `--ignore` (comma-separated picomatch globs), `--fail-above N`, `--fail-coverage P` (cumulative % from the greedy run must be ≥ P — see [PLAN.md](./PLAN.md) for edge cases). CI: [.github/actions/any-map-scan/action.yml](.github/actions/any-map-scan/action.yml) (`npx any-map@… scan . ${{ inputs.args }}`).
3131

32-
Direct intra-project flow currently includes import bindings and resolved cross-file call edges, so `any` can propagate through chains like `export default value` -> `import x` -> `consume(x)`.
32+
Direct intra-project flow currently includes import bindings, property/index reads, plain assignments, and resolved cross-file call edges, so `any` can propagate through chains like `export default value` -> `import x` -> `box.payload` -> `consume(x)`.
3333

3434
### `allowJs` / JavaScript
3535

src/analyzer/build-graph.ts

Lines changed: 151 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ function getEnclosingFunctionLike(
4040
return undefined;
4141
}
4242

43+
function propertyNameText(name: ts.PropertyName): string | undefined {
44+
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text;
45+
if (ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
46+
return name.text;
47+
}
48+
return undefined;
49+
}
50+
4351
export class GraphBuilder {
4452
private readonly checker: ts.TypeChecker;
4553
private readonly nodes = new Map<string, GraphNodeMutable>();
@@ -111,6 +119,83 @@ export class GraphBuilder {
111119
return id;
112120
}
113121

122+
private ensurePropertyNamedNode(
123+
name: ts.PropertyName,
124+
discriminator = "",
125+
): string | undefined {
126+
const text = propertyNameText(name);
127+
if (!text) return undefined;
128+
const sf = name.getSourceFile();
129+
if (!this.isUserSourceFile(sf)) return undefined;
130+
const filePath = this.rel(sf);
131+
const start = name.getStart(sf, false);
132+
const { line, character } = sf.getLineAndCharacterOfPosition(start);
133+
const line1 = line + 1;
134+
const col1 = character + 1;
135+
const id = makeNodeId(filePath, line1, col1, text, discriminator);
136+
if (!this.nodes.has(id)) {
137+
this.nodes.set(id, {
138+
id,
139+
filePath,
140+
line: line1,
141+
column: col1,
142+
name: text,
143+
kind: "property",
144+
typeString: this.typeStringAt(name),
145+
isSource: false,
146+
infectedBy: new Set(),
147+
});
148+
}
149+
return id;
150+
}
151+
152+
private declarationForSymbol(
153+
sym: ts.Symbol | undefined,
154+
): ts.Declaration | undefined {
155+
return (
156+
sym?.declarations?.find(
157+
(decl) =>
158+
ts.isImportClause(decl) ||
159+
ts.isImportSpecifier(decl) ||
160+
ts.isNamespaceImport(decl) ||
161+
ts.isImportEqualsDeclaration(decl),
162+
) ?? sym?.valueDeclaration
163+
);
164+
}
165+
166+
private propertySymbolForElementAccess(
167+
expr: ts.ElementAccessExpression,
168+
): ts.Symbol | undefined {
169+
const arg = expr.argumentExpression;
170+
if (!arg || (!ts.isStringLiteralLike(arg) && !ts.isNumericLiteral(arg))) {
171+
return undefined;
172+
}
173+
const key = arg.text;
174+
const baseType = this.checker.getTypeAtLocation(expr.expression);
175+
const apparent = this.checker.getApparentType(baseType);
176+
return (
177+
this.checker.getPropertyOfType(apparent, key) ??
178+
this.checker.getPropertyOfType(baseType, key)
179+
);
180+
}
181+
182+
private reasonForValueRead(expr: ts.Expression): EdgeReason {
183+
if (ts.isParenthesizedExpression(expr)) {
184+
return this.reasonForValueRead(expr.expression);
185+
}
186+
if (
187+
ts.isAsExpression(expr) ||
188+
ts.isTypeAssertionExpression(expr) ||
189+
ts.isSatisfiesExpression(expr) ||
190+
ts.isNonNullExpression(expr)
191+
) {
192+
return this.reasonForValueRead(expr.expression);
193+
}
194+
if (ts.isPropertyAccessExpression(expr)) return "property-access";
195+
if (ts.isElementAccessExpression(expr)) return "index-access";
196+
return "assignment";
197+
}
198+
114199
private ensureFromValueDeclaration(vd: ts.Declaration): string | undefined {
115200
if (ts.isImportClause(vd) && vd.name) {
116201
return this.ensureImportBinding(vd.name);
@@ -136,6 +221,15 @@ export class GraphBuilder {
136221
if (ts.isPropertyDeclaration(vd) && ts.isIdentifier(vd.name)) {
137222
return this.ensureNamedDecl(vd, "property");
138223
}
224+
if (ts.isGetAccessorDeclaration(vd)) {
225+
return this.ensurePropertyNamedNode(vd.name, "getter");
226+
}
227+
if (ts.isPropertyAssignment(vd)) {
228+
return this.ensurePropertyNamedNode(vd.name, "object");
229+
}
230+
if (ts.isShorthandPropertyAssignment(vd)) {
231+
return this.ensurePropertyNamedNode(vd.name, "object");
232+
}
139233
return undefined;
140234
}
141235

@@ -278,16 +372,32 @@ export class GraphBuilder {
278372

279373
/** Value-flow sources: identifiers / simple references with a registered declaration. */
280374
exprToNodeId(expr: ts.Expression): string | undefined {
281-
if (!ts.isIdentifier(expr)) return undefined;
282-
const sym = this.checker.getSymbolAtLocation(expr);
283-
const vd =
284-
sym?.declarations?.find(
285-
(decl) =>
286-
ts.isImportClause(decl) ||
287-
ts.isImportSpecifier(decl) ||
288-
ts.isNamespaceImport(decl) ||
289-
ts.isImportEqualsDeclaration(decl),
290-
) ?? sym?.valueDeclaration;
375+
if (ts.isParenthesizedExpression(expr)) {
376+
return this.exprToNodeId(expr.expression);
377+
}
378+
if (
379+
ts.isAsExpression(expr) ||
380+
ts.isTypeAssertionExpression(expr) ||
381+
ts.isSatisfiesExpression(expr) ||
382+
ts.isNonNullExpression(expr)
383+
) {
384+
return this.exprToNodeId(expr.expression);
385+
}
386+
387+
let sym: ts.Symbol | undefined;
388+
if (ts.isIdentifier(expr)) {
389+
sym = this.checker.getSymbolAtLocation(expr);
390+
} else if (ts.isPropertyAccessExpression(expr)) {
391+
sym =
392+
this.checker.getSymbolAtLocation(expr.name) ??
393+
this.checker.getSymbolAtLocation(expr);
394+
} else if (ts.isElementAccessExpression(expr)) {
395+
sym = this.propertySymbolForElementAccess(expr);
396+
} else {
397+
return undefined;
398+
}
399+
400+
const vd = this.declarationForSymbol(sym);
291401
if (!vd) return undefined;
292402
return this.ensureFromValueDeclaration(vd);
293403
}
@@ -370,6 +480,20 @@ export class GraphBuilder {
370480
if (ts.isSpreadAssignment(p)) {
371481
const sid = this.exprToNodeId(p.expression);
372482
if (sid) this.addEdge(sid, lhs, "spread");
483+
continue;
484+
}
485+
if (ts.isPropertyAssignment(p)) {
486+
const pid = this.ensureFromValueDeclaration(p);
487+
const rhs = this.exprToNodeId(p.initializer);
488+
if (pid && rhs) {
489+
this.addEdge(rhs, pid, this.reasonForValueRead(p.initializer));
490+
}
491+
continue;
492+
}
493+
if (ts.isShorthandPropertyAssignment(p)) {
494+
const pid = this.ensureFromValueDeclaration(p);
495+
const rhs = this.exprToNodeId(p.name);
496+
if (pid && rhs) this.addEdge(rhs, pid, "assignment");
373497
}
374498
}
375499
}
@@ -382,13 +506,16 @@ export class GraphBuilder {
382506
}
383507
if (
384508
ts.isBinaryExpression(e) &&
385-
e.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
386-
ts.isCallExpression(e.right)
509+
e.operatorToken.kind === ts.SyntaxKind.EqualsToken
387510
) {
388-
const lhsId = ts.isIdentifier(e.left)
389-
? this.exprToNodeId(e.left)
390-
: undefined;
391-
this.edgesFromCall(e.right, lhsId);
511+
const lhsId = this.exprToNodeId(e.left);
512+
if (!lhsId) return;
513+
if (ts.isCallExpression(e.right)) {
514+
this.edgesFromCall(e.right, lhsId);
515+
return;
516+
}
517+
const rhsId = this.exprToNodeId(e.right);
518+
if (rhsId) this.addEdge(rhsId, lhsId, this.reasonForValueRead(e.right));
392519
}
393520
}
394521

@@ -407,6 +534,11 @@ export class GraphBuilder {
407534
this.edgesFromCall(init, lhs);
408535
return;
409536
}
537+
const rhs = this.exprToNodeId(init);
538+
if (rhs) {
539+
this.addEdge(rhs, lhs, this.reasonForValueRead(init));
540+
return;
541+
}
410542
if (ts.isObjectLiteralExpression(init)) {
411543
this.edgesFromObjectLiteral(init, node);
412544
}
@@ -427,7 +559,9 @@ export class GraphBuilder {
427559
if (!fn) return;
428560
const retId = this.ensureReturnNode(fn);
429561
const exId = this.exprToNodeId(node.expression);
430-
if (retId && exId) this.addEdge(exId, retId, "assignment");
562+
if (retId && exId) {
563+
this.addEdge(exId, retId, this.reasonForValueRead(node.expression));
564+
}
431565
}
432566

433567
private visitPropertyDeclaration(node: ts.PropertyDeclaration): void {

tests/unit/graph-builder.test.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@ function hasEdge(
1111
fromName: string,
1212
toName: string,
1313
): boolean {
14-
const idOf = (name: string) => g.nodes.find((n) => n.name === name)?.id;
15-
const from = idOf(fromName);
16-
const to = idOf(toName);
17-
if (!from || !to) return false;
1814
return g.edges.some(
19-
(e) => e.from === from && e.to === to && e.reason === reason,
15+
(e) =>
16+
e.reason === reason &&
17+
g.nodes.some((n) => n.id === e.from && n.name === fromName) &&
18+
g.nodes.some((n) => n.id === e.to && n.name === toName),
2019
);
2120
}
2221

@@ -123,6 +122,22 @@ describe("intra-module graph (m3)", () => {
123122
expect(hasEdge(g, "assignment", "imported", "local")).toBe(true);
124123
});
125124

125+
it("property-access: object literal property read flows to assignment target", () => {
126+
const g = graphFor({
127+
"src/index.ts": `const seed = 1;\nconst obj = { payload: seed };\nconst local = obj.payload;\n`,
128+
});
129+
expect(hasEdge(g, "assignment", "seed", "payload")).toBe(true);
130+
expect(hasEdge(g, "property-access", "payload", "local")).toBe(true);
131+
});
132+
133+
it("index-access: string-literal property read flows to assignment target", () => {
134+
const g = graphFor({
135+
"src/index.ts": `const seed = 1;\nconst obj = { payload: seed };\nconst local = obj["payload"];\n`,
136+
});
137+
expect(hasEdge(g, "assignment", "seed", "payload")).toBe(true);
138+
expect(hasEdge(g, "index-access", "payload", "local")).toBe(true);
139+
});
140+
126141
it("arrow in variable: return uses return slot anchored on binding name", () => {
127142
const g = graphFor({
128143
"src/index.ts": `const arrow = (): number => {\n const v = 3;\n return v;\n};\n`,
@@ -163,6 +178,21 @@ describe("intra-module graph (m3)", () => {
163178
});
164179
expect(hasEdge(g, "parameter-binding", "imported", "formal")).toBe(true);
165180
});
181+
182+
it("parameter-binding uses property reads as call arguments", () => {
183+
const g = graphFor({
184+
"src/index.ts": `const seed = 1;\nconst obj = { payload: seed };\nfunction consume(formal: number): void { void formal; }\nconsume(obj.payload);\n`,
185+
});
186+
expect(hasEdge(g, "assignment", "seed", "payload")).toBe(true);
187+
expect(hasEdge(g, "parameter-binding", "payload", "formal")).toBe(true);
188+
});
189+
190+
it("assignment statement: lhs = rhs adds a direct flow edge", () => {
191+
const g = graphFor({
192+
"src/index.ts": `const source = 1;\nlet sink;\nsink = source;\n`,
193+
});
194+
expect(hasEdge(g, "assignment", "source", "sink")).toBe(true);
195+
});
166196
});
167197

168198
describe("propagation + blast (m4)", () => {
@@ -224,4 +254,24 @@ describe("propagation + blast (m4)", () => {
224254
expect(imported?.infectedBy).toContain(source?.id);
225255
expect(formal?.infectedBy).toContain(source?.id);
226256
});
257+
258+
it("object property reads propagate any infections into downstream locals", () => {
259+
const g = graphFor({
260+
"src/index.ts": `const seed: any = 1;\nconst obj = { payload: seed };\nconst local = obj.payload;\n`,
261+
});
262+
const source = g.nodes.find((n) => n.name === "seed" && n.isSource);
263+
const local = g.nodes.find((n) => n.name === "local");
264+
expect(source).toBeDefined();
265+
expect(local?.infectedBy).toContain(source?.id);
266+
});
267+
268+
it("assignment statements preserve propagated infections", () => {
269+
const g = graphFor({
270+
"src/index.ts": `const seed: any = 1;\nlet sink;\nsink = seed;\n`,
271+
});
272+
const source = g.nodes.find((n) => n.name === "seed" && n.isSource);
273+
const sink = g.nodes.find((n) => n.name === "sink");
274+
expect(source).toBeDefined();
275+
expect(sink?.infectedBy).toContain(source?.id);
276+
});
227277
});

0 commit comments

Comments
 (0)