@clear-capabilities/agentic-security-scanner 0.158.0 → 0.160.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 (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/3531.index.js +1 -1
  5. package/dist/4171.index.js +4 -4
  6. package/dist/{8551.index.js → 7033.index.js} +415 -22
  7. package/dist/9560.index.js +3 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +11 -6
  11. package/src/engine.js +6 -1
  12. package/src/language/CLAUDE.md +48 -1
  13. package/src/language/assurance.js +21 -4
  14. package/src/language/context.js +2 -0
  15. package/src/language/cvss.js +91 -0
  16. package/src/language/fix-lifecycle.js +114 -21
  17. package/src/language/haskell-adapter.js +4 -3
  18. package/src/language/haskell-advisory-feed.js +15 -0
  19. package/src/language/haskell-boundaries.js +220 -0
  20. package/src/language/haskell-cpp.js +460 -0
  21. package/src/language/haskell-ir.js +6 -3
  22. package/src/language/haskell-models.js +4 -3
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +10 -6
  25. package/src/language/haskell-supply.js +42 -8
  26. package/src/language/haskell-web.js +128 -23
  27. package/src/language/nix-advisory-feed.js +275 -0
  28. package/src/language/nix-fix.js +162 -24
  29. package/src/language/nix-ir.js +16 -12
  30. package/src/language/nix-sca.js +71 -12
  31. package/src/language/nixos-eval-forms.js +162 -0
  32. package/src/language/nixos-module-resolver.js +346 -51
  33. package/src/language/resolved-pass.js +57 -3
  34. package/src/lsp/server.js +5 -2
  35. package/src/mcp/tools.js +3 -1
  36. package/src/posture/fix-history.js +30 -3
  37. package/src/report/index.js +4 -1
  38. package/dist/1310.index.js +0 -3161
  39. package/dist/2376.index.js +0 -3151
  40. package/dist/3526.index.js +0 -1019
  41. package/dist/7310.index.js +0 -520
  42. package/dist/8520.index.js +0 -13
  43. package/dist/9091.index.js +0 -3135
@@ -20,7 +20,9 @@
20
20
 
21
21
  import { AdvisoryDb, matchComponent as matchHackage, reachability as hackageReachability, licensePolicy, hackagePurl, normalizeAdvisory } from './haskell-sca.js';
22
22
  import { parseVersion as parsePvp } from './haskell-manifests.js';
23
+ import { advisorySeverity, NO_RATING_BASIS } from './cvss.js';
23
24
  import { parseNix } from './nix-parser.js';
25
+ import { cpeKey } from './nix-advisory-feed.js';
24
26
 
25
27
  export const NIX_SCA_VERSION = 'nix-sca/1';
26
28
  const HEX40 = /^[0-9a-f]{40}$/i;
@@ -59,16 +61,18 @@ export function compareUpstream(a, b) {
59
61
  // ── advisories ───────────────────────────────────────────────────────────────
60
62
  const sha = (s) => String(s || '').toLowerCase().replace(/^sha256[-:]/, '');
61
63
  function eventsToRanges(events) {
62
- const out = []; let lo = null;
64
+ // `introduced_excluding` is not an OSV event: it carries an exclusive start bound (the NVD live feed has them), so 8.0 is not in "after 8.0".
65
+ const out = []; let lo = null; let loExc = false;
63
66
  for (const e of events || []) {
64
- if ('introduced' in e) lo = e.introduced === '0' ? null : e.introduced;
65
- else if ('fixed' in e) { out.push({ lo, hi: e.fixed, hiInc: false }); lo = null; }
66
- else if ('last_affected' in e) { out.push({ lo, hi: e.last_affected, hiInc: true }); lo = null; }
67
+ if ('introduced' in e) { lo = e.introduced === '0' ? null : e.introduced; loExc = false; }
68
+ else if ('introduced_excluding' in e) { lo = e.introduced_excluding; loExc = true; }
69
+ else if ('fixed' in e) { out.push({ lo, loExc, hi: e.fixed, hiInc: false }); lo = null; loExc = false; }
70
+ else if ('last_affected' in e) { out.push({ lo, loExc, hi: e.last_affected, hiInc: true }); lo = null; loExc = false; }
67
71
  }
68
- if (events && events.some((e) => 'introduced' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, hi: null, hiInc: false });
72
+ if (events && events.some((e) => 'introduced' in e || 'introduced_excluding' in e) && (lo !== null || !out.length || !events.some((e) => 'fixed' in e || 'last_affected' in e))) out.push({ lo, loExc, hi: null, hiInc: false });
69
73
  return out;
70
74
  }
71
- const inRange = (v, r) => (r.lo === null || compareUpstream(v, r.lo) >= 0) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
75
+ const inRange = (v, r) => (r.lo === null || (r.loExc ? compareUpstream(v, r.lo) > 0 : compareUpstream(v, r.lo) >= 0)) && (r.hi === null || (r.hiInc ? compareUpstream(v, r.hi) <= 0 : compareUpstream(v, r.hi) < 0));
72
76
 
73
77
  /** Normalize a non-Hackage OSV-style record into what Nix matching needs. */
74
78
  export function normalizeGenericAdvisory(rec) {
@@ -86,12 +90,15 @@ export function normalizeGenericAdvisory(rec) {
86
90
  fixPatches: ((a.database_specific && a.database_specific.fix_patches) || (rec.database_specific && rec.database_specific.fix_patches) || []).map((p) => ({ name: p.name || null, sha256: p.sha256 ? sha(p.sha256) : null })),
87
91
  });
88
92
  }
89
- return { id: rec.id, ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
93
+ return { id: rec.id, severityInfo: advisorySeverity(rec), ids: [rec.id, ...aliases], cves: [rec.id, ...aliases].filter((x) => /^CVE-/.test(x)), summary: rec.summary || '', withdrawn: rec.withdrawn || null, published: rec.published || null, modified: rec.modified || null, references: (rec.references || []).map((r) => r.url).filter(Boolean), affected };
90
94
  }
91
95
 
92
96
  export class NixAdvisoryData {
93
- constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30 } = {}) {
94
- this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays;
97
+ constructor({ records = [], hackage = null, source = 'records', generatedAt = null, now = Date.now(), maxAgeDays = 30, covered = null } = {}) {
98
+ this.source = source; this.generatedAt = generatedAt; this.maxAgeDays = maxAgeDays; this.now = now;
99
+ // `covered` is {'vendor:product': ISO time that CPE identity was last fully read from the live feed}. null means the snapshot makes no
100
+ // per-identity claim (a hand-built or operator-pinned snapshot) and is matched exactly as it always was.
101
+ this.covered = covered && typeof covered === 'object' ? covered : null;
95
102
  this.records = records.map(normalizeGenericAdvisory).filter(Boolean);
96
103
  this.hackage = hackage; // an AdvisoryDb (HS-009) for wrapped Haskell packages
97
104
  const gen = generatedAt ? Date.parse(generatedAt) : NaN;
@@ -99,6 +106,13 @@ export class NixAdvisoryData {
99
106
  this.stale = this.ageDays === null ? true : this.ageDays > maxAgeDays;
100
107
  this.feed = { source, generatedAt, status: this.stale ? 'stale-cache' : 'current', records: this.records.length };
101
108
  }
109
+ /** 'covered' | 'uncovered' | 'stale' for a CPE identity ('vendor:product'); always 'covered' when the snapshot makes no per-identity claim. */
110
+ cpeCoverage(key) {
111
+ if (!this.covered) return 'covered';
112
+ const t = Date.parse(this.covered[key]);
113
+ if (!Number.isFinite(t)) return 'uncovered';
114
+ return (this.now - t) / 86400000 > this.maxAgeDays ? 'stale' : 'covered';
115
+ }
102
116
  }
103
117
 
104
118
  // ── upstream identity ────────────────────────────────────────────────────────
@@ -125,7 +139,7 @@ export function upstreamIdentity(node, drvEnv, meta, overlay) {
125
139
  const ids = meta && meta.identifiers ? meta.identifiers : null;
126
140
  if (ids && typeof ids.purl === 'string') cands.push({ purl: ids.purl, authority: 'explicit', basis: 'meta.identifiers.purl' });
127
141
  const cpes = ids ? [].concat(ids.cpe || [], ids.possibleCPEs || [], ids.v1 && ids.v1.cpeParts ? [ids.v1.cpeParts] : []).filter(Boolean) : [];
128
- const cpeStrings = cpes.map((c) => (typeof c === 'string' ? c : (c.vendor && c.product ? `${c.vendor}:${c.product}` : null))).filter(Boolean);
142
+ const cpeStrings = cpes.map(cpeKey).filter(Boolean); // 'vendor:product' from a full CPE string or a {vendor, product} part; malformed values are dropped
129
143
  const uniqueCpe = [...new Set(cpeStrings)];
130
144
  if (uniqueCpe.length === 1 && ids && ids.cpe && !ids.possibleCPEs) cands.push({ cpe: uniqueCpe[0], authority: 'explicit', basis: 'meta.identifiers.cpe' });
131
145
  else for (const c of uniqueCpe) cands.push({ cpe: c, authority: 'candidate', basis: uniqueCpe.length > 1 ? 'one of several possible CPEs' : 'possible CPE' });
@@ -139,6 +153,32 @@ export function upstreamIdentity(node, drvEnv, meta, overlay) {
139
153
  return { ...base, candidates: uniq, ambiguous: distinctTargets.size > 1 || (strong.length === 0 && uniq.length > 1) || uniq.some((c) => c.authority === 'candidate'), authority: strong.length && distinctTargets.size === 1 && !uniq.some((c) => c.authority === 'candidate') ? strong[0].authority : (uniq[0] ? 'name-only' : 'none'), haskell: !!isHs };
140
154
  }
141
155
 
156
+ /**
157
+ * The upstream identities of every dependency in a closure, in the form the matcher will use, so a pre-scan step can ask a live feed
158
+ * about exactly what the matcher will look up. `cpes` holds 'vendor:product' keys (explicit and candidate); a component with none is
159
+ * listed in `withoutCpe` so the caller can say what a CPE-keyed feed cannot cover.
160
+ */
161
+ export function closureIdentities({ closure, drvEnv = {}, meta = {}, overlays = {} } = {}) {
162
+ const nodes = (closure && closure.nodes) || [];
163
+ const roots = new Set((closure && closure.roots) || []);
164
+ const subjects = new Map();
165
+ for (const n of nodes) {
166
+ if (n.kind !== 'output' || roots.has(n.id)) continue;
167
+ const key = n.deriver || n.id;
168
+ if (!subjects.has(key)) subjects.set(key, { drv: n.deriver || null, node: n });
169
+ const s = subjects.get(key);
170
+ if (n.outputName === 'out' || (s.node.outputName !== 'out' && n.scopes.includes('runtime'))) s.node = n;
171
+ }
172
+ const cpes = new Set(); const withoutCpe = [];
173
+ for (const s of subjects.values()) {
174
+ const node = s.node;
175
+ const ident = upstreamIdentity(node, (s.drv && drvEnv[s.drv]) || null, meta[node.pname] || meta[node.storeName] || null, overlays[node.pname] || null);
176
+ const mine = ident.candidates.filter((c) => c.cpe).map((c) => c.cpe);
177
+ if (mine.length) mine.forEach((k) => cpes.add(k)); else if (node.pname) withoutCpe.push(node.pname);
178
+ }
179
+ return { cpes: [...cpes].sort(), withoutCpe: [...new Set(withoutCpe)].sort() };
180
+ }
181
+
142
182
  // ── overlays and patches ─────────────────────────────────────────────────────
143
183
  const segName = (s) => (s && s.kind === 'static' ? s.name : null);
144
184
  const unparen = (n) => { let x = n; while (x && x.type === 'paren') x = x.expr; return x; };
@@ -297,11 +337,12 @@ export function matchNixVulnerabilities(opts = {}) {
297
337
  const st = decide(r, pe, ident);
298
338
  matched = true;
299
339
  if (haskellUsage) { const rr = hackageReachability(node.pname, haskellUsage.imports, haskellUsage.callees, symbols[adv.id] || null); tiers.reachability = { import: rr.import, function: rr.function, basis: rr.reason }; }
300
- findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
340
+ findings.push(finding({ ...base, tiers: { ...tiers } }, { id: adv.canonicalId, ids: adv.ids, summary: adv.summary, cves: adv.cveAliases, severityInfo: adv.severityInfo }, st.status, st.reason, { source: data.hackage.source, ecosystem: 'Hackage', fixedIn: aff.fixedIn, patchEvidence: pe }, { kev, epss }));
301
341
  }
302
342
  }
303
343
  // generic records by PURL / CPE; only authoritative identities are matched, candidates are leads
304
344
  for (const adv of data.records) {
345
+ if (adv.withdrawn) continue;
305
346
  for (const aff of adv.affected) {
306
347
  const target = ident.candidates.find((c) => (c.purl && aff.purl && c.purl === aff.purl) || (c.cpe && aff.cpe && c.cpe === aff.cpe) || (c.name && aff.name && !aff.purl && !aff.cpe && c.name === aff.name && c.authority === 'name-only') || (c.ecosystem === 'Hackage' && aff.ecosystem === 'Hackage' && c.name === aff.name));
307
348
  if (!target) continue;
@@ -320,7 +361,21 @@ export function matchNixVulnerabilities(opts = {}) {
320
361
  findings.push(finding({ ...base, tiers: { ...tiers } }, adv, st.status, st.reason, { source: data.source, ecosystem: aff.ecosystem, fixedIn: aff.fixedIn, patchEvidence: pe, identityBasis: target.basis, identityAuthority: target.authority }, { kev, epss }));
321
362
  }
322
363
  }
364
+ // A feed that records coverage per CPE identity can only vouch for the identities it actually read. Without a covered, explicit,
365
+ // unambiguous CPE identity, "no match" says nothing, so it is unknown (never not-affected).
366
+ let covGap = null;
367
+ if (!hackageGap && data.covered) {
368
+ const cpeCands = ident.candidates.filter((c) => c.cpe);
369
+ const firm = !ident.ambiguous ? cpeCands.find((c) => c.authority === 'explicit') : null;
370
+ if (firm) {
371
+ const cv = data.cpeCoverage(firm.cpe);
372
+ if (cv === 'covered') mapped = true;
373
+ else covGap = cv === 'stale' ? `the live advisory feed last read ${firm.cpe} longer ago than its age limit: the absence of a match is not a clean result` : `the live advisory feed never read ${firm.cpe} (it was not queried or the query did not complete): the absence of a match is not a clean result`;
374
+ } else if (!matched && !cpeCands.length) covGap = 'the live advisory feed is keyed by CPE and this component declares no CPE identity (a source URL or purl alone is not something it can look up): the absence of a match is not a clean result';
375
+ }
323
376
  if (matched) summary.matched++;
377
+ if (covGap) { if (!matched) summary.unmapped++; push({ status: 'unknown', reason: covGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
378
+ else if (matched) { /* findings above are the result */ }
324
379
  else if (hackageGap) { summary.unmapped++; push({ status: 'unknown', reason: hackageGap }, { identityMapped: false, feedCoverage: 'incomplete' }); }
325
380
  else if (mapped) { summary.mappedNoMatch++; push({ status: 'not-affected', reason: 'mapped to an upstream identity and no advisory in the supplied feed matches this version' }, { identityMapped: true }); }
326
381
  else { summary.unmapped++; push({ status: 'unknown', reason: ident.ambiguous ? 'the upstream identity is ambiguous: no verdict' : 'the upstream identity could not be mapped to anything an advisory is keyed by' }, { identityMapped: false }); }
@@ -340,10 +395,14 @@ function finding(base, adv, status, reason, extra, { kev, epss }) {
340
395
  const kevHit = cves.length ? (kev ? cves.some((c) => kev.has(c)) : 'unknown') : 'not-applicable';
341
396
  const ep = cves.map((c) => epss && epss[c]).filter((x) => typeof x === 'number');
342
397
  const level = { affected: 'high', 'possibly-affected': 'medium', candidate: 'low', 'backported-verified': 'info', fixed: 'info', unknown: 'low', 'not-affected': 'info' }[status] || 'low';
398
+ // The advisory's own rating applies only where the package is judged vulnerable; the other statuses keep their fixed,
399
+ // lower levels (a verified backport must never be raised by the upstream score).
400
+ const sev = adv.severityInfo || { level: null, score: null, basis: NO_RATING_BASIS };
401
+ const rated = !!sev.level && (status === 'affected' || status === 'possibly-affected');
343
402
  return {
344
403
  type: 'vulnerable_dep', ecosystem: 'nix', language: 'nix', capability: 'sca', analysisKind: 'application', evidenceKind: 'closure',
345
404
  name: base.name, version: base.version, osvId: adv.id, ids: adv.ids || [adv.id], cveAliases: cves, summary: adv.summary || '',
346
- status, matchStatus: status, matchReason: reason, severity: level, severityBasis: 'the advisory carries no severity rating',
405
+ status, matchStatus: status, matchReason: reason, severity: rated ? sev.level : level, severityBasis: rated ? sev.basis : sev.level ? `the match status "${status}" fixes this level; the advisory rating (${sev.basis}) is not applied` : sev.basis, ...(rated ? { severityScore: sev.score } : {}),
347
406
  nixBuild: base.nixBuild, identity: base.identity, tiers: base.tiers, feed: base.feed,
348
407
  kev: kevHit, epss: ep.length ? Math.max(...ep) : 'unknown',
349
408
  fixedIn: extra.fixedIn || [], patchEvidence: extra.patchEvidence || { verified: null, claims: [] }, dataSource: { feed: extra.source || base.feed.source, ecosystem: extra.ecosystem || null, identityBasis: extra.identityBasis || null, identityAuthority: extra.identityAuthority || null, feedStatus: base.feed.status },
@@ -0,0 +1,162 @@
1
+ // Pure pieces of the bounded NixOS option evaluator (NIX-002): value classes, equality, and the
2
+ // closed table of library functions the evaluator is willing to run.
3
+ //
4
+ // The evaluator (nixos-module-resolver.js) is a SOUNDNESS-first subset: a value is either fully known
5
+ // or unknown, and any unknown part makes the whole value unknown. This file is the allow-list. A
6
+ // function that is not in these tables is never run, so `import`, `fetch*`, `readFile`, `toFile`,
7
+ // `currentSystem`, `getEnv`, derivations and every other effectful or environment-dependent primitive
8
+ // are unknown by construction (there is no deny-list to forget an entry in).
9
+
10
+ /** Thrown by a table function (and caught by the resolver) when its arguments are not statically usable. */
11
+ export const NOT_STATIC = Object.freeze({ notStatic: true });
12
+
13
+ /** A lambda captured with its definition environment. Never a final option value. */
14
+ export class Closure {
15
+ constructor(param, body, env) { this.param = param; this.body = body; this.env = env; }
16
+ }
17
+
18
+ /** A table function, possibly partially applied. Never a final option value. */
19
+ export class Builtin {
20
+ constructor(name, def, args = []) { this.name = name; this.def = def; this.args = args; }
21
+ }
22
+
23
+ /** A namespace value (`lib`, `builtins`, `lib.strings`, `lib.lists`). Never a final option value. */
24
+ export class Ns {
25
+ constructor(kind) { this.kind = kind; }
26
+ }
27
+
28
+ export const isAttrs = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
29
+ export const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
30
+ const isInt = (v) => typeof v === 'number' && Number.isSafeInteger(v);
31
+
32
+ /** Own-property assignment that is safe for the attribute name `__proto__`. */
33
+ export function setAttr(obj, key, value) {
34
+ Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true });
35
+ }
36
+
37
+ /** True when a value contains only data (no closure, partial application or namespace). Bounded. */
38
+ export function isPlain(v, budget = { n: 20_000 }, depth = 0) {
39
+ if (--budget.n < 0 || depth > 48) return false;
40
+ if (v === null || typeof v === 'string' || typeof v === 'boolean' || typeof v === 'number') return true;
41
+ if (Array.isArray(v)) return v.every((x) => isPlain(x, budget, depth + 1));
42
+ if (isAttrs(v)) return Object.keys(v).every((k) => isPlain(v[k], budget, depth + 1));
43
+ return false;
44
+ }
45
+
46
+ /** Nix `==`: true, false, or null when it cannot be decided statically (functions are never compared). */
47
+ export function deepEq(a, b, depth = 0) {
48
+ if (depth > 48) return null;
49
+ const fnA = a instanceof Closure || a instanceof Builtin || a instanceof Ns;
50
+ const fnB = b instanceof Closure || b instanceof Builtin || b instanceof Ns;
51
+ if (fnA || fnB) return null;
52
+ if (Array.isArray(a) || Array.isArray(b)) {
53
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
54
+ if (a.length !== b.length) return false;
55
+ let unknown = false;
56
+ for (let i = 0; i < a.length; i++) { const r = deepEq(a[i], b[i], depth + 1); if (r === false) return false; if (r === null) unknown = true; }
57
+ return unknown ? null : true;
58
+ }
59
+ if (isAttrs(a) || isAttrs(b)) {
60
+ if (!isAttrs(a) || !isAttrs(b)) return false;
61
+ const ka = Object.keys(a); const kb = Object.keys(b);
62
+ if (ka.length !== kb.length || !ka.every((k) => hasOwn(b, k))) return false;
63
+ let unknown = false;
64
+ for (const k of ka) { const r = deepEq(a[k], b[k], depth + 1); if (r === false) return false; if (r === null) unknown = true; }
65
+ return unknown ? null : true;
66
+ }
67
+ return a === b;
68
+ }
69
+
70
+ const need = (cond) => { if (!cond) throw NOT_STATIC; };
71
+ const MAX_STRING = 65_536;
72
+
73
+ function toStr(v) {
74
+ if (typeof v === 'string') return v;
75
+ if (v === null || v === false) return '';
76
+ if (v === true) return '1';
77
+ if (isInt(v)) return String(v);
78
+ throw NOT_STATIC;
79
+ }
80
+
81
+ // Each entry: arity and a function over the evaluated arguments. `c.apply(f, x)` runs a closure or table
82
+ // function and throws NOT_STATIC if it is not statically known; `c.size(n)` enforces the size cap.
83
+ const DEFS = {
84
+ map: { arity: 2, call: ([f, xs], c) => { need(Array.isArray(xs)); c.size(xs.length); return xs.map((x) => c.apply(f, x)); } },
85
+ filter: {
86
+ arity: 2,
87
+ call: ([f, xs], c) => { need(Array.isArray(xs)); c.size(xs.length); return xs.filter((x) => { const r = c.apply(f, x); need(typeof r === 'boolean'); return r; }); },
88
+ },
89
+ elem: {
90
+ arity: 2,
91
+ call: ([x, xs]) => {
92
+ need(Array.isArray(xs));
93
+ let unknown = false;
94
+ for (const y of xs) { const r = deepEq(x, y); if (r === true) return true; if (r === null) unknown = true; }
95
+ need(!unknown);
96
+ return false;
97
+ },
98
+ },
99
+ length: { arity: 1, call: ([xs]) => { need(Array.isArray(xs)); return xs.length; } },
100
+ concatLists: {
101
+ arity: 1,
102
+ call: ([xss], c) => { need(Array.isArray(xss) && xss.every(Array.isArray)); const out = [].concat(...xss); c.size(out.length); return out; },
103
+ },
104
+ concatMap: {
105
+ arity: 2,
106
+ call: ([f, xs], c) => {
107
+ need(Array.isArray(xs)); c.size(xs.length);
108
+ const parts = xs.map((x) => { const r = c.apply(f, x); need(Array.isArray(r)); return r; });
109
+ const out = [].concat(...parts); c.size(out.length); return out;
110
+ },
111
+ },
112
+ hasAttr: { arity: 2, call: ([name, set]) => { need(typeof name === 'string' && isAttrs(set)); return hasOwn(set, name); } },
113
+ toString: { arity: 1, call: ([v]) => toStr(v) },
114
+ optionals: { arity: 2, call: ([cond, xs]) => { need(typeof cond === 'boolean' && Array.isArray(xs)); return cond ? xs : []; } },
115
+ optional: { arity: 2, call: ([cond, x]) => { need(typeof cond === 'boolean'); return cond ? [x] : []; } },
116
+ optionalAttrs: { arity: 2, call: ([cond, set]) => { need(typeof cond === 'boolean' && isAttrs(set)); return cond ? set : {}; } },
117
+ optionalString: { arity: 2, call: ([cond, s]) => { need(typeof cond === 'boolean' && typeof s === 'string'); return cond ? s : ''; } },
118
+ concatStringsSep: {
119
+ arity: 2,
120
+ call: ([sep, xs], c) => {
121
+ need(typeof sep === 'string' && Array.isArray(xs) && xs.every((x) => typeof x === 'string')); c.size(xs.length);
122
+ const out = xs.join(sep); need(out.length <= MAX_STRING); return out;
123
+ },
124
+ },
125
+ concatMapStringsSep: {
126
+ arity: 3,
127
+ call: ([sep, f, xs], c) => {
128
+ need(typeof sep === 'string' && Array.isArray(xs)); c.size(xs.length);
129
+ const parts = xs.map((x) => { const r = c.apply(f, x); need(typeof r === 'string'); return r; });
130
+ const out = parts.join(sep); need(out.length <= MAX_STRING); return out;
131
+ },
132
+ },
133
+ hasPrefix: { arity: 2, call: ([p, s]) => { need(typeof p === 'string' && typeof s === 'string'); return s.startsWith(p); } },
134
+ hasSuffix: { arity: 2, call: ([p, s]) => { need(typeof p === 'string' && typeof s === 'string'); return s.endsWith(p); } },
135
+ boolToString: { arity: 1, call: ([b]) => { need(typeof b === 'boolean'); return b ? 'true' : 'false'; } },
136
+ // Only a merge of plain lists is a plain list; any other element (an attribute set, a priority or condition
137
+ // wrapper, an unknown) cannot be merged without the option's type, so it is not static.
138
+ mkMerge: { arity: 1, call: ([xss], c) => { need(Array.isArray(xss) && xss.every(Array.isArray)); const out = [].concat(...xss); c.size(out.length); return out; } },
139
+ };
140
+
141
+ const LIB_NAMES = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'optionals', 'optional', 'optionalAttrs', 'optionalString',
142
+ 'concatStringsSep', 'concatMapStringsSep', 'hasPrefix', 'hasSuffix', 'boolToString', 'mkMerge']);
143
+ const LIB_STRINGS = new Set(['concatStringsSep', 'concatMapStringsSep', 'hasPrefix', 'hasSuffix', 'optionalString']);
144
+ const LIB_LISTS = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'optionals', 'optional']);
145
+ const BUILTIN_NAMES = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'hasAttr', 'toString']);
146
+ /** Names a Nix program has without any import (the ones we model). */
147
+ const GLOBAL_NAMES = new Set(['map', 'toString']);
148
+
149
+ const SETS = { lib: LIB_NAMES, builtins: BUILTIN_NAMES, 'lib.strings': LIB_STRINGS, 'lib.lists': LIB_LISTS };
150
+
151
+ /** Resolves `<ns>.<name>`: a table function, a sub-namespace, or undefined (not modelled). */
152
+ export function nsLookup(kind, name) {
153
+ if (kind === 'lib' && name === 'strings') return new Ns('lib.strings');
154
+ if (kind === 'lib' && name === 'lists') return new Ns('lib.lists');
155
+ const set = SETS[kind];
156
+ if (!set || !set.has(name)) return undefined;
157
+ return new Builtin(name, DEFS[name]);
158
+ }
159
+
160
+ export const isGlobalName = (name) => GLOBAL_NAMES.has(name);
161
+ export const globalValue = (name) => (name === 'builtins' ? new Ns('builtins') : nsLookup('builtins', name));
162
+ export const MODELLED = Object.freeze({ lib: [...LIB_NAMES].sort(), builtins: [...BUILTIN_NAMES].sort() });