@opensip-cli/graph 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/README.md +2 -2
  2. package/dist/__tests__/rules/always-throws-branch.test.js +94 -0
  3. package/dist/__tests__/rules/always-throws-branch.test.js.map +1 -1
  4. package/dist/__tests__/rules/duplicated-function-body-self-match.test.d.ts +18 -0
  5. package/dist/__tests__/rules/duplicated-function-body-self-match.test.d.ts.map +1 -0
  6. package/dist/__tests__/rules/duplicated-function-body-self-match.test.js +167 -0
  7. package/dist/__tests__/rules/duplicated-function-body-self-match.test.js.map +1 -0
  8. package/dist/__tests__/rules/no-side-effect-path.test.js +115 -0
  9. package/dist/__tests__/rules/no-side-effect-path.test.js.map +1 -1
  10. package/dist/__tests__/rules/orphan-subtree-dynamic-import.test.d.ts +27 -0
  11. package/dist/__tests__/rules/orphan-subtree-dynamic-import.test.d.ts.map +1 -0
  12. package/dist/__tests__/rules/orphan-subtree-dynamic-import.test.js +189 -0
  13. package/dist/__tests__/rules/orphan-subtree-dynamic-import.test.js.map +1 -0
  14. package/dist/rules/_entry-points.d.ts +16 -2
  15. package/dist/rules/_entry-points.d.ts.map +1 -1
  16. package/dist/rules/_entry-points.js +147 -2
  17. package/dist/rules/_entry-points.js.map +1 -1
  18. package/dist/rules/always-throws-branch.d.ts.map +1 -1
  19. package/dist/rules/always-throws-branch.js +99 -5
  20. package/dist/rules/always-throws-branch.js.map +1 -1
  21. package/dist/rules/duplicated-function-body.js +42 -12
  22. package/dist/rules/duplicated-function-body.js.map +1 -1
  23. package/dist/rules/no-side-effect-path.d.ts.map +1 -1
  24. package/dist/rules/no-side-effect-path.js +73 -9
  25. package/dist/rules/no-side-effect-path.js.map +1 -1
  26. package/package.json +8 -8
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Regression: dynamic `import()` must count as a reachability edge.
3
+ *
4
+ * Verified false positive (the bug this guards): a CLI command module
5
+ * reached only through
6
+ * `const { runReplay } = await import('../commands/.../replay.js')`
7
+ * was flagged as an orphan — and so were the helpers it calls
8
+ * (`parseArgs` / `formatOutcomeTag`) — because the static call-graph
9
+ * resolver cannot trace a binding through a dynamic import, so neither
10
+ * the importing call nor a static import edge ever marked the target
11
+ * module reachable.
12
+ *
13
+ * The fix (heuristic 6 in `_entry-points.ts`) treats the dynamic-import
14
+ * target the same way a static import makes the imported module's
15
+ * exported surface reachable: it resolves the relative specifier to the
16
+ * target file and seeds that file's EXPORTED occurrences as entry points.
17
+ *
18
+ * These tests mirror the real catalog edge topology observed from the
19
+ * TypeScript pipeline for the `const { x } = await import('./mod.js')`
20
+ * form: the importing function carries an UNRESOLVED `import('./mod.js')`
21
+ * call edge (`to: []`) plus an UNRESOLVED `runReplay(...)` call edge
22
+ * (the destructured binding the resolver could not trace), while the
23
+ * target module's exported function resolves edges to its own private
24
+ * helpers.
25
+ */
26
+ import { describe, expect, it } from 'vitest';
27
+ import { buildIndexes } from '../../pipeline/indexes.js';
28
+ import { inferEntryPoints } from '../../rules/_entry-points.js';
29
+ import { orphanSubtreeRule } from '../../rules/orphan-subtree.js';
30
+ import { edge, makeCatalog, occ, staticCall } from './_helpers.js';
31
+ /**
32
+ * Build the `registry.ts` + `replay.ts` cluster:
33
+ * - `dispatch` (exported, registry.ts): dynamic-imports replay.js and
34
+ * calls the destructured `runReplay`; both edges are UNRESOLVED.
35
+ * - `runReplay` (exported, replay.js): calls its private helpers.
36
+ * - `parseArgs` / `formatOutcomeTag` (module-local, replay.js).
37
+ */
38
+ function dynamicImportCluster() {
39
+ const dispatch = occ({
40
+ bodyHash: 'dispatch',
41
+ simpleName: 'dispatch',
42
+ visibility: 'exported',
43
+ filePath: 'src/registry.ts',
44
+ // Unresolved (to: []) — exactly what the TS resolver emits for a
45
+ // dynamic import and a binding it cannot trace.
46
+ calls: [edge("import('./replay.js')"), edge('runReplay([])')],
47
+ });
48
+ const runReplay = occ({
49
+ bodyHash: 'runReplay',
50
+ simpleName: 'runReplay',
51
+ visibility: 'exported',
52
+ filePath: 'src/replay.ts',
53
+ calls: [staticCall('parseArgs'), staticCall('formatOutcomeTag')],
54
+ });
55
+ const parseArgs = occ({
56
+ bodyHash: 'parseArgs',
57
+ simpleName: 'parseArgs',
58
+ visibility: 'module-local',
59
+ filePath: 'src/replay.ts',
60
+ });
61
+ const formatOutcomeTag = occ({
62
+ bodyHash: 'formatOutcomeTag',
63
+ simpleName: 'formatOutcomeTag',
64
+ visibility: 'module-local',
65
+ filePath: 'src/replay.ts',
66
+ });
67
+ return makeCatalog([dispatch, runReplay, parseArgs, formatOutcomeTag]);
68
+ }
69
+ describe('orphan-subtree — dynamic import reachability (heuristic 6)', () => {
70
+ it('seeds an exported dynamic-import target as an entry point', () => {
71
+ const catalog = dynamicImportCluster();
72
+ const eps = inferEntryPoints(catalog, buildIndexes(catalog));
73
+ const runReplayEp = eps.find((e) => e.bodyHash === 'runReplay');
74
+ expect(runReplayEp).toBeDefined();
75
+ // It is seeded specifically by the dynamic-import heuristic (it has no
76
+ // in-project caller, so it would also satisfy no-callers-exported; the
77
+ // dynamic-import reason is what survives dedup for the FIRST classifier
78
+ // that fires — assert the symbol is an entry point either way).
79
+ expect(['dynamic-import', 'no-callers-exported']).toContain(runReplayEp.reason);
80
+ });
81
+ it('does NOT flag a symbol reachable only via `await import()` destructure', () => {
82
+ const catalog = dynamicImportCluster();
83
+ const orphans = orphanSubtreeRule
84
+ // flagExportedOrphans + flagTestOrphans left default; the helpers are
85
+ // module-local so the exported filter does not mask them.
86
+ .evaluate(catalog, buildIndexes(catalog), {})
87
+ .map((s) => s.metadata.simpleName);
88
+ // The whole dynamic-import subtree is reachable — none are orphans.
89
+ expect(orphans).not.toContain('runReplay');
90
+ expect(orphans).not.toContain('parseArgs');
91
+ expect(orphans).not.toContain('formatOutcomeTag');
92
+ });
93
+ it('keeps flagging a genuinely unreferenced exported function (true positive preserved)', () => {
94
+ // A dead exported function whose file is NOT a dynamic-import target,
95
+ // reached only by a dead module-local caller. With flagExportedOrphans
96
+ // it must still be flagged — the dynamic-import heuristic only adds
97
+ // reachability for actually-imported files, never suppresses orphans.
98
+ const deadCaller = occ({
99
+ bodyHash: 'dc',
100
+ simpleName: 'deadCaller',
101
+ visibility: 'module-local',
102
+ filePath: 'src/dead.ts',
103
+ calls: [staticCall('ue')],
104
+ });
105
+ const unusedExport = occ({
106
+ bodyHash: 'ue',
107
+ simpleName: 'unusedExport',
108
+ visibility: 'exported',
109
+ filePath: 'src/dead.ts',
110
+ });
111
+ // Plus the dynamic-import cluster, to prove the two are isolated.
112
+ const cluster = dynamicImportCluster();
113
+ const catalog = makeCatalog([
114
+ deadCaller,
115
+ unusedExport,
116
+ ...Object.values(cluster.functions).flat(),
117
+ ]);
118
+ const indexes = buildIndexes(catalog);
119
+ const orphans = orphanSubtreeRule
120
+ .evaluate(catalog, indexes, { flagExportedOrphans: true })
121
+ .map((s) => s.metadata.simpleName);
122
+ expect(orphans).toContain('deadCaller'); // dead module-local caller
123
+ expect(orphans).toContain('unusedExport'); // genuine exported orphan
124
+ // ...and the dynamic-import subtree stays reachable.
125
+ expect(orphans).not.toContain('runReplay');
126
+ expect(orphans).not.toContain('parseArgs');
127
+ expect(orphans).not.toContain('formatOutcomeTag');
128
+ });
129
+ it('seeds the dynamic-import target even when no-callers-exported does NOT apply', () => {
130
+ // Isolate heuristic 6 from heuristic 5: the dynamic-import target has a
131
+ // (dead) in-project caller, so `no-callers-exported` does NOT classify it.
132
+ // Only the dynamic-import heuristic can seed it as an entry point.
133
+ const importer = occ({
134
+ bodyHash: 'imp',
135
+ simpleName: 'lazyDispatch',
136
+ visibility: 'exported',
137
+ filePath: 'src/registry.ts',
138
+ calls: [edge("import('./replay.js')"), edge('runReplay()')],
139
+ });
140
+ const runReplay = occ({
141
+ bodyHash: 'runReplay',
142
+ simpleName: 'runReplay',
143
+ visibility: 'exported',
144
+ filePath: 'src/replay.ts',
145
+ });
146
+ // A dead module-local caller gives runReplay an in-project caller, so
147
+ // hasExternalCaller() is true and no-callers-exported is suppressed.
148
+ const deadCaller = occ({
149
+ bodyHash: 'dead',
150
+ simpleName: 'deadStaticCaller',
151
+ visibility: 'module-local',
152
+ filePath: 'src/replay.ts',
153
+ calls: [staticCall('runReplay')],
154
+ });
155
+ const catalog = makeCatalog([importer, runReplay, deadCaller]);
156
+ const eps = inferEntryPoints(catalog, buildIndexes(catalog));
157
+ const ep = eps.find((e) => e.bodyHash === 'runReplay');
158
+ expect(ep?.reason).toBe('dynamic-import');
159
+ });
160
+ it('tolerates the `.js → .ts` ESM extension rewrite and bare specifiers', () => {
161
+ // Importing file uses an extensionless specifier; target is a .tsx file.
162
+ const importer = occ({
163
+ bodyHash: 'imp',
164
+ simpleName: 'lazyLoad',
165
+ visibility: 'exported',
166
+ filePath: 'src/app.ts',
167
+ calls: [edge("import('./views/panel')"), edge('mountPanel()')],
168
+ });
169
+ const mountPanel = occ({
170
+ bodyHash: 'mp',
171
+ simpleName: 'mountPanel',
172
+ visibility: 'exported',
173
+ filePath: 'src/views/panel.tsx',
174
+ });
175
+ // A bare/workspace dynamic import resolves outside the catalog — must be
176
+ // ignored without error.
177
+ const bare = occ({
178
+ bodyHash: 'bare',
179
+ simpleName: 'loadExternal',
180
+ visibility: 'exported',
181
+ filePath: 'src/ext.ts',
182
+ calls: [edge("import('node:fs')")],
183
+ });
184
+ const catalog = makeCatalog([importer, mountPanel, bare]);
185
+ const eps = inferEntryPoints(catalog, buildIndexes(catalog));
186
+ expect(eps.find((e) => e.bodyHash === 'mp')).toBeDefined();
187
+ });
188
+ });
189
+ //# sourceMappingURL=orphan-subtree-dynamic-import.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orphan-subtree-dynamic-import.test.js","sourceRoot":"","sources":["../../../src/__tests__/rules/orphan-subtree-dynamic-import.test.ts"],"names":[],"mappings":"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"}
@@ -14,14 +14,28 @@
14
14
  * does not resolve) is still an external entry point — counting its
15
15
  * own recursion as a "caller" would wrongly hide it (and its whole
16
16
  * file-local helper subtree) as an orphan.
17
+ * 6. It's an EXPORTED symbol of a module reached via a dynamic
18
+ * `import('<spec>')` / `await import('<spec>')` expression (including
19
+ * the `const { x } = await import(...)` destructure form). The call
20
+ * graph's static resolver cannot trace a binding through a dynamic
21
+ * import, so a function reached only that way would otherwise look
22
+ * orphaned. We treat the dynamic-import target the same way a static
23
+ * import edge makes the imported module's exported surface reachable:
24
+ * resolve the relative specifier to the target file and seed its
25
+ * exported occurrences as entry points. This is conservative — it
26
+ * only adds reachability (never suppresses a genuine orphan), and it
27
+ * fires solely on exported symbols of an actually-imported file.
17
28
  *
18
29
  * v0.2 ships heuristics 3, 4, 5; 1 and 2 are project-specific and
19
- * deferred until cross-package call resolution is reliable.
30
+ * deferred until cross-package call resolution is reliable. Heuristic 6
31
+ * (dynamic-import reachability) was added to fix a verified false
32
+ * positive: a CLI command reached only via
33
+ * `const { runReplay } = await import('../commands/.../replay.js')`.
20
34
  */
21
35
  import type { Catalog, Indexes } from '../types.js';
22
36
  export interface EntryPoint {
23
37
  readonly bodyHash: string;
24
- readonly reason: 'module-init' | 'name-match' | 'no-callers-exported';
38
+ readonly reason: 'module-init' | 'name-match' | 'no-callers-exported' | 'dynamic-import';
25
39
  }
26
40
  export declare function inferEntryPoints(catalog: Catalog, indexes: Indexes): readonly EntryPoint[];
27
41
  //# sourceMappingURL=_entry-points.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"_entry-points.d.ts","sourceRoot":"","sources":["../../src/rules/_entry-points.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAsB,OAAO,EAAE,MAAM,aAAa,CAAC;AAYxE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,aAAa,GAAG,YAAY,GAAG,qBAAqB,CAAC;CACvE;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,SAAS,UAAU,EAAE,CAW1F"}
1
+ {"version":3,"file":"_entry-points.d.ts","sourceRoot":"","sources":["../../src/rules/_entry-points.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAsB,OAAO,EAAE,MAAM,aAAa,CAAC;AAYxE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,aAAa,GAAG,YAAY,GAAG,qBAAqB,GAAG,gBAAgB,CAAC;CAC1F;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,SAAS,UAAU,EAAE,CAyB1F"}
@@ -15,9 +15,23 @@
15
15
  * does not resolve) is still an external entry point — counting its
16
16
  * own recursion as a "caller" would wrongly hide it (and its whole
17
17
  * file-local helper subtree) as an orphan.
18
+ * 6. It's an EXPORTED symbol of a module reached via a dynamic
19
+ * `import('<spec>')` / `await import('<spec>')` expression (including
20
+ * the `const { x } = await import(...)` destructure form). The call
21
+ * graph's static resolver cannot trace a binding through a dynamic
22
+ * import, so a function reached only that way would otherwise look
23
+ * orphaned. We treat the dynamic-import target the same way a static
24
+ * import edge makes the imported module's exported surface reachable:
25
+ * resolve the relative specifier to the target file and seed its
26
+ * exported occurrences as entry points. This is conservative — it
27
+ * only adds reachability (never suppresses a genuine orphan), and it
28
+ * fires solely on exported symbols of an actually-imported file.
18
29
  *
19
30
  * v0.2 ships heuristics 3, 4, 5; 1 and 2 are project-specific and
20
- * deferred until cross-package call resolution is reliable.
31
+ * deferred until cross-package call resolution is reliable. Heuristic 6
32
+ * (dynamic-import reachability) was added to fix a verified false
33
+ * positive: a CLI command reached only via
34
+ * `const { runReplay } = await import('../commands/.../replay.js')`.
21
35
  */
22
36
  const NAME_HEURISTICS = new Set([
23
37
  'main',
@@ -30,10 +44,23 @@ const NAME_HEURISTICS = new Set([
30
44
  ]);
31
45
  export function inferEntryPoints(catalog, indexes) {
32
46
  const out = [];
47
+ const seen = new Set();
33
48
  for (const occ of indexes.byBodyHash.values()) {
34
49
  const reason = classify(occ, indexes);
35
- if (reason !== null)
50
+ if (reason !== null) {
36
51
  out.push({ bodyHash: occ.bodyHash, reason });
52
+ seen.add(occ.bodyHash);
53
+ }
54
+ }
55
+ // Heuristic 6: exported symbols of any dynamically-imported module are
56
+ // entry points (the static call resolver cannot trace a binding through
57
+ // `import(...)`). Deduped against the heuristics above so a function is
58
+ // never emitted twice.
59
+ for (const hash of dynamicImportEntryHashes(indexes)) {
60
+ if (!seen.has(hash)) {
61
+ out.push({ bodyHash: hash, reason: 'dynamic-import' });
62
+ seen.add(hash);
63
+ }
37
64
  }
38
65
  // Honor caller-supplied override at the rule level via GraphConfig
39
66
  // (handled by the consuming rule). This module returns the inferred
@@ -78,4 +105,122 @@ function hasExternalCaller(occ, indexes) {
78
105
  }
79
106
  return false;
80
107
  }
108
+ /**
109
+ * Matches a dynamic-import call expression and captures its specifier:
110
+ * `import('./x.js')`, `await import("./x")`, and the
111
+ * `const { y } = await import('./x.js')` destructure form all surface in
112
+ * the catalog as a call edge whose `text` begins with `import(` followed
113
+ * by the string-literal specifier. We capture the specifier verbatim.
114
+ */
115
+ const DYNAMIC_IMPORT_RE = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/;
116
+ /**
117
+ * Body hashes of every EXPORTED occurrence that lives in a file targeted
118
+ * by a dynamic `import('<spec>')` anywhere in the project. Built by:
119
+ * 1. collecting every relative dynamic-import specifier (from call-edge
120
+ * text), resolved to a project-relative target path against the
121
+ * importing occurrence's directory;
122
+ * 2. mapping those target paths to the catalog's actual file paths
123
+ * (tolerating the `.js → .ts/.tsx` ESM extension rewrite and
124
+ * `index` directory imports);
125
+ * 3. emitting the exported occurrences declared in each matched file.
126
+ *
127
+ * Bare/workspace specifiers (`@scope/pkg`, `node:fs`) are ignored — they
128
+ * resolve outside the catalog and the exported-no-caller heuristic
129
+ * already covers cross-package surface.
130
+ */
131
+ function dynamicImportEntryHashes(indexes) {
132
+ const targetFiles = collectDynamicImportTargetFiles(indexes);
133
+ if (targetFiles.size === 0)
134
+ return [];
135
+ const exportedByFile = buildExportedByFile(indexes);
136
+ const out = [];
137
+ for (const target of targetFiles) {
138
+ for (const candidate of candidateFilePaths(target)) {
139
+ const occs = exportedByFile.get(candidate);
140
+ if (occs) {
141
+ for (const o of occs)
142
+ out.push(o.bodyHash);
143
+ }
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+ /** Resolve every relative dynamic-import specifier to a project-relative
149
+ * (extension-bearing) target path, keyed off the importing occurrence's
150
+ * directory. */
151
+ function collectDynamicImportTargetFiles(indexes) {
152
+ const targets = new Set();
153
+ for (const occ of indexes.byBodyHash.values()) {
154
+ for (const call of occ.calls) {
155
+ const match = DYNAMIC_IMPORT_RE.exec(call.text);
156
+ if (!match)
157
+ continue;
158
+ const specifier = match[1];
159
+ // Only relative specifiers resolve into the catalog.
160
+ if (!specifier.startsWith('.'))
161
+ continue;
162
+ targets.add(resolveRelative(dirOf(occ.filePath), specifier));
163
+ }
164
+ }
165
+ return targets;
166
+ }
167
+ /** filePath → exported function occurrences declared in that file. */
168
+ function buildExportedByFile(indexes) {
169
+ const byFile = new Map();
170
+ for (const occ of indexes.byBodyHash.values()) {
171
+ if (occ.visibility !== 'exported')
172
+ continue;
173
+ if (!occ.filePath)
174
+ continue;
175
+ const bucket = byFile.get(occ.filePath);
176
+ if (bucket)
177
+ bucket.push(occ);
178
+ else
179
+ byFile.set(occ.filePath, [occ]);
180
+ }
181
+ return byFile;
182
+ }
183
+ /** Project-relative posix directory of a file ('' for a root-level file). */
184
+ function dirOf(filePath) {
185
+ const slash = filePath.lastIndexOf('/');
186
+ return slash === -1 ? '' : filePath.slice(0, slash);
187
+ }
188
+ /** Resolve a relative specifier against a posix directory, collapsing
189
+ * `.` / `..` segments. Returns a project-relative posix path. */
190
+ function resolveRelative(dir, specifier) {
191
+ const segments = dir.length > 0 ? dir.split('/') : [];
192
+ for (const part of specifier.split('/')) {
193
+ if (part === '' || part === '.')
194
+ continue;
195
+ if (part === '..')
196
+ segments.pop();
197
+ else
198
+ segments.push(part);
199
+ }
200
+ return segments.join('/');
201
+ }
202
+ /** Candidate catalog file paths a resolved specifier may map to —
203
+ * tolerating the ESM `.js → .ts/.tsx` rewrite and `index` imports.
204
+ * Order is irrelevant; lookups are exact-match against real files. */
205
+ function candidateFilePaths(target) {
206
+ const base = stripKnownExtension(target);
207
+ return [
208
+ target,
209
+ `${base}.ts`,
210
+ `${base}.tsx`,
211
+ `${base}.js`,
212
+ `${base}.jsx`,
213
+ `${base}.mts`,
214
+ `${base}.cts`,
215
+ `${base}/index.ts`,
216
+ `${base}/index.tsx`,
217
+ `${base}/index.js`,
218
+ ];
219
+ }
220
+ /** Drop a trailing JS/TS module extension so the `.js → .ts` ESM rewrite
221
+ * can be re-applied as candidates. Leaves an extensionless path intact. */
222
+ function stripKnownExtension(path) {
223
+ const match = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.exec(path);
224
+ return match ? path.slice(0, path.length - match[0].length) : path;
225
+ }
81
226
  //# sourceMappingURL=_entry-points.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"_entry-points.js","sourceRoot":"","sources":["../../src/rules/_entry-points.ts"],"names":[],"mappings":"AAAA,2HAA2H;AAC3H;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC;IAC9B,MAAM;IACN,KAAK;IACL,OAAO;IACP,UAAU;IACV,YAAY;IACZ,MAAM;IACN,WAAW;CACZ,CAAC,CAAC;AAOH,MAAM,UAAU,gBAAgB,CAAC,OAAgB,EAAE,OAAgB;IACjE,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,UAAU,CAAC,MAAM,EAAE,EAAE,CAAC;QAC9C,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QACtC,IAAI,MAAM,KAAK,IAAI;YAAE,GAAG,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IACpE,CAAC;IACD,mEAAmE;IACnE,oEAAoE;IACpE,oDAAoD;IACpD,KAAK,OAAO,CAAC;IACb,OAAO,GAAG,CAAC;AACb,CAAC;AAED,SAAS,QAAQ,CAAC,GAAuB,EAAE,OAAgB;IACzD,kEAAkE;IAClE,4DAA4D;IAC5D,8DAA8D;IAC9D,iEAAiE;IACjE,gEAAgE;IAChE,gEAAgE;IAChE,8DAA8D;IAC9D,wBAAwB;IACxB,IAAI,GAAG,CAAC,IAAI,KAAK,aAAa;QAAE,OAAO,aAAa,CAAC;IACrD,IAAI,eAAe,CAAC,GAAG,CAAC,GAAG,CAAC,UAAU,CAAC;QAAE,OAAO,YAAY,CAAC;IAC7D,IAAI,GAAG,CAAC,UAAU,KAAK,UAAU,IAAI,CAAC,iBAAiB,CAAC,GAAG,EAAE,OAAO,CAAC,EAAE,CAAC;QACtE,oEAAoE;QACpE,mEAAmE;QACnE,iEAAiE;QACjE,OAAO,qBAAqB,CAAC;IAC/B,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;GAKG;AACH,SAAS,iBAAiB,CAAC,GAAuB,EAAE,OAAgB;IAClE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAClD,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IACxC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,KAAK,GAAG,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAC;IAC3C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
1
+ {"version":3,"file":"_entry-points.js","sourceRoot":"","sources":["../../src/rules/_entry-points.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"always-throws-branch.d.ts","sourceRoot":"","sources":["../../src/rules/always-throws-branch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AASH,eAAO,MAAM,sBAAsB,4BAoCjC,CAAC"}
1
+ {"version":3,"file":"always-throws-branch.d.ts","sourceRoot":"","sources":["../../src/rules/always-throws-branch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AA6FH,eAAO,MAAM,sBAAsB,4BAqDjC,CAAC"}
@@ -25,11 +25,88 @@
25
25
  import { createGraphSignal } from './create-graph-signal.js';
26
26
  import { defineRule } from './define-rule.js';
27
27
  const TYPESCRIPT_FALLBACK_THROW_REGEX = /^\s*throw\s+(?:new\s+)?[A-Z]\w*/;
28
+ /**
29
+ * Prefix the adapters stamp onto a *creation* edge (`[creates] () => …`),
30
+ * mirroring `CREATION_EDGE_PREFIX` in `lang-adapter/edge-helpers.ts`. An
31
+ * occurrence that is the TARGET of such an edge is an inline callable
32
+ * (arrow / function-expression) that some enclosing scope merely *creates*
33
+ * and returns/passes — not a standalone helper the program calls for its
34
+ * always-throwing effect. Inlined here (rather than imported) to keep this
35
+ * rule module dependency-free of the edge-helper surface; the literal is the
36
+ * documented producer-side contract.
37
+ */
38
+ const CREATION_EDGE_PREFIX = '[creates] ';
39
+ /**
40
+ * A function "appears to always throw" only when a throw is reachable in its
41
+ * OWN control flow. A `throw` that lives inside a NESTED function expression /
42
+ * arrow / returned closure is NOT the outer function's control flow — the outer
43
+ * function merely RETURNS or PASSES the closure; the throw fires only when the
44
+ * inner callable is later invoked.
45
+ *
46
+ * Two boundaries enforce that, both conservative (genuine always-throw helpers
47
+ * keep firing):
48
+ *
49
+ * 1. **Nested-body boundary.** When an adapter attributes a throw-shaped edge
50
+ * to an enclosing occurrence even though the throw textually lives inside a
51
+ * nested function declared within that occurrence's source span, the edge is
52
+ * not part of the enclosing function's own control flow. We drop any edge
53
+ * whose source line falls inside a nested occurrence's `[line, endLine]`
54
+ * span before testing "every remaining edge is a throw."
55
+ *
56
+ * 2. **Returned-closure boundary.** The flagged occurrence is itself an inline
57
+ * callable that an enclosing scope created (the target of a `[creates] …`
58
+ * edge). A Proxy `get` trap of the form `get() { return () => { throw … } }`
59
+ * produces exactly this: the inner arrow's only edge is the throw, but the
60
+ * arrow is a lazily-throwing closure the trap returns — invoking the trap
61
+ * does NOT throw. We never flag an occurrence that is a created inline
62
+ * callable.
63
+ */
64
+ function collectCreatedInlineCallableHashes(catalog) {
65
+ const created = new Set();
66
+ for (const occurrences of Object.values(catalog.functions)) {
67
+ for (const occ of occurrences) {
68
+ for (const e of occ.calls) {
69
+ if (!e.text.startsWith(CREATION_EDGE_PREFIX))
70
+ continue;
71
+ for (const targetHash of e.to)
72
+ created.add(targetHash);
73
+ }
74
+ }
75
+ }
76
+ return created;
77
+ }
78
+ /**
79
+ * Nested occurrences declared *inside* `outer`'s source span (same file,
80
+ * strictly contained `[line, endLine]`, not `outer` itself). A throw edge whose
81
+ * source line falls within one of these spans belongs to the nested function's
82
+ * control flow, not `outer`'s.
83
+ */
84
+ function nestedSpansWithin(outer, catalog) {
85
+ const spans = [];
86
+ for (const occurrences of Object.values(catalog.functions)) {
87
+ for (const occ of occurrences) {
88
+ if (occ.filePath !== outer.filePath)
89
+ continue;
90
+ if (occ.bodyHash === outer.bodyHash && occ.line === outer.line && occ.column === outer.column)
91
+ continue;
92
+ // Strictly contained within the outer's declaration span.
93
+ if (occ.line >= outer.line && occ.endLine <= outer.endLine && occ.line > outer.line) {
94
+ spans.push({ line: occ.line, endLine: occ.endLine });
95
+ }
96
+ }
97
+ }
98
+ return spans;
99
+ }
100
+ /** True when `edge`'s source line falls inside any nested-function span. */
101
+ function edgeIsInsideNestedFunction(edge, nestedSpans) {
102
+ return nestedSpans.some((s) => edge.line >= s.line && edge.line <= s.endLine);
103
+ }
28
104
  export const alwaysThrowsBranchRule = defineRule({
29
105
  slug: 'graph:always-throws-branch',
30
106
  defaultSeverity: 'warning',
31
- evaluate({ indexes, hints, config }) {
107
+ evaluate({ catalog, indexes, hints, config }) {
32
108
  const throwRegex = hints?.throwSyntaxRegex ?? TYPESCRIPT_FALLBACK_THROW_REGEX;
109
+ const createdInlineCallables = collectCreatedInlineCallableHashes(catalog);
33
110
  const signals = [];
34
111
  for (const occ of indexes.byBodyHash.values()) {
35
112
  if (occ.kind === 'module-init')
@@ -41,11 +118,28 @@ export const alwaysThrowsBranchRule = defineRule({
41
118
  // rule applies.
42
119
  if (occ.inTestFile)
43
120
  continue;
121
+ // Returned-closure boundary: this occurrence is an inline callable that
122
+ // an enclosing scope created and returns/passes. A throw in its body
123
+ // fires only when the closure is later invoked — it is NOT the creating
124
+ // function's control flow (the Proxy-`get`-trap false positive).
125
+ if (createdInlineCallables.has(occ.bodyHash))
126
+ continue;
44
127
  if (occ.calls.length === 0)
45
128
  continue;
46
- // All edges look like throw shapes every documented call site
47
- // is a throw / raise / panic per the adapter's regex.
48
- const everyCallIsThrow = occ.calls.every((e) => throwRegex.test(e.text));
129
+ // Nested-body boundary: drop edges that textually live inside a nested
130
+ // function declared within this occurrence's span they are the nested
131
+ // function's control flow, not this one's.
132
+ const nestedSpans = nestedSpansWithin(occ, catalog);
133
+ const ownControlFlowEdges = nestedSpans.length === 0
134
+ ? occ.calls
135
+ : occ.calls.filter((e) => !edgeIsInsideNestedFunction(e, nestedSpans));
136
+ // If every throw-shaped edge was inside a nested function, this
137
+ // function has no own-control-flow throw — do not flag.
138
+ if (ownControlFlowEdges.length === 0)
139
+ continue;
140
+ // All remaining (own-control-flow) edges look like throw shapes — every
141
+ // documented call site is a throw / raise / panic per the adapter's regex.
142
+ const everyCallIsThrow = ownControlFlowEdges.every((e) => throwRegex.test(e.text));
49
143
  if (!everyCallIsThrow)
50
144
  continue;
51
145
  signals.push(createGraphSignal('graph:always-throws-branch', config, {
@@ -56,7 +150,7 @@ export const alwaysThrowsBranchRule = defineRule({
56
150
  suggestion: 'Inline the throw at every caller, or document the precondition this function enforces.',
57
151
  metadata: {
58
152
  qualifiedName: occ.qualifiedName,
59
- edgeCount: occ.calls.length,
153
+ edgeCount: ownControlFlowEdges.length,
60
154
  },
61
155
  }));
62
156
  }
@@ -1 +1 @@
1
- {"version":3,"file":"always-throws-branch.js","sourceRoot":"","sources":["../../src/rules/always-throws-branch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAI9C,MAAM,+BAA+B,GAAG,iCAAiC,CAAC;AAE1E,MAAM,CAAC,MAAM,sBAAsB,GAAG,UAAU,CAAC;IAC/C,IAAI,EAAE,4BAA4B;IAClC,eAAe,EAAE,SAAS;IAC1B,QAAQ,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE;QACjC,MAAM,UAAU,GAAG,KAAK,EAAE,gBAAgB,IAAI,+BAA+B,CAAC;QAC9E,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,UAAU,CAAC,MAAM,EAAE,EAAE,CAAC;YAC9C,IAAI,GAAG,CAAC,IAAI,KAAK,aAAa;gBAAE,SAAS;YACzC,4DAA4D;YAC5D,kEAAkE;YAClE,oEAAoE;YACpE,qEAAqE;YACrE,gBAAgB;YAChB,IAAI,GAAG,CAAC,UAAU;gBAAE,SAAS;YAC7B,IAAI,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC;gBAAE,SAAS;YACrC,gEAAgE;YAChE,sDAAsD;YACtD,MAAM,gBAAgB,GAAG,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;YACzE,IAAI,CAAC,gBAAgB;gBAAE,SAAS;YAChC,OAAO,CAAC,IAAI,CACV,iBAAiB,CAAC,4BAA4B,EAAE,MAAM,EAAE;gBACtD,QAAQ,EAAE,KAAK;gBACf,QAAQ,EAAE,SAAS;gBACnB,OAAO,EAAE,GAAG,GAAG,CAAC,UAAU,2BAA2B;gBACrD,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE;gBAChE,UAAU,EACR,wFAAwF;gBAC1F,QAAQ,EAAE;oBACR,aAAa,EAAE,GAAG,CAAC,aAAa;oBAChC,SAAS,EAAE,GAAG,CAAC,KAAK,CAAC,MAAM;iBAC5B;aACF,CAAC,CACH,CAAC;QACJ,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC;CACF,CAAC,CAAC"}
1
+ {"version":3,"file":"always-throws-branch.js","sourceRoot":"","sources":["../../src/rules/always-throws-branch.ts"],"names":[],"mappings":"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"}