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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/{dad58d1113a12c23a142.json → 0b301d45b231f144a1b4.json} +26 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/9579.index.js +1 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/dist/frontend/src/shell.js +12 -2
  11. package/package.json +26 -9
  12. package/src/dataflow/CLAUDE.md +43 -1
  13. package/src/dataflow/backward.js +42 -7
  14. package/src/dataflow/cross-service-taint.js +83 -1
  15. package/src/dataflow/demand-query.js +929 -0
  16. package/src/dataflow/dep-fingerprint.js +718 -0
  17. package/src/dataflow/engine.js +48 -6
  18. package/src/dataflow/ifds-precise.js +22 -0
  19. package/src/dataflow/incremental-replay.js +118 -0
  20. package/src/dataflow/incremental-session.js +257 -0
  21. package/src/dataflow/incremental.js +237 -35
  22. package/src/dataflow/index.js +25 -94
  23. package/src/dataflow/summaries.js +72 -6
  24. package/src/dataflow/tabulation.js +80 -24
  25. package/src/engine.js +51 -3
  26. package/src/graph/CLAUDE.md +31 -0
  27. package/src/graph/adapters/artifact.js +81 -0
  28. package/src/graph/adapters/crossrepo.js +75 -0
  29. package/src/graph/adapters/dataflow.js +76 -0
  30. package/src/graph/adapters/provenance.js +92 -0
  31. package/src/graph/adapters/semantic.js +89 -0
  32. package/src/graph/crosswalk-builders.js +152 -0
  33. package/src/graph/crosswalk.js +146 -0
  34. package/src/graph/index.js +15 -0
  35. package/src/graph/observation.js +57 -0
  36. package/src/graph/refs.js +105 -0
  37. package/src/graph/registry.js +129 -0
  38. package/src/graph/relations.js +81 -0
  39. package/src/graph/scc.js +241 -0
  40. package/src/graph/view.js +254 -0
  41. package/src/lineage/CLAUDE.md +71 -8
  42. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  43. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  44. package/src/lineage/boundary-contract.js +273 -0
  45. package/src/lineage/completion.js +205 -0
  46. package/src/lineage/continuation.js +140 -0
  47. package/src/lineage/control-credit.js +313 -0
  48. package/src/lineage/correlation.js +199 -0
  49. package/src/lineage/coverage.js +8 -1
  50. package/src/lineage/cross-repo-link.js +22 -0
  51. package/src/lineage/cut-planner.js +548 -0
  52. package/src/lineage/driver.js +219 -66
  53. package/src/lineage/engine.js +166 -21
  54. package/src/lineage/export-json.js +5 -0
  55. package/src/lineage/graph-builder.js +84 -7
  56. package/src/lineage/haskell-view.js +59 -0
  57. package/src/lineage/index.js +3 -0
  58. package/src/lineage/nix-view.js +35 -0
  59. package/src/lineage/owned-build.js +77 -0
  60. package/src/lineage/owned-worker-entry.js +22 -0
  61. package/src/lineage/owned-worker.js +95 -0
  62. package/src/lineage/path-evidence.js +123 -0
  63. package/src/lineage/path-predicates.js +529 -0
  64. package/src/lineage/path-query.js +189 -20
  65. package/src/lineage/path-store.js +191 -17
  66. package/src/lineage/scc-solver.js +186 -0
  67. package/src/lineage/smt-orchestrator.js +138 -0
  68. package/src/lineage/source-seeding.js +36 -2
  69. package/src/lineage/summaries.js +66 -4
  70. package/src/lineage/value-versions.js +130 -0
  71. package/src/lineage/witness-selection.js +95 -0
  72. package/src/pipeline/scan-health.js +18 -0
  73. package/src/posture/CLAUDE.md +13 -0
  74. package/src/posture/evidence-bundle.js +1 -1
  75. package/src/posture/witness-bundle.js +573 -0
  76. package/src/posture/witness-runtime.js +276 -0
  77. package/src/report/index.js +2 -0
@@ -67,53 +67,107 @@ export function enumerateSinks(perFileIR, callGraph) {
67
67
  }
68
68
 
69
69
  /**
70
- * RHS-lite demand-driven slice. For each sink, walk backward through the
71
- * intra-procedural CFG via def-use, then ascend via the call graph for
72
- * each unresolved parameter binding. Returns the set of fn qids that are
73
- * REACHABLE from the sink (and therefore worth deep-analyzing).
74
- *
75
- * sinks: enumerateSinks() output
76
- * callGraph: { functions, callers, callees }
77
- * maxCallerDepth: bounds reverse-call-graph walk
70
+ * Canonical caller index: callee qid -> [caller qid]. Built from each
71
+ * function's recorded `calls` (statement-level call nodes) and resolved with
72
+ * resolveKnownCallee, never the guessing resolve(). The demand selector
73
+ * (demand-query.js) extends it with the embedded call sites that `fn.calls`
74
+ * does not record (assignment right-hand sides, return values, nested
75
+ * arguments, callback registrations).
78
76
  */
79
- export function reachabilitySliceFromSinks(sinks, callGraph, maxCallerDepth = MAX_CALLER_DEPTH) {
80
- const reachableFns = new Set();
81
- if (!Array.isArray(sinks) || !callGraph || !callGraph.functions) return reachableFns;
82
-
83
- // Build a reverse callgraph: who calls fn-qid?
77
+ export function buildCallerQidIndex(callGraph) {
84
78
  const callersOf = new Map();
79
+ if (!callGraph || !callGraph.functions) return callersOf;
85
80
  for (const fn of callGraph.functions.values()) {
86
81
  for (const callee of (fn.calls || [])) {
87
- // callee.callee is a SOURCE-LEVEL name ("b.fill"); the worklist below
88
- // looks this map up by qid, so keying by the name means the lookup can
89
- // never match. Resolve to a qid first.
90
- const name = callee && typeof callee === 'object' ? callee.callee : callee;
91
- if (!name) continue;
82
+ // callee.callee is a SOURCE-LEVEL name ("b.fill"); lookups are by qid,
83
+ // so keying by the name could never match. Resolve to a qid first.
92
84
  // resolveKnownCallee (not resolve): callee.callee may be a flattened
93
85
  // member-call string ("loader.read"); the plain resolve() would guess
94
86
  // via its bare-tail fallback and invent an edge to an unrelated
95
87
  // same-named function. See callgraph.js.
88
+ const name = callee && typeof callee === 'object' ? callee.callee : callee;
89
+ if (!name) continue;
96
90
  const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
97
91
  if (!qid) continue;
98
92
  if (!callersOf.has(qid)) callersOf.set(qid, []);
99
93
  callersOf.get(qid).push(fn.qid);
100
94
  }
101
95
  }
96
+ return callersOf;
97
+ }
98
+
99
+ /**
100
+ * RHS-lite demand-driven slice. For each sink, walk backward through the
101
+ * intra-procedural CFG via def-use, then ascend via the call graph for
102
+ * each unresolved parameter binding. Returns the set of fn qids that are
103
+ * REACHABLE from the sink (and therefore worth deep-analyzing).
104
+ *
105
+ * sinks: enumerateSinks() output
106
+ * callGraph: { functions, callers, callees }
107
+ * maxCallerDepth: bounds reverse-call-graph walk
108
+ */
109
+ export function reachabilitySliceFromSinks(sinks, callGraph, maxCallerDepth = MAX_CALLER_DEPTH) {
110
+ return reachabilitySliceWithCompletion(sinks, callGraph, maxCallerDepth).reachable;
111
+ }
112
+
113
+ /**
114
+ * Same walk as reachabilitySliceFromSinks, plus a completion record
115
+ * (DAG-07 R07.1). A function the walk never reached is skipped by
116
+ * `shouldAnalyzeUnderRhs`, so a walk that stopped at its worklist cap or its
117
+ * caller-depth ceiling has silently narrowed what is analyzed; the record
118
+ * says so, with the number of callers left unexplored.
119
+ *
120
+ * Returns { reachable: Set<qid>, completion: { status, reasons,
121
+ * unexploredFrontierCount, visited, limits } }.
122
+ */
123
+ export function reachabilitySliceWithCompletion(sinks, callGraph, maxCallerDepth = MAX_CALLER_DEPTH) {
124
+ const reachableFns = new Set();
125
+ const completion = {
126
+ status: 'complete', reasons: [], unexploredFrontierCount: 0, visited: 0,
127
+ limits: { maxWorklist: MAX_WORKLIST, maxCallerDepth },
128
+ };
129
+ if (!Array.isArray(sinks) || !callGraph || !callGraph.functions) {
130
+ completion.status = 'unavailable';
131
+ completion.reasons = ['no-call-graph'];
132
+ return { reachable: reachableFns, completion };
133
+ }
134
+
135
+ // Build a reverse callgraph: who calls fn-qid?
136
+ // DAG-02: factored into the shared, exported buildCallerQidIndex so the
137
+ // demand selector (demand-query.js) reuses the same canonical incoming-edge
138
+ // index. Behaviour is unchanged.
139
+ const callersOf = buildCallerQidIndex(callGraph);
102
140
 
103
141
  const work = [];
104
142
  for (const s of sinks) work.push({ qid: s.fnQid, depth: 0 });
105
143
  let visitedCount = 0;
144
+ let depthCut = 0;
145
+ let capped = false;
106
146
  while (work.length) {
107
- if (++visitedCount > MAX_WORKLIST) break;
147
+ if (++visitedCount > MAX_WORKLIST) { capped = true; break; }
108
148
  const { qid, depth } = work.shift();
109
149
  if (reachableFns.has(qid)) continue;
110
150
  reachableFns.add(qid);
111
- if (depth >= maxCallerDepth) continue;
151
+ if (depth >= maxCallerDepth) {
152
+ // Callers of this function exist but are not explored.
153
+ for (const callerQid of (callersOf.get(qid) || [])) if (!reachableFns.has(callerQid)) depthCut += 1;
154
+ continue;
155
+ }
112
156
  for (const callerQid of (callersOf.get(qid) || [])) {
113
157
  if (!reachableFns.has(callerQid)) work.push({ qid: callerQid, depth: depth + 1 });
114
158
  }
115
159
  }
116
- return reachableFns;
160
+ completion.visited = visitedCount;
161
+ if (capped) {
162
+ completion.reasons.push('worklist-limit');
163
+ completion.unexploredFrontierCount += work.length;
164
+ }
165
+ if (depthCut > 0) {
166
+ completion.reasons.push('caller-depth-limit');
167
+ completion.unexploredFrontierCount += depthCut;
168
+ }
169
+ if (completion.reasons.length) completion.status = 'partial';
170
+ return { reachable: reachableFns, completion };
117
171
  }
118
172
 
119
173
  /**
@@ -128,10 +182,12 @@ export function rhsReachableFunctions(perFileIR, callGraph) {
128
182
  const sinks = enumerateSinks(perFileIR, callGraph);
129
183
  if (!sinks.length) {
130
184
  // No sinks → no demand. Return null to signal "analyze all" to caller.
131
- return { reachable: null, sinks: [] };
185
+ // `completion` is an additive property (DAG-07 R07.1); existing callers
186
+ // destructure only { reachable, sinks }.
187
+ return { reachable: null, sinks: [], completion: { status: 'complete', reasons: [], unexploredFrontierCount: 0, visited: 0, limits: {}, note: 'no-sinks-analyze-all' } };
132
188
  }
133
- const reachable = reachabilitySliceFromSinks(sinks, callGraph);
134
- return { reachable, sinks };
189
+ const { reachable, completion } = reachabilitySliceWithCompletion(sinks, callGraph);
190
+ return { reachable, sinks, completion };
135
191
  }
136
192
 
137
193
  /**
package/src/engine.js CHANGED
@@ -217,6 +217,8 @@ import { createStringMemo } from './util/string-memo.js';
217
217
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
218
218
  import { runDeepAnalysis } from './dataflow/index.js';
219
219
  import { buildLineageGraph } from './lineage/index.js';
220
+ import { buildLineageGraphOwned, resolveLineageDeadlineMs } from './lineage/owned-build.js';
221
+ import { completionOfGraph, summarizeForHealth as summarizeCompletionForHealth } from './lineage/completion.js';
220
222
  // v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
221
223
  import { annotateCloneClusters, findCloneOutliers } from './posture/semantic-clone.js';
222
224
  import { annotateAiProvenance } from './posture/ai-code-fingerprint.js';
@@ -9503,6 +9505,7 @@ function _deterministicFileTimings(timings) {
9503
9505
  let _deepCallGraph = null;
9504
9506
  let _deepFailure = null;
9505
9507
  let _deepIncomplete = null;
9508
+ let _deepIncremental = null; // DAG-05 R05.7: cache provenance of the deep pass, when AGENTIC_SECURITY_INCREMENTAL=1
9506
9509
  if (_deepEnabled) {
9507
9510
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
9508
9511
  const t0 = Date.now();
@@ -9547,6 +9550,10 @@ function _deterministicFileTimings(timings) {
9547
9550
  });
9548
9551
  }
9549
9552
  if (Array.isArray(irFindings.analysisIncomplete) && irFindings.analysisIncomplete.length) _deepIncomplete = irFindings.analysisIncomplete;
9553
+ if (irFindings._incrementalStats) {
9554
+ const st = irFindings._incrementalStats;
9555
+ _deepIncremental = { mode: st.mode, reason: st.reason, priorGeneration: st.priorGeneration, provenance: st.provenance, invalidation: st.invalidation, files: st.files, persisted: st.persisted && { committed: st.persisted.committed, error: st.persisted.error }, completion: st.completion };
9556
+ }
9550
9557
  for (const f of irFindings) {
9551
9558
  f.unvalidated = true;
9552
9559
  f.validator_verdict = 'unvalidated';
@@ -9577,6 +9584,21 @@ function _deterministicFileTimings(timings) {
9577
9584
  // the finding-based notice above does not: deep mode never explicitly
9578
9585
  // requested at all (the common default-unset-in-CI case), which previously
9579
9586
  // produced no signal of any kind that assurance had quietly narrowed.
9587
+ // DAG-05: when the cache was asked for, say what it did for this scan. Languages whose analyzers keep
9588
+ // no cross-scan state (Nix: there is no taint IR to key a cache on) are analysed in full every time and named.
9589
+ if (process.env.AGENTIC_SECURITY_INCREMENTAL === '1') {
9590
+ const noHook = Object.keys(fc).some((f) => /\.nix$/i.test(f)) ? ['nix'] : [];
9591
+ if (!_deepIncremental) {
9592
+ _deepIncremental = { mode: 'cold', reason: _deepEnabled ? 'the deep pass recorded no incremental state' : 'deep analysis did not run, so the cross-scan cache is not used' };
9593
+ }
9594
+ if (noHook.length) {
9595
+ _deepIncremental = {
9596
+ ..._deepIncremental, languagesWithoutIncrementalHook: noHook,
9597
+ languages: Object.fromEntries(noHook.map((l) => [l, { incremental: false, mode: 'cold', reason: 'its analysers keep no cross-scan state and run in full on every scan' }])),
9598
+ note: `${noHook.join(', ')} analysers keep no cross-scan state and run in full on every scan`,
9599
+ };
9600
+ }
9601
+ }
9580
9602
  const _deepStatus = {
9581
9603
  requested: _deepRequested,
9582
9604
  enabled: _deepEnabled,
@@ -9590,6 +9612,8 @@ function _deterministicFileTimings(timings) {
9590
9612
  failure: _deepFailure,
9591
9613
  // E2-05: where the deep analysis stopped short (budget, deadline, function limit, a thrown analysis). Present only when something did.
9592
9614
  ...(_deepIncomplete ? { incomplete: _deepIncomplete } : {}),
9615
+ // DAG-05 R05.7: which facts were reused from the cache and which were analysed afresh, and why a run was cold.
9616
+ ...(_deepIncremental ? { incremental: _deepIncremental } : {}),
9593
9617
  };
9594
9618
  // Sub-project E, increment 5 — Data Flow Explorer lineage graph.
9595
9619
  // DELIBERATELY INDEPENDENT of AGENTIC_SECURITY_DEEP/_deepEnabled above
@@ -9621,10 +9645,11 @@ function _deterministicFileTimings(timings) {
9621
9645
  // Reuses the SAME _sharedIR memo _deepEnabled's own block above uses —
9622
9646
  // if deep mode already built it, this is free; if not, this is what
9623
9647
  // first triggers the build (the whole reason this gate is independent).
9624
- const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
9648
+ const _lineageDeadlineMs = resolveLineageDeadlineMs();
9649
+ const { perFile, callGraph } = _lineageDeadlineMs ? {} : (_sharedIR || (_sharedIR = await _buildIR()));
9625
9650
  const _lineageT0 = Date.now();
9626
9651
  setProgress({ phase: 'Lineage graph', current: 0, total: 0, file: `starting (budget ${Math.round(_lineageBudgetMs / 1000)}s)` });
9627
- const _lr = buildLineageGraph(callGraph, {
9652
+ const _lineageOpts = {
9628
9653
  // Live per-function progress from the field-identity driver's own
9629
9654
  // single pass over every function (src/lineage/driver.js).
9630
9655
  onProgress: (p) => setProgress({
@@ -9650,9 +9675,32 @@ function _deterministicFileTimings(timings) {
9650
9675
  deterministic: isDeterministic(),
9651
9676
  perFile,
9652
9677
  fileContents,
9653
- });
9678
+ };
9679
+ // DAG-07 R07.7: with AGENTIC_SECURITY_LINEAGE_DEADLINE_MS set, the build runs in an
9680
+ // owned worker with a watchdog. Unset: the in-process call, exactly as before.
9681
+ let _lr = _lineageDeadlineMs
9682
+ ? await buildLineageGraphOwned(fileContents, { ..._lineageOpts, onProgress: undefined, perFile: undefined, deadlineMs: _lineageDeadlineMs }).done
9683
+ : buildLineageGraph(callGraph, _lineageOpts);
9684
+ // The worker entry is a separate file that the single-file bundle does not ship.
9685
+ // When the worker cannot start, fall back to the in-process build with the
9686
+ // cooperative deadline (no watchdog for a non-polling worker, but the analysis
9687
+ // still stops and reports `cancelled`); never fail the build over it.
9688
+ if (_lineageDeadlineMs && _lr.status === 'failed' && /Cannot find module|ERR_MODULE_NOT_FOUND|owned-worker/i.test(String(_lr.failure || ''))) {
9689
+ // The deadline branch skipped the shared IR (the worker rebuilds its own); build it now.
9690
+ const _ir = _sharedIR || (_sharedIR = await _buildIR());
9691
+ _lr = buildLineageGraph(_ir.callGraph, { ..._lineageOpts, perFile: _ir.perFile, deadline: Date.now() + _lineageDeadlineMs });
9692
+ }
9693
+ if (_lr.terminatedByWatchdog) _lineageStatus.failure = _lr.failure;
9654
9694
  if (_lr.status === 'complete') {
9655
9695
  _lineageGraph = _lr.graph;
9696
+ // DAG-07 R07.2: a graph built from a field analysis that stopped
9697
+ // early must reach scan health, not only the graph body.
9698
+ const _ac = _lr.graph?.coverage?.analysisCompletion;
9699
+ if (_ac && _ac.status !== 'complete') { _lineageStatus.incomplete = _ac.incomplete; _lineageStatus.degraded = _ac.degraded; }
9700
+ // R07.2: the whole envelope (cancelled, construction limits, seeding
9701
+ // limits and zero-flow results included), not only the iteration
9702
+ // and degraded-context lists above.
9703
+ { const _env = completionOfGraph(_lr.graph); if (_env.status !== 'complete') _lineageStatus.completion = summarizeCompletionForHealth(_env); }
9656
9704
  if (_lr.elapsedMs > _lineageBudgetMs) {
9657
9705
  // Same "measured, not truly interrupted" disclosure _deepEnabled's
9658
9706
  // own elapsed > budgetMs branch already makes for IR-taint — see
@@ -0,0 +1,31 @@
1
+ # scanner/src/graph/
2
+
3
+ Typed, read-only graph views and identity crosswalks (DAG-06). A lens over the
4
+ graph layers that already exist; it replaces none of them and changes none of
5
+ their schemas, exports or validators.
6
+
7
+ | Module | Responsibility |
8
+ |---|---|
9
+ | `refs.js` | Reference format `{v, graphKind, repository, graphDigest, entityKind, localId}`, `refKey`/`parseRef`, `GraphViewError` codes, canonical digest |
10
+ | `relations.js` | Relation kinds (data_flow, control_dependence, calls, returns, dependency, declaration, observation, artifact_derivation), edge origins (code, derived, schema, declared, observed), the traversal filter |
11
+ | `view.js` | `IndexedGraphView` (the view contract) and the adjacency index cache keyed by graph kind + digest + adapter version |
12
+ | `adapters/` | `provenance` (PathStore pnode/pedge), `dataflow` (DataFlowGraph v1), `semantic` (SemanticGraph v1), `artifact` (artifact provenance graph), `crossrepo` (CrossRepoLink records, findings) |
13
+ | `registry.js` | Registered views; rejects dangling refs, incompatible adapter versions, same-identity-different-content, ambiguous bare local ids |
14
+ | `crosswalk.js` | Versioned, evidence-bearing mappings; cardinality is explicit; cross-repository `identity` mappings are refused |
15
+ | `crosswalk-builders.js` | finding to call site to witnesses to flow; projected node to underlying sites |
16
+ | `observation.js` | Attributes a runtime observation to a projected node's sites without copying it onto every site |
17
+
18
+ ## Rules
19
+
20
+ - **Read-only, pure, no network.** Adapters never mutate their source graph;
21
+ `reconstruct()` returns the original object. `path-store.js` is duck-typed and
22
+ never imported; no analysis engine is imported anywhere here.
23
+ - **A local id is never a key.** Every index and mapping is keyed by the full
24
+ reference (kind, repository, digest, entity kind, local id).
25
+ - **Two axes.** A query filter chooses relations AND origins. The default
26
+ traverses `code`, `derived` and `schema` edges only; a `declared` cross-repo
27
+ link or an `observed` edge is walked only when the caller asks for that origin.
28
+ - **Coarsening is stated.** A mapping with several targets is `one-to-many`
29
+ with `coarsens` and `unknowns`; nothing is attributed to "every" target.
30
+ - **Versions.** Adapter versions and `CROSSWALK_VERSION` are semver; the registry
31
+ accepts only supported majors.
@@ -0,0 +1,81 @@
1
+ //
2
+ // adapters/artifact.js: read-only view over the artifact provenance graph
3
+ // (artifact/provenance-graph.js: commit -> build -> OCI digest -> SBOM
4
+ // component -> finding, plus an operator-declared deployment).
5
+ //
6
+ // The artifact graph has no repository identity of its own, so the caller names
7
+ // it (`repository`: the image repository or the source repository the artifact
8
+ // was built from). The graph digest is a canonical hash of the graph body.
9
+ //
10
+ // Relations: commit->build, build->digest and digest->deployment are
11
+ // `artifact_derivation`; digest->component and component->finding are
12
+ // `dependency`. An operator-declared deployment edge has origin `declared`; an
13
+ // edge with evidence 'none' (a commit that is not known) is kept, carries an
14
+ // `unknowns` entry, and has grade 'none'.
15
+ //
16
+ // The generator can list the same SBOM component or finding twice (an OS and an
17
+ // application package with one purl). Byte-identical duplicates are collapsed
18
+ // in the view and counted in metadata; duplicates that DIFFER are rejected,
19
+ // because choosing one would be a silent merge. `reconstruct()` returns the
20
+ // original graph, duplicates included.
21
+
22
+ import { digestOf, GraphViewError, canonicalJson } from '../refs.js';
23
+ import { IndexedGraphView, local } from '../view.js';
24
+
25
+ export const ARTIFACT_ADAPTER_VERSION = '1.0.0';
26
+
27
+ function relationFor(fromKind, toKind) {
28
+ if (toKind === 'sbomComponent' || toKind === 'finding') return 'dependency';
29
+ return 'artifact_derivation';
30
+ }
31
+
32
+ export function artifactView(graph, opts) {
33
+ if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
34
+ throw new TypeError('artifactView requires a graph with nodes[] and edges[]');
35
+ }
36
+ const graphDigest = digestOf(graph);
37
+ const byId = new Map();
38
+ let duplicateNodes = 0;
39
+ for (const n of graph.nodes) {
40
+ const prev = byId.get(n.id);
41
+ if (prev) {
42
+ if (canonicalJson(prev) !== canonicalJson(n)) throw new GraphViewError('DUPLICATE_ID', `artifact graph has two different nodes with id ${n.id}`, { id: n.id });
43
+ duplicateNodes += 1;
44
+ } else byId.set(n.id, n);
45
+ }
46
+ const nodes = [...byId.values()].map((n) => ({ entityKind: n.kind, localId: n.id, native: n, summary: { kind: n.kind } }));
47
+ const seenEdges = new Map();
48
+ let duplicateEdges = 0;
49
+ const edges = [];
50
+ for (const e of graph.edges) {
51
+ const id = `agedge:${e.from}->${e.to}`;
52
+ const prev = seenEdges.get(id);
53
+ if (prev) {
54
+ if (canonicalJson(prev) !== canonicalJson(e)) throw new GraphViewError('DUPLICATE_ID', `artifact graph has two different edges ${e.from} -> ${e.to}`, { id });
55
+ duplicateEdges += 1;
56
+ continue;
57
+ }
58
+ seenEdges.set(id, e);
59
+ const from = byId.get(e.from);
60
+ const to = byId.get(e.to);
61
+ 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 });
62
+ const declared = e.evidence === 'operator-declared' || to.kind === 'deployment';
63
+ const none = e.evidence === 'none';
64
+ edges.push({
65
+ id, relation: relationFor(from.kind, to.kind), origin: declared ? 'declared' : 'derived',
66
+ from: local(from.kind, from.id), to: local(to.kind, to.id),
67
+ evidence: { grade: e.evidence ?? 'none', source: 'artifact-provenance-graph' },
68
+ 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' }] : []),
69
+ attrs: e.digestMatches === undefined ? {} : { digestMatches: e.digestMatches }, native: e,
70
+ });
71
+ }
72
+ const unlinked = edges.filter((e) => e.unknowns.some((u) => u.kind === 'unlinked')).length;
73
+ const reasons = unlinked ? [`unlinked-edges:${unlinked}`] : [];
74
+ return new IndexedGraphView({
75
+ graphKind: 'artifact', adapterVersion: ARTIFACT_ADAPTER_VERSION, repository: opts?.repository, graphDigest,
76
+ metadata: { nodes: graph.nodes.length, edges: graph.edges.length, duplicateNodes, duplicateEdges },
77
+ completion: { status: reasons.length ? 'partial' : 'complete', reasons, coverage: null },
78
+ nodes, edges,
79
+ reconstruct: () => graph,
80
+ });
81
+ }
@@ -0,0 +1,75 @@
1
+ //
2
+ // adapters/crossrepo.js: read-only view over CrossRepoLink records
3
+ // (lineage/cross-repo-link.js), plus a small findings view.
4
+ //
5
+ // A cross-repository link is an operator DECLARATION. In this view it is an
6
+ // edge with origin `declared` and evidence grade `declared`, so the default
7
+ // query filter (code-derived and derived edges only) never walks it as if it
8
+ // were a code-derived data flow, and it can never be mistaken for observed
9
+ // telemetry (R06.4). Its endpoints live in OTHER graphs, so they are foreign
10
+ // references, each carrying that graph's repository and digest. An endpoint
11
+ // that no registered graph can resolve is reported by the registry as
12
+ // unresolved, not dropped.
13
+ //
14
+ // Both endpoints are `dataflow` node references: that is the only graph kind a
15
+ // CrossRepoLink record binds today.
16
+
17
+ import { validateCrossRepoLink } from '../../lineage/cross-repo-link.js';
18
+ import { digestOf, makeRef, GraphViewError } from '../refs.js';
19
+ import { IndexedGraphView, foreign } from '../view.js';
20
+
21
+ export const CROSSREPO_ADAPTER_VERSION = '1.0.0';
22
+ export const FINDINGS_ADAPTER_VERSION = '1.0.0';
23
+
24
+ /**
25
+ * @param {object[]} links CrossRepoLink records
26
+ * @param {{localRepository:string}} opts the repository the `local` side belongs to
27
+ */
28
+ export function crossRepoLinkView(links, opts) {
29
+ if (!Array.isArray(links)) throw new TypeError('crossRepoLinkView requires an array of links');
30
+ const localRepository = opts?.localRepository;
31
+ const nodes = [];
32
+ const edges = [];
33
+ for (const l of links) {
34
+ const v = validateCrossRepoLink(l);
35
+ 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 });
36
+ nodes.push({ entityKind: 'link', localId: l.id, native: l, summary: { declaredBy: l.declaredBy, declaredAt: l.declaredAt } });
37
+ const from = makeRef({ graphKind: 'dataflow', repository: localRepository, graphDigest: l.local.graphDigest, entityKind: 'node', localId: l.local.nodeId });
38
+ const to = makeRef({ graphKind: 'dataflow', repository: l.remote.repository, graphDigest: l.remote.graphDigest, entityKind: 'node', localId: l.remote.nodeId });
39
+ edges.push({
40
+ id: l.id, relation: 'data_flow', origin: 'declared',
41
+ from: foreign(from), to: foreign(to),
42
+ evidence: { grade: 'declared', source: 'operator', declaredBy: l.declaredBy },
43
+ unknowns: [{ kind: 'not-observed', reason: 'a declared link is an assertion, not an observed data flow' }],
44
+ attrs: { rationale: l.rationale ?? null, provenance: l.provenance }, native: l,
45
+ });
46
+ }
47
+ return new IndexedGraphView({
48
+ graphKind: 'cross-repo-links', adapterVersion: CROSSREPO_ADAPTER_VERSION, repository: localRepository, graphDigest: digestOf(links),
49
+ metadata: { links: links.length },
50
+ completion: { status: 'complete', reasons: [], coverage: null },
51
+ nodes, edges, allowForeign: true,
52
+ reconstruct: () => links,
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Findings as referenceable entities (no edges): the starting point of a
58
+ * finding -> call site -> witnesses -> flow join.
59
+ * @param {object[]} findings scanner findings; localId is `stableId` when present, else `id`
60
+ */
61
+ export function findingsView(findings, opts) {
62
+ if (!Array.isArray(findings)) throw new TypeError('findingsView requires an array of findings');
63
+ const nodes = findings.map((f) => {
64
+ const id = f.stableId ?? f.id;
65
+ if (typeof id !== 'string' || !id) throw new GraphViewError('BAD_REF', 'every finding needs a stableId or id');
66
+ return { entityKind: 'finding', localId: id, native: f, summary: { file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null, severity: f.severity ?? null } };
67
+ });
68
+ return new IndexedGraphView({
69
+ graphKind: 'findings', adapterVersion: FINDINGS_ADAPTER_VERSION, repository: opts?.repository, graphDigest: digestOf(findings),
70
+ metadata: { findings: findings.length },
71
+ completion: { status: 'unknown', reasons: ['a finding list says nothing about what was not scanned'], coverage: null },
72
+ nodes, edges: [],
73
+ reconstruct: () => findings,
74
+ });
75
+ }
@@ -0,0 +1,76 @@
1
+ //
2
+ // adapters/dataflow.js: read-only view over a DataFlowGraph v1 document (the
3
+ // graph-builder output / signed export body).
4
+ //
5
+ // The graph keeps its own schema, validator and digest. This view:
6
+ // - uses the existing `computeGraphDigest` as the graph digest, so the
7
+ // digest in every reference equals the digest the export already signs;
8
+ // - exposes node, edge, flow, dataElement, evidence and transformation
9
+ // entities under their original ids;
10
+ // - maps `edge.provenance` to the origin axis (code -> code, schema ->
11
+ // schema, manual -> declared, runtime -> observed), so a runtime-observed
12
+ // or manually declared edge is never traversed as a code-derived one by a
13
+ // default query;
14
+ // - reports completion from `scanHealth`, `coverage` and `limitations`.
15
+ //
16
+ // Projected nodes are category-granular: one node can stand for many source
17
+ // sites. That coarsening is not hidden here; it is recorded by crosswalks
18
+ // (one-to-many) built in crosswalk-builders.js.
19
+
20
+ import { computeGraphDigest } from '../../lineage/export-json.js';
21
+ import { IndexedGraphView, local } from '../view.js';
22
+
23
+ export const DATAFLOW_ADAPTER_VERSION = '1.0.0';
24
+
25
+ const ORIGIN_OF_PROVENANCE = { code: 'code', schema: 'schema', manual: 'declared', runtime: 'observed' };
26
+
27
+ const ENTITY_SECTIONS = [
28
+ ['node', 'nodes'], ['edge', 'edges'], ['flow', 'flows'],
29
+ ['dataElement', 'dataElements'], ['evidence', 'evidence'], ['transformation', 'transformations'],
30
+ ];
31
+
32
+ /**
33
+ * @param {object} graph DataFlowGraph v1 (e.g. buildDataFlowGraph(...).graph)
34
+ * @param {{repository?:string}} [opts]
35
+ */
36
+ export function dataflowView(graph, opts = {}) {
37
+ if (!graph || typeof graph !== 'object' || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
38
+ throw new TypeError('dataflowView requires a DataFlowGraph v1 object with nodes[] and edges[]');
39
+ }
40
+ const repository = opts.repository ?? graph.scope?.repository;
41
+ const graphDigest = computeGraphDigest(graph);
42
+
43
+ const nodes = [];
44
+ for (const [kind, section] of ENTITY_SECTIONS) {
45
+ for (const e of graph[section] || []) {
46
+ 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 });
47
+ }
48
+ }
49
+ const edges = graph.edges.map((e) => {
50
+ const unknowns = [];
51
+ if (e.protocol?.destinationResolution && e.protocol.destinationResolution !== 'literal') unknowns.push({ kind: 'destination-resolution', reason: e.protocol.destinationResolution });
52
+ if (e.coverageStatus && e.coverageStatus !== 'modeled') unknowns.push({ kind: 'coverage', reason: e.coverageStatus });
53
+ return {
54
+ id: e.id, relation: 'data_flow', origin: ORIGIN_OF_PROVENANCE[e.provenance] ?? 'schema',
55
+ from: local('node', e.from), to: local('node', e.to),
56
+ evidence: { grade: e.evidenceGrade ?? e.coverageStatus ?? 'none', source: 'dataflow-graph', evidenceRefs: e.evidenceRefs ?? [] },
57
+ unknowns, attrs: { provenance: e.provenance, relationship: e.relationship }, native: e,
58
+ };
59
+ });
60
+
61
+ const health = graph.scanHealth || {};
62
+ const reasons = [];
63
+ const status = health.status === 'complete' ? 'complete' : health.status ? 'partial' : 'unknown';
64
+ if (health.status && health.status !== 'complete') reasons.push(`scan-health:${health.status}${health.reason ? `:${health.reason}` : ''}`);
65
+ if (graph.coverage?.pathBudgetTruncation) reasons.push('path-budget-truncation');
66
+ if (health.analysisCompletion && health.analysisCompletion.status && health.analysisCompletion.status !== 'complete') reasons.push(`analysis-${health.analysisCompletion.status}`);
67
+ if ((graph.limitations || []).length) reasons.push(`limitations:${graph.limitations.length}`);
68
+
69
+ return new IndexedGraphView({
70
+ graphKind: 'dataflow', adapterVersion: DATAFLOW_ADAPTER_VERSION, repository, graphDigest,
71
+ 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])) },
72
+ completion: { status: reasons.length && status === 'complete' ? 'partial' : status, reasons, coverage: graph.coverage ?? null },
73
+ nodes, edges,
74
+ reconstruct: () => graph,
75
+ });
76
+ }
@@ -0,0 +1,92 @@
1
+ //
2
+ // adapters/provenance.js: read-only view over a PathStore provenance DAG
3
+ // (pnode / pedge). Duck-typed on the store's read API (`nodes()`, `edges()`,
4
+ // `limitStatus()`, `diagnostics()`); it does not import path-store.js or any
5
+ // analysis engine.
6
+ //
7
+ // Entities: `pnode` (a point in the DAG) and `pedge` (one witness: a join of an
8
+ // in-half and an out-half at one program site). A pedge is BOTH an entity, so a
9
+ // crosswalk can name it as a witness, and a relation edge between two pnodes.
10
+ //
11
+ // Relation choice, from the store's own fields (nothing is guessed):
12
+ // widenReason 'control-dependence' -> control_dependence
13
+ // target path '<return>' -> returns
14
+ // crossScope (argument into a callee) -> calls
15
+ // otherwise -> data_flow
16
+ // Evidence grade reuses the flow-grade vocabulary: severed (loss reason),
17
+ // ambiguous (correlation ambiguity), widened, implicit, else explicit.
18
+
19
+ import { digestOf } from '../refs.js';
20
+ import { IndexedGraphView, local } from '../view.js';
21
+
22
+ export const PROVENANCE_ADAPTER_VERSION = '1.0.0';
23
+
24
+ function relationOf(e, toNode) {
25
+ if ((e.widenReasons || []).includes('control-dependence')) return 'control_dependence';
26
+ if (toNode && toNode.path === '<return>') return 'returns';
27
+ if (e.crossScope) return 'calls';
28
+ return 'data_flow';
29
+ }
30
+
31
+ function gradeOf(e) {
32
+ if ((e.lossReasons || []).length) return 'severed';
33
+ if (e.ambiguousCorrelation) return 'ambiguous';
34
+ if ((e.widenReasons || []).includes('control-dependence')) return 'implicit';
35
+ if ((e.widenReasons || []).length) return 'widened';
36
+ return 'explicit';
37
+ }
38
+
39
+ /**
40
+ * @param {{nodes:()=>object[], edges:()=>object[], limitStatus?:()=>object, diagnostics?:()=>object}} store
41
+ * @param {{repository:string, completion?:object}} opts
42
+ * `completion` may carry the driver's analysisCompletion record; it is
43
+ * folded into the view's completion() so an unfinished analysis never
44
+ * reads as complete.
45
+ */
46
+ export function provenanceView(store, opts) {
47
+ if (!store || typeof store.nodes !== 'function' || typeof store.edges !== 'function') {
48
+ throw new TypeError('provenanceView requires a store with nodes() and edges()');
49
+ }
50
+ const repository = opts?.repository;
51
+ const pnodes = store.nodes();
52
+ const pedges = store.edges();
53
+ const byId = new Map(pnodes.map((n) => [n.id, n]));
54
+ const graphDigest = digestOf({ nodes: pnodes, edges: pedges });
55
+
56
+ const nodes = [
57
+ ...pnodes.map((n) => ({ entityKind: 'pnode', localId: n.id, native: n, summary: { kind: n.kind, scope: n.scope, path: n.path, truncated: !!n.truncated } })),
58
+ ...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 } })),
59
+ ];
60
+ const edges = pedges.map((e) => {
61
+ const unknowns = [
62
+ ...(e.widenReasons || []).map((r) => ({ kind: 'widen', reason: r })),
63
+ ...(e.lossReasons || []).map((r) => ({ kind: 'loss', reason: r })),
64
+ ];
65
+ if (e.truncated) unknowns.push({ kind: 'truncated', reason: 'analysis-incomplete' });
66
+ return {
67
+ id: e.id, relation: relationOf(e, byId.get(e.toNodeId)), origin: 'code',
68
+ from: local('pnode', e.fromNodeId), to: local('pnode', e.toNodeId),
69
+ evidence: { grade: gradeOf(e), source: 'path-store' },
70
+ unknowns, attrs: { line: e.line ?? null, scope: e.scope, dataElementId: e.dataElementId }, native: e,
71
+ };
72
+ });
73
+
74
+ const reasons = [];
75
+ const lim = typeof store.limitStatus === 'function' ? store.limitStatus() : null;
76
+ if (lim?.hit) reasons.push('store-limit-hit');
77
+ const diag = typeof store.diagnostics === 'function' ? store.diagnostics() : null;
78
+ if (diag?.truncations?.length) reasons.push(`truncated-units:${diag.truncations.length}`);
79
+ if (diag?.orphanedPeerSources?.length) reasons.push(`orphaned-peer-sources:${diag.orphanedPeerSources.length}`);
80
+ if (diag?.malformed?.length) reasons.push(`malformed-hops:${diag.malformed.length}`);
81
+ const ac = opts?.completion ?? null;
82
+ if (ac && ac.status && ac.status !== 'complete') reasons.push(`analysis-${ac.status}`);
83
+ const status = reasons.length ? 'partial' : (lim || ac) ? 'complete' : 'unknown';
84
+
85
+ return new IndexedGraphView({
86
+ graphKind: 'provenance', adapterVersion: PROVENANCE_ADAPTER_VERSION, repository, graphDigest,
87
+ metadata: { source: 'PathStore', pnodes: pnodes.length, pedges: pedges.length, relationEdges: edges.length },
88
+ completion: { status, reasons, coverage: { limits: lim, analysis: ac } },
89
+ nodes, edges,
90
+ reconstruct: () => ({ nodes: pnodes, edges: pedges }),
91
+ });
92
+ }