@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/bin/agentic-security.js +111 -1
- package/dist/1128.index.js +912 -0
- package/dist/1665.index.js +828 -0
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +29 -12
- package/src/dataflow/CLAUDE.md +46 -1
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +1168 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +113 -8
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +72 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +174 -0
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +17 -0
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16182
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5817.index.js +0 -714
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- package/dist/9999.index.js +0 -1698
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
//
|
|
2
|
+
// adapters/semantic.js: read-only view over a SemanticGraph v1
|
|
3
|
+
// (ir/semantic-graph.js). Imports only the schema constants and validator; it
|
|
4
|
+
// never builds or mutates a graph.
|
|
5
|
+
//
|
|
6
|
+
// A graph that fails `validateGraph`, or whose major version is not the
|
|
7
|
+
// supported one, is REJECTED (INCOMPATIBLE_VERSION / INVALID_GRAPH) rather than
|
|
8
|
+
// partially adapted.
|
|
9
|
+
//
|
|
10
|
+
// Entities: symbol, callSite, allocation, fieldAccess, valueOrigin,
|
|
11
|
+
// serviceBoundary, storageEdge, sanitizer, guard (singular names of the
|
|
12
|
+
// graph's own lists). The semantic graph stores references as id fields, not
|
|
13
|
+
// an edge list, so the relation edges here are DERIVED (origin 'derived') and
|
|
14
|
+
// get deterministic ids; the underlying confidence tier becomes the evidence
|
|
15
|
+
// grade, and an unresolved call is carried as an `unknowns` entry, never as a
|
|
16
|
+
// fabricated callee edge.
|
|
17
|
+
//
|
|
18
|
+
// symbol --declaration--> callSite (the caller declares/contains it)
|
|
19
|
+
// symbol --declaration--> valueOrigin / fieldAccess / allocation
|
|
20
|
+
// callSite --calls--> symbol (resolved calls only)
|
|
21
|
+
// valueOrigin --data_flow--> callSite (an argument origin feeds the call)
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
SEMANTIC_GRAPH_SCHEMA, SUPPORTED_MAJOR, validateGraph, ENTITY_LISTS,
|
|
25
|
+
} from '../../ir/semantic-graph.js';
|
|
26
|
+
import { GraphViewError, digestOf } from '../refs.js';
|
|
27
|
+
import { IndexedGraphView, local } from '../view.js';
|
|
28
|
+
|
|
29
|
+
export const SEMANTIC_ADAPTER_VERSION = '1.0.0';
|
|
30
|
+
|
|
31
|
+
const SINGULAR = {
|
|
32
|
+
symbols: 'symbol', callSites: 'callSite', allocations: 'allocation', fieldAccesses: 'fieldAccess',
|
|
33
|
+
valueOrigins: 'valueOrigin', serviceBoundaries: 'serviceBoundary', storageEdges: 'storageEdge',
|
|
34
|
+
sanitizers: 'sanitizer', guards: 'guard',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} graph SemanticGraph v1 (buildSemanticGraph output)
|
|
39
|
+
* @param {{repository:string}} opts
|
|
40
|
+
*/
|
|
41
|
+
export function semanticView(graph, opts) {
|
|
42
|
+
const m = typeof graph?.schemaVersion === 'string' ? /^(\d+)\./.exec(graph.schemaVersion) : null;
|
|
43
|
+
if (graph?.schema === SEMANTIC_GRAPH_SCHEMA && m && Number(m[1]) !== SUPPORTED_MAJOR) {
|
|
44
|
+
throw new GraphViewError('INCOMPATIBLE_VERSION', `semantic graph major ${m[1]} is not supported (supported: ${SUPPORTED_MAJOR})`, { schemaVersion: graph.schemaVersion });
|
|
45
|
+
}
|
|
46
|
+
const v = validateGraph(graph);
|
|
47
|
+
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) });
|
|
48
|
+
|
|
49
|
+
const graphDigest = digestOf(graph);
|
|
50
|
+
const nodes = [];
|
|
51
|
+
for (const list of ENTITY_LISTS) {
|
|
52
|
+
for (const e of graph[list]) {
|
|
53
|
+
nodes.push({ entityKind: SINGULAR[list], localId: e.id, native: e, summary: { file: e.file, line: e.line, tier: e.confidence?.tier ?? null } });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const edges = [];
|
|
57
|
+
const edge = (relation, from, to, grade, extra = {}) => {
|
|
58
|
+
edges.push({
|
|
59
|
+
id: `sgedge:${relation}:${from.localId}:${to.localId}`, relation, origin: 'derived',
|
|
60
|
+
from, to, evidence: { grade, source: 'semantic-graph' }, unknowns: extra.unknowns ?? [], attrs: extra.attrs ?? {}, native: null,
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
for (const c of graph.callSites) {
|
|
64
|
+
edge('declaration', local('symbol', c.caller), local('callSite', c.id), c.confidence.tier);
|
|
65
|
+
if (c.callee) edge('calls', local('callSite', c.id), local('symbol', c.callee), c.confidence.tier, { attrs: { resolution: c.resolution } });
|
|
66
|
+
for (const a of c.args || []) {
|
|
67
|
+
if (a.origin) edge('data_flow', local('valueOrigin', a.origin), local('callSite', c.id), c.confidence.tier, { attrs: { argIndex: a.index } });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const o of graph.valueOrigins) if (o.symbol) edge('declaration', local('symbol', o.symbol), local('valueOrigin', o.id), o.confidence.tier);
|
|
71
|
+
for (const f of graph.fieldAccesses) if (f.symbol) edge('declaration', local('symbol', f.symbol), local('fieldAccess', f.id), f.confidence.tier);
|
|
72
|
+
for (const a of graph.allocations) if (a.symbol) edge('declaration', local('symbol', a.symbol), local('allocation', a.id), a.confidence.tier);
|
|
73
|
+
|
|
74
|
+
// Unresolved calls are unknowns on the view, not edges.
|
|
75
|
+
const unresolvedCalls = graph.callSites.filter((c) => !c.callee).length;
|
|
76
|
+
const reasons = [];
|
|
77
|
+
const schemaOnly = ENTITY_LISTS.filter((k) => graph.coverage[k].state === 'schema-only');
|
|
78
|
+
if (schemaOnly.length) reasons.push(`schema-only:${schemaOnly.join(',')}`);
|
|
79
|
+
if (unresolvedCalls) reasons.push(`unresolved-calls:${unresolvedCalls}`);
|
|
80
|
+
if ((graph.dynamicFeatures || []).length) reasons.push(`dynamic-features:${graph.dynamicFeatures.length}`);
|
|
81
|
+
|
|
82
|
+
return new IndexedGraphView({
|
|
83
|
+
graphKind: 'semantic', adapterVersion: SEMANTIC_ADAPTER_VERSION, repository: opts?.repository, graphDigest,
|
|
84
|
+
metadata: { schema: graph.schema, schemaVersion: graph.schemaVersion, producer: graph.producer ?? null, limits: graph.limits ?? [], unresolvedCalls },
|
|
85
|
+
completion: { status: reasons.length ? 'partial' : 'complete', reasons, coverage: graph.coverage },
|
|
86
|
+
nodes, edges,
|
|
87
|
+
reconstruct: () => graph,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
//
|
|
2
|
+
// crosswalk-builders.js: build the standard crosswalk mappings from registered
|
|
3
|
+
// views. Every builder joins on facts the two graphs both carry and records
|
|
4
|
+
// the join as evidence; none invents an identity.
|
|
5
|
+
//
|
|
6
|
+
// finding -> semantic call site file + line (location-exact)
|
|
7
|
+
// call site -> provenance witnesses enclosing function (qid) + line
|
|
8
|
+
// witness -> projected flow data element id
|
|
9
|
+
// projected node -> underlying sites decision kind/category/coverage/externality
|
|
10
|
+
//
|
|
11
|
+
// Where a join cannot pin one target, the mapping lists ALL candidates
|
|
12
|
+
// (one-to-many) and carries an `unknowns` entry. It never picks one silently.
|
|
13
|
+
|
|
14
|
+
import { canonicalJson } from './refs.js';
|
|
15
|
+
|
|
16
|
+
const stripLine = (qid) => String(qid).replace(/@\d+$/, '');
|
|
17
|
+
const normFile = (f) => String(f ?? '').replace(/^\.\//, '').replace(/\\/g, '/');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Join findings to semantic call sites by exact file and line.
|
|
21
|
+
* @returns {object[]} the mappings added
|
|
22
|
+
*/
|
|
23
|
+
export function mapFindingsToCallSites(crosswalk, findings, semantic) {
|
|
24
|
+
const byLoc = new Map();
|
|
25
|
+
for (const cs of semantic.entities('callSite')) {
|
|
26
|
+
const k = `${normFile(cs.native.file)}:${cs.native.line}`;
|
|
27
|
+
if (!byLoc.has(k)) byLoc.set(k, []);
|
|
28
|
+
byLoc.get(k).push(cs);
|
|
29
|
+
}
|
|
30
|
+
const added = [];
|
|
31
|
+
for (const f of findings.entities('finding')) {
|
|
32
|
+
const { file, line } = f.native;
|
|
33
|
+
if (!Number.isInteger(line) || !file) {
|
|
34
|
+
added.push(crosswalk.add({
|
|
35
|
+
type: 'finding-to-callsite', from: f.ref, to: [], unmapped: 'finding-has-no-file-and-line',
|
|
36
|
+
evidence: { method: 'file-line', grade: 'none', basis: 'the finding carries no integer line, so it cannot be located' },
|
|
37
|
+
}));
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const hits = byLoc.get(`${normFile(file)}:${line}`) ?? [];
|
|
41
|
+
if (hits.length === 0) continue; // not a call-site finding; absence is visible as a trace gap
|
|
42
|
+
added.push(crosswalk.add({
|
|
43
|
+
type: 'finding-to-callsite', from: f.ref, to: hits.map((h) => h.ref), coarsens: hits.length > 1,
|
|
44
|
+
evidence: { method: 'file-line', grade: hits.length === 1 ? 'location-exact' : 'location-ambiguous', basis: `${normFile(file)}:${line}` },
|
|
45
|
+
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' }] : [],
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
return added;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Join semantic call sites to provenance witnesses (pedges): same enclosing
|
|
53
|
+
* function and same line. The provenance scope is `<qid>@<line>`; the semantic
|
|
54
|
+
* symbol carries the qid without the line suffix.
|
|
55
|
+
*/
|
|
56
|
+
export function mapCallSitesToWitnesses(crosswalk, semantic, provenance) {
|
|
57
|
+
const witnessByKey = new Map();
|
|
58
|
+
for (const w of provenance.entities('pedge')) {
|
|
59
|
+
const k = `${stripLine(w.native.scope)}\u0000${w.native.line}`;
|
|
60
|
+
if (!witnessByKey.has(k)) witnessByKey.set(k, []);
|
|
61
|
+
witnessByKey.get(k).push(w);
|
|
62
|
+
}
|
|
63
|
+
const symQid = new Map(semantic.entities('symbol').map((s) => [s.localId, s.native.qid]));
|
|
64
|
+
const sitesPerKey = new Map();
|
|
65
|
+
const keyOf = (cs) => `${symQid.get(cs.native.caller)}\u0000${cs.native.line}`;
|
|
66
|
+
const sites = semantic.entities('callSite');
|
|
67
|
+
for (const cs of sites) sitesPerKey.set(keyOf(cs), (sitesPerKey.get(keyOf(cs)) ?? 0) + 1);
|
|
68
|
+
const added = [];
|
|
69
|
+
for (const cs of sites) {
|
|
70
|
+
const hits = witnessByKey.get(keyOf(cs)) ?? [];
|
|
71
|
+
if (hits.length === 0) continue;
|
|
72
|
+
const shared = sitesPerKey.get(keyOf(cs));
|
|
73
|
+
added.push(crosswalk.add({
|
|
74
|
+
type: 'callsite-to-witness', from: cs.ref, to: hits.map((h) => h.ref), coarsens: hits.length > 1 || shared > 1,
|
|
75
|
+
evidence: { method: 'scope-line', grade: shared > 1 ? 'location-ambiguous' : 'location-exact', basis: `${symQid.get(cs.native.caller)} line ${cs.native.line}` },
|
|
76
|
+
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' }] : [],
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
return added;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Join provenance witnesses to the projected flows that carry their data
|
|
84
|
+
* element. A flow aggregates many witnesses (and many sites), so every mapping
|
|
85
|
+
* is flagged `coarsens`.
|
|
86
|
+
*/
|
|
87
|
+
export function mapWitnessesToFlows(crosswalk, provenance, dataflow) {
|
|
88
|
+
const flowsByDe = new Map();
|
|
89
|
+
for (const fl of dataflow.entities('flow')) {
|
|
90
|
+
for (const de of fl.native.dataElementIds || []) {
|
|
91
|
+
if (!flowsByDe.has(de)) flowsByDe.set(de, []);
|
|
92
|
+
flowsByDe.get(de).push(fl);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const added = [];
|
|
96
|
+
for (const w of provenance.entities('pedge')) {
|
|
97
|
+
const hits = flowsByDe.get(w.native.dataElementId) ?? [];
|
|
98
|
+
if (hits.length === 0) continue;
|
|
99
|
+
added.push(crosswalk.add({
|
|
100
|
+
type: 'witness-to-flow', from: w.ref, to: hits.map((h) => h.ref), coarsens: true,
|
|
101
|
+
evidence: { method: 'data-element', grade: 'identity-shared', basis: w.native.dataElementId },
|
|
102
|
+
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' }],
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
return added;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const calleeText = (e) => {
|
|
109
|
+
if (!e || typeof e !== 'object') return null;
|
|
110
|
+
if (e.kind === 'ident') return e.name;
|
|
111
|
+
if (e.kind === 'member') { const o = calleeText(e.object); return o ? `${o}.${e.prop ?? '[]'}` : null; }
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Map each projected (category-granular) dataflow node to the underlying call
|
|
117
|
+
* sites it stands for. `sites` is the sink-site list `buildDataFlowGraph`
|
|
118
|
+
* returns. A node with several sites maps one-to-many and is flagged `coarsens`;
|
|
119
|
+
* a site that no semantic call site matches is listed under `unknowns`.
|
|
120
|
+
*/
|
|
121
|
+
export function mapProjectedNodesToSites(crosswalk, dataflow, semantic, sites) {
|
|
122
|
+
const callSites = semantic.entities('callSite');
|
|
123
|
+
const added = [];
|
|
124
|
+
for (const n of dataflow.entities('node')) {
|
|
125
|
+
const node = n.native;
|
|
126
|
+
const matching = (sites || []).filter((s) => s.decision
|
|
127
|
+
&& s.decision.kind === node.kind
|
|
128
|
+
&& (s.decision.category ?? null) === (node.subtype ?? null)
|
|
129
|
+
&& s.decision.coverageStatus === node.coverageStatus
|
|
130
|
+
&& s.decision.externality === node.externality?.value
|
|
131
|
+
&& canonicalJson(s.destination ?? null) === canonicalJson(node.destination ?? null));
|
|
132
|
+
if (matching.length === 0) continue;
|
|
133
|
+
const targets = new Map();
|
|
134
|
+
const unmapped = [];
|
|
135
|
+
for (const s of matching) {
|
|
136
|
+
const atLine = callSites.filter((c) => normFile(c.native.file) === normFile(s.file) && c.native.line === s.line);
|
|
137
|
+
const want = calleeText(s.calleeExpr);
|
|
138
|
+
const exact = want ? atLine.filter((c) => c.native.calleeText === want) : [];
|
|
139
|
+
const picked = exact.length ? exact : atLine;
|
|
140
|
+
if (picked.length === 0) unmapped.push({ file: s.file, line: s.line, reason: 'no semantic call site at this location' });
|
|
141
|
+
for (const c of picked) targets.set(c.localId, c);
|
|
142
|
+
}
|
|
143
|
+
const to = [...targets.values()].map((c) => c.ref);
|
|
144
|
+
const unknowns = unmapped.map((u) => ({ kind: 'unmapped-site', ...u }));
|
|
145
|
+
if (to.length === 0) continue;
|
|
146
|
+
added.push(crosswalk.add({
|
|
147
|
+
type: 'node-to-sites', from: n.ref, to, coarsens: to.length > 1, unknowns,
|
|
148
|
+
evidence: { method: 'decision-match', grade: to.length === 1 ? 'projection-exact' : 'projection-coarse', basis: `${node.kind}/${node.subtype ?? 'none'}: ${matching.length} sink site(s)` },
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
return added;
|
|
152
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
//
|
|
2
|
+
// crosswalk.js: evidence-bearing identity mappings between graph layers
|
|
3
|
+
// (DAG-06 R06.2, R06.5).
|
|
4
|
+
//
|
|
5
|
+
// A crosswalk never merges entities. It records "this entity in graph A
|
|
6
|
+
// corresponds to these entities in graph B, by this method, with this
|
|
7
|
+
// evidence". Every mapping is:
|
|
8
|
+
// - versioned (CROSSWALK_VERSION);
|
|
9
|
+
// - explicit about cardinality (`one-to-one`, `one-to-many`, or `none` with a
|
|
10
|
+
// stated reason), so a projection that coarsens many underlying sites into
|
|
11
|
+
// one projected node says so instead of pretending to be one-to-one;
|
|
12
|
+
// - evidence-bearing: method, grade and basis are required;
|
|
13
|
+
// - checked against the registry when one is supplied: a reference to a graph
|
|
14
|
+
// or entity that is not registered is rejected as DANGLING_REF.
|
|
15
|
+
//
|
|
16
|
+
// Mapping types and the graph layers each connects:
|
|
17
|
+
// finding-to-callsite findings -> semantic (location join)
|
|
18
|
+
// callsite-to-witness semantic -> provenance (scope + line join)
|
|
19
|
+
// witness-to-flow provenance -> dataflow (data element join)
|
|
20
|
+
// node-to-sites dataflow node -> semantic (a projected node's underlying call sites)
|
|
21
|
+
// identity same layer -> same layer (the same entity under another reference)
|
|
22
|
+
//
|
|
23
|
+
// `identity` mappings are only legal inside ONE repository. Declaring two
|
|
24
|
+
// entities in different repositories "the same" because their local ids match
|
|
25
|
+
// is exactly the collision this module exists to prevent, and is rejected as
|
|
26
|
+
// CROSS_REPO_ID_COLLISION.
|
|
27
|
+
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { GraphViewError, validateRef, refKey, canonicalJson } from './refs.js';
|
|
30
|
+
|
|
31
|
+
export const CROSSWALK_VERSION = '1.0.0';
|
|
32
|
+
|
|
33
|
+
export const MAPPING_TYPES = Object.freeze({
|
|
34
|
+
'finding-to-callsite': { from: { graphKind: 'findings', entityKind: 'finding' }, to: { graphKind: 'semantic', entityKind: 'callSite' } },
|
|
35
|
+
'callsite-to-witness': { from: { graphKind: 'semantic', entityKind: 'callSite' }, to: { graphKind: 'provenance', entityKind: 'pedge' } },
|
|
36
|
+
'witness-to-flow': { from: { graphKind: 'provenance', entityKind: 'pedge' }, to: { graphKind: 'dataflow', entityKind: 'flow' } },
|
|
37
|
+
'node-to-sites': { from: { graphKind: 'dataflow', entityKind: 'node' }, to: { graphKind: 'semantic', entityKind: 'callSite' } },
|
|
38
|
+
identity: { from: null, to: null },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const CARDINALITY = (n) => (n === 0 ? 'none' : n === 1 ? 'one-to-one' : 'one-to-many');
|
|
42
|
+
|
|
43
|
+
function checkEnd(ref, want, label, type) {
|
|
44
|
+
const bad = validateRef(ref);
|
|
45
|
+
if (bad) throw new GraphViewError('BAD_REF', `${type} ${label}: ${bad}`, { ref });
|
|
46
|
+
if (want && (ref.graphKind !== want.graphKind || ref.entityKind !== want.entityKind)) {
|
|
47
|
+
throw new GraphViewError('BAD_MAPPING', `${type} ${label} must be a ${want.graphKind} ${want.entityKind}, got ${ref.graphKind} ${ref.entityKind}`, { ref });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createCrosswalk({ registry = null } = {}) {
|
|
52
|
+
const mappings = new Map(); // id -> record
|
|
53
|
+
const forward = new Map(); // refKey -> record[]
|
|
54
|
+
const reverse = new Map(); // refKey -> record[]
|
|
55
|
+
const push = (m, k, r) => { const a = m.get(k); if (a) a.push(r); else m.set(k, [r]); };
|
|
56
|
+
|
|
57
|
+
function add(spec) {
|
|
58
|
+
const type = spec?.type;
|
|
59
|
+
const desc = MAPPING_TYPES[type];
|
|
60
|
+
if (!desc) throw new GraphViewError('BAD_MAPPING', `unknown mapping type ${JSON.stringify(type)}`);
|
|
61
|
+
checkEnd(spec.from, desc.from, 'from', type);
|
|
62
|
+
if (!Array.isArray(spec.to)) throw new GraphViewError('BAD_MAPPING', `${type}: "to" is a list (empty with an unmapped reason when nothing matched)`);
|
|
63
|
+
for (const t of spec.to) checkEnd(t, desc.to, 'to', type);
|
|
64
|
+
|
|
65
|
+
const ev = spec.evidence;
|
|
66
|
+
if (!ev || typeof ev.method !== 'string' || !ev.method || typeof ev.grade !== 'string' || !ev.grade || typeof ev.basis !== 'string' || !ev.basis) {
|
|
67
|
+
throw new GraphViewError('BAD_MAPPING', `${type}: evidence needs { method, grade, basis }`);
|
|
68
|
+
}
|
|
69
|
+
const keys = spec.to.map(refKey);
|
|
70
|
+
if (new Set(keys).size !== keys.length) throw new GraphViewError('BAD_MAPPING', `${type}: duplicate targets in one mapping`);
|
|
71
|
+
if (spec.to.length === 0 && !spec.unmapped) throw new GraphViewError('BAD_MAPPING', `${type}: an empty mapping must say why in "unmapped"`);
|
|
72
|
+
|
|
73
|
+
if (type === 'identity') {
|
|
74
|
+
for (const t of spec.to) {
|
|
75
|
+
if (t.repository !== spec.from.repository) {
|
|
76
|
+
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 });
|
|
77
|
+
}
|
|
78
|
+
if (t.entityKind !== spec.from.entityKind || t.graphKind !== spec.from.graphKind) {
|
|
79
|
+
throw new GraphViewError('BAD_MAPPING', 'identity maps an entity to the same kind of entity');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (registry) {
|
|
84
|
+
for (const r of [spec.from, ...spec.to]) {
|
|
85
|
+
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 });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const id = `xwalk:${createHash('sha256').update(canonicalJson([type, refKey(spec.from), [...keys].sort(), ev.method])).digest('hex').slice(0, 16)}`;
|
|
90
|
+
if (mappings.has(id)) return mappings.get(id);
|
|
91
|
+
const record = Object.freeze({
|
|
92
|
+
id, version: CROSSWALK_VERSION, type, from: spec.from, to: Object.freeze([...spec.to]),
|
|
93
|
+
cardinality: CARDINALITY(spec.to.length),
|
|
94
|
+
coarsens: !!spec.coarsens,
|
|
95
|
+
evidence: Object.freeze({ ...ev }),
|
|
96
|
+
unknowns: Object.freeze([...(spec.unknowns ?? [])]),
|
|
97
|
+
unmapped: spec.unmapped ?? null,
|
|
98
|
+
});
|
|
99
|
+
mappings.set(id, record);
|
|
100
|
+
push(forward, refKey(record.from), record);
|
|
101
|
+
for (const k of keys) push(reverse, k, record);
|
|
102
|
+
return record;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ofType = (list, type) => (type ? list.filter((m) => m.type === type) : list);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
version: CROSSWALK_VERSION,
|
|
109
|
+
add,
|
|
110
|
+
all: () => [...mappings.values()],
|
|
111
|
+
/** Mappings that start at `ref`. */
|
|
112
|
+
from: (ref, type) => ofType(forward.get(refKey(ref)) ?? [], type),
|
|
113
|
+
/** Mappings that end at `ref` (the reverse direction; many sources may share a target). */
|
|
114
|
+
to: (ref, type) => ofType(reverse.get(refKey(ref)) ?? [], type),
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Follow a chain of mapping types from `start`. Returns every chain found
|
|
118
|
+
* and, for each step, which references had NO mapping (a gap is reported,
|
|
119
|
+
* never skipped). `evidence` lists every mapping used, so the join is
|
|
120
|
+
* traceable end to end.
|
|
121
|
+
*/
|
|
122
|
+
trace(start, types, { maxChains = 1000 } = {}) {
|
|
123
|
+
let chains = [{ refs: [start], mappings: [] }];
|
|
124
|
+
const gaps = [];
|
|
125
|
+
let truncated = false;
|
|
126
|
+
for (const type of types) {
|
|
127
|
+
const next = [];
|
|
128
|
+
for (const c of chains) {
|
|
129
|
+
const last = c.refs[c.refs.length - 1];
|
|
130
|
+
const ms = this.from(last, type);
|
|
131
|
+
if (ms.length === 0) { gaps.push({ type, ref: last }); continue; }
|
|
132
|
+
for (const m of ms) {
|
|
133
|
+
if (m.to.length === 0) { gaps.push({ type, ref: last, unmapped: m.unmapped, mapping: m.id }); continue; }
|
|
134
|
+
for (const t of m.to) {
|
|
135
|
+
if (next.length >= maxChains) { truncated = true; break; }
|
|
136
|
+
next.push({ refs: [...c.refs, t], mappings: [...c.mappings, m.id] });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
chains = next;
|
|
141
|
+
}
|
|
142
|
+
const used = new Set(chains.flatMap((c) => c.mappings));
|
|
143
|
+
return { start, types: [...types], chains, gaps, truncated, evidence: [...used].map((id) => mappings.get(id)) };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Typed graph views and identity crosswalks (DAG-06). See CLAUDE.md in this directory.
|
|
2
|
+
export * from './refs.js';
|
|
3
|
+
export * from './relations.js';
|
|
4
|
+
export { IndexedGraphView, local, foreign, adjacencyCacheStats, clearAdjacencyCache } from './view.js';
|
|
5
|
+
export { provenanceView, PROVENANCE_ADAPTER_VERSION } from './adapters/provenance.js';
|
|
6
|
+
export { dataflowView, DATAFLOW_ADAPTER_VERSION } from './adapters/dataflow.js';
|
|
7
|
+
export { semanticView, SEMANTIC_ADAPTER_VERSION } from './adapters/semantic.js';
|
|
8
|
+
export { artifactView, ARTIFACT_ADAPTER_VERSION } from './adapters/artifact.js';
|
|
9
|
+
export { crossRepoLinkView, findingsView, CROSSREPO_ADAPTER_VERSION, FINDINGS_ADAPTER_VERSION } from './adapters/crossrepo.js';
|
|
10
|
+
export { createGraphRegistry, SUPPORTED_ADAPTER_MAJORS } from './registry.js';
|
|
11
|
+
export { createCrosswalk, CROSSWALK_VERSION, MAPPING_TYPES } from './crosswalk.js';
|
|
12
|
+
export {
|
|
13
|
+
mapFindingsToCallSites, mapCallSitesToWitnesses, mapWitnessesToFlows, mapProjectedNodesToSites,
|
|
14
|
+
} from './crosswalk-builders.js';
|
|
15
|
+
export { attributeObservation } from './observation.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//
|
|
2
|
+
// observation.js: attributing a runtime observation to the underlying sites of
|
|
3
|
+
// a projected node (DAG-06 acceptance: "a projected node with several
|
|
4
|
+
// underlying sites cannot falsely assign one runtime observation to every
|
|
5
|
+
// site").
|
|
6
|
+
//
|
|
7
|
+
// A runtime observation is matched to a PROJECTED node (a destination, store or
|
|
8
|
+
// queue). When that node stands for several call sites (a `node-to-sites`
|
|
9
|
+
// mapping with more than one target), the observation says nothing about which
|
|
10
|
+
// site produced it unless it carries its own discriminator. The result is:
|
|
11
|
+
//
|
|
12
|
+
// attributed exactly one site is justified: the node has a single site, or
|
|
13
|
+
// the observation names a site (`file` + `line`, or a call-site
|
|
14
|
+
// id) that matches exactly one candidate.
|
|
15
|
+
// ambiguous several candidates remain. `attributedTo` is EMPTY, every
|
|
16
|
+
// candidate is listed, and no site receives the observation.
|
|
17
|
+
// unmatched the observation names a site that is not one of the candidates.
|
|
18
|
+
//
|
|
19
|
+
// "Observed" is therefore a property of the projected node unless a site is
|
|
20
|
+
// positively identified. An observation is never copied onto each site.
|
|
21
|
+
|
|
22
|
+
import { refKey } from './refs.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} crosswalk
|
|
26
|
+
* @param {object} registry
|
|
27
|
+
* @param {object} nodeRef ref to the projected dataflow node the observation matched
|
|
28
|
+
* @param {{file?:string, line?:number, callSiteId?:string}} [observation]
|
|
29
|
+
*/
|
|
30
|
+
export function attributeObservation(crosswalk, registry, nodeRef, observation = {}) {
|
|
31
|
+
const mappings = crosswalk.from(nodeRef, 'node-to-sites');
|
|
32
|
+
const candidates = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (const m of mappings) for (const t of m.to) if (!seen.has(refKey(t))) { seen.add(refKey(t)); candidates.push(t); }
|
|
35
|
+
const base = { node: nodeRef, candidates, mappings: mappings.map((m) => m.id) };
|
|
36
|
+
|
|
37
|
+
if (candidates.length === 0) {
|
|
38
|
+
return { ...base, status: 'unmatched', attributedTo: [], reason: 'the projected node has no recorded underlying sites' };
|
|
39
|
+
}
|
|
40
|
+
const normFile = (f) => String(f ?? '').replace(/^\.\//, '');
|
|
41
|
+
const hasDiscriminator = observation.callSiteId || (observation.file && Number.isInteger(observation.line));
|
|
42
|
+
if (hasDiscriminator) {
|
|
43
|
+
const hit = candidates.filter((c) => {
|
|
44
|
+
if (observation.callSiteId) return c.localId === observation.callSiteId;
|
|
45
|
+
const n = registry.node(c).native;
|
|
46
|
+
return normFile(n.file) === normFile(observation.file) && n.line === observation.line;
|
|
47
|
+
});
|
|
48
|
+
if (hit.length === 1) return { ...base, status: 'attributed', attributedTo: hit, basis: 'observation-names-site' };
|
|
49
|
+
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' };
|
|
50
|
+
return { ...base, status: 'ambiguous', attributedTo: [], candidates: hit, reason: 'several call sites share the named location' };
|
|
51
|
+
}
|
|
52
|
+
if (candidates.length === 1) return { ...base, status: 'attributed', attributedTo: candidates, basis: 'sole-underlying-site' };
|
|
53
|
+
return {
|
|
54
|
+
...base, status: 'ambiguous', attributedTo: [],
|
|
55
|
+
reason: `the projected node stands for ${candidates.length} sites and the observation does not say which one produced it`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// refs.js: the graph reference format (DAG-06 R06.2).
|
|
3
|
+
//
|
|
4
|
+
// A reference names ONE entity in ONE immutable graph:
|
|
5
|
+
// { v, graphKind, repository, graphDigest, entityKind, localId }
|
|
6
|
+
//
|
|
7
|
+
// The local id is only unique inside its graph, so it is never used alone as
|
|
8
|
+
// a key. The repository identity and the graph digest are part of the
|
|
9
|
+
// reference, which is what keeps two repositories with identical local ids
|
|
10
|
+
// distinct and stops a stale reference from silently resolving against a
|
|
11
|
+
// different version of the graph.
|
|
12
|
+
//
|
|
13
|
+
// Pure functions, no I/O.
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export const REF_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
/** The graph layers that have an adapter. Every one keeps its own native schema. */
|
|
20
|
+
export const GRAPH_KINDS = Object.freeze([
|
|
21
|
+
'provenance', // PathStore pnode/pedge DAG
|
|
22
|
+
'dataflow', // DataFlowGraph v1
|
|
23
|
+
'semantic', // SemanticGraph v1
|
|
24
|
+
'artifact', // artifact provenance graph
|
|
25
|
+
'cross-repo-links', // declared CrossRepoLink records
|
|
26
|
+
'findings', // scanner findings, as referenceable entities
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export class GraphViewError extends Error {
|
|
30
|
+
constructor(code, message, detail = {}) {
|
|
31
|
+
super(`${code}: ${message}`);
|
|
32
|
+
this.name = 'GraphViewError';
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.detail = detail;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isNonEmpty = (v) => typeof v === 'string' && v.length > 0;
|
|
39
|
+
|
|
40
|
+
/** Deterministic key-sorted JSON, the canonical form every digest here hashes. */
|
|
41
|
+
export function canonicalJson(value) {
|
|
42
|
+
const canon = (v) => {
|
|
43
|
+
if (Array.isArray(v)) return v.map(canon);
|
|
44
|
+
if (v && typeof v === 'object') {
|
|
45
|
+
const o = {};
|
|
46
|
+
for (const k of Object.keys(v).sort()) if (v[k] !== undefined) o[k] = canon(v[k]);
|
|
47
|
+
return o;
|
|
48
|
+
}
|
|
49
|
+
return v;
|
|
50
|
+
};
|
|
51
|
+
return JSON.stringify(canon(value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function digestOf(value) {
|
|
55
|
+
return createHash('sha256').update(canonicalJson(value)).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a reference object. Throws GraphViewError('BAD_REF') on a missing
|
|
60
|
+
* component: a reference with a blank part is never constructed.
|
|
61
|
+
*/
|
|
62
|
+
export function makeRef({ graphKind, repository, graphDigest, entityKind, localId }) {
|
|
63
|
+
const ref = { v: REF_VERSION, graphKind, repository, graphDigest, entityKind, localId };
|
|
64
|
+
const bad = validateRef(ref);
|
|
65
|
+
if (bad) throw new GraphViewError('BAD_REF', bad, { ref });
|
|
66
|
+
return Object.freeze(ref);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @returns {string|null} a problem description, or null when the reference is well formed */
|
|
70
|
+
export function validateRef(ref) {
|
|
71
|
+
if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return 'a reference is an object';
|
|
72
|
+
if (ref.v !== REF_VERSION) return `unsupported reference version ${JSON.stringify(ref.v)} (supported: ${REF_VERSION})`;
|
|
73
|
+
if (!GRAPH_KINDS.includes(ref.graphKind)) return `unknown graph kind ${JSON.stringify(ref.graphKind)}`;
|
|
74
|
+
for (const f of ['repository', 'graphDigest', 'entityKind', 'localId']) {
|
|
75
|
+
if (!isNonEmpty(ref[f])) return `${f} is required`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The identity of the graph a reference points into (no entity part). */
|
|
81
|
+
export function graphIdentityKey(ref) {
|
|
82
|
+
return [ref.graphKind, ref.repository, ref.graphDigest].map(encodeURIComponent).join('|');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stable string form; also the map key used by every index. */
|
|
86
|
+
export function refKey(ref) {
|
|
87
|
+
const bad = validateRef(ref);
|
|
88
|
+
if (bad) throw new GraphViewError('BAD_REF', bad, { ref });
|
|
89
|
+
return `gref${ref.v}|${[ref.graphKind, ref.repository, ref.graphDigest, ref.entityKind, ref.localId].map(encodeURIComponent).join('|')}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseRef(text) {
|
|
93
|
+
if (typeof text !== 'string') throw new GraphViewError('BAD_REF', 'a reference string is required');
|
|
94
|
+
const parts = text.split('|');
|
|
95
|
+
const m = /^gref(\d+)$/.exec(parts[0] || '');
|
|
96
|
+
if (!m || parts.length !== 6) throw new GraphViewError('BAD_REF', 'malformed reference string');
|
|
97
|
+
let decoded;
|
|
98
|
+
try { decoded = parts.slice(1).map(decodeURIComponent); } catch { throw new GraphViewError('BAD_REF', 'malformed escape in reference string'); }
|
|
99
|
+
const [graphKind, repository, graphDigest, entityKind, localId] = decoded;
|
|
100
|
+
return makeRef({ graphKind, repository, graphDigest, entityKind, localId, v: Number(m[1]) });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function sameRef(a, b) {
|
|
104
|
+
return refKey(a) === refKey(b);
|
|
105
|
+
}
|