@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1

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 (228) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/agentic-security.js +111 -1
  3. package/dist/1128.index.js +912 -0
  4. package/dist/1665.index.js +828 -0
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/5752.index.js +466 -0
  8. package/dist/7405.index.js +1 -1
  9. package/dist/8158.index.js +1713 -0
  10. package/dist/8798.index.js +1 -1
  11. package/dist/9382.index.js +2 -2
  12. package/dist/9579.index.js +1 -1
  13. package/dist/agentic-security.mjs +29 -29
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
  16. package/dist/frontend/src/shell.js +12 -2
  17. package/package.json +29 -12
  18. package/src/dataflow/CLAUDE.md +46 -1
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/backward.js +42 -7
  21. package/src/dataflow/cross-service-taint.js +83 -1
  22. package/src/dataflow/demand-query.js +1168 -0
  23. package/src/dataflow/dep-fingerprint.js +718 -0
  24. package/src/dataflow/engine.js +48 -6
  25. package/src/dataflow/exploit-prover.js +6 -2
  26. package/src/dataflow/ifds-precise.js +22 -0
  27. package/src/dataflow/incremental-replay.js +118 -0
  28. package/src/dataflow/incremental-session.js +257 -0
  29. package/src/dataflow/incremental.js +237 -35
  30. package/src/dataflow/index.js +25 -94
  31. package/src/dataflow/ir-cache.js +268 -0
  32. package/src/dataflow/privacy-taint.js +7 -0
  33. package/src/dataflow/summaries.js +72 -6
  34. package/src/dataflow/tabulation.js +80 -24
  35. package/src/engine.js +113 -8
  36. package/src/graph/CLAUDE.md +31 -0
  37. package/src/graph/adapters/artifact.js +81 -0
  38. package/src/graph/adapters/crossrepo.js +75 -0
  39. package/src/graph/adapters/dataflow.js +76 -0
  40. package/src/graph/adapters/provenance.js +92 -0
  41. package/src/graph/adapters/semantic.js +89 -0
  42. package/src/graph/crosswalk-builders.js +152 -0
  43. package/src/graph/crosswalk.js +146 -0
  44. package/src/graph/index.js +15 -0
  45. package/src/graph/observation.js +57 -0
  46. package/src/graph/refs.js +105 -0
  47. package/src/graph/registry.js +129 -0
  48. package/src/graph/relations.js +81 -0
  49. package/src/graph/scc.js +241 -0
  50. package/src/graph/view.js +254 -0
  51. package/src/ir/CLAUDE.md +4 -0
  52. package/src/ir/callgraph.js +36 -8
  53. package/src/ir/index.js +18 -4
  54. package/src/ir/parser-js.js +10 -0
  55. package/src/language/CLAUDE.md +13 -0
  56. package/src/language/nix-option-types.js +170 -0
  57. package/src/language/nix-script-taint.js +46 -3
  58. package/src/language/nix-secrets.js +20 -2
  59. package/src/lineage/CLAUDE.md +72 -8
  60. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  61. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  62. package/src/lineage/boundary-contract.js +273 -0
  63. package/src/lineage/completion.js +205 -0
  64. package/src/lineage/continuation.js +140 -0
  65. package/src/lineage/control-credit.js +313 -0
  66. package/src/lineage/correlation.js +199 -0
  67. package/src/lineage/coverage.js +8 -1
  68. package/src/lineage/cross-repo-link.js +22 -0
  69. package/src/lineage/cut-planner.js +548 -0
  70. package/src/lineage/driver.js +219 -66
  71. package/src/lineage/engine.js +166 -21
  72. package/src/lineage/export-json.js +5 -0
  73. package/src/lineage/graph-builder.js +84 -7
  74. package/src/lineage/haskell-view.js +59 -0
  75. package/src/lineage/index.js +3 -0
  76. package/src/lineage/nix-view.js +35 -0
  77. package/src/lineage/owned-build.js +77 -0
  78. package/src/lineage/owned-worker-entry.js +22 -0
  79. package/src/lineage/owned-worker.js +95 -0
  80. package/src/lineage/path-evidence.js +123 -0
  81. package/src/lineage/path-predicates.js +529 -0
  82. package/src/lineage/path-query.js +189 -20
  83. package/src/lineage/path-store.js +191 -17
  84. package/src/lineage/scc-solver.js +186 -0
  85. package/src/lineage/smt-orchestrator.js +174 -0
  86. package/src/lineage/smt-z3-backend.js +294 -0
  87. package/src/lineage/source-seeding.js +36 -2
  88. package/src/lineage/summaries.js +66 -4
  89. package/src/lineage/value-versions.js +130 -0
  90. package/src/lineage/witness-selection.js +95 -0
  91. package/src/pipeline/scan-health.js +18 -0
  92. package/src/posture/CLAUDE.md +17 -0
  93. package/src/posture/business-logic.js +16 -7
  94. package/src/posture/dag-commands.js +442 -0
  95. package/src/posture/evidence-bundle.js +1 -1
  96. package/src/posture/finding-defaults.js +19 -0
  97. package/src/posture/git-history.js +91 -1
  98. package/src/posture/witness-bundle.js +573 -0
  99. package/src/posture/witness-runtime.js +276 -0
  100. package/src/report/index.js +2 -0
  101. package/src/sast/CLAUDE.md +2 -0
  102. package/src/sast/_call-args.js +70 -0
  103. package/src/sast/init-scope.js +87 -0
  104. package/src/sast/listen-bind-address.js +49 -0
  105. package/src/sast/redirect-target.js +106 -0
  106. package/src/sast/reflected-input.js +76 -0
  107. package/src/sast/send-file-root.js +27 -0
  108. package/src/sast/sensitive-data-logging.js +38 -1
  109. package/dist/119.index.js +0 -197
  110. package/dist/128.index.js +0 -129
  111. package/dist/1293.index.js +0 -5689
  112. package/dist/1336.index.js +0 -16182
  113. package/dist/1351.index.js +0 -133
  114. package/dist/1387.index.js +0 -271
  115. package/dist/1437.index.js +0 -23
  116. package/dist/1445.index.js +0 -110
  117. package/dist/1505.index.js +0 -151
  118. package/dist/1557.index.js +0 -293
  119. package/dist/1621.index.js +0 -311
  120. package/dist/1751.index.js +0 -160
  121. package/dist/1765.index.js +0 -727
  122. package/dist/1861.index.js +0 -690
  123. package/dist/1906.index.js +0 -433
  124. package/dist/208.index.js +0 -1631
  125. package/dist/2223.index.js +0 -159
  126. package/dist/2238.index.js +0 -295
  127. package/dist/2530.index.js +0 -152
  128. package/dist/2565.index.js +0 -116
  129. package/dist/2574.index.js +0 -127
  130. package/dist/2620.index.js +0 -345
  131. package/dist/2773.index.js +0 -98
  132. package/dist/2775.index.js +0 -32
  133. package/dist/2966.index.js +0 -171
  134. package/dist/2972.index.js +0 -87
  135. package/dist/2991.index.js +0 -131
  136. package/dist/3063.index.js +0 -833
  137. package/dist/3075.index.js +0 -227
  138. package/dist/3121.index.js +0 -297
  139. package/dist/3314.index.js +0 -263
  140. package/dist/3480.index.js +0 -1822
  141. package/dist/3481.index.js +0 -24
  142. package/dist/3506.index.js +0 -268
  143. package/dist/3533.index.js +0 -591
  144. package/dist/3653.index.js +0 -450
  145. package/dist/3740.index.js +0 -722
  146. package/dist/3822.index.js +0 -131
  147. package/dist/3868.index.js +0 -292
  148. package/dist/3918.index.js +0 -192
  149. package/dist/4090.index.js +0 -288
  150. package/dist/4113.index.js +0 -1286
  151. package/dist/4121.index.js +0 -1190
  152. package/dist/4142.index.js +0 -690
  153. package/dist/4168.index.js +0 -509
  154. package/dist/4281.index.js +0 -993
  155. package/dist/4336.index.js +0 -436
  156. package/dist/4387.index.js +0 -532
  157. package/dist/4413.index.js +0 -302
  158. package/dist/4476.index.js +0 -4510
  159. package/dist/4491.index.js +0 -133
  160. package/dist/4538.index.js +0 -185
  161. package/dist/4684.index.js +0 -319
  162. package/dist/4740.index.js +0 -165
  163. package/dist/4780.index.js +0 -209
  164. package/dist/4802.index.js +0 -225
  165. package/dist/4812.index.js +0 -379
  166. package/dist/4835.index.js +0 -126
  167. package/dist/494.index.js +0 -226
  168. package/dist/4991.index.js +0 -549
  169. package/dist/5199.index.js +0 -305
  170. package/dist/5216.index.js +0 -109
  171. package/dist/5439.index.js +0 -301
  172. package/dist/5443.index.js +0 -97
  173. package/dist/5496.index.js +0 -377
  174. package/dist/5738.index.js +0 -240
  175. package/dist/5801.index.js +0 -588
  176. package/dist/5817.index.js +0 -714
  177. package/dist/5858.index.js +0 -223
  178. package/dist/5933.index.js +0 -143
  179. package/dist/601.index.js +0 -23
  180. package/dist/6173.index.js +0 -273
  181. package/dist/6291.index.js +0 -1871
  182. package/dist/6312.index.js +0 -666
  183. package/dist/634.index.js +0 -16182
  184. package/dist/644.index.js +0 -87
  185. package/dist/6508.index.js +0 -283
  186. package/dist/6560.index.js +0 -645
  187. package/dist/6576.index.js +0 -422
  188. package/dist/6579.index.js +0 -271
  189. package/dist/6655.index.js +0 -3221
  190. package/dist/6745.index.js +0 -305
  191. package/dist/6818.index.js +0 -21642
  192. package/dist/6932.index.js +0 -1190
  193. package/dist/6972.index.js +0 -167
  194. package/dist/6995.index.js +0 -163
  195. package/dist/7249.index.js +0 -191
  196. package/dist/7372.index.js +0 -1197
  197. package/dist/7546.index.js +0 -541
  198. package/dist/76.index.js +0 -448
  199. package/dist/7879.index.js +0 -163
  200. package/dist/8025.index.js +0 -130
  201. package/dist/8054.index.js +0 -248
  202. package/dist/808.index.js +0 -1330
  203. package/dist/8097.index.js +0 -201
  204. package/dist/8126.index.js +0 -191
  205. package/dist/8178.index.js +0 -78
  206. package/dist/8382.index.js +0 -93
  207. package/dist/8408.index.js +0 -261
  208. package/dist/8540.index.js +0 -174
  209. package/dist/8635.index.js +0 -802
  210. package/dist/8688.index.js +0 -410
  211. package/dist/8699.index.js +0 -779
  212. package/dist/8891.index.js +0 -457
  213. package/dist/8916.index.js +0 -885
  214. package/dist/9146.index.js +0 -170
  215. package/dist/9168.index.js +0 -841
  216. package/dist/9292.index.js +0 -369
  217. package/dist/9422.index.js +0 -866
  218. package/dist/9440.index.js +0 -453
  219. package/dist/9479.index.js +0 -152
  220. package/dist/9481.index.js +0 -220
  221. package/dist/9539.index.js +0 -100
  222. package/dist/9547.index.js +0 -103
  223. package/dist/9692.index.js +0 -90
  224. package/dist/9800.index.js +0 -462
  225. package/dist/9899.index.js +0 -138
  226. package/dist/9981.index.js +0 -206
  227. package/dist/999.index.js +0 -117
  228. package/dist/9999.index.js +0 -1698
@@ -0,0 +1,254 @@
1
+ //
2
+ // view.js: the read-only typed graph view contract (DAG-06 R06.1) and the
3
+ // shared adjacency-index cache (R06.6).
4
+ //
5
+ // A GraphView is a READ-ONLY lens over one existing graph. It never replaces
6
+ // the graph's own schema: every node and edge keeps its original object in
7
+ // `native`, every original id is preserved, and `reconstruct()` hands back the
8
+ // source graph so an adapter round trip loses nothing.
9
+ //
10
+ // The contract (every adapter exposes exactly this):
11
+ // identity { graphKind, repository, graphDigest, adapterVersion }
12
+ // metadata() adapter/graph facts: counts, schema versions, sources
13
+ // completion() { status: 'complete'|'partial'|'unknown', reasons[], coverage }
14
+ // has(ref) boolean, never throws on a well-formed ref
15
+ // node(ref) the entity, or throws DANGLING_REF / WRONG_GRAPH
16
+ // incoming(ref, f) edges ending at ref, restricted by the relation filter
17
+ // outgoing(ref, f) edges starting at ref, restricted by the relation filter
18
+ // reconstruct() the original source graph (round trip)
19
+ //
20
+ // Adjacency indexes are built once per immutable (graphKind, digest, adapter
21
+ // version) and shared by every view over the same content. The index is
22
+ // keyed by LOCAL ids and references are rebuilt per view, so two repositories
23
+ // that happen to share content never share each other's identity.
24
+
25
+ import { createHash } from 'node:crypto';
26
+ import {
27
+ GraphViewError, GRAPH_KINDS, makeRef, refKey, validateRef, REF_VERSION,
28
+ } from './refs.js';
29
+ import { normalizeFilter, edgeMatches, RELATION_KINDS, EDGE_ORIGINS } from './relations.js';
30
+
31
+ const localKey = (entityKind, localId) => `${entityKind}\u0000${localId}`;
32
+
33
+ // ---- adjacency cache -------------------------------------------------------------------------------------------------------
34
+
35
+ const CACHE_LIMIT = 64;
36
+ const _cache = new Map(); // cacheKey -> { fingerprint, index }
37
+ const _stats = { builds: 0, hits: 0, evictions: 0 };
38
+
39
+ export function adjacencyCacheStats() { return { ..._stats, size: _cache.size }; }
40
+ export function clearAdjacencyCache() { _cache.clear(); _stats.builds = 0; _stats.hits = 0; _stats.evictions = 0; }
41
+
42
+ function buildIndex(templates) {
43
+ const out = new Map();
44
+ const inn = new Map();
45
+ const push = (m, k, e) => { const a = m.get(k); if (a) a.push(e); else m.set(k, [e]); };
46
+ const endKey = (end) => (end.local ? localKey(end.entityKind, end.localId) : refKey(end.ref));
47
+ for (const t of templates) {
48
+ push(out, endKey(t.from), t);
49
+ push(inn, endKey(t.to), t);
50
+ }
51
+ return { out, in: inn };
52
+ }
53
+
54
+ function sharedIndex({ graphKind, graphDigest, adapterVersion }, templates, fingerprint) {
55
+ const key = `${graphKind}|${graphDigest}|${adapterVersion}`;
56
+ const hit = _cache.get(key);
57
+ if (hit) {
58
+ if (hit.fingerprint !== fingerprint) {
59
+ // The digest is the cache key because a graph with that digest is immutable.
60
+ // Different content under the same digest means the digest was not computed
61
+ // from the content, and serving the cached index would be a silent wrong answer.
62
+ throw new GraphViewError('DIGEST_CONFLICT', `graph digest ${graphDigest} was seen with different content`, { key });
63
+ }
64
+ _stats.hits += 1;
65
+ _cache.delete(key); _cache.set(key, hit); // LRU touch
66
+ return hit.index;
67
+ }
68
+ const index = buildIndex(templates);
69
+ _stats.builds += 1;
70
+ _cache.set(key, { fingerprint, index });
71
+ if (_cache.size > CACHE_LIMIT) {
72
+ _cache.delete(_cache.keys().next().value);
73
+ _stats.evictions += 1;
74
+ }
75
+ return index;
76
+ }
77
+
78
+ // ---- edge templates --------------------------------------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Adapters describe edges with `local(entityKind, localId)` endpoints for
82
+ * entities of their own graph, or `foreign(ref)` for an endpoint that lives in
83
+ * another graph (cross-repository declarations).
84
+ */
85
+ export const local = (entityKind, localId) => ({ local: true, entityKind, localId });
86
+ export const foreign = (ref) => {
87
+ const bad = validateRef(ref);
88
+ if (bad) throw new GraphViewError('BAD_REF', bad);
89
+ return { local: false, ref };
90
+ };
91
+
92
+ function checkTemplate(t) {
93
+ if (!t || typeof t.id !== 'string' || !t.id) throw new GraphViewError('BAD_EDGE', 'an edge needs an id');
94
+ if (!RELATION_KINDS.includes(t.relation)) throw new GraphViewError('BAD_EDGE', `edge ${t.id}: unknown relation ${JSON.stringify(t.relation)}`);
95
+ if (!EDGE_ORIGINS.includes(t.origin)) throw new GraphViewError('BAD_EDGE', `edge ${t.id}: unknown origin ${JSON.stringify(t.origin)}`);
96
+ if (!t.from || !t.to) throw new GraphViewError('BAD_EDGE', `edge ${t.id}: both endpoints are required`);
97
+ }
98
+
99
+ // ---- the view --------------------------------------------------------------------------------------------------------------
100
+
101
+ export class IndexedGraphView {
102
+ /**
103
+ * @param {object} spec
104
+ * @param {string} spec.graphKind
105
+ * @param {string} spec.adapterVersion semver of the adapter, not of the graph schema
106
+ * @param {string} spec.repository
107
+ * @param {string} spec.graphDigest digest of the SOURCE graph, preserved as given
108
+ * @param {object} spec.metadata
109
+ * @param {object} spec.completion { status, reasons, coverage }
110
+ * @param {{entityKind:string, localId:string, native:any, summary?:object}[]} spec.nodes
111
+ * @param {object[]} spec.edges edge templates (see local()/foreign())
112
+ * @param {() => any} spec.reconstruct returns the original source graph
113
+ * @param {boolean} [spec.allowForeign] endpoints may live in other graphs
114
+ */
115
+ constructor(spec) {
116
+ if (!GRAPH_KINDS.includes(spec.graphKind)) throw new GraphViewError('BAD_REF', `unknown graph kind ${spec.graphKind}`);
117
+ if (!/^\d+\.\d+\.\d+$/.test(spec.adapterVersion || '')) throw new GraphViewError('BAD_VERSION', 'adapterVersion must be a semantic version');
118
+ for (const f of ['repository', 'graphDigest']) {
119
+ if (typeof spec[f] !== 'string' || !spec[f]) throw new GraphViewError('BAD_REF', `${f} is required`);
120
+ }
121
+ this.graphKind = spec.graphKind;
122
+ this.adapterVersion = spec.adapterVersion;
123
+ this.repository = spec.repository;
124
+ this.graphDigest = spec.graphDigest;
125
+ this._metadata = spec.metadata ?? {};
126
+ this._completion = spec.completion ?? { status: 'unknown', reasons: ['adapter supplied no completion record'], coverage: null };
127
+ this._reconstruct = spec.reconstruct;
128
+ this._allowForeign = !!spec.allowForeign;
129
+
130
+ this._nodes = new Map();
131
+ for (const n of spec.nodes) {
132
+ const k = localKey(n.entityKind, n.localId);
133
+ if (this._nodes.has(k)) throw new GraphViewError('DUPLICATE_ID', `${this.graphKind} graph has two ${n.entityKind} entities with id ${n.localId}`, { entityKind: n.entityKind, localId: n.localId });
134
+ this._nodes.set(k, n);
135
+ }
136
+ this._templates = [];
137
+ const seen = new Set();
138
+ for (const t of spec.edges) {
139
+ checkTemplate(t);
140
+ if (seen.has(t.id)) throw new GraphViewError('DUPLICATE_ID', `${this.graphKind} graph has two edges with id ${t.id}`, { edgeId: t.id });
141
+ seen.add(t.id);
142
+ for (const end of [t.from, t.to]) {
143
+ if (end.local && !this._nodes.has(localKey(end.entityKind, end.localId))) {
144
+ throw new GraphViewError('DANGLING_REF', `edge ${t.id} points at ${end.entityKind} ${end.localId}, which is not in the graph`, { edgeId: t.id });
145
+ }
146
+ if (!end.local && !this._allowForeign) throw new GraphViewError('BAD_EDGE', `edge ${t.id}: foreign endpoints are only allowed in declaration views`);
147
+ }
148
+ this._templates.push(t);
149
+ }
150
+ // Content fingerprint over every entity key and edge id (order-insensitive). A
151
+ // digest names one immutable graph; the fingerprint is how a digest that does
152
+ // NOT match its content is caught instead of served from the shared index.
153
+ const h = createHash('sha256');
154
+ for (const k of [...this._nodes.keys()].sort()) h.update(k).update('\u0001');
155
+ h.update('\u0002');
156
+ for (const t of [...this._templates].map((x) => x.id).sort()) h.update(t).update('\u0001');
157
+ this.fingerprint = h.digest('hex');
158
+ this._index = sharedIndex(this.identity, this._templates, this.fingerprint);
159
+ }
160
+
161
+ get identity() {
162
+ return { graphKind: this.graphKind, repository: this.repository, graphDigest: this.graphDigest, adapterVersion: this.adapterVersion };
163
+ }
164
+
165
+ /** Reference to one of this graph's own entities. Throws DANGLING_REF if it is not there. */
166
+ ref(entityKind, localId) {
167
+ if (!this._nodes.has(localKey(entityKind, localId))) {
168
+ throw new GraphViewError('DANGLING_REF', `no ${entityKind} ${localId} in this ${this.graphKind} graph`, { entityKind, localId });
169
+ }
170
+ return makeRef({ graphKind: this.graphKind, repository: this.repository, graphDigest: this.graphDigest, entityKind, localId });
171
+ }
172
+
173
+ owns(ref) {
174
+ return ref.graphKind === this.graphKind && ref.repository === this.repository && ref.graphDigest === this.graphDigest;
175
+ }
176
+
177
+ _checkRef(ref) {
178
+ const bad = validateRef(ref);
179
+ if (bad) {
180
+ throw new GraphViewError(ref && ref.v !== undefined && ref.v !== REF_VERSION ? 'VERSION_INCOMPATIBLE' : 'BAD_REF', bad, { ref });
181
+ }
182
+ }
183
+
184
+ has(ref) {
185
+ this._checkRef(ref);
186
+ return this.owns(ref) && this._nodes.has(localKey(ref.entityKind, ref.localId));
187
+ }
188
+
189
+ node(ref) {
190
+ this._checkRef(ref);
191
+ if (!this.owns(ref)) {
192
+ throw new GraphViewError('WRONG_GRAPH', `reference points into ${ref.graphKind}/${ref.repository}/${ref.graphDigest.slice(0, 12)}, not this view`, { ref });
193
+ }
194
+ const n = this._nodes.get(localKey(ref.entityKind, ref.localId));
195
+ if (!n) throw new GraphViewError('DANGLING_REF', `no ${ref.entityKind} ${ref.localId} in this ${this.graphKind} graph`, { ref });
196
+ return { ref, entityKind: n.entityKind, localId: n.localId, summary: n.summary ?? null, native: n.native };
197
+ }
198
+
199
+ _lookup(map, ref) {
200
+ this._checkRef(ref);
201
+ let key;
202
+ if (this.owns(ref)) {
203
+ key = localKey(ref.entityKind, ref.localId);
204
+ if (!this._nodes.has(key)) throw new GraphViewError('DANGLING_REF', `no ${ref.entityKind} ${ref.localId} in this ${this.graphKind} graph`, { ref });
205
+ } else if (this._allowForeign) {
206
+ key = refKey(ref);
207
+ } else {
208
+ throw new GraphViewError('WRONG_GRAPH', 'reference points into a different graph', { ref });
209
+ }
210
+ return map.get(key) ?? [];
211
+ }
212
+
213
+ _materialize(t) {
214
+ const end = (e) => (e.local ? this.ref(e.entityKind, e.localId) : e.ref);
215
+ return {
216
+ id: t.id, relation: t.relation, origin: t.origin,
217
+ from: end(t.from), to: end(t.to),
218
+ evidence: t.evidence ?? { grade: 'none', source: null },
219
+ unknowns: t.unknowns ?? [],
220
+ attrs: t.attrs ?? {},
221
+ native: t.native,
222
+ };
223
+ }
224
+
225
+ outgoing(ref, filter) {
226
+ const nf = normalizeFilter(filter);
227
+ return this._lookup(this._index.out, ref).filter((t) => edgeMatches(t, nf)).map((t) => this._materialize(t));
228
+ }
229
+
230
+ incoming(ref, filter) {
231
+ const nf = normalizeFilter(filter);
232
+ return this._lookup(this._index.in, ref).filter((t) => edgeMatches(t, nf)).map((t) => this._materialize(t));
233
+ }
234
+
235
+ /** Entities in insertion order, optionally of one kind. */
236
+ entities(entityKind) {
237
+ const out = [];
238
+ for (const n of this._nodes.values()) if (!entityKind || n.entityKind === entityKind) out.push(this.node(this.ref(n.entityKind, n.localId)));
239
+ return out;
240
+ }
241
+
242
+ edges() { return this._templates.map((t) => this._materialize(t)); }
243
+
244
+ metadata() {
245
+ return { ...this._metadata, graphKind: this.graphKind, repository: this.repository, graphDigest: this.graphDigest, adapterVersion: this.adapterVersion, nodeCount: this._nodes.size, edgeCount: this._templates.length };
246
+ }
247
+
248
+ completion() {
249
+ const c = this._completion;
250
+ return { status: c.status, reasons: [...(c.reasons ?? [])], coverage: c.coverage ?? null };
251
+ }
252
+
253
+ reconstruct() { return this._reconstruct(); }
254
+ }
package/src/ir/CLAUDE.md CHANGED
@@ -267,6 +267,10 @@ down enterprise runners sometimes don't. Targets for retirement:
267
267
  interprocedural analysis, not just the older, vaguer "some constructs get
268
268
  dropped" cost.
269
269
 
270
+ ## Cross-scan IR cache and node ids
271
+
272
+ `buildProjectIR(files, { irCache })` and `buildProjectIRAsync` accept an optional cache object (`dataflow/ir-cache.js`, opened by `engine.js` only when the cross-scan cache is on): an unchanged JavaScript or TypeScript file is read back instead of parsed, and a new parse is stored. With no cache the behaviour is exactly as before. Node ids (`n123`) come from a process-global counter in `parser-js.js`; a restored file is renumbered from the same counter through `reserveNodeIds(count)` so ids stay unique across the process. The ids sit in `cfg.entry`, `cfg.exit`, the `cfg.nodes` keys and each node's `id`/`succ`/`pred`, `fn.returns`, `fn.calls[].site`, `fn.writes[*][].node` and `fn.reads[*][]`; if a parser change puts an id anywhere else, the store-time check refuses to cache that file (it counts as `skipped` in the cache's `stats()`, and the file is parsed on every scan), so a new id-bearing field costs speed, never correctness. Name resolution in `callgraph.js` is answered from a name-to-candidates index (`qidsByName`, built in the order the per-file tables were built), so a name that no file defines is a lookup, not a scan of every file.
273
+
270
274
  ## Semantic graph (`semantic-graph.js`, engineering-excellence E2-01)
271
275
 
272
276
  An additive, typed, versioned view over this IR: `Symbol`, `CallSite`, `Allocation`, `FieldAccess`, `ValueOrigin`, plus the schema-only lists `ServiceBoundary`, `StorageEdge`, `Sanitizer`, `Guard`. Ids are content-derived and never include a line, so a blank line above a function moves no id. It reads the CFG (the one structure every language's IR shares; `writes` exists for JavaScript only). Nothing on the scan path imports it, so findings and `chain[]` cannot change; a test pins that. Columns are `null` (the IR has lines only) and say `locationPrecision: "line"`. Contract and the measured per-language construct matrix: `docs/engineering-excellence/IR_CONTRACT.md` (regenerate with `node scripts/engineering-excellence/ir-matrix.mjs`). Do not add a `site` field: IR site ids come from a process-global counter and are not stable across builds.
@@ -272,6 +272,32 @@ export function buildCallGraph(perFileIR, fileContents) {
272
272
  // `File::read` in C++): a fabricated cross-language edge, reached through
273
273
  // these older, coarser lookups BEFORE the qname-specific gate further down
274
274
  // is ever consulted.
275
+ // Name -> candidate qids across all files, in the order the file tables were
276
+ // built. The permissive lookups below used to walk EVERY file's table for each
277
+ // name that was not found locally, which is every call to a library method
278
+ // (`push`, `get`, `map`): thousands of names times hundreds of files. Visiting
279
+ // only the tables that hold the name, in the same order, returns the same
280
+ // first usable candidate.
281
+ const qidsByName = new Map();
282
+ for (const m of byNameInFile.values()) {
283
+ for (const [nm, q] of m) {
284
+ let l = qidsByName.get(nm);
285
+ if (!l) { l = []; qidsByName.set(nm, l); }
286
+ l.push(q);
287
+ }
288
+ }
289
+ // Re-export entries that can match a name (the key ends with `::<name>`, or the
290
+ // source name equals it), by index into the insertion order.
291
+ const reexportEntries = [...reexportMap];
292
+ const reexportCandidates = new Map();
293
+ if (reexportEntries.length) {
294
+ const addCand = (nm, i) => { let l = reexportCandidates.get(nm); if (!l) { l = new Set(); reexportCandidates.set(nm, l); } l.add(i); };
295
+ reexportEntries.forEach(([key, v], i) => {
296
+ let at = key.indexOf('::');
297
+ while (at >= 0) { addCand(key.slice(at + 2), i); at = key.indexOf('::', at + 1); }
298
+ addCand(v.sourceName, i);
299
+ });
300
+ }
275
301
  function isCrossLanguageUnsafe(qid, callerFile) {
276
302
  const cand = qid && functions.get(qid);
277
303
  if (!cand || !cand.qname) return false;
@@ -339,23 +365,25 @@ export function buildCallGraph(perFileIR, fileContents) {
339
365
  if (viaThis) return viaThis;
340
366
  }
341
367
  }
342
- for (const m of byNameInFile.values()) {
343
- if (m.has(name) && !isCrossLanguageUnsafe(m.get(name), callerFile)) return m.get(name);
368
+ for (const q of qidsByName.get(name) || []) {
369
+ if (!isCrossLanguageUnsafe(q, callerFile)) return q;
344
370
  }
345
371
  if (classMethods.has(name) && !isCrossLanguageUnsafe(classMethods.get(name), callerFile)) {
346
372
  return classMethods.get(name);
347
373
  }
348
374
  if (allowTailGuess && name.includes('.')) {
349
375
  const tail = name.split('.').slice(-1)[0];
350
- for (const m of byNameInFile.values()) {
351
- if (m.has(tail) && !isCrossLanguageUnsafe(m.get(tail), callerFile)) return m.get(tail);
376
+ for (const q of qidsByName.get(tail) || []) {
377
+ if (!isCrossLanguageUnsafe(q, callerFile)) return q;
352
378
  }
353
379
  }
354
380
  // Follow re-exports: if name was re-exported from another file, resolve there
355
- for (const [key, { sourceName }] of reexportMap) {
356
- if (key.endsWith(`::${name}`) || (sourceName === name)) {
357
- for (const m of byNameInFile.values()) {
358
- if (m.has(sourceName) && !isCrossLanguageUnsafe(m.get(sourceName), callerFile)) return m.get(sourceName);
381
+ const reCands = reexportCandidates.get(name);
382
+ if (reCands) {
383
+ for (const i of [...reCands].sort((a, b) => a - b)) {
384
+ const { sourceName } = reexportEntries[i][1];
385
+ for (const q of qidsByName.get(sourceName) || []) {
386
+ if (!isCrossLanguageUnsafe(q, callerFile)) return q;
359
387
  }
360
388
  }
361
389
  }
package/src/ir/index.js CHANGED
@@ -132,10 +132,23 @@ function _parsePythonFiles(pyEntries) {
132
132
  return pyEntries.map(({ file, content }) => parsePythonFileRegex(file, content)).filter(Boolean);
133
133
  }
134
134
 
135
+ // JavaScript / TypeScript parse through the optional cross-scan IR cache (dataflow/ir-cache.js): an unchanged
136
+ // file is read back with fresh node ids instead of parsed again, and a new parse is stored. `irCache` is an
137
+ // injected object ({load, store}); with none, this is exactly parseJsFile.
138
+ function parseJsCached(file, code, irCache) {
139
+ if (!irCache) return parseJsFile(file, code);
140
+ const hit = irCache.load(file, code);
141
+ if (hit) return hit;
142
+ const ir = parseJsFile(file, code);
143
+ if (ir) irCache.store(file, code, ir);
144
+ return ir;
145
+ }
146
+
135
147
  // Synchronous default — JS/TS + Python only. Engine.js calls this directly.
136
148
  // Java IR requires async import of java-parser; callers who want it can use
137
149
  // buildProjectIRAsync instead.
138
- export function buildProjectIR(fileContents) {
150
+ export function buildProjectIR(fileContents, opts = {}) {
151
+ const irCache = opts && opts.irCache ? opts.irCache : null;
139
152
  const perFile = {};
140
153
  const pyBatch = [];
141
154
  const hsFiles = {};
@@ -148,7 +161,7 @@ export function buildProjectIR(fileContents) {
148
161
  // null — it does not zero out every other language's coverage.
149
162
  try {
150
163
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) {
151
- const ir = parseJsFile(file, code);
164
+ const ir = parseJsCached(file, code, irCache);
152
165
  if (ir) perFile[file] = ir;
153
166
  } else if (/\.hs$/i.test(file)) {
154
167
  hsFiles[file] = code; // lowered together: Haskell resolution is whole-program (imports, re-exports)
@@ -238,7 +251,8 @@ function withHaskellCallGraph(cg, hsCg) {
238
251
  }
239
252
 
240
253
  // Async variant — includes Java IR via java-parser.
241
- export async function buildProjectIRAsync(fileContents) {
254
+ export async function buildProjectIRAsync(fileContents, opts = {}) {
255
+ const irCache = opts && opts.irCache ? opts.irCache : null;
242
256
  const perFile = {};
243
257
  const pyBatch = [];
244
258
  const hsFiles = {};
@@ -247,7 +261,7 @@ export async function buildProjectIRAsync(fileContents) {
247
261
  // file can't abort IR construction for the whole project.
248
262
  try {
249
263
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) {
250
- const ir = parseJsFile(file, code);
264
+ const ir = parseJsCached(file, code, irCache);
251
265
  if (ir) perFile[file] = ir;
252
266
  } else if (/\.hs$/i.test(file)) {
253
267
  hsFiles[file] = code;
@@ -46,6 +46,16 @@ import presetTypescript from '@babel/preset-typescript';
46
46
 
47
47
  let _nodeIdSeq = 0;
48
48
  function nextNodeId() { return 'n' + (++_nodeIdSeq); }
49
+ /**
50
+ * Reserve `count` consecutive node ids and return the sequence value before them
51
+ * (the first reserved id is `n<base + 1>`). A cached IR is given fresh ids from
52
+ * here, so an id is as unique across the whole process as one the parser made.
53
+ */
54
+ export function reserveNodeIds(count) {
55
+ const base = _nodeIdSeq;
56
+ _nodeIdSeq += count;
57
+ return base;
58
+ }
49
59
 
50
60
  // Round-5 lineage-engine finding, generalized in round 6: resolve a Babel
51
61
  // object-property-shaped node's key (`ObjectProperty` from either an
@@ -96,6 +96,19 @@ Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell
96
96
  - **Bounded**: `maxEvaluations`, `maxExprDepth`, `maxValueSize` are caps that surface as `truncated`, not as a guess.
97
97
  - Assumption stated, not proven: `lib` is nixpkgs' `lib` whenever the module declares a `lib` parameter and the file does not rebind it.
98
98
 
99
+ ## Declared option types (`nix-option-types.js`)
100
+
101
+ `buildOptionTypeIndex` reads `mkOption { type = ...; }`, `mkEnableOption` and `mkPackageOption` declarations from the files in scope and
102
+ records, per dotted option path, whether the declared type BOUNDS the value (bool, int and `ints.*`, port, float, number, package, a
103
+ literal-word `enum`, and `nullOr`/`either`/`uniq` of bounded types). `nix-script-taint.js` `Evaluator.refine` turns a reference to a
104
+ bounded option into a constant, so a shell interpolation of an `int` option is not an injection and a `bool` option called `forwardPass`
105
+ is not a credential. Rules to keep: only options DECLARED in scope are known (an upstream option keeps its origin, except that the
106
+ module-system convention for a package option, `package`/`finalPackage`/`fooPackage`, is a store path and a declared option of that name
107
+ with an unbounded type is NOT); two declarations of one option are bounded only if both are; `str`, `path`, `lines`, `listOf`,
108
+ `submodule` and anything computed are unbounded. `nix-secrets.js` skips documentation attributes of `mkOption` (`example`,
109
+ `description`, `defaultText`; `default` is configuration and is not skipped) and does not treat a set-valued binding as a credential.
110
+ Pinned both ways in `test/nix/nix-option-types.test.js`.
111
+
99
112
  ## Tests and benches
100
113
 
101
114
  | Scope | Command | Notes |
@@ -0,0 +1,170 @@
1
+ // Declared option types, read from the module sources that are in scope.
2
+ //
3
+ // A NixOS or Home Manager option is declared with `mkOption { type = ...; }`
4
+ // (or `mkEnableOption`, `mkPackageOption`). The module system checks a value
5
+ // against that type before the module's `config` is used, so the TYPE bounds
6
+ // what a reference to the option can contain, whatever the name suggests:
7
+ //
8
+ // bool, int, ints.*, port, float, number digits, `0`/`1`, a sign or a dot
9
+ // package, shellPackage a store path (`/nix/store/<hash>-<name>`),
10
+ // whose name part is restricted by Nix to
11
+ // [A-Za-z0-9+._?=-]
12
+ // enum [ "a" "b" ] one of the listed literals
13
+ // nullOr T, either A B, uniq T only when every member is itself bounded
14
+ //
15
+ // `str`, `path`, `lines`, `attrs`, `listOf`, `submodule`, an unknown or computed
16
+ // type are all UNBOUNDED here: they can carry any characters, so nothing is
17
+ // inferred from them.
18
+ //
19
+ // Two consumers use the answer in opposite directions, and both are sound for
20
+ // the same reason:
21
+ // * the script-injection analysis treats a reference to a bounded option as
22
+ // a constant, because no value of that type contains a shell metacharacter;
23
+ // * the secret analysis does not treat a reference to a bounded option as a
24
+ // credential, because a bool, a number, a package path or a choice among
25
+ // listed words does not hold a secret, whatever the option is called
26
+ // (`cacheCredentials`, `forwardPass`, `wheelNeedsPassword`).
27
+ //
28
+ // Only options DECLARED in the files handed to the analyzer are known. An
29
+ // option declared elsewhere (upstream NixOS) has no entry, so it keeps the
30
+ // previous behaviour: unknown stays conservative.
31
+
32
+ const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
33
+ const segName = (s) => (s && s.kind === 'static' ? s.name : null);
34
+ const attrKey = (path) => (path || []).map(segName);
35
+
36
+ const BOUNDED_LEAF = new Set(['bool', 'int', 'float', 'number', 'port', 'package', 'shellPackage', 'unsigned', 'positive', 'u8', 'u16', 'u32', 's8', 's16', 's32', 'between']);
37
+ const BOUNDED_NAMESPACES = new Set(['ints', 'numbers']);
38
+ const SAFE_ENUM_MEMBER = /^[A-Za-z0-9_.+-]*$/;
39
+
40
+ function flatten(n) {
41
+ const args = []; let fn = unparen(n);
42
+ while (fn && fn.type === 'app') { args.unshift(fn.arg); fn = unparen(fn.fn); }
43
+ return { fn, args };
44
+ }
45
+
46
+ function calleeName(fn) {
47
+ const f = unparen(fn);
48
+ if (!f) return null;
49
+ if (f.type === 'ident') return f.name;
50
+ if (f.type === 'select') { const segs = f.attrpath.map(segName); return segs[segs.length - 1] || null; }
51
+ return null;
52
+ }
53
+
54
+ function selectSegments(n) {
55
+ const segs = n.attrpath.map(segName);
56
+ return segs.every((s) => s !== null) ? segs : null;
57
+ }
58
+
59
+ /** Classify a `type = ...` expression. Returns { kind, bounded }. */
60
+ export function classifyOptionType(expr, depth = 0) {
61
+ const n = unparen(expr);
62
+ if (!n || depth > 8) return { kind: 'unknown', bounded: false };
63
+ if (n.type === 'ident') return { kind: n.name, bounded: BOUNDED_LEAF.has(n.name) };
64
+ if (n.type === 'select') {
65
+ const segs = selectSegments(n);
66
+ if (!segs) return { kind: 'unknown', bounded: false };
67
+ const last = segs[segs.length - 1];
68
+ if (segs.some((s) => BOUNDED_NAMESPACES.has(s))) return { kind: `${segs.slice(-2).join('.')}`, bounded: true };
69
+ return { kind: last, bounded: BOUNDED_LEAF.has(last) };
70
+ }
71
+ if (n.type === 'app') {
72
+ const { fn, args } = flatten(n);
73
+ const name = calleeName(fn);
74
+ const fnSel = unparen(fn);
75
+ if (fnSel && fnSel.type === 'select') {
76
+ const segs = selectSegments(fnSel);
77
+ if (segs && segs.some((s) => BOUNDED_NAMESPACES.has(s))) return { kind: segs.slice(-2).join('.'), bounded: true };
78
+ }
79
+ if (name === 'nullOr' || name === 'uniq' || name === 'unique') return args[0] ? classifyOptionType(args[0], depth + 1) : { kind: name, bounded: false };
80
+ if (name === 'either') {
81
+ const parts = args.slice(0, 2).map((a) => classifyOptionType(a, depth + 1));
82
+ return { kind: 'either', bounded: parts.length === 2 && parts.every((p) => p.bounded) };
83
+ }
84
+ if (name === 'enum') {
85
+ const list = unparen(args[0]);
86
+ if (list && list.type === 'list') {
87
+ const members = list.items.map((i) => unparen(i));
88
+ const ok = members.length > 0 && members.every((m) => m && m.type === 'string' && !m.interpolated && SAFE_ENUM_MEMBER.test(m.literal || ''));
89
+ return { kind: 'enum', bounded: ok };
90
+ }
91
+ return { kind: 'enum', bounded: false };
92
+ }
93
+ return { kind: name || 'unknown', bounded: false };
94
+ }
95
+ return { kind: 'unknown', bounded: false };
96
+ }
97
+
98
+ function declarationOf(valueNode) {
99
+ const v = unparen(valueNode);
100
+ if (!v || v.type !== 'app') return null;
101
+ const { fn, args } = flatten(v);
102
+ const name = calleeName(fn);
103
+ if (name === 'mkEnableOption') return { kind: 'bool', bounded: true };
104
+ if (name === 'mkPackageOption' || name === 'mkPackageOptionMD') return { kind: 'package', bounded: true };
105
+ if (name === 'mkOption') {
106
+ const a = unparen(args[0]);
107
+ if (!a || a.type !== 'attrset') return { kind: 'unknown', bounded: false };
108
+ for (const b of a.bindings || []) {
109
+ if (b.kind !== 'attr') continue;
110
+ const k = attrKey(b.path);
111
+ if (k.length === 1 && k[0] === 'type') return classifyOptionType(b.value);
112
+ }
113
+ return { kind: 'untyped', bounded: false };
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function record(index, key, decl) {
119
+ const prev = index.get(key);
120
+ // two declarations of one option: bounded only if every declaration is
121
+ index.set(key, prev ? { kind: prev.kind, bounded: prev.bounded && decl.bounded } : decl);
122
+ }
123
+
124
+ function collect(node, prefix, index, depth) {
125
+ if (!node || typeof node !== 'object' || depth > 200) return;
126
+ if (Array.isArray(node)) { for (const x of node) collect(x, prefix, index, depth + 1); return; }
127
+ if (node.type === 'attrset') {
128
+ for (const b of node.bindings || []) {
129
+ if (b.kind !== 'attr' || !b.value) continue;
130
+ const keys = attrKey(b.path);
131
+ if (keys.some((k) => k === null)) { collect(b.value, null, index, depth + 1); continue; }
132
+ let full = null;
133
+ if (prefix !== null) full = [...prefix, ...keys];
134
+ else if (keys[0] === 'options') full = keys.slice(1);
135
+ if (full === null) { collect(b.value, null, index, depth + 1); continue; }
136
+ const decl = full.length ? declarationOf(b.value) : null;
137
+ if (decl) { record(index, full.join('.'), decl); continue; }
138
+ const v = unparen(b.value);
139
+ if (v && v.type === 'attrset') collect(v, full, index, depth + 1);
140
+ else collect(b.value, null, index, depth + 1);
141
+ }
142
+ return;
143
+ }
144
+ for (const [k, c] of Object.entries(node)) {
145
+ if (k === 'span' || k === 'loc') continue;
146
+ if (c && typeof c === 'object') collect(c, prefix === null ? null : null, index, depth + 1);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Index every option declared in the parsed files: dotted path -> { kind, bounded }.
152
+ * `parsed` is an iterable of ASTs.
153
+ */
154
+ export function buildOptionTypeIndex(parsed) {
155
+ const index = new Map();
156
+ for (const ast of parsed) collect(ast, null, index, 0);
157
+ return index;
158
+ }
159
+
160
+ /**
161
+ * Is the option behind an origin detail (`option config.a.b.c`) declared with a
162
+ * bounded type? Returns the declaration, or null when it is unknown or unbounded.
163
+ */
164
+ export function boundedOptionFor(index, detail) {
165
+ if (!index || typeof detail !== 'string') return null;
166
+ const m = /^option (?:config|osConfig)\.(.+)$/.exec(detail);
167
+ if (!m) return null;
168
+ const d = index.get(m[1]);
169
+ return d && d.bounded ? d : null;
170
+ }