@clear-capabilities/agentic-security-scanner 0.185.0 → 0.186.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/bin/agentic-security.js +66 -18
- package/dist/1128.index.js +25 -21
- package/dist/{5817.index.js → 1602.index.js} +378 -29
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/{0b301d45b231f144a1b4.json → 4d06c8944ae121d7085c.json} +8 -8
- 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/9560.index.js +1 -1
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +6 -3
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/demand-query.js +279 -40
- package/src/dataflow/dep-fingerprint.js +1 -1
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/engine.js +62 -5
- 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 +2 -1
- package/src/lineage/smt-orchestrator.js +47 -11
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/posture/CLAUDE.md +5 -1
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/proof-fabric/cli.js +1 -1
- package/src/posture/proof-fabric/container.js +2 -2
- package/src/posture/proof-fabric/generate-haskell.js +205 -0
- package/src/posture/proof-fabric/generate.js +6 -3
- package/src/posture/proof-fabric/reproducer.js +4 -2
- package/src/posture/proof-fabric/runner.js +4 -4
- 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 -16331
- 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/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,1713 @@
|
|
|
1
|
+
export const id = 8158;
|
|
2
|
+
export const ids = [8158];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 88158:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// EXPORTS
|
|
10
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
+
GraphViewError: () => (/* reexport */ refs_GraphViewError),
|
|
12
|
+
dataflowView: () => (/* reexport */ dataflowView)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// UNUSED EXPORTS: ARTIFACT_ADAPTER_VERSION, CROSSREPO_ADAPTER_VERSION, CROSSWALK_VERSION, DATAFLOW_ADAPTER_VERSION, DEFAULT_TRUSTED_ORIGINS, EDGE_ORIGINS, FINDINGS_ADAPTER_VERSION, GRAPH_KINDS, IndexedGraphView, MAPPING_TYPES, PROVENANCE_ADAPTER_VERSION, REF_VERSION, RELATION_KINDS, SEMANTIC_ADAPTER_VERSION, SUPPORTED_ADAPTER_MAJORS, adjacencyCacheStats, artifactView, attributeObservation, canonicalJson, clearAdjacencyCache, createCrosswalk, createGraphRegistry, crossRepoLinkView, digestOf, edgeMatches, findingsView, foreign, graphIdentityKey, local, makeRef, mapCallSitesToWitnesses, mapFindingsToCallSites, mapProjectedNodesToSites, mapWitnessesToFlows, normalizeFilter, parseRef, provenanceView, refKey, sameRef, semanticView, validateRef
|
|
16
|
+
|
|
17
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
18
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
19
|
+
;// CONCATENATED MODULE: ./src/graph/refs.js
|
|
20
|
+
//
|
|
21
|
+
// refs.js: the graph reference format (DAG-06 R06.2).
|
|
22
|
+
//
|
|
23
|
+
// A reference names ONE entity in ONE immutable graph:
|
|
24
|
+
// { v, graphKind, repository, graphDigest, entityKind, localId }
|
|
25
|
+
//
|
|
26
|
+
// The local id is only unique inside its graph, so it is never used alone as
|
|
27
|
+
// a key. The repository identity and the graph digest are part of the
|
|
28
|
+
// reference, which is what keeps two repositories with identical local ids
|
|
29
|
+
// distinct and stops a stale reference from silently resolving against a
|
|
30
|
+
// different version of the graph.
|
|
31
|
+
//
|
|
32
|
+
// Pure functions, no I/O.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
const refs_REF_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
/** The graph layers that have an adapter. Every one keeps its own native schema. */
|
|
39
|
+
const GRAPH_KINDS = Object.freeze([
|
|
40
|
+
'provenance', // PathStore pnode/pedge DAG
|
|
41
|
+
'dataflow', // DataFlowGraph v1
|
|
42
|
+
'semantic', // SemanticGraph v1
|
|
43
|
+
'artifact', // artifact provenance graph
|
|
44
|
+
'cross-repo-links', // declared CrossRepoLink records
|
|
45
|
+
'findings', // scanner findings, as referenceable entities
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
class refs_GraphViewError extends Error {
|
|
49
|
+
constructor(code, message, detail = {}) {
|
|
50
|
+
super(`${code}: ${message}`);
|
|
51
|
+
this.name = 'GraphViewError';
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.detail = detail;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isNonEmpty = (v) => typeof v === 'string' && v.length > 0;
|
|
58
|
+
|
|
59
|
+
/** Deterministic key-sorted JSON, the canonical form every digest here hashes. */
|
|
60
|
+
function refs_canonicalJson(value) {
|
|
61
|
+
const canon = (v) => {
|
|
62
|
+
if (Array.isArray(v)) return v.map(canon);
|
|
63
|
+
if (v && typeof v === 'object') {
|
|
64
|
+
const o = {};
|
|
65
|
+
for (const k of Object.keys(v).sort()) if (v[k] !== undefined) o[k] = canon(v[k]);
|
|
66
|
+
return o;
|
|
67
|
+
}
|
|
68
|
+
return v;
|
|
69
|
+
};
|
|
70
|
+
return JSON.stringify(canon(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function refs_digestOf(value) {
|
|
74
|
+
return createHash('sha256').update(refs_canonicalJson(value)).digest('hex');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a reference object. Throws GraphViewError('BAD_REF') on a missing
|
|
79
|
+
* component: a reference with a blank part is never constructed.
|
|
80
|
+
*/
|
|
81
|
+
function refs_makeRef({ graphKind, repository, graphDigest, entityKind, localId }) {
|
|
82
|
+
const ref = { v: refs_REF_VERSION, graphKind, repository, graphDigest, entityKind, localId };
|
|
83
|
+
const bad = refs_validateRef(ref);
|
|
84
|
+
if (bad) throw new refs_GraphViewError('BAD_REF', bad, { ref });
|
|
85
|
+
return Object.freeze(ref);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @returns {string|null} a problem description, or null when the reference is well formed */
|
|
89
|
+
function refs_validateRef(ref) {
|
|
90
|
+
if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return 'a reference is an object';
|
|
91
|
+
if (ref.v !== refs_REF_VERSION) return `unsupported reference version ${JSON.stringify(ref.v)} (supported: ${refs_REF_VERSION})`;
|
|
92
|
+
if (!GRAPH_KINDS.includes(ref.graphKind)) return `unknown graph kind ${JSON.stringify(ref.graphKind)}`;
|
|
93
|
+
for (const f of ['repository', 'graphDigest', 'entityKind', 'localId']) {
|
|
94
|
+
if (!isNonEmpty(ref[f])) return `${f} is required`;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The identity of the graph a reference points into (no entity part). */
|
|
100
|
+
function refs_graphIdentityKey(ref) {
|
|
101
|
+
return [ref.graphKind, ref.repository, ref.graphDigest].map(encodeURIComponent).join('|');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Stable string form; also the map key used by every index. */
|
|
105
|
+
function refs_refKey(ref) {
|
|
106
|
+
const bad = refs_validateRef(ref);
|
|
107
|
+
if (bad) throw new refs_GraphViewError('BAD_REF', bad, { ref });
|
|
108
|
+
return `gref${ref.v}|${[ref.graphKind, ref.repository, ref.graphDigest, ref.entityKind, ref.localId].map(encodeURIComponent).join('|')}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseRef(text) {
|
|
112
|
+
if (typeof text !== 'string') throw new refs_GraphViewError('BAD_REF', 'a reference string is required');
|
|
113
|
+
const parts = text.split('|');
|
|
114
|
+
const m = /^gref(\d+)$/.exec(parts[0] || '');
|
|
115
|
+
if (!m || parts.length !== 6) throw new refs_GraphViewError('BAD_REF', 'malformed reference string');
|
|
116
|
+
let decoded;
|
|
117
|
+
try { decoded = parts.slice(1).map(decodeURIComponent); } catch { throw new refs_GraphViewError('BAD_REF', 'malformed escape in reference string'); }
|
|
118
|
+
const [graphKind, repository, graphDigest, entityKind, localId] = decoded;
|
|
119
|
+
return refs_makeRef({ graphKind, repository, graphDigest, entityKind, localId, v: Number(m[1]) });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sameRef(a, b) {
|
|
123
|
+
return refs_refKey(a) === refs_refKey(b);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
;// CONCATENATED MODULE: ./src/graph/relations.js
|
|
127
|
+
//
|
|
128
|
+
// relations.js: relation kinds and the traversal filter (DAG-06 R06.3, R06.4).
|
|
129
|
+
//
|
|
130
|
+
// Two independent axes, never collapsed into one:
|
|
131
|
+
// - RELATION: what the edge means (data flow, calls, ...).
|
|
132
|
+
// - ORIGIN: how the edge came to exist (derived from code, declared by an
|
|
133
|
+
// operator, observed at runtime, ...).
|
|
134
|
+
//
|
|
135
|
+
// A query chooses the relations it may traverse AND the origins it trusts.
|
|
136
|
+
// The default filter traverses code-derived and derived edges only, so an
|
|
137
|
+
// operator declaration or a runtime observation is never silently walked as
|
|
138
|
+
// if it were a code-derived data flow (R06.4).
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
const RELATION_KINDS = Object.freeze([
|
|
143
|
+
'data_flow', // a value moves from one place to another
|
|
144
|
+
'control_dependence', // execution of B depends on a decision at A
|
|
145
|
+
'calls', // a call site invokes a callee
|
|
146
|
+
'returns', // a callee result comes back to a caller
|
|
147
|
+
'dependency', // A needs B (package, module, component)
|
|
148
|
+
'declaration', // A declares/contains B (symbol -> call site, origin)
|
|
149
|
+
'observation', // a runtime observation attached to an entity
|
|
150
|
+
'artifact_derivation', // A was built into / produced B
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const EDGE_ORIGINS = Object.freeze([
|
|
154
|
+
'code', // read directly from analyzed source
|
|
155
|
+
'derived', // computed from code-derived facts by a projection or index
|
|
156
|
+
'declared', // asserted by an operator (cross-repo links, deployments)
|
|
157
|
+
'observed', // seen at runtime (telemetry)
|
|
158
|
+
'schema', // taken from a schema or manifest
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/** Origins a default query trusts as "the program's own structure". */
|
|
162
|
+
const DEFAULT_TRUSTED_ORIGINS = Object.freeze(['code', 'derived', 'schema']);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Normalize a relation filter. Accepted forms:
|
|
166
|
+
* undefined / null -> every relation, trusted origins only
|
|
167
|
+
* 'calls' | ['calls', ...] -> those relations, trusted origins only
|
|
168
|
+
* { relations, origins } -> explicit on both axes
|
|
169
|
+
* Unknown relation or origin names throw, so a typo never silently widens
|
|
170
|
+
* or empties a query.
|
|
171
|
+
*/
|
|
172
|
+
function normalizeFilter(filter) {
|
|
173
|
+
let relations = null;
|
|
174
|
+
let origins = DEFAULT_TRUSTED_ORIGINS;
|
|
175
|
+
if (filter === undefined || filter === null) {
|
|
176
|
+
// defaults
|
|
177
|
+
} else if (typeof filter === 'string') {
|
|
178
|
+
relations = [filter];
|
|
179
|
+
} else if (Array.isArray(filter)) {
|
|
180
|
+
relations = filter;
|
|
181
|
+
} else if (typeof filter === 'object') {
|
|
182
|
+
if (filter.relations !== undefined && filter.relations !== null) {
|
|
183
|
+
relations = typeof filter.relations === 'string' ? [filter.relations] : filter.relations;
|
|
184
|
+
}
|
|
185
|
+
if (filter.origins !== undefined && filter.origins !== null) {
|
|
186
|
+
origins = typeof filter.origins === 'string' ? [filter.origins] : filter.origins;
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
throw new refs_GraphViewError('BAD_FILTER', 'a relation filter is a name, a list of names or { relations, origins }');
|
|
190
|
+
}
|
|
191
|
+
if (relations !== null) {
|
|
192
|
+
if (!Array.isArray(relations)) throw new refs_GraphViewError('BAD_FILTER', 'relations must be a list');
|
|
193
|
+
for (const r of relations) {
|
|
194
|
+
if (!RELATION_KINDS.includes(r)) throw new refs_GraphViewError('BAD_FILTER', `unknown relation kind ${JSON.stringify(r)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!Array.isArray(origins)) throw new refs_GraphViewError('BAD_FILTER', 'origins must be a list');
|
|
198
|
+
for (const o of origins) {
|
|
199
|
+
if (!EDGE_ORIGINS.includes(o)) throw new refs_GraphViewError('BAD_FILTER', `unknown edge origin ${JSON.stringify(o)}`);
|
|
200
|
+
}
|
|
201
|
+
return { relations: relations ? new Set(relations) : null, origins: new Set(origins) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function edgeMatches(edge, nf) {
|
|
205
|
+
if (nf.relations && !nf.relations.has(edge.relation)) return false;
|
|
206
|
+
return nf.origins.has(edge.origin);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
;// CONCATENATED MODULE: ./src/graph/view.js
|
|
210
|
+
//
|
|
211
|
+
// view.js: the read-only typed graph view contract (DAG-06 R06.1) and the
|
|
212
|
+
// shared adjacency-index cache (R06.6).
|
|
213
|
+
//
|
|
214
|
+
// A GraphView is a READ-ONLY lens over one existing graph. It never replaces
|
|
215
|
+
// the graph's own schema: every node and edge keeps its original object in
|
|
216
|
+
// `native`, every original id is preserved, and `reconstruct()` hands back the
|
|
217
|
+
// source graph so an adapter round trip loses nothing.
|
|
218
|
+
//
|
|
219
|
+
// The contract (every adapter exposes exactly this):
|
|
220
|
+
// identity { graphKind, repository, graphDigest, adapterVersion }
|
|
221
|
+
// metadata() adapter/graph facts: counts, schema versions, sources
|
|
222
|
+
// completion() { status: 'complete'|'partial'|'unknown', reasons[], coverage }
|
|
223
|
+
// has(ref) boolean, never throws on a well-formed ref
|
|
224
|
+
// node(ref) the entity, or throws DANGLING_REF / WRONG_GRAPH
|
|
225
|
+
// incoming(ref, f) edges ending at ref, restricted by the relation filter
|
|
226
|
+
// outgoing(ref, f) edges starting at ref, restricted by the relation filter
|
|
227
|
+
// reconstruct() the original source graph (round trip)
|
|
228
|
+
//
|
|
229
|
+
// Adjacency indexes are built once per immutable (graphKind, digest, adapter
|
|
230
|
+
// version) and shared by every view over the same content. The index is
|
|
231
|
+
// keyed by LOCAL ids and references are rebuilt per view, so two repositories
|
|
232
|
+
// that happen to share content never share each other's identity.
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
const localKey = (entityKind, localId) => `${entityKind}\u0000${localId}`;
|
|
239
|
+
|
|
240
|
+
// ---- adjacency cache -------------------------------------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
const CACHE_LIMIT = 64;
|
|
243
|
+
const _cache = new Map(); // cacheKey -> { fingerprint, index }
|
|
244
|
+
const _stats = { builds: 0, hits: 0, evictions: 0 };
|
|
245
|
+
|
|
246
|
+
function adjacencyCacheStats() { return { ..._stats, size: _cache.size }; }
|
|
247
|
+
function clearAdjacencyCache() { _cache.clear(); _stats.builds = 0; _stats.hits = 0; _stats.evictions = 0; }
|
|
248
|
+
|
|
249
|
+
function buildIndex(templates) {
|
|
250
|
+
const out = new Map();
|
|
251
|
+
const inn = new Map();
|
|
252
|
+
const push = (m, k, e) => { const a = m.get(k); if (a) a.push(e); else m.set(k, [e]); };
|
|
253
|
+
const endKey = (end) => (end.local ? localKey(end.entityKind, end.localId) : refs_refKey(end.ref));
|
|
254
|
+
for (const t of templates) {
|
|
255
|
+
push(out, endKey(t.from), t);
|
|
256
|
+
push(inn, endKey(t.to), t);
|
|
257
|
+
}
|
|
258
|
+
return { out, in: inn };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sharedIndex({ graphKind, graphDigest, adapterVersion }, templates, fingerprint) {
|
|
262
|
+
const key = `${graphKind}|${graphDigest}|${adapterVersion}`;
|
|
263
|
+
const hit = _cache.get(key);
|
|
264
|
+
if (hit) {
|
|
265
|
+
if (hit.fingerprint !== fingerprint) {
|
|
266
|
+
// The digest is the cache key because a graph with that digest is immutable.
|
|
267
|
+
// Different content under the same digest means the digest was not computed
|
|
268
|
+
// from the content, and serving the cached index would be a silent wrong answer.
|
|
269
|
+
throw new refs_GraphViewError('DIGEST_CONFLICT', `graph digest ${graphDigest} was seen with different content`, { key });
|
|
270
|
+
}
|
|
271
|
+
_stats.hits += 1;
|
|
272
|
+
_cache.delete(key); _cache.set(key, hit); // LRU touch
|
|
273
|
+
return hit.index;
|
|
274
|
+
}
|
|
275
|
+
const index = buildIndex(templates);
|
|
276
|
+
_stats.builds += 1;
|
|
277
|
+
_cache.set(key, { fingerprint, index });
|
|
278
|
+
if (_cache.size > CACHE_LIMIT) {
|
|
279
|
+
_cache.delete(_cache.keys().next().value);
|
|
280
|
+
_stats.evictions += 1;
|
|
281
|
+
}
|
|
282
|
+
return index;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- edge templates --------------------------------------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Adapters describe edges with `local(entityKind, localId)` endpoints for
|
|
289
|
+
* entities of their own graph, or `foreign(ref)` for an endpoint that lives in
|
|
290
|
+
* another graph (cross-repository declarations).
|
|
291
|
+
*/
|
|
292
|
+
const view_local = (entityKind, localId) => ({ local: true, entityKind, localId });
|
|
293
|
+
const view_foreign = (ref) => {
|
|
294
|
+
const bad = validateRef(ref);
|
|
295
|
+
if (bad) throw new GraphViewError('BAD_REF', bad);
|
|
296
|
+
return { local: false, ref };
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
function checkTemplate(t) {
|
|
300
|
+
if (!t || typeof t.id !== 'string' || !t.id) throw new refs_GraphViewError('BAD_EDGE', 'an edge needs an id');
|
|
301
|
+
if (!RELATION_KINDS.includes(t.relation)) throw new refs_GraphViewError('BAD_EDGE', `edge ${t.id}: unknown relation ${JSON.stringify(t.relation)}`);
|
|
302
|
+
if (!EDGE_ORIGINS.includes(t.origin)) throw new refs_GraphViewError('BAD_EDGE', `edge ${t.id}: unknown origin ${JSON.stringify(t.origin)}`);
|
|
303
|
+
if (!t.from || !t.to) throw new refs_GraphViewError('BAD_EDGE', `edge ${t.id}: both endpoints are required`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- the view --------------------------------------------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
class view_IndexedGraphView {
|
|
309
|
+
/**
|
|
310
|
+
* @param {object} spec
|
|
311
|
+
* @param {string} spec.graphKind
|
|
312
|
+
* @param {string} spec.adapterVersion semver of the adapter, not of the graph schema
|
|
313
|
+
* @param {string} spec.repository
|
|
314
|
+
* @param {string} spec.graphDigest digest of the SOURCE graph, preserved as given
|
|
315
|
+
* @param {object} spec.metadata
|
|
316
|
+
* @param {object} spec.completion { status, reasons, coverage }
|
|
317
|
+
* @param {{entityKind:string, localId:string, native:any, summary?:object}[]} spec.nodes
|
|
318
|
+
* @param {object[]} spec.edges edge templates (see local()/foreign())
|
|
319
|
+
* @param {() => any} spec.reconstruct returns the original source graph
|
|
320
|
+
* @param {boolean} [spec.allowForeign] endpoints may live in other graphs
|
|
321
|
+
*/
|
|
322
|
+
constructor(spec) {
|
|
323
|
+
if (!GRAPH_KINDS.includes(spec.graphKind)) throw new refs_GraphViewError('BAD_REF', `unknown graph kind ${spec.graphKind}`);
|
|
324
|
+
if (!/^\d+\.\d+\.\d+$/.test(spec.adapterVersion || '')) throw new refs_GraphViewError('BAD_VERSION', 'adapterVersion must be a semantic version');
|
|
325
|
+
for (const f of ['repository', 'graphDigest']) {
|
|
326
|
+
if (typeof spec[f] !== 'string' || !spec[f]) throw new refs_GraphViewError('BAD_REF', `${f} is required`);
|
|
327
|
+
}
|
|
328
|
+
this.graphKind = spec.graphKind;
|
|
329
|
+
this.adapterVersion = spec.adapterVersion;
|
|
330
|
+
this.repository = spec.repository;
|
|
331
|
+
this.graphDigest = spec.graphDigest;
|
|
332
|
+
this._metadata = spec.metadata ?? {};
|
|
333
|
+
this._completion = spec.completion ?? { status: 'unknown', reasons: ['adapter supplied no completion record'], coverage: null };
|
|
334
|
+
this._reconstruct = spec.reconstruct;
|
|
335
|
+
this._allowForeign = !!spec.allowForeign;
|
|
336
|
+
|
|
337
|
+
this._nodes = new Map();
|
|
338
|
+
for (const n of spec.nodes) {
|
|
339
|
+
const k = localKey(n.entityKind, n.localId);
|
|
340
|
+
if (this._nodes.has(k)) throw new refs_GraphViewError('DUPLICATE_ID', `${this.graphKind} graph has two ${n.entityKind} entities with id ${n.localId}`, { entityKind: n.entityKind, localId: n.localId });
|
|
341
|
+
this._nodes.set(k, n);
|
|
342
|
+
}
|
|
343
|
+
this._templates = [];
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
for (const t of spec.edges) {
|
|
346
|
+
checkTemplate(t);
|
|
347
|
+
if (seen.has(t.id)) throw new refs_GraphViewError('DUPLICATE_ID', `${this.graphKind} graph has two edges with id ${t.id}`, { edgeId: t.id });
|
|
348
|
+
seen.add(t.id);
|
|
349
|
+
for (const end of [t.from, t.to]) {
|
|
350
|
+
if (end.local && !this._nodes.has(localKey(end.entityKind, end.localId))) {
|
|
351
|
+
throw new refs_GraphViewError('DANGLING_REF', `edge ${t.id} points at ${end.entityKind} ${end.localId}, which is not in the graph`, { edgeId: t.id });
|
|
352
|
+
}
|
|
353
|
+
if (!end.local && !this._allowForeign) throw new refs_GraphViewError('BAD_EDGE', `edge ${t.id}: foreign endpoints are only allowed in declaration views`);
|
|
354
|
+
}
|
|
355
|
+
this._templates.push(t);
|
|
356
|
+
}
|
|
357
|
+
// Content fingerprint over every entity key and edge id (order-insensitive). A
|
|
358
|
+
// digest names one immutable graph; the fingerprint is how a digest that does
|
|
359
|
+
// NOT match its content is caught instead of served from the shared index.
|
|
360
|
+
const h = (0,external_node_crypto_.createHash)('sha256');
|
|
361
|
+
for (const k of [...this._nodes.keys()].sort()) h.update(k).update('\u0001');
|
|
362
|
+
h.update('\u0002');
|
|
363
|
+
for (const t of [...this._templates].map((x) => x.id).sort()) h.update(t).update('\u0001');
|
|
364
|
+
this.fingerprint = h.digest('hex');
|
|
365
|
+
this._index = sharedIndex(this.identity, this._templates, this.fingerprint);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
get identity() {
|
|
369
|
+
return { graphKind: this.graphKind, repository: this.repository, graphDigest: this.graphDigest, adapterVersion: this.adapterVersion };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Reference to one of this graph's own entities. Throws DANGLING_REF if it is not there. */
|
|
373
|
+
ref(entityKind, localId) {
|
|
374
|
+
if (!this._nodes.has(localKey(entityKind, localId))) {
|
|
375
|
+
throw new refs_GraphViewError('DANGLING_REF', `no ${entityKind} ${localId} in this ${this.graphKind} graph`, { entityKind, localId });
|
|
376
|
+
}
|
|
377
|
+
return refs_makeRef({ graphKind: this.graphKind, repository: this.repository, graphDigest: this.graphDigest, entityKind, localId });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
owns(ref) {
|
|
381
|
+
return ref.graphKind === this.graphKind && ref.repository === this.repository && ref.graphDigest === this.graphDigest;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
_checkRef(ref) {
|
|
385
|
+
const bad = refs_validateRef(ref);
|
|
386
|
+
if (bad) {
|
|
387
|
+
throw new refs_GraphViewError(ref && ref.v !== undefined && ref.v !== refs_REF_VERSION ? 'VERSION_INCOMPATIBLE' : 'BAD_REF', bad, { ref });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
has(ref) {
|
|
392
|
+
this._checkRef(ref);
|
|
393
|
+
return this.owns(ref) && this._nodes.has(localKey(ref.entityKind, ref.localId));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
node(ref) {
|
|
397
|
+
this._checkRef(ref);
|
|
398
|
+
if (!this.owns(ref)) {
|
|
399
|
+
throw new refs_GraphViewError('WRONG_GRAPH', `reference points into ${ref.graphKind}/${ref.repository}/${ref.graphDigest.slice(0, 12)}, not this view`, { ref });
|
|
400
|
+
}
|
|
401
|
+
const n = this._nodes.get(localKey(ref.entityKind, ref.localId));
|
|
402
|
+
if (!n) throw new refs_GraphViewError('DANGLING_REF', `no ${ref.entityKind} ${ref.localId} in this ${this.graphKind} graph`, { ref });
|
|
403
|
+
return { ref, entityKind: n.entityKind, localId: n.localId, summary: n.summary ?? null, native: n.native };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
_lookup(map, ref) {
|
|
407
|
+
this._checkRef(ref);
|
|
408
|
+
let key;
|
|
409
|
+
if (this.owns(ref)) {
|
|
410
|
+
key = localKey(ref.entityKind, ref.localId);
|
|
411
|
+
if (!this._nodes.has(key)) throw new refs_GraphViewError('DANGLING_REF', `no ${ref.entityKind} ${ref.localId} in this ${this.graphKind} graph`, { ref });
|
|
412
|
+
} else if (this._allowForeign) {
|
|
413
|
+
key = refs_refKey(ref);
|
|
414
|
+
} else {
|
|
415
|
+
throw new refs_GraphViewError('WRONG_GRAPH', 'reference points into a different graph', { ref });
|
|
416
|
+
}
|
|
417
|
+
return map.get(key) ?? [];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
_materialize(t) {
|
|
421
|
+
const end = (e) => (e.local ? this.ref(e.entityKind, e.localId) : e.ref);
|
|
422
|
+
return {
|
|
423
|
+
id: t.id, relation: t.relation, origin: t.origin,
|
|
424
|
+
from: end(t.from), to: end(t.to),
|
|
425
|
+
evidence: t.evidence ?? { grade: 'none', source: null },
|
|
426
|
+
unknowns: t.unknowns ?? [],
|
|
427
|
+
attrs: t.attrs ?? {},
|
|
428
|
+
native: t.native,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
outgoing(ref, filter) {
|
|
433
|
+
const nf = normalizeFilter(filter);
|
|
434
|
+
return this._lookup(this._index.out, ref).filter((t) => edgeMatches(t, nf)).map((t) => this._materialize(t));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
incoming(ref, filter) {
|
|
438
|
+
const nf = normalizeFilter(filter);
|
|
439
|
+
return this._lookup(this._index.in, ref).filter((t) => edgeMatches(t, nf)).map((t) => this._materialize(t));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Entities in insertion order, optionally of one kind. */
|
|
443
|
+
entities(entityKind) {
|
|
444
|
+
const out = [];
|
|
445
|
+
for (const n of this._nodes.values()) if (!entityKind || n.entityKind === entityKind) out.push(this.node(this.ref(n.entityKind, n.localId)));
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
edges() { return this._templates.map((t) => this._materialize(t)); }
|
|
450
|
+
|
|
451
|
+
metadata() {
|
|
452
|
+
return { ...this._metadata, graphKind: this.graphKind, repository: this.repository, graphDigest: this.graphDigest, adapterVersion: this.adapterVersion, nodeCount: this._nodes.size, edgeCount: this._templates.length };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
completion() {
|
|
456
|
+
const c = this._completion;
|
|
457
|
+
return { status: c.status, reasons: [...(c.reasons ?? [])], coverage: c.coverage ?? null };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
reconstruct() { return this._reconstruct(); }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
;// CONCATENATED MODULE: ./src/graph/adapters/provenance.js
|
|
464
|
+
//
|
|
465
|
+
// adapters/provenance.js: read-only view over a PathStore provenance DAG
|
|
466
|
+
// (pnode / pedge). Duck-typed on the store's read API (`nodes()`, `edges()`,
|
|
467
|
+
// `limitStatus()`, `diagnostics()`); it does not import path-store.js or any
|
|
468
|
+
// analysis engine.
|
|
469
|
+
//
|
|
470
|
+
// Entities: `pnode` (a point in the DAG) and `pedge` (one witness: a join of an
|
|
471
|
+
// in-half and an out-half at one program site). A pedge is BOTH an entity, so a
|
|
472
|
+
// crosswalk can name it as a witness, and a relation edge between two pnodes.
|
|
473
|
+
//
|
|
474
|
+
// Relation choice, from the store's own fields (nothing is guessed):
|
|
475
|
+
// widenReason 'control-dependence' -> control_dependence
|
|
476
|
+
// target path '<return>' -> returns
|
|
477
|
+
// crossScope (argument into a callee) -> calls
|
|
478
|
+
// otherwise -> data_flow
|
|
479
|
+
// Evidence grade reuses the flow-grade vocabulary: severed (loss reason),
|
|
480
|
+
// ambiguous (correlation ambiguity), widened, implicit, else explicit.
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
const PROVENANCE_ADAPTER_VERSION = '1.0.0';
|
|
486
|
+
|
|
487
|
+
function relationOf(e, toNode) {
|
|
488
|
+
if ((e.widenReasons || []).includes('control-dependence')) return 'control_dependence';
|
|
489
|
+
if (toNode && toNode.path === '<return>') return 'returns';
|
|
490
|
+
if (e.crossScope) return 'calls';
|
|
491
|
+
return 'data_flow';
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function gradeOf(e) {
|
|
495
|
+
if ((e.lossReasons || []).length) return 'severed';
|
|
496
|
+
if (e.ambiguousCorrelation) return 'ambiguous';
|
|
497
|
+
if ((e.widenReasons || []).includes('control-dependence')) return 'implicit';
|
|
498
|
+
if ((e.widenReasons || []).length) return 'widened';
|
|
499
|
+
return 'explicit';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* @param {{nodes:()=>object[], edges:()=>object[], limitStatus?:()=>object, diagnostics?:()=>object}} store
|
|
504
|
+
* @param {{repository:string, completion?:object}} opts
|
|
505
|
+
* `completion` may carry the driver's analysisCompletion record; it is
|
|
506
|
+
* folded into the view's completion() so an unfinished analysis never
|
|
507
|
+
* reads as complete.
|
|
508
|
+
*/
|
|
509
|
+
function provenanceView(store, opts) {
|
|
510
|
+
if (!store || typeof store.nodes !== 'function' || typeof store.edges !== 'function') {
|
|
511
|
+
throw new TypeError('provenanceView requires a store with nodes() and edges()');
|
|
512
|
+
}
|
|
513
|
+
const repository = opts?.repository;
|
|
514
|
+
const pnodes = store.nodes();
|
|
515
|
+
const pedges = store.edges();
|
|
516
|
+
const byId = new Map(pnodes.map((n) => [n.id, n]));
|
|
517
|
+
const graphDigest = digestOf({ nodes: pnodes, edges: pedges });
|
|
518
|
+
|
|
519
|
+
const nodes = [
|
|
520
|
+
...pnodes.map((n) => ({ entityKind: 'pnode', localId: n.id, native: n, summary: { kind: n.kind, scope: n.scope, path: n.path, truncated: !!n.truncated } })),
|
|
521
|
+
...pedges.map((e) => ({ entityKind: 'pedge', localId: e.id, native: e, summary: { scope: e.scope, line: e.line ?? null, siteNodeId: e.siteNodeId ?? null, dataElementId: e.dataElementId } })),
|
|
522
|
+
];
|
|
523
|
+
const edges = pedges.map((e) => {
|
|
524
|
+
const unknowns = [
|
|
525
|
+
...(e.widenReasons || []).map((r) => ({ kind: 'widen', reason: r })),
|
|
526
|
+
...(e.lossReasons || []).map((r) => ({ kind: 'loss', reason: r })),
|
|
527
|
+
];
|
|
528
|
+
if (e.truncated) unknowns.push({ kind: 'truncated', reason: 'analysis-incomplete' });
|
|
529
|
+
return {
|
|
530
|
+
id: e.id, relation: relationOf(e, byId.get(e.toNodeId)), origin: 'code',
|
|
531
|
+
from: local('pnode', e.fromNodeId), to: local('pnode', e.toNodeId),
|
|
532
|
+
evidence: { grade: gradeOf(e), source: 'path-store' },
|
|
533
|
+
unknowns, attrs: { line: e.line ?? null, scope: e.scope, dataElementId: e.dataElementId }, native: e,
|
|
534
|
+
};
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const reasons = [];
|
|
538
|
+
const lim = typeof store.limitStatus === 'function' ? store.limitStatus() : null;
|
|
539
|
+
if (lim?.hit) reasons.push('store-limit-hit');
|
|
540
|
+
const diag = typeof store.diagnostics === 'function' ? store.diagnostics() : null;
|
|
541
|
+
if (diag?.truncations?.length) reasons.push(`truncated-units:${diag.truncations.length}`);
|
|
542
|
+
if (diag?.orphanedPeerSources?.length) reasons.push(`orphaned-peer-sources:${diag.orphanedPeerSources.length}`);
|
|
543
|
+
if (diag?.malformed?.length) reasons.push(`malformed-hops:${diag.malformed.length}`);
|
|
544
|
+
const ac = opts?.completion ?? null;
|
|
545
|
+
if (ac && ac.status && ac.status !== 'complete') reasons.push(`analysis-${ac.status}`);
|
|
546
|
+
const status = reasons.length ? 'partial' : (lim || ac) ? 'complete' : 'unknown';
|
|
547
|
+
|
|
548
|
+
return new IndexedGraphView({
|
|
549
|
+
graphKind: 'provenance', adapterVersion: PROVENANCE_ADAPTER_VERSION, repository, graphDigest,
|
|
550
|
+
metadata: { source: 'PathStore', pnodes: pnodes.length, pedges: pedges.length, relationEdges: edges.length },
|
|
551
|
+
completion: { status, reasons, coverage: { limits: lim, analysis: ac } },
|
|
552
|
+
nodes, edges,
|
|
553
|
+
reconstruct: () => ({ nodes: pnodes, edges: pedges }),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// EXTERNAL MODULE: ./src/lineage/export-json.js
|
|
558
|
+
var export_json = __webpack_require__(40859);
|
|
559
|
+
;// CONCATENATED MODULE: ./src/graph/adapters/dataflow.js
|
|
560
|
+
//
|
|
561
|
+
// adapters/dataflow.js: read-only view over a DataFlowGraph v1 document (the
|
|
562
|
+
// graph-builder output / signed export body).
|
|
563
|
+
//
|
|
564
|
+
// The graph keeps its own schema, validator and digest. This view:
|
|
565
|
+
// - uses the existing `computeGraphDigest` as the graph digest, so the
|
|
566
|
+
// digest in every reference equals the digest the export already signs;
|
|
567
|
+
// - exposes node, edge, flow, dataElement, evidence and transformation
|
|
568
|
+
// entities under their original ids;
|
|
569
|
+
// - maps `edge.provenance` to the origin axis (code -> code, schema ->
|
|
570
|
+
// schema, manual -> declared, runtime -> observed), so a runtime-observed
|
|
571
|
+
// or manually declared edge is never traversed as a code-derived one by a
|
|
572
|
+
// default query;
|
|
573
|
+
// - reports completion from `scanHealth`, `coverage` and `limitations`.
|
|
574
|
+
//
|
|
575
|
+
// Projected nodes are category-granular: one node can stand for many source
|
|
576
|
+
// sites. That coarsening is not hidden here; it is recorded by crosswalks
|
|
577
|
+
// (one-to-many) built in crosswalk-builders.js.
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
const DATAFLOW_ADAPTER_VERSION = '1.0.0';
|
|
583
|
+
|
|
584
|
+
const ORIGIN_OF_PROVENANCE = { code: 'code', schema: 'schema', manual: 'declared', runtime: 'observed' };
|
|
585
|
+
|
|
586
|
+
const ENTITY_SECTIONS = [
|
|
587
|
+
['node', 'nodes'], ['edge', 'edges'], ['flow', 'flows'],
|
|
588
|
+
['dataElement', 'dataElements'], ['evidence', 'evidence'], ['transformation', 'transformations'],
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* @param {object} graph DataFlowGraph v1 (e.g. buildDataFlowGraph(...).graph)
|
|
593
|
+
* @param {{repository?:string}} [opts]
|
|
594
|
+
*/
|
|
595
|
+
function dataflowView(graph, opts = {}) {
|
|
596
|
+
if (!graph || typeof graph !== 'object' || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
|
597
|
+
throw new TypeError('dataflowView requires a DataFlowGraph v1 object with nodes[] and edges[]');
|
|
598
|
+
}
|
|
599
|
+
const repository = opts.repository ?? graph.scope?.repository;
|
|
600
|
+
const graphDigest = (0,export_json.computeGraphDigest)(graph);
|
|
601
|
+
|
|
602
|
+
const nodes = [];
|
|
603
|
+
for (const [kind, section] of ENTITY_SECTIONS) {
|
|
604
|
+
for (const e of graph[section] || []) {
|
|
605
|
+
nodes.push({ entityKind: kind, localId: e.id, native: e, summary: kind === 'node' ? { kind: e.kind, subtype: e.subtype ?? null, label: e.label ?? null, coverageStatus: e.coverageStatus ?? null } : null });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const edges = graph.edges.map((e) => {
|
|
609
|
+
const unknowns = [];
|
|
610
|
+
if (e.protocol?.destinationResolution && e.protocol.destinationResolution !== 'literal') unknowns.push({ kind: 'destination-resolution', reason: e.protocol.destinationResolution });
|
|
611
|
+
if (e.coverageStatus && e.coverageStatus !== 'modeled') unknowns.push({ kind: 'coverage', reason: e.coverageStatus });
|
|
612
|
+
return {
|
|
613
|
+
id: e.id, relation: 'data_flow', origin: ORIGIN_OF_PROVENANCE[e.provenance] ?? 'schema',
|
|
614
|
+
from: view_local('node', e.from), to: view_local('node', e.to),
|
|
615
|
+
evidence: { grade: e.evidenceGrade ?? e.coverageStatus ?? 'none', source: 'dataflow-graph', evidenceRefs: e.evidenceRefs ?? [] },
|
|
616
|
+
unknowns, attrs: { provenance: e.provenance, relationship: e.relationship }, native: e,
|
|
617
|
+
};
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const health = graph.scanHealth || {};
|
|
621
|
+
const reasons = [];
|
|
622
|
+
const status = health.status === 'complete' ? 'complete' : health.status ? 'partial' : 'unknown';
|
|
623
|
+
if (health.status && health.status !== 'complete') reasons.push(`scan-health:${health.status}${health.reason ? `:${health.reason}` : ''}`);
|
|
624
|
+
if (graph.coverage?.pathBudgetTruncation) reasons.push('path-budget-truncation');
|
|
625
|
+
if (health.analysisCompletion && health.analysisCompletion.status && health.analysisCompletion.status !== 'complete') reasons.push(`analysis-${health.analysisCompletion.status}`);
|
|
626
|
+
if ((graph.limitations || []).length) reasons.push(`limitations:${graph.limitations.length}`);
|
|
627
|
+
|
|
628
|
+
return new view_IndexedGraphView({
|
|
629
|
+
graphKind: 'dataflow', adapterVersion: DATAFLOW_ADAPTER_VERSION, repository, graphDigest,
|
|
630
|
+
metadata: { schemaVersion: graph.schemaVersion ?? null, graphId: graph.graphId ?? null, scope: graph.scope ?? null, counts: Object.fromEntries(ENTITY_SECTIONS.map(([k, s]) => [k, (graph[s] || []).length])) },
|
|
631
|
+
completion: { status: reasons.length && status === 'complete' ? 'partial' : status, reasons, coverage: graph.coverage ?? null },
|
|
632
|
+
nodes, edges,
|
|
633
|
+
reconstruct: () => graph,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
;// CONCATENATED MODULE: ./src/dataflow/analysis-limits.js
|
|
638
|
+
// Dynamic features the static analysis cannot resolve, reported instead of silently approximated (E2-03).
|
|
639
|
+
//
|
|
640
|
+
// Where the engine meets one of these it takes the sound direction (a write through an unknown key taints the whole container, a read through
|
|
641
|
+
// an unknown key reads the whole container) and loses precision. That is the right trade, but it must be visible: a result that depended on it is
|
|
642
|
+
// less precise than one that did not, and a reviewer should be able to see which functions contain such constructs.
|
|
643
|
+
//
|
|
644
|
+
// Pure: reads the Layer-1 IR only.
|
|
645
|
+
const LIMIT_KINDS = Object.freeze({
|
|
646
|
+
'computed-write': 'a write through a key that is not a literal (`o[k] = v`): the engine taints the whole container `o`, so clean sibling fields read afterwards are over-approximated as tainted',
|
|
647
|
+
'computed-read': 'a read through a key that is not a literal (`o[k]`): the engine cannot tell which field is read, so any tainted field of `o` reaches it',
|
|
648
|
+
'eval-like': 'code built at run time (`eval`, `new Function`, `setTimeout` with a string): its content is not analysed',
|
|
649
|
+
'dynamic-require': 'a module name computed at run time (`require(name)`, `import(name)`): the callee is unknown, so the call stays unresolved',
|
|
650
|
+
'reflective-call': 'a call through reflection (`Reflect.apply`, `fn.call/apply` on a computed target, `obj[name]()`): the callee is unknown',
|
|
651
|
+
'getter-or-proxy': 'property access that may run user code (`Proxy`, `Object.defineProperty` accessors): reads and writes may have effects the engine does not model',
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
const expressions = (e, acc = []) => {
|
|
655
|
+
if (!e || typeof e !== 'object') return acc;
|
|
656
|
+
acc.push(e);
|
|
657
|
+
for (const k of ['object', 'callee', 'left', 'right', 'value']) if (e[k] && typeof e[k] === 'object') expressions(e[k], acc);
|
|
658
|
+
for (const k of ['args', 'parts', 'elements', 'props', 'branches']) if (Array.isArray(e[k])) for (const x of e[k]) expressions(x && x.value && !x.kind ? x.value : x, acc);
|
|
659
|
+
return acc;
|
|
660
|
+
};
|
|
661
|
+
const nameOf = (c) => (typeof c === 'string' ? c : c && c.kind === 'ident' ? c.name : c && c.kind === 'member' ? `${nameOf(c.object)}.${c.prop}` : '');
|
|
662
|
+
|
|
663
|
+
/** @returns {{kind:string, line:number, detail:string, effect:string}[]} */
|
|
664
|
+
function limitsOfFunction(fn) {
|
|
665
|
+
const out = [];
|
|
666
|
+
const add = (kind, line, detail) => out.push({ kind, line: Number.isInteger(line) ? line : (fn.line || 0), detail, effect: LIMIT_KINDS[kind] });
|
|
667
|
+
for (const n of Object.values(fn.cfg?.nodes || {})) {
|
|
668
|
+
if (n.kind === 'assign') {
|
|
669
|
+
if (typeof n.target === 'string' && /\.\*$/.test(n.target)) add('computed-write', n.line, n.target);
|
|
670
|
+
for (const x of expressions(n.source)) inspect(x, n.line, add);
|
|
671
|
+
} else if (n.kind === 'call') {
|
|
672
|
+
inspect({ kind: 'call', callee: n.callee, args: n.args }, n.line, add);
|
|
673
|
+
for (const a of n.args || []) for (const x of expressions(a)) inspect(x, n.line, add);
|
|
674
|
+
} else if (n.kind === 'return' && n.value) for (const x of expressions(n.value)) inspect(x, n.line, add);
|
|
675
|
+
}
|
|
676
|
+
return out;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function inspect(x, line, add) {
|
|
680
|
+
if (x.kind === 'member' && x.prop === '*') add('computed-read', line, `${nameOf(x.object)}[…]`);
|
|
681
|
+
if (x.kind === 'call') {
|
|
682
|
+
const c = nameOf(x.callee);
|
|
683
|
+
if (/^(?:eval|Function|new Function|setTimeout|setInterval)$/.test(c)) { if (c === 'eval' || c === 'Function' || c === 'new Function' || (x.args && x.args[0] && x.args[0].kind !== 'function-value' && x.args[0].kind !== 'ident' && x.args[0].kind !== 'literal')) add('eval-like', line, c); else if ((c === 'setTimeout' || c === 'setInterval') && x.args && x.args[0] && x.args[0].kind === 'literal' && typeof x.args[0].value === 'string') add('eval-like', line, `${c}(string)`); }
|
|
684
|
+
if (c === 'require' && x.args && x.args[0] && x.args[0].kind !== 'literal') add('dynamic-require', line, 'require(<expression>)');
|
|
685
|
+
if (/^Reflect\.(?:apply|construct|get|set)$/.test(c)) add('reflective-call', line, c);
|
|
686
|
+
if (x.callee && x.callee.kind === 'member' && x.callee.prop === '*') add('reflective-call', line, `${nameOf(x.callee.object)}[…](…)`);
|
|
687
|
+
if (/^(?:Object\.defineProperty|Object\.defineProperties|Object\.setPrototypeOf)$/.test(c) || c === 'Proxy' || c === 'new Proxy') add('getter-or-proxy', line, c);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* @param {{perFile:Record<string,{functions:object[]}>}} projectIR
|
|
693
|
+
* @returns {{file:string, qid:string, function:string, kind:string, line:number, detail:string, effect:string}[]} sorted, stable
|
|
694
|
+
*/
|
|
695
|
+
function analysis_limits_analysisLimits(projectIR) {
|
|
696
|
+
const out = [];
|
|
697
|
+
for (const file of Object.keys(projectIR?.perFile || {}).sort()) {
|
|
698
|
+
for (const fn of projectIR.perFile[file].functions || []) {
|
|
699
|
+
for (const l of limitsOfFunction(fn)) out.push({ file, qid: String(fn.qid).replace(/@\d+$/, ''), function: fn.name, ...l });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line || (a.kind < b.kind ? -1 : 1)));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
;// CONCATENATED MODULE: ./src/ir/semantic-graph.js
|
|
706
|
+
// SemanticGraph v1: a typed, versioned, additive view over the Layer-1 IR (E2-01).
|
|
707
|
+
//
|
|
708
|
+
// WHY. The IR (`buildProjectIR`) is a bag of per-function records consumed by the taint engine. It has no stable identifiers, no shared
|
|
709
|
+
// entity types and no serialization contract, so every analysis that needs "the call at this place" or "where did this value come from"
|
|
710
|
+
// re-derives it with its own conventions, and nothing can be compared across runs. This module names the entities once, gives each a
|
|
711
|
+
// STABLE id, and fixes a versioned wire format.
|
|
712
|
+
//
|
|
713
|
+
// ADDITIVE. Building the graph reads the IR and never modifies it; it is not wired into findings, `chain[]` or last-scan.json. Existing
|
|
714
|
+
// output is unchanged by construction (a test pins that). A consumer opts in by calling `buildSemanticGraph`.
|
|
715
|
+
//
|
|
716
|
+
// HONEST. An entity type the IR cannot yet supply (Sanitizer, Guard, ServiceBoundary, StorageEdge) is declared in the schema, emitted as an
|
|
717
|
+
// empty list, and reported `schema-only` in `coverage` with the reason. A location the IR does not carry (column) is `null` and the entity
|
|
718
|
+
// says `locationPrecision: 'line'`. Nothing is guessed to fill a field.
|
|
719
|
+
//
|
|
720
|
+
// STABLE IDS. An id is the first 16 hex of SHA-256 over the entity's kind and its identity fields, which never include a line number: adding
|
|
721
|
+
// a blank line above a function does not change any id in it. Ties between otherwise identical entities (two `exec(x)` in one function) are
|
|
722
|
+
// broken by their order of appearance in that function.
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
const semantic_graph_SEMANTIC_GRAPH_SCHEMA = 'agentic-security/semantic-graph';
|
|
727
|
+
const SEMANTIC_GRAPH_VERSION = '1.0.0';
|
|
728
|
+
const semantic_graph_SUPPORTED_MAJOR = 1;
|
|
729
|
+
|
|
730
|
+
const semantic_graph_ENTITY_LISTS = Object.freeze(['symbols', 'callSites', 'allocations', 'fieldAccesses', 'valueOrigins', 'serviceBoundaries', 'storageEdges', 'sanitizers', 'guards']);
|
|
731
|
+
const CONFIDENCE_TIERS = Object.freeze(['observed', 'inferred', 'unresolved']);
|
|
732
|
+
const SYMBOL_KINDS = Object.freeze(['module', 'function', 'class', 'variable', 'parameter']);
|
|
733
|
+
const ORIGIN_KINDS = Object.freeze(['parameter', 'call-result', 'literal', 'member', 'concat', 'object', 'array', 'function', 'identifier', 'unknown']);
|
|
734
|
+
|
|
735
|
+
const ID_PREFIX = Object.freeze({ symbols: 'sym', callSites: 'call', allocations: 'alloc', fieldAccesses: 'field', valueOrigins: 'orig', serviceBoundaries: 'svc', storageEdges: 'store', sanitizers: 'san', guards: 'guard' });
|
|
736
|
+
|
|
737
|
+
const h16 = (parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
|
|
738
|
+
const stripLine = (qid) => String(qid).replace(/@\d+$/, '');
|
|
739
|
+
|
|
740
|
+
// ---- canonical serialization ------------------------------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
function canon(v) {
|
|
743
|
+
if (Array.isArray(v)) return v.map(canon);
|
|
744
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])]));
|
|
745
|
+
return v;
|
|
746
|
+
}
|
|
747
|
+
function serializeGraph(graph) {
|
|
748
|
+
const g = { ...graph };
|
|
749
|
+
for (const k of semantic_graph_ENTITY_LISTS) g[k] = [...(graph[k] || [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
750
|
+
return JSON.stringify(canon(g));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---- validation -------------------------------------------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
/** @returns {{ok:boolean, errors:{code:string,path:string,message:string}[]}} Never throws on hostile input. */
|
|
756
|
+
function semantic_graph_validateGraph(g) {
|
|
757
|
+
const errors = [];
|
|
758
|
+
const err = (code, path, message) => errors.push({ code, path, message });
|
|
759
|
+
if (!g || typeof g !== 'object' || Array.isArray(g)) return { ok: false, errors: [{ code: 'NOT_AN_OBJECT', path: '', message: 'a graph is an object' }] };
|
|
760
|
+
if (g.schema !== semantic_graph_SEMANTIC_GRAPH_SCHEMA) { err('SCHEMA_MISMATCH', 'schema', `expected ${semantic_graph_SEMANTIC_GRAPH_SCHEMA}`); return { ok: false, errors }; }
|
|
761
|
+
const m = typeof g.schemaVersion === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(g.schemaVersion) : null;
|
|
762
|
+
if (!m) { err('BAD_VERSION', 'schemaVersion', 'must be a semantic version'); return { ok: false, errors }; }
|
|
763
|
+
if (Number(m[1]) !== semantic_graph_SUPPORTED_MAJOR) { err('UNSUPPORTED_MAJOR', 'schemaVersion', `major ${m[1]} is not supported (supported: ${semantic_graph_SUPPORTED_MAJOR}); migrate it first`); return { ok: false, errors }; }
|
|
764
|
+
const ids = new Map();
|
|
765
|
+
for (const list of semantic_graph_ENTITY_LISTS) {
|
|
766
|
+
if (!Array.isArray(g[list])) { err('MISSING_FIELD', list, 'must be a list (empty when none)'); continue; }
|
|
767
|
+
g[list].forEach((e, i) => {
|
|
768
|
+
const p = `${list}[${i}]`;
|
|
769
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) { err('BAD_TYPE', p, 'must be an object'); return; }
|
|
770
|
+
if (typeof e.id !== 'string' || !new RegExp(`^${ID_PREFIX[list]}:[0-9a-f]{16}$`).test(e.id)) err('BAD_ID', `${p}.id`, `must look like ${ID_PREFIX[list]}:<16 hex>`);
|
|
771
|
+
else if (ids.has(e.id)) err('DUPLICATE_ID', `${p}.id`, `duplicate id ${e.id}`); else ids.set(e.id, list);
|
|
772
|
+
if (typeof e.file !== 'string' || !e.file || e.file.startsWith('/') || e.file.split('/').includes('..')) err('BAD_TYPE', `${p}.file`, 'must be a repository-relative path');
|
|
773
|
+
if (!(Number.isInteger(e.line) && e.line >= 0)) err('BAD_TYPE', `${p}.line`, 'line must be a non-negative integer');
|
|
774
|
+
if (!(e.column === null || (Number.isInteger(e.column) && e.column >= 0))) err('BAD_TYPE', `${p}.column`, 'column must be null or a non-negative integer');
|
|
775
|
+
if (e.column === null && e.locationPrecision !== 'line') err('RULE_VIOLATION', `${p}.locationPrecision`, "a null column requires locationPrecision 'line'");
|
|
776
|
+
const c = e.confidence;
|
|
777
|
+
if (!c || typeof c !== 'object' || !CONFIDENCE_TIERS.includes(c.tier) || typeof c.basis !== 'string' || !c.basis) err('BAD_TYPE', `${p}.confidence`, 'needs { tier, basis }');
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
if (errors.length) return { ok: false, errors };
|
|
781
|
+
const need = (e, p, field, list) => { if (e[field] !== null && e[field] !== undefined && ids.get(e[field]) !== list) err('DANGLING_REF', `${p}.${field}`, `${e[field]} is not a ${list} id`); };
|
|
782
|
+
g.symbols.forEach((e, i) => { if (!SYMBOL_KINDS.includes(e.kind)) err('UNKNOWN_ENUM', `symbols[${i}].kind`, `unknown symbol kind ${e.kind}`); need(e, `symbols[${i}]`, 'parent', 'symbols'); });
|
|
783
|
+
g.callSites.forEach((e, i) => { need(e, `callSites[${i}]`, 'caller', 'symbols'); need(e, `callSites[${i}]`, 'callee', 'symbols'); if (e.callee && e.resolution !== 'resolved') err('RULE_VIOLATION', `callSites[${i}].resolution`, 'a call with a callee must be resolved'); if (!e.callee && e.resolution === 'resolved') err('RULE_VIOLATION', `callSites[${i}].resolution`, 'a resolved call needs a callee'); (e.args || []).forEach((a, j) => need(a, `callSites[${i}].args[${j}]`, 'origin', 'valueOrigins')); });
|
|
784
|
+
g.valueOrigins.forEach((e, i) => { if (!ORIGIN_KINDS.includes(e.kind)) err('UNKNOWN_ENUM', `valueOrigins[${i}].kind`, `unknown origin kind ${e.kind}`); need(e, `valueOrigins[${i}]`, 'symbol', 'symbols'); });
|
|
785
|
+
g.fieldAccesses.forEach((e, i) => { need(e, `fieldAccesses[${i}]`, 'symbol', 'symbols'); if (!['read', 'write'].includes(e.mode)) err('UNKNOWN_ENUM', `fieldAccesses[${i}].mode`, 'mode must be read or write'); });
|
|
786
|
+
g.allocations.forEach((e, i) => need(e, `allocations[${i}]`, 'symbol', 'symbols'));
|
|
787
|
+
if (!g.coverage || typeof g.coverage !== 'object') err('MISSING_FIELD', 'coverage', 'coverage is required');
|
|
788
|
+
else for (const list of semantic_graph_ENTITY_LISTS) { const c = g.coverage[list]; if (!c || !['populated', 'schema-only'].includes(c.state) || !c.reason) err('BAD_TYPE', `coverage.${list}`, 'needs { state, reason }'); else if (c.state === 'schema-only' && g[list].length) err('RULE_VIOLATION', `coverage.${list}`, 'schema-only but not empty'); else if (c.state === 'populated' && !g[list].length) err('RULE_VIOLATION', `coverage.${list}`, 'populated but empty'); }
|
|
789
|
+
return { ok: errors.length === 0, errors };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Parse and validate a serialized graph. Returns { ok, graph?, errors }. */
|
|
793
|
+
function parseGraph(text) {
|
|
794
|
+
let g; try { g = JSON.parse(text); } catch (e) { return { ok: false, errors: [{ code: 'BAD_JSON', path: '', message: e.message }] }; }
|
|
795
|
+
const v = semantic_graph_validateGraph(g);
|
|
796
|
+
return v.ok ? { ok: true, graph: g, errors: [] } : { ok: false, errors: v.errors };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Bring an older-minor graph to this version. A different major is refused: there is no silent best-effort. */
|
|
800
|
+
function migrateGraph(g) {
|
|
801
|
+
const m = typeof g?.schemaVersion === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(g.schemaVersion) : null;
|
|
802
|
+
if (!m || g.schema !== semantic_graph_SEMANTIC_GRAPH_SCHEMA) return { ok: false, reason: 'not a semantic graph' };
|
|
803
|
+
if (Number(m[1]) !== semantic_graph_SUPPORTED_MAJOR) return { ok: false, reason: `major ${m[1]} has no migration path to ${SEMANTIC_GRAPH_VERSION}` };
|
|
804
|
+
return { ok: true, graph: { ...g, schemaVersion: SEMANTIC_GRAPH_VERSION }, migrated: g.schemaVersion !== SEMANTIC_GRAPH_VERSION };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ---- builder ----------------------------------------------------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
const exprText = (e) => {
|
|
810
|
+
if (!e || typeof e !== 'object') return '';
|
|
811
|
+
switch (e.kind) {
|
|
812
|
+
case 'ident': return e.name;
|
|
813
|
+
case 'member': return `${exprText(e.object)}.${e.prop ?? '[]'}`;
|
|
814
|
+
case 'call': return `${exprText(e.callee)}(...)`;
|
|
815
|
+
case 'literal': return JSON.stringify(e.value);
|
|
816
|
+
default: return `<${e.kind || 'unknown'}>`;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
const originKindOf = (e, params) => {
|
|
820
|
+
if (!e) return 'unknown';
|
|
821
|
+
if (e.kind === 'ident') return params.includes(e.name) ? 'parameter' : 'identifier';
|
|
822
|
+
if (e.kind === 'call') return 'call-result';
|
|
823
|
+
if (e.kind === 'literal') return 'literal';
|
|
824
|
+
if (e.kind === 'member') return 'member';
|
|
825
|
+
if (e.kind === 'binary' || e.kind === 'tpl') return 'concat';
|
|
826
|
+
if (e.kind === 'object') return 'object';
|
|
827
|
+
if (e.kind === 'array') return 'array';
|
|
828
|
+
if (e.kind === 'function-value') return 'function';
|
|
829
|
+
return 'unknown';
|
|
830
|
+
};
|
|
831
|
+
const loc = (file, line) => ({ file, line: Number.isInteger(line) ? line : 0, column: null, locationPrecision: 'line' });
|
|
832
|
+
const OBSERVED = { tier: 'observed', basis: 'read directly from the Layer-1 IR' };
|
|
833
|
+
const UNRESOLVED = { tier: 'unresolved', basis: 'the IR holds no resolved target for this call' };
|
|
834
|
+
|
|
835
|
+
function memberChain(e) {
|
|
836
|
+
const path = [];
|
|
837
|
+
let cur = e;
|
|
838
|
+
while (cur && cur.kind === 'member') { path.unshift(cur.prop ?? '[]'); cur = cur.object; }
|
|
839
|
+
return cur && cur.kind === 'ident' ? { base: cur.name, path } : null;
|
|
840
|
+
}
|
|
841
|
+
function walkExprs(e, visit) {
|
|
842
|
+
if (!e || typeof e !== 'object') return;
|
|
843
|
+
visit(e);
|
|
844
|
+
for (const k of ['object', 'callee', 'left', 'right', 'test', 'cons', 'alt']) if (e[k]) walkExprs(e[k], visit);
|
|
845
|
+
for (const k of ['args', 'parts', 'elements', 'values']) if (Array.isArray(e[k])) e[k].forEach((x) => walkExprs(x, visit));
|
|
846
|
+
if (Array.isArray(e.props)) e.props.forEach((p) => walkExprs(p?.value ?? p, visit));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* @param {{perFile:object, callGraph?:object}} projectIR the object `buildProjectIR` returns
|
|
851
|
+
* @param {{engineVersion?:string}} [meta]
|
|
852
|
+
*/
|
|
853
|
+
function buildSemanticGraph(projectIR, meta = {}) {
|
|
854
|
+
const g = Object.fromEntries(semantic_graph_ENTITY_LISTS.map((k) => [k, []]));
|
|
855
|
+
const ordinal = new Map();
|
|
856
|
+
const next = (key) => { const n = ordinal.get(key) ?? 0; ordinal.set(key, n + 1); return n; };
|
|
857
|
+
const symByQid = new Map();
|
|
858
|
+
const files = Object.keys(projectIR?.perFile || {}).sort();
|
|
859
|
+
|
|
860
|
+
// pass 1: symbols
|
|
861
|
+
for (const file of files) {
|
|
862
|
+
const pf = projectIR.perFile[file];
|
|
863
|
+
for (const fn of pf.functions || []) {
|
|
864
|
+
const isModule = fn.name === '<module>';
|
|
865
|
+
const key = `${file}|${stripLine(fn.qid)}`;
|
|
866
|
+
const id = `sym:${h16(['symbol', file, stripLine(fn.qid), next(`sym|${key}`)])}`;
|
|
867
|
+
symByQid.set(fn.qid, id);
|
|
868
|
+
g.symbols.push({ id, kind: isModule ? 'module' : 'function', name: fn.name, qid: stripLine(fn.qid), parent: null, params: [...(fn.params || [])], ...loc(file, fn.line), confidence: OBSERVED });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// function-by-name index for DIRECT same-file resolution only; anything else stays unresolved (cross-file resolution is E2-02)
|
|
872
|
+
const byFileName = new Map();
|
|
873
|
+
for (const file of files) for (const fn of projectIR.perFile[file].functions || []) if (fn.name !== '<module>') { const k = `${file}|${fn.name}`; byFileName.set(k, byFileName.has(k) ? null : symByQid.get(fn.qid)); }
|
|
874
|
+
|
|
875
|
+
// pass 2: call sites, origins, field accesses, allocations
|
|
876
|
+
for (const file of files) {
|
|
877
|
+
for (const fn of projectIR.perFile[file].functions || []) {
|
|
878
|
+
const symId = symByQid.get(fn.qid);
|
|
879
|
+
const params = fn.params || [];
|
|
880
|
+
const originFor = (expr, variable, line) => {
|
|
881
|
+
const kind = originKindOf(expr, params);
|
|
882
|
+
const o = { id: `orig:${h16(['origin', symId, variable, kind, exprText(expr), next(`orig|${symId}|${variable}|${kind}|${exprText(expr)}`)])}`, symbol: symId, variable, kind, text: exprText(expr), ...loc(file, line), confidence: kind === 'unknown' ? { tier: 'unresolved', basis: 'the IR could not model this expression' } : OBSERVED };
|
|
883
|
+
g.valueOrigins.push(o);
|
|
884
|
+
return o.id;
|
|
885
|
+
};
|
|
886
|
+
const field = (expr, mode, line) => {
|
|
887
|
+
const mc = memberChain(expr);
|
|
888
|
+
if (!mc || !mc.path.length) return;
|
|
889
|
+
g.fieldAccesses.push({ id: `field:${h16(['field', symId, mc.base, mc.path, mode, next(`field|${symId}|${mc.base}|${mc.path.join('.')}|${mode}`)])}`, symbol: symId, base: mc.base, path: mc.path, mode, ...loc(file, line), confidence: OBSERVED });
|
|
890
|
+
};
|
|
891
|
+
const alloc = (expr, line) => {
|
|
892
|
+
const kind = expr.kind === 'object' ? 'object' : expr.kind === 'array' ? 'array' : expr.kind === 'function-value' ? 'function' : null;
|
|
893
|
+
if (!kind) return;
|
|
894
|
+
g.allocations.push({ id: `alloc:${h16(['alloc', symId, kind, next(`alloc|${symId}|${kind}`)])}`, symbol: symId, kind, ...loc(file, line), confidence: OBSERVED });
|
|
895
|
+
};
|
|
896
|
+
for (const c of fn.calls || []) {
|
|
897
|
+
const calleeText = c.callee;
|
|
898
|
+
const direct = byFileName.get(`${file}|${calleeText}`) || null;
|
|
899
|
+
const args = (c.args || []).map((a, index) => { walkExprs(a, (x) => { if (x.kind === 'member') field(x, 'read', c.line); alloc(x, c.line); }); return { index, origin: originFor(a, `arg:${calleeText}:${index}`, c.line) }; });
|
|
900
|
+
g.callSites.push({ id: `call:${h16(['call', symId, calleeText, next(`call|${symId}|${calleeText}`)])}`, caller: symId, calleeText, callee: direct, resolution: direct ? 'resolved' : 'unresolved', args, ...loc(file, c.line), confidence: direct ? { tier: 'inferred', basis: 'a function of this name exists in the same file' } : UNRESOLVED });
|
|
901
|
+
}
|
|
902
|
+
const callFromExpr = (x, line) => {
|
|
903
|
+
const calleeText = exprText(x.callee).replace(/\(\.\.\.\)$/, '');
|
|
904
|
+
const direct = x.callee?.kind === 'ident' ? (byFileName.get(`${file}|${calleeText}`) || null) : null;
|
|
905
|
+
const args = (x.args || []).map((a, index) => ({ index, origin: originFor(a, `arg:${calleeText}:${index}`, line) }));
|
|
906
|
+
g.callSites.push({ id: `call:${h16(['call', symId, calleeText, next(`call|${symId}|${calleeText}`)])}`, caller: symId, calleeText, callee: direct, resolution: direct ? 'resolved' : 'unresolved', args, ...loc(file, line), confidence: direct ? { tier: 'inferred', basis: 'a function of this name exists in the same file' } : UNRESOLVED });
|
|
907
|
+
};
|
|
908
|
+
// The CFG is the one structure every language's IR shares (`writes` exists for JavaScript only): assignment nodes carry { target, source, line }.
|
|
909
|
+
const assigns = Object.values(fn.cfg?.nodes || {}).filter((n) => n.kind === 'assign' && n.target).sort((x, y) => (x.line ?? 0) - (y.line ?? 0) || String(x.id ?? '').localeCompare(String(y.id ?? '')));
|
|
910
|
+
for (const d of assigns) {
|
|
911
|
+
const variable = String(d.target);
|
|
912
|
+
const line = d.line ?? fn.line;
|
|
913
|
+
originFor(d.source, variable, line);
|
|
914
|
+
// calls that appear INSIDE an assignment source (`const cmd = h.build(x)`) are not in fn.calls; they are call sites all the same
|
|
915
|
+
walkExprs(d.source, (x) => { if (x.kind === 'member') field(x, 'read', line); alloc(x, line); if (x.kind === 'call' && x.callee) callFromExpr(x, line); });
|
|
916
|
+
const mc = variable.includes('.') ? variable.split('.') : null;
|
|
917
|
+
if (mc) g.fieldAccesses.push({ id: `field:${h16(['field', symId, mc[0], mc.slice(1), 'write', next(`field|${symId}|${mc[0]}|${mc.slice(1).join('.')}|write`)])}`, symbol: symId, base: mc[0], path: mc.slice(1), mode: 'write', ...loc(file, line), confidence: OBSERVED });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// E2-07: service relationships are opt-in. A caller that ran dataflow/service-edges.js passes the result as `meta.serviceEdges`; HTTP and
|
|
923
|
+
// messaging relationships become `serviceBoundaries`, persistence relationships become `storageEdges`, and relationships with no counterpart
|
|
924
|
+
// are kept as boundaries with `to: null` and unresolved confidence. Without it both lists stay schema-only, as before.
|
|
925
|
+
const se = meta.serviceEdges && meta.serviceEdges.optedIn ? meta.serviceEdges : null;
|
|
926
|
+
if (se) {
|
|
927
|
+
const tier = (e) => (e === 'observed' ? 'observed' : e === 'inferred' ? 'inferred' : 'unresolved');
|
|
928
|
+
for (const e of se.edges) {
|
|
929
|
+
const list = e.kind === 'persistence' ? g.storageEdges : g.serviceBoundaries;
|
|
930
|
+
list.push({ id: `${e.kind === 'persistence' ? 'store' : 'svc'}:${e.id.slice('svcedge:'.length)}`, kind: e.kind, from: e.from, to: e.to, via: e.via, ...loc(e.from.file, e.from.line), evidence: e.evidence, confidence: { tier: tier(e.evidence), basis: e.basis } });
|
|
931
|
+
}
|
|
932
|
+
for (const u of se.unknown) g.serviceBoundaries.push({ id: `svc:${h16(['unknown', u.service, u.file, u.kind, u.reason])}`, kind: u.kind, from: { service: u.service, file: u.file, line: u.line }, to: null, via: null, ...loc(u.file, u.line), evidence: 'unknown', confidence: { tier: 'unresolved', basis: u.reason } });
|
|
933
|
+
}
|
|
934
|
+
const SCHEMA_ONLY = {
|
|
935
|
+
...(se ? {} : { serviceBoundaries: 'opt-in: pass meta.serviceEdges (dataflow/service-edges.js) with two or more declared services; the IR itself carries no service-boundary facts', storageEdges: 'opt-in: pass meta.serviceEdges (dataflow/service-edges.js); the IR itself carries no persistence facts' }),
|
|
936
|
+
sanitizers: 'sanitizer decisions are made by the dataflow layer (sanitizer-gate.js), not recorded in the IR; they are folded in by E2-08',
|
|
937
|
+
guards: 'guard dominance is computed by dataflow/guard-dominance.js, not recorded in the IR; it is folded in by E2-08',
|
|
938
|
+
};
|
|
939
|
+
const REASON_POP = { serviceBoundaries: 'HTTP and messaging relationships between declared services, and the ones with no counterpart', storageEdges: 'tables one declared service writes and another reads', symbols: 'one per IR function and per file top level', callSites: 'every call recorded in an IR function, plus calls nested inside assignment sources', allocations: 'object, array and function-valued expressions in call arguments and assignments', fieldAccesses: 'member reads in arguments and assignments, and dotted writes', valueOrigins: 'every call argument and every assignment node in the CFG of a function' };
|
|
940
|
+
const coverage = {};
|
|
941
|
+
for (const k of semantic_graph_ENTITY_LISTS) coverage[k] = SCHEMA_ONLY[k] ? { state: 'schema-only', reason: SCHEMA_ONLY[k] } : g[k].length ? { state: 'populated', reason: REASON_POP[k] } : { state: 'schema-only', reason: `no ${k} in this input` };
|
|
942
|
+
const limits = [
|
|
943
|
+
'columns are null: the IR carries line numbers only',
|
|
944
|
+
'a call resolves only to a same-file function of the same name; imports, re-exports and methods are unresolved until E2-02',
|
|
945
|
+
'heap, alias and closure facts are not in the IR, so field accesses are syntactic, not points-to results',
|
|
946
|
+
];
|
|
947
|
+
// Dynamic constructs the analysis cannot resolve, per function (E2-03): the graph says where precision was given up, instead of leaving it implicit.
|
|
948
|
+
const dynamicFeatures = analysisLimits(projectIR).map((l) => ({ symbol: symByQid.get(`${l.qid}@${(projectIR.perFile[l.file].functions.find((f) => stripLine(f.qid) === l.qid) || {}).line}`) ?? null, file: l.file, line: l.line, kind: l.kind, detail: l.detail, effect: l.effect }));
|
|
949
|
+
return { schema: semantic_graph_SEMANTIC_GRAPH_SCHEMA, schemaVersion: SEMANTIC_GRAPH_VERSION, producer: { layer: 'ir-v1', engineVersion: meta.engineVersion ?? null }, ...g, coverage, limits, dynamicFeatures };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
;// CONCATENATED MODULE: ./src/graph/adapters/semantic.js
|
|
953
|
+
//
|
|
954
|
+
// adapters/semantic.js: read-only view over a SemanticGraph v1
|
|
955
|
+
// (ir/semantic-graph.js). Imports only the schema constants and validator; it
|
|
956
|
+
// never builds or mutates a graph.
|
|
957
|
+
//
|
|
958
|
+
// A graph that fails `validateGraph`, or whose major version is not the
|
|
959
|
+
// supported one, is REJECTED (INCOMPATIBLE_VERSION / INVALID_GRAPH) rather than
|
|
960
|
+
// partially adapted.
|
|
961
|
+
//
|
|
962
|
+
// Entities: symbol, callSite, allocation, fieldAccess, valueOrigin,
|
|
963
|
+
// serviceBoundary, storageEdge, sanitizer, guard (singular names of the
|
|
964
|
+
// graph's own lists). The semantic graph stores references as id fields, not
|
|
965
|
+
// an edge list, so the relation edges here are DERIVED (origin 'derived') and
|
|
966
|
+
// get deterministic ids; the underlying confidence tier becomes the evidence
|
|
967
|
+
// grade, and an unresolved call is carried as an `unknowns` entry, never as a
|
|
968
|
+
// fabricated callee edge.
|
|
969
|
+
//
|
|
970
|
+
// symbol --declaration--> callSite (the caller declares/contains it)
|
|
971
|
+
// symbol --declaration--> valueOrigin / fieldAccess / allocation
|
|
972
|
+
// callSite --calls--> symbol (resolved calls only)
|
|
973
|
+
// valueOrigin --data_flow--> callSite (an argument origin feeds the call)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
const SEMANTIC_ADAPTER_VERSION = '1.0.0';
|
|
980
|
+
|
|
981
|
+
const SINGULAR = {
|
|
982
|
+
symbols: 'symbol', callSites: 'callSite', allocations: 'allocation', fieldAccesses: 'fieldAccess',
|
|
983
|
+
valueOrigins: 'valueOrigin', serviceBoundaries: 'serviceBoundary', storageEdges: 'storageEdge',
|
|
984
|
+
sanitizers: 'sanitizer', guards: 'guard',
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* @param {object} graph SemanticGraph v1 (buildSemanticGraph output)
|
|
989
|
+
* @param {{repository:string}} opts
|
|
990
|
+
*/
|
|
991
|
+
function semanticView(graph, opts) {
|
|
992
|
+
const m = typeof graph?.schemaVersion === 'string' ? /^(\d+)\./.exec(graph.schemaVersion) : null;
|
|
993
|
+
if (graph?.schema === SEMANTIC_GRAPH_SCHEMA && m && Number(m[1]) !== SUPPORTED_MAJOR) {
|
|
994
|
+
throw new GraphViewError('INCOMPATIBLE_VERSION', `semantic graph major ${m[1]} is not supported (supported: ${SUPPORTED_MAJOR})`, { schemaVersion: graph.schemaVersion });
|
|
995
|
+
}
|
|
996
|
+
const v = validateGraph(graph);
|
|
997
|
+
if (!v.ok) throw new GraphViewError('INVALID_GRAPH', `semantic graph failed validation: ${v.errors[0].code} at ${v.errors[0].path}`, { errors: v.errors.slice(0, 5) });
|
|
998
|
+
|
|
999
|
+
const graphDigest = digestOf(graph);
|
|
1000
|
+
const nodes = [];
|
|
1001
|
+
for (const list of ENTITY_LISTS) {
|
|
1002
|
+
for (const e of graph[list]) {
|
|
1003
|
+
nodes.push({ entityKind: SINGULAR[list], localId: e.id, native: e, summary: { file: e.file, line: e.line, tier: e.confidence?.tier ?? null } });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
const edges = [];
|
|
1007
|
+
const edge = (relation, from, to, grade, extra = {}) => {
|
|
1008
|
+
edges.push({
|
|
1009
|
+
id: `sgedge:${relation}:${from.localId}:${to.localId}`, relation, origin: 'derived',
|
|
1010
|
+
from, to, evidence: { grade, source: 'semantic-graph' }, unknowns: extra.unknowns ?? [], attrs: extra.attrs ?? {}, native: null,
|
|
1011
|
+
});
|
|
1012
|
+
};
|
|
1013
|
+
for (const c of graph.callSites) {
|
|
1014
|
+
edge('declaration', local('symbol', c.caller), local('callSite', c.id), c.confidence.tier);
|
|
1015
|
+
if (c.callee) edge('calls', local('callSite', c.id), local('symbol', c.callee), c.confidence.tier, { attrs: { resolution: c.resolution } });
|
|
1016
|
+
for (const a of c.args || []) {
|
|
1017
|
+
if (a.origin) edge('data_flow', local('valueOrigin', a.origin), local('callSite', c.id), c.confidence.tier, { attrs: { argIndex: a.index } });
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
for (const o of graph.valueOrigins) if (o.symbol) edge('declaration', local('symbol', o.symbol), local('valueOrigin', o.id), o.confidence.tier);
|
|
1021
|
+
for (const f of graph.fieldAccesses) if (f.symbol) edge('declaration', local('symbol', f.symbol), local('fieldAccess', f.id), f.confidence.tier);
|
|
1022
|
+
for (const a of graph.allocations) if (a.symbol) edge('declaration', local('symbol', a.symbol), local('allocation', a.id), a.confidence.tier);
|
|
1023
|
+
|
|
1024
|
+
// Unresolved calls are unknowns on the view, not edges.
|
|
1025
|
+
const unresolvedCalls = graph.callSites.filter((c) => !c.callee).length;
|
|
1026
|
+
const reasons = [];
|
|
1027
|
+
const schemaOnly = ENTITY_LISTS.filter((k) => graph.coverage[k].state === 'schema-only');
|
|
1028
|
+
if (schemaOnly.length) reasons.push(`schema-only:${schemaOnly.join(',')}`);
|
|
1029
|
+
if (unresolvedCalls) reasons.push(`unresolved-calls:${unresolvedCalls}`);
|
|
1030
|
+
if ((graph.dynamicFeatures || []).length) reasons.push(`dynamic-features:${graph.dynamicFeatures.length}`);
|
|
1031
|
+
|
|
1032
|
+
return new IndexedGraphView({
|
|
1033
|
+
graphKind: 'semantic', adapterVersion: SEMANTIC_ADAPTER_VERSION, repository: opts?.repository, graphDigest,
|
|
1034
|
+
metadata: { schema: graph.schema, schemaVersion: graph.schemaVersion, producer: graph.producer ?? null, limits: graph.limits ?? [], unresolvedCalls },
|
|
1035
|
+
completion: { status: reasons.length ? 'partial' : 'complete', reasons, coverage: graph.coverage },
|
|
1036
|
+
nodes, edges,
|
|
1037
|
+
reconstruct: () => graph,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
;// CONCATENATED MODULE: ./src/graph/adapters/artifact.js
|
|
1042
|
+
//
|
|
1043
|
+
// adapters/artifact.js: read-only view over the artifact provenance graph
|
|
1044
|
+
// (artifact/provenance-graph.js: commit -> build -> OCI digest -> SBOM
|
|
1045
|
+
// component -> finding, plus an operator-declared deployment).
|
|
1046
|
+
//
|
|
1047
|
+
// The artifact graph has no repository identity of its own, so the caller names
|
|
1048
|
+
// it (`repository`: the image repository or the source repository the artifact
|
|
1049
|
+
// was built from). The graph digest is a canonical hash of the graph body.
|
|
1050
|
+
//
|
|
1051
|
+
// Relations: commit->build, build->digest and digest->deployment are
|
|
1052
|
+
// `artifact_derivation`; digest->component and component->finding are
|
|
1053
|
+
// `dependency`. An operator-declared deployment edge has origin `declared`; an
|
|
1054
|
+
// edge with evidence 'none' (a commit that is not known) is kept, carries an
|
|
1055
|
+
// `unknowns` entry, and has grade 'none'.
|
|
1056
|
+
//
|
|
1057
|
+
// The generator can list the same SBOM component or finding twice (an OS and an
|
|
1058
|
+
// application package with one purl). Byte-identical duplicates are collapsed
|
|
1059
|
+
// in the view and counted in metadata; duplicates that DIFFER are rejected,
|
|
1060
|
+
// because choosing one would be a silent merge. `reconstruct()` returns the
|
|
1061
|
+
// original graph, duplicates included.
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
const ARTIFACT_ADAPTER_VERSION = '1.0.0';
|
|
1067
|
+
|
|
1068
|
+
function relationFor(fromKind, toKind) {
|
|
1069
|
+
if (toKind === 'sbomComponent' || toKind === 'finding') return 'dependency';
|
|
1070
|
+
return 'artifact_derivation';
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function artifactView(graph, opts) {
|
|
1074
|
+
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
|
1075
|
+
throw new TypeError('artifactView requires a graph with nodes[] and edges[]');
|
|
1076
|
+
}
|
|
1077
|
+
const graphDigest = digestOf(graph);
|
|
1078
|
+
const byId = new Map();
|
|
1079
|
+
let duplicateNodes = 0;
|
|
1080
|
+
for (const n of graph.nodes) {
|
|
1081
|
+
const prev = byId.get(n.id);
|
|
1082
|
+
if (prev) {
|
|
1083
|
+
if (canonicalJson(prev) !== canonicalJson(n)) throw new GraphViewError('DUPLICATE_ID', `artifact graph has two different nodes with id ${n.id}`, { id: n.id });
|
|
1084
|
+
duplicateNodes += 1;
|
|
1085
|
+
} else byId.set(n.id, n);
|
|
1086
|
+
}
|
|
1087
|
+
const nodes = [...byId.values()].map((n) => ({ entityKind: n.kind, localId: n.id, native: n, summary: { kind: n.kind } }));
|
|
1088
|
+
const seenEdges = new Map();
|
|
1089
|
+
let duplicateEdges = 0;
|
|
1090
|
+
const edges = [];
|
|
1091
|
+
for (const e of graph.edges) {
|
|
1092
|
+
const id = `agedge:${e.from}->${e.to}`;
|
|
1093
|
+
const prev = seenEdges.get(id);
|
|
1094
|
+
if (prev) {
|
|
1095
|
+
if (canonicalJson(prev) !== canonicalJson(e)) throw new GraphViewError('DUPLICATE_ID', `artifact graph has two different edges ${e.from} -> ${e.to}`, { id });
|
|
1096
|
+
duplicateEdges += 1;
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
seenEdges.set(id, e);
|
|
1100
|
+
const from = byId.get(e.from);
|
|
1101
|
+
const to = byId.get(e.to);
|
|
1102
|
+
if (!from || !to) throw new GraphViewError('DANGLING_REF', `artifact edge ${e.from} -> ${e.to} points at a node that is not in the graph`, { id });
|
|
1103
|
+
const declared = e.evidence === 'operator-declared' || to.kind === 'deployment';
|
|
1104
|
+
const none = e.evidence === 'none';
|
|
1105
|
+
edges.push({
|
|
1106
|
+
id, relation: relationFor(from.kind, to.kind), origin: declared ? 'declared' : 'derived',
|
|
1107
|
+
from: local(from.kind, from.id), to: local(to.kind, to.id),
|
|
1108
|
+
evidence: { grade: e.evidence ?? 'none', source: 'artifact-provenance-graph' },
|
|
1109
|
+
unknowns: none ? [{ kind: 'unlinked', reason: 'no evidence links these two nodes' }] : (e.digestMatches === false ? [{ kind: 'digest-mismatch', reason: 'declared digest differs from the scanned artifact' }] : []),
|
|
1110
|
+
attrs: e.digestMatches === undefined ? {} : { digestMatches: e.digestMatches }, native: e,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
const unlinked = edges.filter((e) => e.unknowns.some((u) => u.kind === 'unlinked')).length;
|
|
1114
|
+
const reasons = unlinked ? [`unlinked-edges:${unlinked}`] : [];
|
|
1115
|
+
return new IndexedGraphView({
|
|
1116
|
+
graphKind: 'artifact', adapterVersion: ARTIFACT_ADAPTER_VERSION, repository: opts?.repository, graphDigest,
|
|
1117
|
+
metadata: { nodes: graph.nodes.length, edges: graph.edges.length, duplicateNodes, duplicateEdges },
|
|
1118
|
+
completion: { status: reasons.length ? 'partial' : 'complete', reasons, coverage: null },
|
|
1119
|
+
nodes, edges,
|
|
1120
|
+
reconstruct: () => graph,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// EXTERNAL MODULE: ./src/lineage/cross-repo-link.js
|
|
1125
|
+
var cross_repo_link = __webpack_require__(24974);
|
|
1126
|
+
;// CONCATENATED MODULE: ./src/graph/adapters/crossrepo.js
|
|
1127
|
+
//
|
|
1128
|
+
// adapters/crossrepo.js: read-only view over CrossRepoLink records
|
|
1129
|
+
// (lineage/cross-repo-link.js), plus a small findings view.
|
|
1130
|
+
//
|
|
1131
|
+
// A cross-repository link is an operator DECLARATION. In this view it is an
|
|
1132
|
+
// edge with origin `declared` and evidence grade `declared`, so the default
|
|
1133
|
+
// query filter (code-derived and derived edges only) never walks it as if it
|
|
1134
|
+
// were a code-derived data flow, and it can never be mistaken for observed
|
|
1135
|
+
// telemetry (R06.4). Its endpoints live in OTHER graphs, so they are foreign
|
|
1136
|
+
// references, each carrying that graph's repository and digest. An endpoint
|
|
1137
|
+
// that no registered graph can resolve is reported by the registry as
|
|
1138
|
+
// unresolved, not dropped.
|
|
1139
|
+
//
|
|
1140
|
+
// Both endpoints are `dataflow` node references: that is the only graph kind a
|
|
1141
|
+
// CrossRepoLink record binds today.
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
const CROSSREPO_ADAPTER_VERSION = '1.0.0';
|
|
1148
|
+
const FINDINGS_ADAPTER_VERSION = '1.0.0';
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* @param {object[]} links CrossRepoLink records
|
|
1152
|
+
* @param {{localRepository:string}} opts the repository the `local` side belongs to
|
|
1153
|
+
*/
|
|
1154
|
+
function crossRepoLinkView(links, opts) {
|
|
1155
|
+
if (!Array.isArray(links)) throw new TypeError('crossRepoLinkView requires an array of links');
|
|
1156
|
+
const localRepository = opts?.localRepository;
|
|
1157
|
+
const nodes = [];
|
|
1158
|
+
const edges = [];
|
|
1159
|
+
for (const l of links) {
|
|
1160
|
+
const v = validateCrossRepoLink(l);
|
|
1161
|
+
if (!v.valid) throw new GraphViewError('INVALID_LINK', `cross-repo link ${l?.id ?? '?'} is invalid: ${v.errors[0].path} ${v.errors[0].message}`, { errors: v.errors });
|
|
1162
|
+
nodes.push({ entityKind: 'link', localId: l.id, native: l, summary: { declaredBy: l.declaredBy, declaredAt: l.declaredAt } });
|
|
1163
|
+
const from = makeRef({ graphKind: 'dataflow', repository: localRepository, graphDigest: l.local.graphDigest, entityKind: 'node', localId: l.local.nodeId });
|
|
1164
|
+
const to = makeRef({ graphKind: 'dataflow', repository: l.remote.repository, graphDigest: l.remote.graphDigest, entityKind: 'node', localId: l.remote.nodeId });
|
|
1165
|
+
edges.push({
|
|
1166
|
+
id: l.id, relation: 'data_flow', origin: 'declared',
|
|
1167
|
+
from: foreign(from), to: foreign(to),
|
|
1168
|
+
evidence: { grade: 'declared', source: 'operator', declaredBy: l.declaredBy },
|
|
1169
|
+
unknowns: [{ kind: 'not-observed', reason: 'a declared link is an assertion, not an observed data flow' }],
|
|
1170
|
+
attrs: { rationale: l.rationale ?? null, provenance: l.provenance }, native: l,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
return new IndexedGraphView({
|
|
1174
|
+
graphKind: 'cross-repo-links', adapterVersion: CROSSREPO_ADAPTER_VERSION, repository: localRepository, graphDigest: digestOf(links),
|
|
1175
|
+
metadata: { links: links.length },
|
|
1176
|
+
completion: { status: 'complete', reasons: [], coverage: null },
|
|
1177
|
+
nodes, edges, allowForeign: true,
|
|
1178
|
+
reconstruct: () => links,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Findings as referenceable entities (no edges): the starting point of a
|
|
1184
|
+
* finding -> call site -> witnesses -> flow join.
|
|
1185
|
+
* @param {object[]} findings scanner findings; localId is `stableId` when present, else `id`
|
|
1186
|
+
*/
|
|
1187
|
+
function findingsView(findings, opts) {
|
|
1188
|
+
if (!Array.isArray(findings)) throw new TypeError('findingsView requires an array of findings');
|
|
1189
|
+
const nodes = findings.map((f) => {
|
|
1190
|
+
const id = f.stableId ?? f.id;
|
|
1191
|
+
if (typeof id !== 'string' || !id) throw new GraphViewError('BAD_REF', 'every finding needs a stableId or id');
|
|
1192
|
+
return { entityKind: 'finding', localId: id, native: f, summary: { file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null, severity: f.severity ?? null } };
|
|
1193
|
+
});
|
|
1194
|
+
return new IndexedGraphView({
|
|
1195
|
+
graphKind: 'findings', adapterVersion: FINDINGS_ADAPTER_VERSION, repository: opts?.repository, graphDigest: digestOf(findings),
|
|
1196
|
+
metadata: { findings: findings.length },
|
|
1197
|
+
completion: { status: 'unknown', reasons: ['a finding list says nothing about what was not scanned'], coverage: null },
|
|
1198
|
+
nodes, edges: [],
|
|
1199
|
+
reconstruct: () => findings,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
;// CONCATENATED MODULE: ./src/graph/registry.js
|
|
1204
|
+
//
|
|
1205
|
+
// registry.js: the set of graph views a query may span (DAG-06 R06.6).
|
|
1206
|
+
//
|
|
1207
|
+
// What registration rejects, each with a typed GraphViewError:
|
|
1208
|
+
// INCOMPATIBLE_VERSION an adapter whose major version this build cannot read
|
|
1209
|
+
// ID_COLLISION the same (graph kind, repository, digest) registered
|
|
1210
|
+
// twice with different content: a digest names ONE
|
|
1211
|
+
// immutable graph
|
|
1212
|
+
// BAD_REF a malformed view
|
|
1213
|
+
//
|
|
1214
|
+
// What lookups reject:
|
|
1215
|
+
// DANGLING_REF the graph is not registered, or the entity is not in it
|
|
1216
|
+
// VERSION_INCOMPATIBLE a reference written by a newer reference format
|
|
1217
|
+
// AMBIGUOUS_LOCAL_ID a bare local id that exists in more than one graph; a
|
|
1218
|
+
// local id is only meaningful inside its graph, so the
|
|
1219
|
+
// registry will not pick one
|
|
1220
|
+
//
|
|
1221
|
+
// A reference never resolves across repositories: the repository is part of the
|
|
1222
|
+
// reference, so `pnode:abc` in repository A and `pnode:abc` in repository B are
|
|
1223
|
+
// two different entities.
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
const major = (v) => Number(String(v).split('.')[0]);
|
|
1233
|
+
|
|
1234
|
+
/** Adapter majors this build understands, per graph kind. */
|
|
1235
|
+
const SUPPORTED_ADAPTER_MAJORS = Object.freeze({
|
|
1236
|
+
provenance: [major(PROVENANCE_ADAPTER_VERSION)],
|
|
1237
|
+
dataflow: [major(DATAFLOW_ADAPTER_VERSION)],
|
|
1238
|
+
semantic: [major(SEMANTIC_ADAPTER_VERSION)],
|
|
1239
|
+
artifact: [major(ARTIFACT_ADAPTER_VERSION)],
|
|
1240
|
+
'cross-repo-links': [major(CROSSREPO_ADAPTER_VERSION)],
|
|
1241
|
+
findings: [major(FINDINGS_ADAPTER_VERSION)],
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
function createGraphRegistry() {
|
|
1245
|
+
const views = new Map(); // identity key -> view
|
|
1246
|
+
|
|
1247
|
+
function viewFor(ref) {
|
|
1248
|
+
const bad = validateRef(ref);
|
|
1249
|
+
if (bad) {
|
|
1250
|
+
throw new GraphViewError(ref && ref.v !== undefined && ref.v !== REF_VERSION ? 'VERSION_INCOMPATIBLE' : 'BAD_REF', bad, { ref });
|
|
1251
|
+
}
|
|
1252
|
+
const v = views.get(graphIdentityKey(ref));
|
|
1253
|
+
if (!v) throw new GraphViewError('DANGLING_REF', `no registered ${ref.graphKind} graph for repository ${ref.repository} with digest ${ref.graphDigest.slice(0, 12)}`, { ref });
|
|
1254
|
+
return v;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const foreignViews = () => [...views.values()].filter((v) => v._allowForeign);
|
|
1258
|
+
|
|
1259
|
+
return {
|
|
1260
|
+
register(view) {
|
|
1261
|
+
const kinds = SUPPORTED_ADAPTER_MAJORS[view?.graphKind];
|
|
1262
|
+
if (!kinds) throw new GraphViewError('BAD_REF', `unknown graph kind ${view?.graphKind}`);
|
|
1263
|
+
if (!kinds.includes(major(view.adapterVersion))) {
|
|
1264
|
+
throw new GraphViewError('INCOMPATIBLE_VERSION', `${view.graphKind} adapter ${view.adapterVersion} is not readable by this build (supported majors: ${kinds.join(',')})`);
|
|
1265
|
+
}
|
|
1266
|
+
const key = graphIdentityKey(view);
|
|
1267
|
+
const prev = views.get(key);
|
|
1268
|
+
if (prev) {
|
|
1269
|
+
if (prev.fingerprint !== view.fingerprint) {
|
|
1270
|
+
throw new GraphViewError('ID_COLLISION', `${view.graphKind}/${view.repository} digest ${view.graphDigest.slice(0, 12)} is already registered with different content`, { key });
|
|
1271
|
+
}
|
|
1272
|
+
return prev;
|
|
1273
|
+
}
|
|
1274
|
+
views.set(key, view);
|
|
1275
|
+
return view;
|
|
1276
|
+
},
|
|
1277
|
+
|
|
1278
|
+
views() { return [...views.values()]; },
|
|
1279
|
+
viewFor,
|
|
1280
|
+
|
|
1281
|
+
has(ref) {
|
|
1282
|
+
const v = views.get(graphIdentityKey(ref));
|
|
1283
|
+
return !!v && v.has(ref);
|
|
1284
|
+
},
|
|
1285
|
+
|
|
1286
|
+
node(ref) { return viewFor(ref).node(ref); },
|
|
1287
|
+
|
|
1288
|
+
outgoing(ref, filter) {
|
|
1289
|
+
const out = viewFor(ref).outgoing(ref, filter);
|
|
1290
|
+
for (const fv of foreignViews()) if (fv.graphKind !== ref.graphKind || fv.repository !== ref.repository || fv.graphDigest !== ref.graphDigest) out.push(...fv.outgoing(ref, filter));
|
|
1291
|
+
return out;
|
|
1292
|
+
},
|
|
1293
|
+
|
|
1294
|
+
incoming(ref, filter) {
|
|
1295
|
+
const inn = viewFor(ref).incoming(ref, filter);
|
|
1296
|
+
for (const fv of foreignViews()) if (fv.graphKind !== ref.graphKind || fv.repository !== ref.repository || fv.graphDigest !== ref.graphDigest) inn.push(...fv.incoming(ref, filter));
|
|
1297
|
+
return inn;
|
|
1298
|
+
},
|
|
1299
|
+
|
|
1300
|
+
/** A bare local id is ambiguous when more than one registered graph has it. */
|
|
1301
|
+
resolveLocal(entityKind, localId, { graphKind } = {}) {
|
|
1302
|
+
const hits = [];
|
|
1303
|
+
for (const v of views.values()) {
|
|
1304
|
+
if (graphKind && v.graphKind !== graphKind) continue;
|
|
1305
|
+
try { hits.push(v.ref(entityKind, localId)); } catch { /* not in this graph */ }
|
|
1306
|
+
}
|
|
1307
|
+
if (hits.length === 0) throw new GraphViewError('DANGLING_REF', `no ${entityKind} ${localId} in any registered graph`);
|
|
1308
|
+
if (hits.length > 1) throw new GraphViewError('AMBIGUOUS_LOCAL_ID', `${entityKind} ${localId} exists in ${hits.length} registered graphs; use a full reference`, { candidates: hits });
|
|
1309
|
+
return hits[0];
|
|
1310
|
+
},
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Classify a foreign endpoint (for example, a cross-repository link end):
|
|
1314
|
+
* resolved registered, and the entity exists
|
|
1315
|
+
* unresolved no registered graph with that identity (not an error:
|
|
1316
|
+
* the remote graph simply is not loaded)
|
|
1317
|
+
* dangling the graph is registered but the entity is not in it
|
|
1318
|
+
* repository-mismatch a graph with that digest is registered, but under a
|
|
1319
|
+
* different repository than the reference claims
|
|
1320
|
+
*/
|
|
1321
|
+
classify(ref) {
|
|
1322
|
+
const bad = validateRef(ref);
|
|
1323
|
+
if (bad) throw new GraphViewError('BAD_REF', bad, { ref });
|
|
1324
|
+
const v = views.get(graphIdentityKey(ref));
|
|
1325
|
+
if (v) return v.has(ref) ? 'resolved' : 'dangling';
|
|
1326
|
+
for (const o of views.values()) {
|
|
1327
|
+
if (o.graphKind === ref.graphKind && o.graphDigest === ref.graphDigest && o.repository !== ref.repository) return 'repository-mismatch';
|
|
1328
|
+
}
|
|
1329
|
+
return 'unresolved';
|
|
1330
|
+
},
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
;// CONCATENATED MODULE: ./src/graph/crosswalk.js
|
|
1335
|
+
//
|
|
1336
|
+
// crosswalk.js: evidence-bearing identity mappings between graph layers
|
|
1337
|
+
// (DAG-06 R06.2, R06.5).
|
|
1338
|
+
//
|
|
1339
|
+
// A crosswalk never merges entities. It records "this entity in graph A
|
|
1340
|
+
// corresponds to these entities in graph B, by this method, with this
|
|
1341
|
+
// evidence". Every mapping is:
|
|
1342
|
+
// - versioned (CROSSWALK_VERSION);
|
|
1343
|
+
// - explicit about cardinality (`one-to-one`, `one-to-many`, or `none` with a
|
|
1344
|
+
// stated reason), so a projection that coarsens many underlying sites into
|
|
1345
|
+
// one projected node says so instead of pretending to be one-to-one;
|
|
1346
|
+
// - evidence-bearing: method, grade and basis are required;
|
|
1347
|
+
// - checked against the registry when one is supplied: a reference to a graph
|
|
1348
|
+
// or entity that is not registered is rejected as DANGLING_REF.
|
|
1349
|
+
//
|
|
1350
|
+
// Mapping types and the graph layers each connects:
|
|
1351
|
+
// finding-to-callsite findings -> semantic (location join)
|
|
1352
|
+
// callsite-to-witness semantic -> provenance (scope + line join)
|
|
1353
|
+
// witness-to-flow provenance -> dataflow (data element join)
|
|
1354
|
+
// node-to-sites dataflow node -> semantic (a projected node's underlying call sites)
|
|
1355
|
+
// identity same layer -> same layer (the same entity under another reference)
|
|
1356
|
+
//
|
|
1357
|
+
// `identity` mappings are only legal inside ONE repository. Declaring two
|
|
1358
|
+
// entities in different repositories "the same" because their local ids match
|
|
1359
|
+
// is exactly the collision this module exists to prevent, and is rejected as
|
|
1360
|
+
// CROSS_REPO_ID_COLLISION.
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
const CROSSWALK_VERSION = '1.0.0';
|
|
1366
|
+
|
|
1367
|
+
const MAPPING_TYPES = Object.freeze({
|
|
1368
|
+
'finding-to-callsite': { from: { graphKind: 'findings', entityKind: 'finding' }, to: { graphKind: 'semantic', entityKind: 'callSite' } },
|
|
1369
|
+
'callsite-to-witness': { from: { graphKind: 'semantic', entityKind: 'callSite' }, to: { graphKind: 'provenance', entityKind: 'pedge' } },
|
|
1370
|
+
'witness-to-flow': { from: { graphKind: 'provenance', entityKind: 'pedge' }, to: { graphKind: 'dataflow', entityKind: 'flow' } },
|
|
1371
|
+
'node-to-sites': { from: { graphKind: 'dataflow', entityKind: 'node' }, to: { graphKind: 'semantic', entityKind: 'callSite' } },
|
|
1372
|
+
identity: { from: null, to: null },
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
const CARDINALITY = (n) => (n === 0 ? 'none' : n === 1 ? 'one-to-one' : 'one-to-many');
|
|
1376
|
+
|
|
1377
|
+
function checkEnd(ref, want, label, type) {
|
|
1378
|
+
const bad = validateRef(ref);
|
|
1379
|
+
if (bad) throw new GraphViewError('BAD_REF', `${type} ${label}: ${bad}`, { ref });
|
|
1380
|
+
if (want && (ref.graphKind !== want.graphKind || ref.entityKind !== want.entityKind)) {
|
|
1381
|
+
throw new GraphViewError('BAD_MAPPING', `${type} ${label} must be a ${want.graphKind} ${want.entityKind}, got ${ref.graphKind} ${ref.entityKind}`, { ref });
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function createCrosswalk({ registry = null } = {}) {
|
|
1386
|
+
const mappings = new Map(); // id -> record
|
|
1387
|
+
const forward = new Map(); // refKey -> record[]
|
|
1388
|
+
const reverse = new Map(); // refKey -> record[]
|
|
1389
|
+
const push = (m, k, r) => { const a = m.get(k); if (a) a.push(r); else m.set(k, [r]); };
|
|
1390
|
+
|
|
1391
|
+
function add(spec) {
|
|
1392
|
+
const type = spec?.type;
|
|
1393
|
+
const desc = MAPPING_TYPES[type];
|
|
1394
|
+
if (!desc) throw new GraphViewError('BAD_MAPPING', `unknown mapping type ${JSON.stringify(type)}`);
|
|
1395
|
+
checkEnd(spec.from, desc.from, 'from', type);
|
|
1396
|
+
if (!Array.isArray(spec.to)) throw new GraphViewError('BAD_MAPPING', `${type}: "to" is a list (empty with an unmapped reason when nothing matched)`);
|
|
1397
|
+
for (const t of spec.to) checkEnd(t, desc.to, 'to', type);
|
|
1398
|
+
|
|
1399
|
+
const ev = spec.evidence;
|
|
1400
|
+
if (!ev || typeof ev.method !== 'string' || !ev.method || typeof ev.grade !== 'string' || !ev.grade || typeof ev.basis !== 'string' || !ev.basis) {
|
|
1401
|
+
throw new GraphViewError('BAD_MAPPING', `${type}: evidence needs { method, grade, basis }`);
|
|
1402
|
+
}
|
|
1403
|
+
const keys = spec.to.map(refKey);
|
|
1404
|
+
if (new Set(keys).size !== keys.length) throw new GraphViewError('BAD_MAPPING', `${type}: duplicate targets in one mapping`);
|
|
1405
|
+
if (spec.to.length === 0 && !spec.unmapped) throw new GraphViewError('BAD_MAPPING', `${type}: an empty mapping must say why in "unmapped"`);
|
|
1406
|
+
|
|
1407
|
+
if (type === 'identity') {
|
|
1408
|
+
for (const t of spec.to) {
|
|
1409
|
+
if (t.repository !== spec.from.repository) {
|
|
1410
|
+
throw new GraphViewError('CROSS_REPO_ID_COLLISION', `identity mapping between repositories ${spec.from.repository} and ${t.repository} refused: matching local ids do not make two entities the same`, { from: spec.from, to: t });
|
|
1411
|
+
}
|
|
1412
|
+
if (t.entityKind !== spec.from.entityKind || t.graphKind !== spec.from.graphKind) {
|
|
1413
|
+
throw new GraphViewError('BAD_MAPPING', 'identity maps an entity to the same kind of entity');
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
if (registry) {
|
|
1418
|
+
for (const r of [spec.from, ...spec.to]) {
|
|
1419
|
+
if (!registry.has(r)) throw new GraphViewError('DANGLING_REF', `${type}: ${r.graphKind} ${r.entityKind} ${r.localId} (repository ${r.repository}) is not in a registered graph`, { ref: r });
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const id = `xwalk:${createHash('sha256').update(canonicalJson([type, refKey(spec.from), [...keys].sort(), ev.method])).digest('hex').slice(0, 16)}`;
|
|
1424
|
+
if (mappings.has(id)) return mappings.get(id);
|
|
1425
|
+
const record = Object.freeze({
|
|
1426
|
+
id, version: CROSSWALK_VERSION, type, from: spec.from, to: Object.freeze([...spec.to]),
|
|
1427
|
+
cardinality: CARDINALITY(spec.to.length),
|
|
1428
|
+
coarsens: !!spec.coarsens,
|
|
1429
|
+
evidence: Object.freeze({ ...ev }),
|
|
1430
|
+
unknowns: Object.freeze([...(spec.unknowns ?? [])]),
|
|
1431
|
+
unmapped: spec.unmapped ?? null,
|
|
1432
|
+
});
|
|
1433
|
+
mappings.set(id, record);
|
|
1434
|
+
push(forward, refKey(record.from), record);
|
|
1435
|
+
for (const k of keys) push(reverse, k, record);
|
|
1436
|
+
return record;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const ofType = (list, type) => (type ? list.filter((m) => m.type === type) : list);
|
|
1440
|
+
|
|
1441
|
+
return {
|
|
1442
|
+
version: CROSSWALK_VERSION,
|
|
1443
|
+
add,
|
|
1444
|
+
all: () => [...mappings.values()],
|
|
1445
|
+
/** Mappings that start at `ref`. */
|
|
1446
|
+
from: (ref, type) => ofType(forward.get(refKey(ref)) ?? [], type),
|
|
1447
|
+
/** Mappings that end at `ref` (the reverse direction; many sources may share a target). */
|
|
1448
|
+
to: (ref, type) => ofType(reverse.get(refKey(ref)) ?? [], type),
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Follow a chain of mapping types from `start`. Returns every chain found
|
|
1452
|
+
* and, for each step, which references had NO mapping (a gap is reported,
|
|
1453
|
+
* never skipped). `evidence` lists every mapping used, so the join is
|
|
1454
|
+
* traceable end to end.
|
|
1455
|
+
*/
|
|
1456
|
+
trace(start, types, { maxChains = 1000 } = {}) {
|
|
1457
|
+
let chains = [{ refs: [start], mappings: [] }];
|
|
1458
|
+
const gaps = [];
|
|
1459
|
+
let truncated = false;
|
|
1460
|
+
for (const type of types) {
|
|
1461
|
+
const next = [];
|
|
1462
|
+
for (const c of chains) {
|
|
1463
|
+
const last = c.refs[c.refs.length - 1];
|
|
1464
|
+
const ms = this.from(last, type);
|
|
1465
|
+
if (ms.length === 0) { gaps.push({ type, ref: last }); continue; }
|
|
1466
|
+
for (const m of ms) {
|
|
1467
|
+
if (m.to.length === 0) { gaps.push({ type, ref: last, unmapped: m.unmapped, mapping: m.id }); continue; }
|
|
1468
|
+
for (const t of m.to) {
|
|
1469
|
+
if (next.length >= maxChains) { truncated = true; break; }
|
|
1470
|
+
next.push({ refs: [...c.refs, t], mappings: [...c.mappings, m.id] });
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
chains = next;
|
|
1475
|
+
}
|
|
1476
|
+
const used = new Set(chains.flatMap((c) => c.mappings));
|
|
1477
|
+
return { start, types: [...types], chains, gaps, truncated, evidence: [...used].map((id) => mappings.get(id)) };
|
|
1478
|
+
},
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
;// CONCATENATED MODULE: ./src/graph/crosswalk-builders.js
|
|
1483
|
+
//
|
|
1484
|
+
// crosswalk-builders.js: build the standard crosswalk mappings from registered
|
|
1485
|
+
// views. Every builder joins on facts the two graphs both carry and records
|
|
1486
|
+
// the join as evidence; none invents an identity.
|
|
1487
|
+
//
|
|
1488
|
+
// finding -> semantic call site file + line (location-exact)
|
|
1489
|
+
// call site -> provenance witnesses enclosing function (qid) + line
|
|
1490
|
+
// witness -> projected flow data element id
|
|
1491
|
+
// projected node -> underlying sites decision kind/category/coverage/externality
|
|
1492
|
+
//
|
|
1493
|
+
// Where a join cannot pin one target, the mapping lists ALL candidates
|
|
1494
|
+
// (one-to-many) and carries an `unknowns` entry. It never picks one silently.
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
const crosswalk_builders_stripLine = (qid) => String(qid).replace(/@\d+$/, '');
|
|
1499
|
+
const normFile = (f) => String(f ?? '').replace(/^\.\//, '').replace(/\\/g, '/');
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Join findings to semantic call sites by exact file and line.
|
|
1503
|
+
* @returns {object[]} the mappings added
|
|
1504
|
+
*/
|
|
1505
|
+
function mapFindingsToCallSites(crosswalk, findings, semantic) {
|
|
1506
|
+
const byLoc = new Map();
|
|
1507
|
+
for (const cs of semantic.entities('callSite')) {
|
|
1508
|
+
const k = `${normFile(cs.native.file)}:${cs.native.line}`;
|
|
1509
|
+
if (!byLoc.has(k)) byLoc.set(k, []);
|
|
1510
|
+
byLoc.get(k).push(cs);
|
|
1511
|
+
}
|
|
1512
|
+
const added = [];
|
|
1513
|
+
for (const f of findings.entities('finding')) {
|
|
1514
|
+
const { file, line } = f.native;
|
|
1515
|
+
if (!Number.isInteger(line) || !file) {
|
|
1516
|
+
added.push(crosswalk.add({
|
|
1517
|
+
type: 'finding-to-callsite', from: f.ref, to: [], unmapped: 'finding-has-no-file-and-line',
|
|
1518
|
+
evidence: { method: 'file-line', grade: 'none', basis: 'the finding carries no integer line, so it cannot be located' },
|
|
1519
|
+
}));
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
const hits = byLoc.get(`${normFile(file)}:${line}`) ?? [];
|
|
1523
|
+
if (hits.length === 0) continue; // not a call-site finding; absence is visible as a trace gap
|
|
1524
|
+
added.push(crosswalk.add({
|
|
1525
|
+
type: 'finding-to-callsite', from: f.ref, to: hits.map((h) => h.ref), coarsens: hits.length > 1,
|
|
1526
|
+
evidence: { method: 'file-line', grade: hits.length === 1 ? 'location-exact' : 'location-ambiguous', basis: `${normFile(file)}:${line}` },
|
|
1527
|
+
unknowns: hits.length > 1 ? [{ kind: 'ambiguous-location', count: hits.length, reason: 'several call sites share this line; the finding names a line, not a call' }] : [],
|
|
1528
|
+
}));
|
|
1529
|
+
}
|
|
1530
|
+
return added;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Join semantic call sites to provenance witnesses (pedges): same enclosing
|
|
1535
|
+
* function and same line. The provenance scope is `<qid>@<line>`; the semantic
|
|
1536
|
+
* symbol carries the qid without the line suffix.
|
|
1537
|
+
*/
|
|
1538
|
+
function mapCallSitesToWitnesses(crosswalk, semantic, provenance) {
|
|
1539
|
+
const witnessByKey = new Map();
|
|
1540
|
+
for (const w of provenance.entities('pedge')) {
|
|
1541
|
+
const k = `${crosswalk_builders_stripLine(w.native.scope)}\u0000${w.native.line}`;
|
|
1542
|
+
if (!witnessByKey.has(k)) witnessByKey.set(k, []);
|
|
1543
|
+
witnessByKey.get(k).push(w);
|
|
1544
|
+
}
|
|
1545
|
+
const symQid = new Map(semantic.entities('symbol').map((s) => [s.localId, s.native.qid]));
|
|
1546
|
+
const sitesPerKey = new Map();
|
|
1547
|
+
const keyOf = (cs) => `${symQid.get(cs.native.caller)}\u0000${cs.native.line}`;
|
|
1548
|
+
const sites = semantic.entities('callSite');
|
|
1549
|
+
for (const cs of sites) sitesPerKey.set(keyOf(cs), (sitesPerKey.get(keyOf(cs)) ?? 0) + 1);
|
|
1550
|
+
const added = [];
|
|
1551
|
+
for (const cs of sites) {
|
|
1552
|
+
const hits = witnessByKey.get(keyOf(cs)) ?? [];
|
|
1553
|
+
if (hits.length === 0) continue;
|
|
1554
|
+
const shared = sitesPerKey.get(keyOf(cs));
|
|
1555
|
+
added.push(crosswalk.add({
|
|
1556
|
+
type: 'callsite-to-witness', from: cs.ref, to: hits.map((h) => h.ref), coarsens: hits.length > 1 || shared > 1,
|
|
1557
|
+
evidence: { method: 'scope-line', grade: shared > 1 ? 'location-ambiguous' : 'location-exact', basis: `${symQid.get(cs.native.caller)} line ${cs.native.line}` },
|
|
1558
|
+
unknowns: shared > 1 ? [{ kind: 'shared-line', callSites: shared, reason: 'provenance records a line, not a call; these witnesses cannot be pinned to one of the call sites on that line' }] : [],
|
|
1559
|
+
}));
|
|
1560
|
+
}
|
|
1561
|
+
return added;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Join provenance witnesses to the projected flows that carry their data
|
|
1566
|
+
* element. A flow aggregates many witnesses (and many sites), so every mapping
|
|
1567
|
+
* is flagged `coarsens`.
|
|
1568
|
+
*/
|
|
1569
|
+
function mapWitnessesToFlows(crosswalk, provenance, dataflow) {
|
|
1570
|
+
const flowsByDe = new Map();
|
|
1571
|
+
for (const fl of dataflow.entities('flow')) {
|
|
1572
|
+
for (const de of fl.native.dataElementIds || []) {
|
|
1573
|
+
if (!flowsByDe.has(de)) flowsByDe.set(de, []);
|
|
1574
|
+
flowsByDe.get(de).push(fl);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
const added = [];
|
|
1578
|
+
for (const w of provenance.entities('pedge')) {
|
|
1579
|
+
const hits = flowsByDe.get(w.native.dataElementId) ?? [];
|
|
1580
|
+
if (hits.length === 0) continue;
|
|
1581
|
+
added.push(crosswalk.add({
|
|
1582
|
+
type: 'witness-to-flow', from: w.ref, to: hits.map((h) => h.ref), coarsens: true,
|
|
1583
|
+
evidence: { method: 'data-element', grade: 'identity-shared', basis: w.native.dataElementId },
|
|
1584
|
+
unknowns: [{ kind: 'flow-granularity', reason: 'a projected flow aggregates every witness of its data element; the flow does not say which site this witness belongs to' }],
|
|
1585
|
+
}));
|
|
1586
|
+
}
|
|
1587
|
+
return added;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const calleeText = (e) => {
|
|
1591
|
+
if (!e || typeof e !== 'object') return null;
|
|
1592
|
+
if (e.kind === 'ident') return e.name;
|
|
1593
|
+
if (e.kind === 'member') { const o = calleeText(e.object); return o ? `${o}.${e.prop ?? '[]'}` : null; }
|
|
1594
|
+
return null;
|
|
1595
|
+
};
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Map each projected (category-granular) dataflow node to the underlying call
|
|
1599
|
+
* sites it stands for. `sites` is the sink-site list `buildDataFlowGraph`
|
|
1600
|
+
* returns. A node with several sites maps one-to-many and is flagged `coarsens`;
|
|
1601
|
+
* a site that no semantic call site matches is listed under `unknowns`.
|
|
1602
|
+
*/
|
|
1603
|
+
function mapProjectedNodesToSites(crosswalk, dataflow, semantic, sites) {
|
|
1604
|
+
const callSites = semantic.entities('callSite');
|
|
1605
|
+
const added = [];
|
|
1606
|
+
for (const n of dataflow.entities('node')) {
|
|
1607
|
+
const node = n.native;
|
|
1608
|
+
const matching = (sites || []).filter((s) => s.decision
|
|
1609
|
+
&& s.decision.kind === node.kind
|
|
1610
|
+
&& (s.decision.category ?? null) === (node.subtype ?? null)
|
|
1611
|
+
&& s.decision.coverageStatus === node.coverageStatus
|
|
1612
|
+
&& s.decision.externality === node.externality?.value
|
|
1613
|
+
&& canonicalJson(s.destination ?? null) === canonicalJson(node.destination ?? null));
|
|
1614
|
+
if (matching.length === 0) continue;
|
|
1615
|
+
const targets = new Map();
|
|
1616
|
+
const unmapped = [];
|
|
1617
|
+
for (const s of matching) {
|
|
1618
|
+
const atLine = callSites.filter((c) => normFile(c.native.file) === normFile(s.file) && c.native.line === s.line);
|
|
1619
|
+
const want = calleeText(s.calleeExpr);
|
|
1620
|
+
const exact = want ? atLine.filter((c) => c.native.calleeText === want) : [];
|
|
1621
|
+
const picked = exact.length ? exact : atLine;
|
|
1622
|
+
if (picked.length === 0) unmapped.push({ file: s.file, line: s.line, reason: 'no semantic call site at this location' });
|
|
1623
|
+
for (const c of picked) targets.set(c.localId, c);
|
|
1624
|
+
}
|
|
1625
|
+
const to = [...targets.values()].map((c) => c.ref);
|
|
1626
|
+
const unknowns = unmapped.map((u) => ({ kind: 'unmapped-site', ...u }));
|
|
1627
|
+
if (to.length === 0) continue;
|
|
1628
|
+
added.push(crosswalk.add({
|
|
1629
|
+
type: 'node-to-sites', from: n.ref, to, coarsens: to.length > 1, unknowns,
|
|
1630
|
+
evidence: { method: 'decision-match', grade: to.length === 1 ? 'projection-exact' : 'projection-coarse', basis: `${node.kind}/${node.subtype ?? 'none'}: ${matching.length} sink site(s)` },
|
|
1631
|
+
}));
|
|
1632
|
+
}
|
|
1633
|
+
return added;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
;// CONCATENATED MODULE: ./src/graph/observation.js
|
|
1637
|
+
//
|
|
1638
|
+
// observation.js: attributing a runtime observation to the underlying sites of
|
|
1639
|
+
// a projected node (DAG-06 acceptance: "a projected node with several
|
|
1640
|
+
// underlying sites cannot falsely assign one runtime observation to every
|
|
1641
|
+
// site").
|
|
1642
|
+
//
|
|
1643
|
+
// A runtime observation is matched to a PROJECTED node (a destination, store or
|
|
1644
|
+
// queue). When that node stands for several call sites (a `node-to-sites`
|
|
1645
|
+
// mapping with more than one target), the observation says nothing about which
|
|
1646
|
+
// site produced it unless it carries its own discriminator. The result is:
|
|
1647
|
+
//
|
|
1648
|
+
// attributed exactly one site is justified: the node has a single site, or
|
|
1649
|
+
// the observation names a site (`file` + `line`, or a call-site
|
|
1650
|
+
// id) that matches exactly one candidate.
|
|
1651
|
+
// ambiguous several candidates remain. `attributedTo` is EMPTY, every
|
|
1652
|
+
// candidate is listed, and no site receives the observation.
|
|
1653
|
+
// unmatched the observation names a site that is not one of the candidates.
|
|
1654
|
+
//
|
|
1655
|
+
// "Observed" is therefore a property of the projected node unless a site is
|
|
1656
|
+
// positively identified. An observation is never copied onto each site.
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* @param {object} crosswalk
|
|
1662
|
+
* @param {object} registry
|
|
1663
|
+
* @param {object} nodeRef ref to the projected dataflow node the observation matched
|
|
1664
|
+
* @param {{file?:string, line?:number, callSiteId?:string}} [observation]
|
|
1665
|
+
*/
|
|
1666
|
+
function attributeObservation(crosswalk, registry, nodeRef, observation = {}) {
|
|
1667
|
+
const mappings = crosswalk.from(nodeRef, 'node-to-sites');
|
|
1668
|
+
const candidates = [];
|
|
1669
|
+
const seen = new Set();
|
|
1670
|
+
for (const m of mappings) for (const t of m.to) if (!seen.has(refKey(t))) { seen.add(refKey(t)); candidates.push(t); }
|
|
1671
|
+
const base = { node: nodeRef, candidates, mappings: mappings.map((m) => m.id) };
|
|
1672
|
+
|
|
1673
|
+
if (candidates.length === 0) {
|
|
1674
|
+
return { ...base, status: 'unmatched', attributedTo: [], reason: 'the projected node has no recorded underlying sites' };
|
|
1675
|
+
}
|
|
1676
|
+
const normFile = (f) => String(f ?? '').replace(/^\.\//, '');
|
|
1677
|
+
const hasDiscriminator = observation.callSiteId || (observation.file && Number.isInteger(observation.line));
|
|
1678
|
+
if (hasDiscriminator) {
|
|
1679
|
+
const hit = candidates.filter((c) => {
|
|
1680
|
+
if (observation.callSiteId) return c.localId === observation.callSiteId;
|
|
1681
|
+
const n = registry.node(c).native;
|
|
1682
|
+
return normFile(n.file) === normFile(observation.file) && n.line === observation.line;
|
|
1683
|
+
});
|
|
1684
|
+
if (hit.length === 1) return { ...base, status: 'attributed', attributedTo: hit, basis: 'observation-names-site' };
|
|
1685
|
+
if (hit.length === 0) return { ...base, status: 'unmatched', attributedTo: [], reason: 'the observation names a site that is not one of the node\'s underlying sites' };
|
|
1686
|
+
return { ...base, status: 'ambiguous', attributedTo: [], candidates: hit, reason: 'several call sites share the named location' };
|
|
1687
|
+
}
|
|
1688
|
+
if (candidates.length === 1) return { ...base, status: 'attributed', attributedTo: candidates, basis: 'sole-underlying-site' };
|
|
1689
|
+
return {
|
|
1690
|
+
...base, status: 'ambiguous', attributedTo: [],
|
|
1691
|
+
reason: `the projected node stands for ${candidates.length} sites and the observation does not say which one produced it`,
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
;// CONCATENATED MODULE: ./src/graph/index.js
|
|
1696
|
+
// Typed graph views and identity crosswalks (DAG-06). See CLAUDE.md in this directory.
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
/***/ })
|
|
1712
|
+
|
|
1713
|
+
};
|