@clear-capabilities/agentic-security-scanner 0.153.1 → 0.154.0

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 (136) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/bin/agentic-security.js +123 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/2862.index.js +325 -0
  6. package/dist/3499.index.js +5 -1
  7. package/dist/3531.index.js +261 -0
  8. package/dist/3596.index.js +103 -0
  9. package/dist/4113.index.js +15 -3
  10. package/dist/4171.index.js +319 -0
  11. package/dist/5637.index.js +10 -4
  12. package/dist/6359.index.js +433 -0
  13. package/dist/7838.index.js +15 -3
  14. package/dist/8551.index.js +1164 -0
  15. package/dist/8587.index.js +410 -0
  16. package/dist/8714.index.js +98 -0
  17. package/dist/8798.index.js +218 -0
  18. package/dist/9220.index.js +35 -3
  19. package/dist/9560.index.js +26 -3
  20. package/dist/agentic-security.mjs +51 -51
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +18 -3
  23. package/src/dataflow/CLAUDE.md +9 -0
  24. package/src/dataflow/catalog-haskell.js +37 -0
  25. package/src/dataflow/catalog.js +4 -0
  26. package/src/dataflow/engine.js +33 -3
  27. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  28. package/src/dataflow/privacy-catalog.js +5 -1
  29. package/src/discovery/CLAUDE.md +6 -0
  30. package/src/discovery/index.js +12 -3
  31. package/src/discovery/lenses.js +4 -1
  32. package/src/discovery/partition.js +28 -0
  33. package/src/egress/redact.js +8 -0
  34. package/src/engine.js +140 -19
  35. package/src/ir/index.js +39 -2
  36. package/src/language/CLAUDE.md +74 -0
  37. package/src/language/accuracy.js +90 -0
  38. package/src/language/aibom.js +307 -0
  39. package/src/language/assurance.js +193 -0
  40. package/src/language/bom-validate.js +227 -0
  41. package/src/language/bom.js +236 -0
  42. package/src/language/bridges.js +392 -0
  43. package/src/language/compliance-map.js +61 -0
  44. package/src/language/context.js +199 -0
  45. package/src/language/contracts.js +339 -0
  46. package/src/language/discovery.js +359 -0
  47. package/src/language/engine-pass.js +120 -0
  48. package/src/language/fix-lifecycle.js +120 -0
  49. package/src/language/haskell-adapter.js +51 -0
  50. package/src/language/haskell-disclosure.js +170 -0
  51. package/src/language/haskell-findings.js +44 -0
  52. package/src/language/haskell-fix.js +409 -0
  53. package/src/language/haskell-grammar.js +78 -0
  54. package/src/language/haskell-guards.js +382 -0
  55. package/src/language/haskell-ir.js +1377 -0
  56. package/src/language/haskell-llm.js +128 -0
  57. package/src/language/haskell-manifests.js +1290 -0
  58. package/src/language/haskell-models.js +278 -0
  59. package/src/language/haskell-parser.js +1073 -0
  60. package/src/language/haskell-resolved-graph.js +414 -0
  61. package/src/language/haskell-sca.js +387 -0
  62. package/src/language/haskell-security-rules.js +332 -0
  63. package/src/language/haskell-supply.js +140 -0
  64. package/src/language/haskell-sweep.js +85 -0
  65. package/src/language/haskell-syntax.js +895 -0
  66. package/src/language/haskell-web.js +749 -0
  67. package/src/language/nix-adapter.js +42 -0
  68. package/src/language/nix-agents.js +155 -0
  69. package/src/language/nix-build-trust.js +790 -0
  70. package/src/language/nix-closure.js +331 -0
  71. package/src/language/nix-eval-isolation.js +274 -0
  72. package/src/language/nix-fix.js +268 -0
  73. package/src/language/nix-grammar.js +97 -0
  74. package/src/language/nix-inventory.js +379 -0
  75. package/src/language/nix-ir.js +642 -0
  76. package/src/language/nix-parser.js +908 -0
  77. package/src/language/nix-privacy.js +114 -0
  78. package/src/language/nix-sca.js +358 -0
  79. package/src/language/nix-script-taint.js +885 -0
  80. package/src/language/nix-secrets.js +396 -0
  81. package/src/language/nixos-hardening.js +564 -0
  82. package/src/language/nixos-module-resolver.js +595 -0
  83. package/src/language/nixos-option-catalog.js +96 -0
  84. package/src/language/pragma.js +150 -0
  85. package/src/language/resolved-pass.js +146 -0
  86. package/src/language/secrets.js +323 -0
  87. package/src/language/state-artifacts.js +78 -0
  88. package/src/language/support-registry.js +224 -0
  89. package/src/language/witness.js +185 -0
  90. package/src/lineage/CLAUDE.md +8 -0
  91. package/src/lineage/coverage.js +2 -0
  92. package/src/lineage/cross-repo-link.js +22 -0
  93. package/src/lineage/graph-builder.js +5 -1
  94. package/src/lineage/haskell-view.js +319 -0
  95. package/src/lineage/index.js +8 -1
  96. package/src/lineage/nix-view.js +198 -0
  97. package/src/lineage/sink-registry.js +10 -1
  98. package/src/lineage/source-registry.js +3 -0
  99. package/src/lineage/transform-catalog.js +12 -0
  100. package/src/llm-validator/index.js +16 -2
  101. package/src/lsp/server.js +69 -9
  102. package/src/mcp/CLAUDE.md +7 -0
  103. package/src/mcp/tools.js +26 -3
  104. package/src/pipeline/coverage-ledger.js +0 -0
  105. package/src/pipeline/scan-health.js +19 -1
  106. package/src/posture/accuracy-scorecard.js +51 -0
  107. package/src/posture/aibom.js +61 -3
  108. package/src/posture/artifact-registry.js +12 -0
  109. package/src/posture/auditor-walkthrough.js +41 -0
  110. package/src/posture/clustering.js +2 -2
  111. package/src/posture/evidence-bundle.js +7 -1
  112. package/src/posture/execution-proof.js +15 -0
  113. package/src/posture/falsification.js +7 -0
  114. package/src/posture/family-resolve.js +7 -0
  115. package/src/posture/fix-history.js +40 -0
  116. package/src/posture/fix-verify.js +15 -3
  117. package/src/posture/provenance/git-evidence.js +24 -0
  118. package/src/posture/provenance/origin-resolver.js +14 -2
  119. package/src/posture/provenance/predicate-replay.js +11 -2
  120. package/src/posture/provenance/sca-origin.js +20 -3
  121. package/src/posture/sbom-diff.js +7 -6
  122. package/src/posture/sbom.js +85 -32
  123. package/src/posture/sca-policy.js +10 -1
  124. package/src/posture/sca-verdict.js +8 -1
  125. package/src/posture/secret-history.js +62 -18
  126. package/src/posture/stable-id.js +21 -2
  127. package/src/posture/state-dir.js +21 -0
  128. package/src/posture/threat-model-auto.js +4 -4
  129. package/src/posture/triage.js +18 -1
  130. package/src/posture/watch-mode.js +34 -3
  131. package/src/pr-delta.js +9 -4
  132. package/src/report/index.js +35 -9
  133. package/src/runScan.js +56 -5
  134. package/src/sast/code-injection-multilang.js +15 -0
  135. package/src/sast/go-extended.js +11 -0
  136. package/src/sast/pipeline.js +34 -1
@@ -0,0 +1,642 @@
1
+ // Nix expression/config IR (NIX-001).
2
+ //
3
+ // Normalizes a parsed Nix file (nix-parser.js) into a bounded configuration IR:
4
+ // flattened attribute assignments with full attribute paths and original spans,
5
+ // an attribute tree that merges `a = { x = 1; }; a.y = 2;` style definitions,
6
+ // import / callPackage edges, flake inputs and outputs, overlays, functions and
7
+ // string/path interpolations.
8
+ //
9
+ // What it will not do is evaluate. Whatever it cannot know statically is exposed
10
+ // in `unresolved` instead of being guessed: dynamic attribute names, dynamic or
11
+ // non-literal imports, import cycles and missing import targets (graph level),
12
+ // lazy recursion (`rec` / `let` cycles and fixed-point helpers), syntax errors
13
+ // and parser/IR budget hits. A literal string is `{literal}`; anything with an
14
+ // interpolation is `{literal:null, interpolated:true}`, so interpolated text is
15
+ // never mistaken for a constant.
16
+ //
17
+ // Comments and string contents are not nodes, so an option name appearing in
18
+ // either can never produce an assignment.
19
+
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import { parseNix, childrenOf } from './nix-parser.js';
23
+
24
+ export const DEFAULT_IR_BUDGETS = Object.freeze({
25
+ maxBindings: 20_000,
26
+ maxImports: 5_000,
27
+ maxFunctions: 2_000,
28
+ maxInterpolations: 5_000,
29
+ maxIrDepth: 400,
30
+ maxSiblings: 500,
31
+ });
32
+
33
+ export const DEFAULT_GRAPH_BUDGETS = Object.freeze({
34
+ maxFiles: 200,
35
+ maxImportDepth: 50,
36
+ });
37
+
38
+ const PRIORITY_WRAPPERS = new Set(['mkDefault', 'mkForce', 'mkOverride', 'mkBefore', 'mkAfter', 'mkOptionDefault', 'mkVMOverride', 'mkAfter']);
39
+ const COND_WRAPPERS = new Set(['mkIf', 'optionalAttrs']);
40
+ const BUILDER_CALLEE = /^(mkDerivation|buildPythonPackage|buildPythonApplication|buildGoModule|buildRustPackage|buildNpmPackage|mkShell|buildFHSEnv|runCommand|writeShellScriptBin)$/;
41
+ const FIXPOINT_FUNCS = new Set(['fix', 'fix\'', 'extends', 'makeExtensible', 'makeScope', 'makeScopeWithSplicing']);
42
+
43
+ const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
44
+
45
+ /** Splits `f a b` into its callee and arguments. */
46
+ function appChain(node) {
47
+ const args = [];
48
+ let fn = node;
49
+ while (fn && fn.type === 'app') { args.unshift(fn.arg); fn = fn.fn; }
50
+ return { fn, args };
51
+ }
52
+
53
+ /** `foo` or `lib.foo` or `pkgs.lib.foo` -> 'foo'; anything else null. */
54
+ function calleeName(fn) {
55
+ const f = unparen(fn);
56
+ if (!f) return null;
57
+ if (f.type === 'ident') return f.name;
58
+ if (f.type === 'select' && f.attrpath.length) {
59
+ const last = f.attrpath[f.attrpath.length - 1];
60
+ if (last.kind === 'static') return last.name;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function staticPath(segs) {
66
+ const out = [];
67
+ let dynamic = false;
68
+ for (const s of segs) {
69
+ if (s.kind === 'static') out.push(s.name); else { out.push(null); dynamic = true; }
70
+ }
71
+ return { path: out, dynamic };
72
+ }
73
+
74
+ export function foldLiteralPath(node) {
75
+ const n = unparen(node);
76
+ if (!n) return null;
77
+ if (n.type === 'path' && !n.interpolated) return n.literal;
78
+ if (n.type === 'string' && !n.interpolated) return n.literal;
79
+ if (n.type === 'binop' && n.op === '+') {
80
+ const l = foldLiteralPath(n.left);
81
+ const r = foldLiteralPath(n.right);
82
+ if (l !== null && r !== null && unparen(n.left).type === 'path') return l.replace(/\/$/, '') + (r.startsWith('/') ? '' : '/') + r.replace(/^\//, '');
83
+ }
84
+ return null;
85
+ }
86
+
87
+ export function resolveImportPath(fromFile, literal) {
88
+ if (typeof literal !== 'string' || !literal) return null;
89
+ if (literal.startsWith('/')) return path.posix.normalize(literal);
90
+ if (literal.startsWith('~')) return null;
91
+ const dir = path.posix.dirname(String(fromFile || 'default.nix').split(path.sep).join('/'));
92
+ return path.posix.normalize(path.posix.join(dir, literal));
93
+ }
94
+
95
+ class IrBuilder {
96
+ constructor(parse, opts) {
97
+ this.parse = parse;
98
+ this.file = opts.file || parse.file || null;
99
+ this.source = typeof opts.source === 'string' ? opts.source : null;
100
+ this.b = { ...DEFAULT_IR_BUDGETS, ...(opts.budgets || {}) };
101
+ this.bindings = [];
102
+ this.imports = [];
103
+ this.overlays = [];
104
+ this.functions = [];
105
+ this.interpolations = [];
106
+ this.unresolved = [];
107
+ this.truncated = new Set();
108
+ this.seen = new Set();
109
+ }
110
+
111
+ snippet(node) {
112
+ if (!this.source) return null;
113
+ const t = this.source.slice(node.start, Math.min(node.end, node.start + 120));
114
+ return t.length < node.end - node.start ? `${t}...` : t;
115
+ }
116
+
117
+ gap(kind, detail, span, extra = {}) {
118
+ const key = `${kind}:${span ? span.startOffset : ''}:${detail}`;
119
+ if (this.seen.has(key)) return;
120
+ this.seen.add(key);
121
+ this.unresolved.push({ kind, detail, span: span || null, ...extra });
122
+ }
123
+
124
+ budget(name) {
125
+ if (!this.truncated.has(name)) {
126
+ this.truncated.add(name);
127
+ this.gap('ir-budget', `IR budget "${name}" reached (limit ${this.b[name]}); the rest of the file is not in the IR`, null);
128
+ }
129
+ }
130
+
131
+ summarize(node) {
132
+ const n = unparen(node);
133
+ if (!n) return { type: 'none' };
134
+ switch (n.type) {
135
+ case 'string':
136
+ return {
137
+ type: 'string', literal: n.literal, interpolated: n.interpolated, indented: n.indented,
138
+ interpolations: n.parts.filter((p) => p.kind === 'interp').map((p) => ({ span: p.span, exprKind: unparen(p.expr).type, text: this.snippet(p.expr) })),
139
+ };
140
+ case 'path': return { type: 'path', literal: n.literal, interpolated: n.interpolated, pathKind: n.pathKind };
141
+ case 'spath': return { type: 'search-path', literal: n.value, interpolated: false };
142
+ case 'uri': return { type: 'uri', literal: n.value, interpolated: false };
143
+ case 'int': case 'float': return { type: 'number', value: n.value };
144
+ case 'ident':
145
+ if (n.name === 'true' || n.name === 'false') return { type: 'bool', value: n.name === 'true' };
146
+ if (n.name === 'null') return { type: 'null', value: null };
147
+ return { type: 'reference', name: n.name };
148
+ case 'list': return { type: 'list', length: n.items.length, items: n.items.slice(0, 64).map((x) => this.summarize(x)) };
149
+ case 'attrset': return { type: 'attrset', rec: n.rec };
150
+ case 'lambda': return { type: 'function', params: n.param.kind === 'ident' ? [n.param.name] : n.param.formals.map((f) => f.name) };
151
+ default: return { type: 'expression', kind: n.type, text: this.snippet(n) };
152
+ }
153
+ }
154
+
155
+ addBinding(rec) {
156
+ if (this.bindings.length >= this.b.maxBindings) { this.budget('maxBindings'); return null; }
157
+ this.bindings.push(rec);
158
+ return rec;
159
+ }
160
+
161
+ /** Returns the assignments a value node contributes under `ctx.prefix`. */
162
+ flatten(node, ctx, depth) {
163
+ const n = unparen(node);
164
+ if (!n) return [];
165
+ if (depth > this.b.maxIrDepth) { this.budget('maxIrDepth'); return []; }
166
+ switch (n.type) {
167
+ case 'attrset': return this.flattenAttrs(n, ctx, depth);
168
+ case 'let': {
169
+ const out = this.recordScoped(n, 'let', ctx);
170
+ return out.concat(this.flatten(n.body, ctx, depth + 1));
171
+ }
172
+ case 'with': return this.flatten(n.body, { ...ctx, withs: [...ctx.withs, this.snippet(n.env)] }, depth + 1);
173
+ case 'assert': return this.flatten(n.body, ctx, depth + 1);
174
+ case 'if': {
175
+ const c = { kind: 'if', span: n.cond.span, text: this.snippet(n.cond) };
176
+ return this.flatten(n.then, { ...ctx, conditions: [...ctx.conditions, { ...c, branch: 'then' }] }, depth + 1)
177
+ .concat(this.flatten(n.else, { ...ctx, conditions: [...ctx.conditions, { ...c, branch: 'else' }] }, depth + 1));
178
+ }
179
+ case 'binop':
180
+ if (n.op === '//') {
181
+ const left = this.flatten(n.left, ctx, depth + 1);
182
+ const right = this.flatten(n.right, ctx, depth + 1);
183
+ const over = new Set();
184
+ for (const r of right) if (r.path.length > ctx.prefix.length && r.path[ctx.prefix.length] !== null) over.add(r.path[ctx.prefix.length]);
185
+ const kept = [];
186
+ for (const l of left) {
187
+ if (over.has(l.path[ctx.prefix.length])) l.overridden = true;
188
+ else kept.push(l);
189
+ }
190
+ for (const r of right) r.update = true;
191
+ return kept.concat(right);
192
+ }
193
+ return this.leaf(n, ctx);
194
+ case 'app': {
195
+ const { fn, args } = appChain(n);
196
+ const name = calleeName(fn);
197
+ if (name === 'mkMerge' && args.length === 1 && unparen(args[0]).type === 'list') {
198
+ let out = [];
199
+ for (const it of unparen(args[0]).items) out = out.concat(this.flatten(it, { ...ctx, merge: 'mkMerge' }, depth + 1));
200
+ return out;
201
+ }
202
+ if (COND_WRAPPERS.has(name) && args.length === 2) {
203
+ const cond = { kind: name, span: args[0].span, text: this.snippet(args[0]), branch: 'then' };
204
+ return this.flatten(args[1], { ...ctx, conditions: [...ctx.conditions, cond] }, depth + 1);
205
+ }
206
+ if (PRIORITY_WRAPPERS.has(name) && args.length >= 1) {
207
+ // mkOverride carries its numeric priority as the first argument; keep it so the module resolver can rank it
208
+ const priorityArg = name === 'mkOverride' && args.length >= 2 ? this.summarize(args[0]) : null;
209
+ return this.flatten(args[args.length - 1], { ...ctx, priority: name, priorityArg }, depth + 1);
210
+ }
211
+ // a file that is just `mkDerivation { ... }` / `callPackage ./x.nix { ... }`: the last attrset argument is the configuration
212
+ if (!ctx.prefix.length && args.length) {
213
+ const last = unparen(args[args.length - 1]);
214
+ if (last && last.type === 'attrset') return this.flattenAttrs(last, { ...ctx, callee: name }, depth + 1);
215
+ }
216
+ return this.leaf(n, ctx);
217
+ }
218
+ default: return this.leaf(n, ctx);
219
+ }
220
+ }
221
+
222
+ leaf(n, ctx) {
223
+ if (!ctx.prefix.length) return [];
224
+ const rec = this.addBinding({
225
+ path: ctx.prefix, pathText: ctx.prefix.map((p) => (p === null ? '${...}' : p)).join('.'),
226
+ value: this.summarize(n), span: ctx.span || n.span, valueSpan: n.span,
227
+ origin: ctx.origin || 'attr', scope: ctx.scope, dynamic: ctx.prefix.includes(null),
228
+ conditions: ctx.conditions, priority: ctx.priority || null, merge: ctx.merge || null,
229
+ withs: ctx.withs, overridden: false, update: false, callee: ctx.callee || null,
230
+ });
231
+ if (rec && ctx.priorityArg) rec.priorityArg = ctx.priorityArg;
232
+ if (rec && n.type === 'lambda') this.noteBoundaryLambda(n, ctx);
233
+ return rec ? [rec] : [];
234
+ }
235
+
236
+ /** The `outputs` function of a flake is analyzed as its own root. */
237
+ noteBoundaryLambda(n, ctx) {
238
+ if (ctx.scope === 'file' && ctx.prefix.length === 1 && ctx.prefix[0] === 'outputs') {
239
+ let body = n;
240
+ while (body && body.type === 'lambda') body = body.body;
241
+ this.flatten(body, { prefix: [], scope: 'outputs', conditions: [], withs: [], priority: null, merge: null }, 1);
242
+ }
243
+ }
244
+
245
+ flattenAttrs(n, ctx, depth) {
246
+ let out = [];
247
+ for (const b of n.bindings) {
248
+ if (b.kind === 'inherit') {
249
+ for (const nm of b.names) {
250
+ if (nm.kind !== 'static') { this.gap('dynamic-attribute', 'dynamic name in inherit', nm.span); continue; }
251
+ const rec = this.addBinding({
252
+ path: [...ctx.prefix, nm.name], pathText: [...ctx.prefix, nm.name].join('.'),
253
+ value: { type: 'inherit', from: b.from ? this.snippet(b.from) : null, name: nm.name }, span: b.span, valueSpan: nm.span,
254
+ origin: 'inherit', scope: ctx.scope, dynamic: false, conditions: ctx.conditions, priority: null, merge: ctx.merge || null,
255
+ withs: ctx.withs, overridden: false, update: false,
256
+ });
257
+ if (rec) out.push(rec);
258
+ }
259
+ continue;
260
+ }
261
+ const { path: segs, dynamic } = staticPath(b.path);
262
+ if (dynamic) {
263
+ for (const s of b.path) if (s.kind !== 'static') this.gap('dynamic-attribute', `attribute name computed at evaluation time (${this.snippet({ start: s.span.startOffset, end: s.span.endOffset }) || 'interpolated'})`, s.span);
264
+ }
265
+ const sub = { ...ctx, prefix: [...ctx.prefix, ...segs], span: b.span, origin: 'attr' };
266
+ out = out.concat(this.flatten(b.value, sub, depth + 1));
267
+ }
268
+ return out;
269
+ }
270
+
271
+ /** let bindings, recorded under scope `let` so they never read as option assignments. */
272
+ recordScoped(n, scope, ctx) {
273
+ const out = [];
274
+ for (const b of n.bindings) {
275
+ if (b.kind === 'inherit') {
276
+ for (const nm of b.names) if (nm.kind === 'static') {
277
+ const r = this.addBinding({ path: [nm.name], pathText: nm.name, value: { type: 'inherit', from: b.from ? this.snippet(b.from) : null, name: nm.name }, span: b.span, valueSpan: nm.span, origin: 'inherit', scope, dynamic: false, conditions: ctx.conditions, priority: null, merge: null, withs: ctx.withs, overridden: false, update: false });
278
+ if (r) out.push(r);
279
+ }
280
+ continue;
281
+ }
282
+ const { path: segs, dynamic } = staticPath(b.path);
283
+ const r = this.addBinding({ path: segs, pathText: segs.map((p) => (p === null ? '${...}' : p)).join('.'), value: this.summarize(b.value), span: b.span, valueSpan: b.value.span, origin: 'let', scope, dynamic, conditions: ctx.conditions, priority: null, merge: null, withs: ctx.withs, overridden: false, update: false });
284
+ if (r) out.push(r);
285
+ if (dynamic) this.gap('dynamic-attribute', 'dynamic attribute name in let binding', b.span);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ // ── whole-tree pass: imports, functions, interpolations, laziness ──
291
+
292
+ walkAll(root) {
293
+ const stack = [root];
294
+ while (stack.length) {
295
+ const n = stack.pop();
296
+ this.visit(n);
297
+ const kids = childrenOf(n);
298
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
299
+ }
300
+ }
301
+
302
+ visit(n) {
303
+ switch (n.type) {
304
+ case 'lambda': {
305
+ if (this.functions.length < this.b.maxFunctions) {
306
+ this.functions.push({
307
+ kind: n.param.kind, params: n.param.kind === 'ident' ? [n.param.name] : n.param.formals.map((f) => f.name),
308
+ ellipsis: n.param.ellipsis, atName: n.param.atName, span: n.span,
309
+ });
310
+ } else this.budget('maxFunctions');
311
+ break;
312
+ }
313
+ case 'string': case 'path': {
314
+ for (const p of n.parts) if (p.kind === 'interp') this.noteInterp(n, p);
315
+ break;
316
+ }
317
+ case 'app': {
318
+ const { fn, args } = appChain(n);
319
+ const name = calleeName(fn);
320
+ const f = unparen(fn);
321
+ if (f && f.type === 'ident' && f.name === 'import' && args.length >= 1 && !this.chainSeen(n)) this.noteImport('import', args[0], n);
322
+ else if ((name === 'callPackage' || name === 'callPackages') && args.length >= 1 && !this.chainSeen(n)) this.noteImport('call-package', args[0], n);
323
+ else if (name && FIXPOINT_FUNCS.has(name) && !this.chainSeen(n)) this.gap('lazy-recursion', `fixed-point helper "${name}" builds a lazily recursive value that is not evaluated`, n.span, { name });
324
+ break;
325
+ }
326
+ case 'attrset': case 'let': {
327
+ if (n.type === 'attrset') for (const b of n.bindings) {
328
+ if (b.kind !== 'attr') continue;
329
+ const last = b.path[b.path.length - 1];
330
+ if (last && last.kind === 'static' && last.name === 'imports') this.noteImportsList(b);
331
+ if (last && last.kind === 'static' && last.name === 'overlays') this.noteOverlays(b);
332
+ }
333
+ if (n.type === 'attrset' && n.rec) this.checkRecursion(n, 'rec attribute set');
334
+ else if (n.type === 'let') this.checkRecursion(n, 'let');
335
+ break;
336
+ }
337
+ default: break;
338
+ }
339
+ }
340
+
341
+ chainSeen(n) {
342
+ // an app chain `f a b` is nested as app(app(f,a),b); report once, from the outermost node
343
+ this.chains = this.chains || new Set();
344
+ const { fn } = appChain(n);
345
+ const key = `${fn.start}:${fn.end}`;
346
+ if (this.chains.has(key)) return true;
347
+ this.chains.add(key);
348
+ return false;
349
+ }
350
+
351
+ noteInterp(owner, part) {
352
+ if (this.interpolations.length >= this.b.maxInterpolations) { this.budget('maxInterpolations'); return; }
353
+ this.interpolations.push({
354
+ span: part.span, exprKind: unparen(part.expr).type, text: this.snippet(part.expr),
355
+ context: owner.type === 'path' ? 'path' : owner.indented ? 'indented-string' : 'string', ownerSpan: owner.span,
356
+ });
357
+ }
358
+
359
+ noteImportsList(b) {
360
+ const v = unparen(b.value);
361
+ if (!v) return;
362
+ if (v.type === 'list') { for (const it of v.items) this.addImport('module-import', it, b.span); return; }
363
+ this.addImport('module-import', v, b.span);
364
+ }
365
+
366
+ noteImport(kind, target, call) {
367
+ this.addImport(kind, target, call.span);
368
+ }
369
+
370
+ addImport(kind, target, viaSpan) {
371
+ if (this.imports.length >= this.b.maxImports) { this.budget('maxImports'); return; }
372
+ const t = unparen(target);
373
+ const folded = foldLiteralPath(t);
374
+ let rec;
375
+ if (t.type === 'path' || (folded !== null && t.type !== 'string') || (t.type === 'string' && folded !== null && folded.startsWith('/'))) {
376
+ if (folded !== null) {
377
+ rec = { kind, target: { type: 'path', literal: folded }, literal: true, resolved: resolveImportPath(this.file, folded), span: t.span, viaSpan };
378
+ } else {
379
+ rec = { kind, target: { type: 'path', literal: null, interpolated: true }, literal: false, resolved: null, span: t.span, viaSpan };
380
+ this.gap('dynamic-import', `import target contains an interpolation and cannot be resolved statically (${this.snippet(t)})`, t.span, { importKind: kind });
381
+ }
382
+ } else if (t.type === 'spath') {
383
+ rec = { kind, target: { type: 'search-path', literal: t.value }, literal: true, external: true, resolved: null, span: t.span, viaSpan };
384
+ } else {
385
+ rec = { kind, target: { type: t.type === 'string' ? 'string' : 'expression', literal: t.type === 'string' ? t.literal : null, text: this.snippet(t) }, literal: false, resolved: null, span: t.span, viaSpan };
386
+ this.gap('dynamic-import', `import target is not a literal path (${t.type}: ${this.snippet(t)})`, t.span, { importKind: kind });
387
+ }
388
+ this.imports.push(rec);
389
+ }
390
+
391
+ noteOverlays(b) {
392
+ const v = unparen(b.value);
393
+ if (!v || v.type !== 'list') return;
394
+ for (const it of v.items) {
395
+ const l = unparen(it);
396
+ if (l.type === 'lambda') this.addOverlay(l);
397
+ else this.overlays.push({ kind: 'reference', span: l.span, text: this.snippet(l), bindings: 0 });
398
+ }
399
+ }
400
+
401
+ addOverlay(l) {
402
+ const params = [];
403
+ let body = l;
404
+ while (body && body.type === 'lambda' && params.length < 2) { params.push(body.param.kind === 'ident' ? body.param.name : '{pattern}'); body = body.body; }
405
+ const before = this.bindings.length;
406
+ this.flatten(body, { prefix: [], scope: 'overlay', conditions: [], withs: [], priority: null, merge: null }, 1);
407
+ this.overlays.push({ kind: 'function', params, span: l.span, bindings: this.bindings.length - before });
408
+ }
409
+
410
+ /** Reports bindings that sit on a reference cycle among their siblings. */
411
+ checkRecursion(n, what) {
412
+ const names = new Map();
413
+ for (const b of n.bindings) {
414
+ if (b.kind === 'attr' && b.path[0] && b.path[0].kind === 'static') names.set(b.path[0].name, b);
415
+ else if (b.kind === 'inherit') for (const nm of b.names) if (nm.kind === 'static') names.set(nm.name, null);
416
+ }
417
+ if (names.size === 0 || names.size > this.b.maxSiblings) { if (names.size > this.b.maxSiblings) this.budget('maxSiblings'); return; }
418
+ const refs = new Map();
419
+ for (const [name, b] of names) {
420
+ const set = new Set();
421
+ if (b) {
422
+ const stack = [b.value];
423
+ let guard = 0;
424
+ while (stack.length && guard++ < 20_000) {
425
+ const x = stack.pop();
426
+ if (!x) continue;
427
+ if (x.type === 'ident' && names.has(x.name)) set.add(x.name);
428
+ for (const k of childrenOf(x)) stack.push(k);
429
+ }
430
+ }
431
+ refs.set(name, set);
432
+ }
433
+ const reaches = (from, to) => {
434
+ const seen = new Set();
435
+ const st = [...refs.get(from)];
436
+ while (st.length) {
437
+ const c = st.pop();
438
+ if (c === to) return true;
439
+ if (seen.has(c)) continue;
440
+ seen.add(c);
441
+ for (const nx of refs.get(c) || []) st.push(nx);
442
+ }
443
+ return false;
444
+ };
445
+ for (const [name, b] of names) {
446
+ if (b && reaches(name, name)) this.gap('lazy-recursion', `"${name}" refers back to itself through ${what} siblings (${[...refs.get(name)].join(', ')}); its value is only defined lazily and is not evaluated`, b.span, { name });
447
+ }
448
+ }
449
+ }
450
+
451
+ function classify(ast, bindings, file) {
452
+ let root = unparen(ast);
453
+ if (!root) return 'expression';
454
+ const base = String(file || '').split('/').pop();
455
+ let lam = null;
456
+ while (root && root.type === 'lambda') { lam = lam || root; root = unparen(root.body); }
457
+ while (root && (root.type === 'let' || root.type === 'with' || root.type === 'assert')) root = unparen(root.body);
458
+ const top = new Set(bindings.filter((b) => b.scope === 'file' && b.path.length >= 1).map((b) => b.path[0]));
459
+ if (!lam && root && root.type === 'attrset' && top.has('outputs') && (top.has('inputs') || top.has('description') || base === 'flake.nix')) return 'flake';
460
+ if (lam && lam.param.kind === 'ident' && lam.body && unparen(lam.body).type === 'lambda' && unparen(unparen(lam.body).body) && ['attrset', 'let', 'with'].includes(unparen(unparen(lam.body).body).type)) return 'overlay';
461
+ const formals = lam && lam.param.kind === 'pattern' ? new Set(lam.param.formals.map((f) => f.name)) : new Set();
462
+ if (formals.has('config') || formals.has('options') || formals.has('modulesPath') || top.has('imports') || top.has('options') || top.has('config')) return 'module';
463
+ if (bindings.some((b) => b.callee && BUILDER_CALLEE.test(b.callee))) return 'package';
464
+ if (formals.has('stdenv') || formals.has('mkDerivation') || formals.has('callPackage') || formals.has('fetchurl') || formals.has('fetchFromGitHub')) return 'package';
465
+ return 'expression';
466
+ }
467
+
468
+ /**
469
+ * Builds the config IR from a parseNix() result.
470
+ * @param {object} parse
471
+ * @param {{file?:string, source?:string, budgets?:object}} [opts]
472
+ */
473
+ export function buildNixIR(parse, opts = {}) {
474
+ const ib = new IrBuilder(parse, opts);
475
+ const ir = {
476
+ kind: 'nix-config-ir', version: 1, file: ib.file, status: parse.status, fileKind: 'expression',
477
+ bindings: ib.bindings, imports: ib.imports, overlays: ib.overlays, functions: ib.functions, interpolations: ib.interpolations,
478
+ unresolved: ib.unresolved, conflicts: [], flake: null, attrTree: null, truncated: [], usedNixBinary: false,
479
+ };
480
+ for (const e of parse.errors || []) ib.gap('syntax-error', e.detail, e.span);
481
+ if (parse.status === 'budget_exceeded') ib.gap('budget-exceeded', `parser budget "${parse.budget.name}" exceeded (limit ${parse.budget.limit}); the file was not analyzed`, null, { budget: parse.budget.name });
482
+ if (parse.status === 'missing_grammar') ib.gap('missing-grammar', parse.gap ? parse.gap.detail : 'Nix grammar unavailable', null);
483
+ if (parse.status === 'failed' && !(parse.errors || []).length) ib.gap('parse-failed', 'file could not be parsed', null);
484
+ if (!parse.ast) { ir.truncated = [...ib.truncated]; return ir; }
485
+
486
+ // peel root lambdas so their bodies are the module/flake body
487
+ let body = unparen(parse.ast);
488
+ let rootFn = null;
489
+ while (body && body.type === 'lambda') { rootFn = rootFn || body; body = unparen(body.body); }
490
+ const ctx = { prefix: [], scope: 'file', conditions: [], withs: [], priority: null, merge: null };
491
+ ib.flatten(body, ctx, 1);
492
+ ib.walkAll(parse.ast);
493
+ ir.fileKind = classify(parse.ast, ib.bindings, ib.file);
494
+ if (rootFn) ir.rootFunction = { kind: rootFn.param.kind, params: rootFn.param.kind === 'ident' ? [rootFn.param.name] : rootFn.param.formals.map((f) => f.name), ellipsis: rootFn.param.ellipsis, span: rootFn.span };
495
+ if (ir.fileKind === 'overlay') {
496
+ const l = unparen(parse.ast);
497
+ if (!ib.overlays.some((o) => o.span.startOffset === l.span.startOffset)) ib.addOverlay(l);
498
+ }
499
+ ir.attrTree = buildAttrTree(ib.bindings, ir.conflicts);
500
+ if (ir.fileKind === 'flake') ir.flake = flakeInfo(ib.bindings);
501
+ ir.truncated = [...ib.truncated];
502
+ if (parse.status === 'partial') ir.status = 'partial';
503
+ else if (ib.unresolved.length) ir.status = 'partial';
504
+ return ir;
505
+ }
506
+
507
+ function flakeInfo(bindings) {
508
+ const flake = { description: null, inputs: [], outputs: null };
509
+ const inputs = new Map();
510
+ for (const b of bindings) {
511
+ if (b.scope !== 'file' || b.origin === 'let') continue;
512
+ if (b.path[0] === 'description' && b.path.length === 1) flake.description = b.value.literal === undefined ? null : b.value.literal;
513
+ if (b.path[0] === 'outputs' && b.path.length === 1) {
514
+ flake.outputs = { span: b.valueSpan, params: b.value.type === 'function' ? b.value.params : [] };
515
+ }
516
+ if (b.path[0] === 'inputs' && b.path.length >= 2 && b.path[1] !== null) {
517
+ const name = b.path[1];
518
+ if (!inputs.has(name)) inputs.set(name, { name, url: null, follows: null, flake: true, nestedFollows: [], span: b.span, dynamic: false });
519
+ const inp = inputs.get(name);
520
+ if (b.path.length === 2 && b.value.type === 'string') inp.url = b.value.literal;
521
+ else if (b.path[2] === 'url' && b.path.length === 3) { inp.url = b.value.literal === undefined ? null : b.value.literal; if (b.value.interpolated) inp.dynamic = true; }
522
+ else if (b.path[2] === 'follows' && b.path.length === 3) inp.follows = b.value.literal === undefined ? null : b.value.literal;
523
+ else if (b.path[2] === 'flake' && b.path.length === 3) inp.flake = b.value.type === 'bool' ? b.value.value : true;
524
+ else if (b.path[2] === 'inputs' && b.path[b.path.length - 1] === 'follows') inp.nestedFollows.push({ path: b.path.slice(3, -1), follows: b.value.literal === undefined ? null : b.value.literal });
525
+ }
526
+ }
527
+ flake.inputs = [...inputs.values()];
528
+ return flake;
529
+ }
530
+
531
+ function buildAttrTree(bindings, conflicts) {
532
+ const root = { children: new Map(), bindings: [] };
533
+ for (const b of bindings) {
534
+ if (b.scope === 'let' || b.dynamic || b.overridden || !b.path.length) continue;
535
+ let node = root;
536
+ for (const seg of b.path) {
537
+ if (!node.children.has(seg)) node.children.set(seg, { children: new Map(), bindings: [] });
538
+ node = node.children.get(seg);
539
+ }
540
+ node.bindings.push(b);
541
+ }
542
+ const walk = (node, p) => {
543
+ const plain = node.bindings.filter((b) => !b.merge && !b.conditions.length && !b.priority);
544
+ if (plain.length > 1) conflicts.push({ kind: 'duplicate-definition', path: p, spans: plain.map((b) => b.span) });
545
+ if (node.bindings.length && node.children.size && node.bindings.some((b) => b.value.type !== 'attrset' && b.value.type !== 'expression')) conflicts.push({ kind: 'value-and-subattributes', path: p, spans: node.bindings.map((b) => b.span) });
546
+ for (const [k, c] of node.children) walk(c, [...p, k]);
547
+ };
548
+ walk(root, []);
549
+ return root;
550
+ }
551
+
552
+ /** Definitions at exactly `attrPath` (array or dotted string). */
553
+ export function lookupAttr(ir, attrPath) {
554
+ const segs = Array.isArray(attrPath) ? attrPath : String(attrPath).split('.');
555
+ let node = ir.attrTree;
556
+ for (const s of segs) {
557
+ if (!node) return [];
558
+ node = node.children.get(s);
559
+ }
560
+ return node ? node.bindings : [];
561
+ }
562
+
563
+ /**
564
+ * Parses and builds the IR in one call. Never throws.
565
+ */
566
+ export function analyzeNix(source, opts = {}) {
567
+ const parse = parseNix(source, opts);
568
+ const ir = buildNixIR(parse, { file: opts.file, source: typeof source === 'string' ? source : null, budgets: opts.irBudgets });
569
+ return { parse, ir };
570
+ }
571
+
572
+ /**
573
+ * Bounded static import graph. Follows only literal local path imports; reads files
574
+ * through `readFile` (default: confined to `root`), never runs nix.
575
+ * @param {string} entry entry file path, relative to root
576
+ * @param {{root?:string, readFile?:(p:string)=>string|null, budgets?:object, parseBudgets?:object}} [opts]
577
+ */
578
+ export function buildNixImportGraph(entry, opts = {}) {
579
+ const budgets = { ...DEFAULT_GRAPH_BUDGETS, ...(opts.budgets || {}) };
580
+ const root = opts.root ? path.resolve(opts.root) : null;
581
+ const read = opts.readFile || ((p) => {
582
+ if (!root) return null;
583
+ const abs = path.resolve(root, p);
584
+ if (abs !== root && !abs.startsWith(root + path.sep)) return null;
585
+ try {
586
+ const st = fs.statSync(abs);
587
+ if (!st.isFile()) return null;
588
+ return fs.readFileSync(abs, 'utf8');
589
+ } catch { return null; }
590
+ });
591
+ const graph = { entry, files: [], edges: [], unresolved: [], truncated: false, usedNixBinary: false };
592
+ const done = new Map();
593
+ const cycles = new Set();
594
+ const norm = (p) => path.posix.normalize(String(p).split(path.sep).join('/'));
595
+
596
+ const visit = (file, chain) => {
597
+ if (chain.length > budgets.maxImportDepth) {
598
+ graph.truncated = true;
599
+ graph.unresolved.push({ kind: 'import-depth', detail: `import chain deeper than ${budgets.maxImportDepth}`, file, span: null });
600
+ return;
601
+ }
602
+ if (done.has(file)) return;
603
+ if (done.size >= budgets.maxFiles) {
604
+ if (!graph.truncated) graph.unresolved.push({ kind: 'import-budget', detail: `more than ${budgets.maxFiles} files in the import graph`, file, span: null });
605
+ graph.truncated = true;
606
+ return;
607
+ }
608
+ const text = read(file);
609
+ if (text === null || text === undefined) { done.set(file, { file, status: 'missing' }); return; }
610
+ const { parse, ir } = analyzeNix(text, { file, budgets: opts.parseBudgets });
611
+ done.set(file, { file, status: ir.status, fileKind: ir.fileKind });
612
+ for (const u of ir.unresolved) graph.unresolved.push({ ...u, file });
613
+ for (const imp of ir.imports) {
614
+ if (!imp.literal || imp.external || !imp.resolved) continue;
615
+ const cands = /\.nix$/.test(imp.resolved) ? [imp.resolved] : [`${imp.resolved}/default.nix`, imp.resolved];
616
+ let target = null;
617
+ for (const c of cands) {
618
+ const k = norm(c);
619
+ if (chain.includes(k) || k === file || done.has(k) || (read(k) !== null && read(k) !== undefined)) { target = k; break; }
620
+ }
621
+ if (!target) {
622
+ graph.unresolved.push({ kind: 'missing-import', detail: `imported file ${imp.resolved} does not exist or is outside the scanned root`, file, span: imp.span });
623
+ continue;
624
+ }
625
+ graph.edges.push({ from: file, to: target, kind: imp.kind, span: imp.span });
626
+ if (target === file || chain.includes(target)) {
627
+ const cyc = [...chain.slice(chain.indexOf(target) >= 0 ? chain.indexOf(target) : chain.length), file, target];
628
+ const key = [...new Set(cyc)].sort().join('|');
629
+ if (!cycles.has(key)) {
630
+ cycles.add(key);
631
+ graph.unresolved.push({ kind: 'import-cycle', detail: `import cycle: ${cyc.join(' -> ')}`, file, span: imp.span, cycle: cyc });
632
+ }
633
+ continue;
634
+ }
635
+ visit(target, [...chain, file]);
636
+ }
637
+ void parse;
638
+ };
639
+ visit(norm(entry), []);
640
+ graph.files = [...done.values()];
641
+ return graph;
642
+ }