@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.
- package/CHANGELOG.md +37 -0
- package/bin/agentic-security.js +111 -1
- package/dist/1128.index.js +912 -0
- package/dist/1665.index.js +828 -0
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +29 -12
- package/src/dataflow/CLAUDE.md +46 -1
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +1168 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +113 -8
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +72 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +174 -0
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +17 -0
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16182
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5817.index.js +0 -714
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- 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.
|
package/src/ir/callgraph.js
CHANGED
|
@@ -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
|
|
343
|
-
if (
|
|
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
|
|
351
|
-
if (
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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 =
|
|
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 =
|
|
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;
|
package/src/ir/parser-js.js
CHANGED
|
@@ -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
|
package/src/language/CLAUDE.md
CHANGED
|
@@ -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
|
+
}
|