@clear-capabilities/agentic-security-scanner 0.171.0 → 0.172.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +6 -0
  3. package/dist/1336.index.js +891 -719
  4. package/dist/550.index.js +548 -0
  5. package/dist/7405.index.js +885 -0
  6. package/dist/9382.index.js +4386 -0
  7. package/dist/9579.index.js +1197 -0
  8. package/dist/agentic-security.mjs +4 -4
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +4 -4
  11. package/src/context-intelligence/CLAUDE.md +25 -0
  12. package/src/context-intelligence/adapters/existing-ir.js +14 -0
  13. package/src/context-intelligence/adapters/haskell-hie.js +165 -0
  14. package/src/context-intelligence/build.js +68 -0
  15. package/src/context-intelligence/candidates.js +115 -0
  16. package/src/context-intelligence/canonical.js +120 -0
  17. package/src/context-intelligence/capabilities.js +33 -0
  18. package/src/context-intelligence/cli.js +426 -0
  19. package/src/context-intelligence/config.js +91 -0
  20. package/src/context-intelligence/controls.js +244 -0
  21. package/src/context-intelligence/diff.js +97 -0
  22. package/src/context-intelligence/entrypoints.js +336 -0
  23. package/src/context-intelligence/evaluation/index.js +235 -0
  24. package/src/context-intelligence/evaluation/runner.js +139 -0
  25. package/src/context-intelligence/evaluation/scripted-reader.js +289 -0
  26. package/src/context-intelligence/files.js +80 -0
  27. package/src/context-intelligence/graph-schema.js +227 -0
  28. package/src/context-intelligence/imports.js +123 -0
  29. package/src/context-intelligence/manifest.js +99 -0
  30. package/src/context-intelligence/packer.js +224 -0
  31. package/src/context-intelligence/persist.js +40 -0
  32. package/src/context-intelligence/prompts/packs.js +82 -0
  33. package/src/context-intelligence/queue.js +355 -0
  34. package/src/context-intelligence/reachability.js +188 -0
  35. package/src/context-intelligence/repo-info.js +16 -0
  36. package/src/context-intelligence/review.js +370 -0
  37. package/src/context-intelligence/spans.js +147 -0
  38. package/src/context-intelligence/store.js +203 -0
  39. package/src/posture/artifact-registry.js +2 -0
@@ -0,0 +1,227 @@
1
+ //
2
+ // Versioned, typed declaration/reference graph (symbol-graph/1).
3
+ //
4
+ // This is an ADAPTER-FED CONTRACT, not a second analyzer: symbols and calls come from the shared IR (or, additively,
5
+ // from imported compiler metadata). What this module adds is the part the context packer needs and the IR does not
6
+ // carry: stable ids, hash-verifiable source spans, per-edge provenance and conditionality, and an explicit,
7
+ // classified record of every call that could not be resolved. An unresolved call is data, never a leaf that looks
8
+ // like fully analysed code.
9
+ import { hashOf, sha256, sliceLines } from './canonical.js';
10
+ import { spanFor } from './spans.js';
11
+ import { buildImportMap, resolveViaImports } from './imports.js';
12
+
13
+ export const GRAPH_SCHEMA_VERSION = 'symbol-graph/1';
14
+
15
+ export const CONTINUATIONS = /^(?:next|done|cb|callback|resolve|reject|reply|fn_next)$/;
16
+
17
+ /** Unresolved-call reasons that could be hiding first-party code (and so block a `complete` claim). */
18
+ export const MATERIAL_REASONS = Object.freeze(new Set([
19
+ 'name-matches-first-party-declaration', 'dynamic-callee', 'call-through-local-or-parameter',
20
+ 'ambiguous-import-target', 'import-target-not-declared-in-module', 'unresolved-relative-import',
21
+ ]));
22
+
23
+ export const EDGE_KINDS = Object.freeze(['call', 'dynamic_possible', 'external_unknown']);
24
+ export const RESOLVERS = Object.freeze(['source-static', 'compiler-enriched']);
25
+
26
+ /** Calls that cannot carry security meaning and are never first-party code. Classified, not dropped. */
27
+ const INERT_CALL = /^(?:console\.\w+|Math\.\w+|JSON\.\w+|Object\.(?:keys|values|entries|assign|freeze)|Array\.\w+|Number\.\w+|String\.\w+|Promise\.\w+|parseInt|parseFloat|isNaN|String|Number|Boolean|next|require|print|len|str|int|float|bool|list|dict|tuple|range|isinstance|enumerate|zip|sorted|min|max|sum|abs|round|type|super|pure|fromMaybe|maybe|fmap|show|fst|snd|not|\w+\.(?:json|send|status|sendStatus|end|render|redirect|setHeader|header|then|catch|finally|push|pop|shift|unshift|slice|splice|join|split|map|filter|reduce|forEach|includes|indexOf|toString|trim|replace|toLowerCase|toUpperCase|keys|values|entries|startswith|endswith|lower|upper|items|strip))$/;
28
+ const DYNAMIC_CALL = /(?:\[|\bapply\b|\bcall\b|\beval\b|\bFunction\b|__import__|getattr|dynamic|<dynamic>|\bsend\()/;
29
+
30
+ function lastSegment(name) {
31
+ const s = String(name || '');
32
+ const parts = s.split(/[.:]|::/);
33
+ return parts[parts.length - 1] || s;
34
+ }
35
+
36
+ /** Largest CFG line of a function (0 when it has no CFG). Used only as a lower bound for a span end. */
37
+ function cfgMaxLine(fn) {
38
+ let m = 0;
39
+ const nodes = fn && fn.cfg && fn.cfg.nodes;
40
+ if (nodes) for (const n of Object.values(nodes)) if (Number.isInteger(n.line) && n.line > m) m = n.line;
41
+ return m;
42
+ }
43
+
44
+ /** Names a function binds locally: its parameters and every assignment target in its CFG. */
45
+ function localNamesOf(fn) {
46
+ const set = new Set((fn && Array.isArray(fn.params) ? fn.params : []).map(String));
47
+ const nodes = fn && fn.cfg && fn.cfg.nodes;
48
+ if (nodes) for (const n of Object.values(nodes)) if (n.kind === 'assign' && typeof n.target === 'string') set.add(n.target);
49
+ return set;
50
+ }
51
+
52
+ /**
53
+ * Is `nodeId` on every modelled path from entry to exit? (CFG dominance, by removal.) Bounded: a CFG larger than
54
+ * the cap reports 'unknown' rather than paying a quadratic cost on a generated file.
55
+ */
56
+ export function conditionalityOf(fn, nodeId) {
57
+ const cfg = fn && fn.cfg;
58
+ if (!cfg || !cfg.nodes || !nodeId || !cfg.nodes[nodeId]) return 'unknown';
59
+ if (Object.keys(cfg.nodes).length > 600) return 'unknown';
60
+ if (nodeId === cfg.entry || nodeId === cfg.exit) return 'unconditional';
61
+ const seen = new Set([cfg.entry]);
62
+ const stack = [cfg.entry];
63
+ while (stack.length) {
64
+ const cur = stack.pop();
65
+ for (const s of cfg.nodes[cur]?.succ || []) {
66
+ if (s === nodeId || seen.has(s)) continue;
67
+ seen.add(s);
68
+ stack.push(s);
69
+ }
70
+ }
71
+ return seen.has(cfg.exit) ? 'conditional' : 'unconditional';
72
+ }
73
+
74
+ /**
75
+ * Build the graph from IR facts.
76
+ * fileContents { path: normalised text }
77
+ * functions Map<qid, fn> (the IR call graph's `functions`)
78
+ * edges [{caller, callee|null, calleeName, line, site}]
79
+ * provenance 'source-static' | 'compiler-enriched'
80
+ */
81
+ export function buildSymbolGraph({ fileContents, functions, edges }) {
82
+ const linesOf = new Map();
83
+ const lines = (f) => {
84
+ if (!linesOf.has(f)) linesOf.set(f, (fileContents[f] || '').split('\n'));
85
+ return linesOf.get(f);
86
+ };
87
+
88
+ // Stable ids: file + declared name + ordinal among same-named declarations in that file, ordered by line. An edit
89
+ // ABOVE a function therefore does not rename it; only adding a same-named sibling above it does.
90
+ const rows = [...functions.values()]
91
+ .filter(fn => fn && fn.file && fileContents[fn.file] !== undefined)
92
+ .sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : (a.line - b.line) || (a.qid < b.qid ? -1 : 1));
93
+ const ordinal = new Map();
94
+ const idByQid = new Map();
95
+ const symbols = [];
96
+ for (const fn of rows) {
97
+ const anon = /^(?:anon|anonymous|<anonymous>|lambda)\b/.test(fn.name || '') || !fn.name;
98
+ const base = anon ? `${fn.file}::<anon>@${fn.line}` : `${fn.file}::${fn.name}`;
99
+ const k = ordinal.get(base) || 0;
100
+ ordinal.set(base, k + 1);
101
+ const id = !anon && k === 0 ? base : (anon ? base : `${base}#${k + 1}`);
102
+ idByQid.set(fn.qid, id);
103
+ const span = spanFor(fn.file, lines(fn.file), fn.line, cfgMaxLine(fn));
104
+ const text = sliceLines(fileContents[fn.file], span.startLine, span.endLine);
105
+ symbols.push({
106
+ id,
107
+ name: fn.name || '<anon>',
108
+ kind: fn.name === '<module>' ? 'module-top-level' : 'function',
109
+ file: fn.file,
110
+ startLine: span.startLine,
111
+ endLine: span.endLine,
112
+ declLine: fn.line,
113
+ spanPrecision: span.precision,
114
+ spanHash: sha256(text),
115
+ params: Array.isArray(fn.params) ? fn.params.map(String) : [],
116
+ resolver: 'source-static',
117
+ });
118
+ }
119
+ symbols.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
120
+
121
+ const byLastName = new Map();
122
+ for (const s of symbols) {
123
+ if (s.kind !== 'function' || s.name.startsWith('<')) continue;
124
+ const key = lastSegment(s.name);
125
+ if (!byLastName.has(key)) byLastName.set(key, []);
126
+ byLastName.get(key).push(s.id);
127
+ }
128
+
129
+ const imports = buildImportMap(fileContents);
130
+ const symbolsByFileName = new Map();
131
+ for (const s of symbols) {
132
+ if (s.kind !== 'function' || s.id.includes('::<anon>@')) continue;
133
+ for (const key of new Set([s.name, lastSegment(s.name)])) {
134
+ const k = `${s.file}\0${key}`;
135
+ if (!symbolsByFileName.has(k)) symbolsByFileName.set(k, []);
136
+ symbolsByFileName.get(k).push(s.id);
137
+ }
138
+ }
139
+
140
+ const byFullName = new Map();
141
+ for (const s of symbols) {
142
+ if (s.kind !== 'function' || s.id.includes('::<anon>@')) continue;
143
+ if (!byFullName.has(s.name)) byFullName.set(s.name, []);
144
+ byFullName.get(s.name).push(s.id);
145
+ }
146
+
147
+ const graphEdges = [];
148
+ for (const e of edges || []) {
149
+ const from = idByQid.get(e.caller);
150
+ if (!from) continue;
151
+ const callerFn = functions.get(e.caller);
152
+ const callerFile = callerFn.file;
153
+ const base = {
154
+ from,
155
+ line: Number.isInteger(e.line) ? e.line : 0,
156
+ calleeName: String(e.calleeName || ''),
157
+ resolver: 'source-static',
158
+ conditionality: conditionalityOf(callerFn, e.site),
159
+ resolution: 'ir',
160
+ };
161
+ const to = e.callee ? idByQid.get(e.callee) : null;
162
+ if (to) {
163
+ graphEdges.push({ ...base, to, kind: 'call', confidence: 'high', unresolvedReason: null, candidates: [] });
164
+ continue;
165
+ }
166
+ const name = base.calleeName;
167
+ const push = (kind, confidence, unresolvedReason, candidates = [], extra = {}) => graphEdges.push({ ...base, ...extra, to: null, kind, confidence, unresolvedReason, candidates });
168
+
169
+ if (INERT_CALL.test(name) || CONTINUATIONS.test(name)) { push('external_unknown', 'high', 'inert-builtin'); continue; }
170
+
171
+ // a fully qualified name (`Auth.requireUser`, `Class.method`) that matches exactly one declaration IS that declaration
172
+ if (/[.:]/.test(name)) {
173
+ const q = (byFullName.get(name) || []).filter(id => id !== from);
174
+ if (q.length === 1) { graphEdges.push({ ...base, to: q[0], kind: 'call', confidence: 'high', unresolvedReason: null, candidates: [], resolution: 'qualified-name' }); continue; }
175
+ }
176
+
177
+ const imp = resolveViaImports(imports, callerFile, name);
178
+ if (imp && imp.kind === 'exact-import') {
179
+ const ids = (symbolsByFileName.get(`${imp.targetFile}\0${imp.member}`) || []).filter(id => id !== from);
180
+ if (ids.length === 1) { graphEdges.push({ ...base, to: ids[0], kind: 'call', confidence: 'high', unresolvedReason: null, candidates: [], resolution: 'import' }); continue; }
181
+ push('external_unknown', 'low', ids.length ? 'ambiguous-import-target' : 'import-target-not-declared-in-module', ids.slice(0, 5));
182
+ continue;
183
+ }
184
+ if (imp && imp.kind === 'third-party') { push('external_unknown', 'high', 'third-party-import'); continue; }
185
+ if (imp && imp.kind === 'unresolved-import') { push('external_unknown', 'low', 'unresolved-relative-import'); continue; }
186
+
187
+ // a bare identifier that is a parameter or a local assignment is a call through a value: the target is data
188
+ if (/^[A-Za-z_$][\w$]*$/.test(name) && !imp && localNamesOf(callerFn).has(name)) { push('dynamic_possible', 'low', 'call-through-local-or-parameter'); continue; }
189
+ if (DYNAMIC_CALL.test(name) || name === '') { push('dynamic_possible', 'low', 'dynamic-callee'); continue; }
190
+
191
+ const cand = (byLastName.get(lastSegment(name)) || []).filter(id => id !== from).slice(0, 5);
192
+ if (cand.length) push('external_unknown', 'low', 'name-matches-first-party-declaration', cand);
193
+ else push('external_unknown', 'high', 'external-boundary');
194
+ }
195
+ graphEdges.sort((a, b) =>
196
+ (a.from < b.from ? -1 : a.from > b.from ? 1 : 0) || (a.line - b.line) || (a.calleeName < b.calleeName ? -1 : a.calleeName > b.calleeName ? 1 : 0) || ((a.to || '') < (b.to || '') ? -1 : 1));
197
+
198
+ const snapshotHash = hashOf(Object.keys(fileContents).sort().map(f => [f, sha256(fileContents[f])]));
199
+ const body = { schemaVersion: GRAPH_SCHEMA_VERSION, snapshotHash, symbols, edges: graphEdges, imports: imports.map(i => ({ file: i.file, local: i.local, source: i.source, member: i.member, kind: i.kind, target: i.target })) };
200
+ return { ...body, hash: hashOf(body) };
201
+ }
202
+
203
+ /**
204
+ * Structural validation. Returns a list of problems (empty = valid): dangling ids, spans outside the file, a span
205
+ * hash that no longer matches the text, an edge that names a symbol that is not in the graph.
206
+ */
207
+ export function validateGraph(graph, fileContents) {
208
+ const problems = [];
209
+ if (!graph || graph.schemaVersion !== GRAPH_SCHEMA_VERSION) return ['unsupported-schema-version'];
210
+ const ids = new Set(graph.symbols.map(s => s.id));
211
+ if (ids.size !== graph.symbols.length) problems.push('duplicate-symbol-id');
212
+ for (const s of graph.symbols) {
213
+ const text = fileContents[s.file];
214
+ if (typeof text !== 'string') { problems.push(`missing-file:${s.file}`); continue; }
215
+ const n = text.split('\n').length;
216
+ if (!(s.startLine >= 1 && s.endLine >= s.startLine && s.endLine <= n)) { problems.push(`span-out-of-range:${s.id}`); continue; }
217
+ if (sha256(sliceLines(text, s.startLine, s.endLine)) !== s.spanHash) problems.push(`stale-span-hash:${s.id}`);
218
+ }
219
+ for (const e of graph.edges) {
220
+ if (!ids.has(e.from)) problems.push(`dangling-from:${e.from}`);
221
+ if (e.to && !ids.has(e.to)) problems.push(`dangling-to:${e.to}`);
222
+ for (const c of e.candidates || []) if (!ids.has(c)) problems.push(`dangling-candidate:${c}`);
223
+ }
224
+ const { hash, ...rest } = graph;
225
+ if (hashOf(rest) !== hash) problems.push('graph-hash-mismatch');
226
+ return problems;
227
+ }
@@ -0,0 +1,123 @@
1
+ //
2
+ // Import maps (JS/TS and Python): which local name in a file refers to which module, and which declaration in it.
3
+ //
4
+ // The shared IR resolves a call to a declaration only for the shapes it models; a destructured `require` or a named
5
+ // ES import is not one of them, so a call such as `getReport(id)` arrives as an edge to nowhere. Left alone, nearly
6
+ // every Express/Node route would be reported as having an unresolved call. This module recovers those edges from the
7
+ // import statements themselves and records HOW each was resolved, so the result is still evidence and not a guess:
8
+ //
9
+ // exact-import the local name is bound by an import of a module in the repository, and that module declares a
10
+ // function of that name (or, for a namespace, of the member name)
11
+ // third-party the module is a package, not a repository file: a boundary, not first-party code
12
+ // unresolved a relative import that no repository file satisfies
13
+ //
14
+ // Scope, stated plainly: this is statement-level pattern matching. Re-export chains, `import()` expressions,
15
+ // computed `require` arguments and package-relative aliases (tsconfig paths) are not followed.
16
+ import * as path from 'node:path';
17
+
18
+ const JS_EXTS = ['', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '/index.js', '/index.mjs', '/index.ts', '/index.tsx'];
19
+
20
+ function resolveJsModule(fromFile, source, files) {
21
+ if (!source.startsWith('.')) return { kind: 'third-party', target: null };
22
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), source));
23
+ for (const ext of JS_EXTS) if (files.has(base + ext)) return { kind: 'repo', target: base + ext };
24
+ return { kind: 'unresolved', target: null };
25
+ }
26
+
27
+ function resolvePyModule(fromFile, dotted, files) {
28
+ let dir = path.posix.dirname(fromFile);
29
+ let mod = dotted;
30
+ const lead = /^\.+/.exec(mod);
31
+ const candidatesBase = [];
32
+ if (lead) {
33
+ for (let i = 1; i < lead[0].length; i++) dir = path.posix.dirname(dir);
34
+ mod = mod.slice(lead[0].length);
35
+ candidatesBase.push(dir === '.' ? '' : dir);
36
+ } else {
37
+ candidatesBase.push('', path.posix.dirname(fromFile) === '.' ? '' : path.posix.dirname(fromFile));
38
+ }
39
+ for (const b of candidatesBase) {
40
+ const rel = mod.replace(/\./g, '/');
41
+ for (const cand of [rel ? `${rel}.py` : '__init__.py', rel ? `${rel}/__init__.py` : '__init__.py']) {
42
+ const full = b ? path.posix.join(b, cand) : cand;
43
+ if (files.has(full)) return { kind: 'repo', target: full };
44
+ }
45
+ }
46
+ return { kind: lead ? 'unresolved' : 'third-party', target: null };
47
+ }
48
+
49
+ function stripComments(text) {
50
+ return text.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' ')).replace(/(^|[^:'"`\\])\/\/[^\n]*/g, (m, p1) => p1 + ' '.repeat(m.length - p1.length));
51
+ }
52
+
53
+ function names(list) {
54
+ return list.split(',').map(s => s.trim()).filter(Boolean).map(s => {
55
+ const m = /^(?:\w+\s+as\s+)?([\w$]+)$/.exec(s.replace(/^type\s+/, '').replace(/^([\w$]+)\s*:\s*([\w$]+)$/, '$1 as $2'));
56
+ const parts = s.replace(/^type\s+/, '').split(/\s+as\s+|\s*:\s*/).map(x => x.trim()).filter(Boolean);
57
+ void m;
58
+ if (parts.length === 2) return { imported: parts[0], local: parts[1] };
59
+ if (parts.length === 1) return { imported: parts[0], local: parts[0] };
60
+ return null;
61
+ }).filter(Boolean).filter(n => /^[\w$]+$/.test(n.imported) && /^[\w$]+$/.test(n.local));
62
+ }
63
+
64
+ /** Import entries for one JS/TS file. */
65
+ function jsImports(file, text, files) {
66
+ const t = stripComments(text);
67
+ const out = [];
68
+ const add = (local, source, member) => out.push({ file, local, source, member, ...resolveJsModule(file, source, files) });
69
+ for (const m of t.matchAll(/\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+)?require\(\s*['"]([^'"]+)['"]\s*\)/g)) for (const n of names(m[1])) add(n.local, m[2], n.imported);
70
+ for (const m of t.matchAll(/\b(?:const|let|var)\s+([\w$]+)\s*=\s*(?:await\s+)?require\(\s*['"]([^'"]+)['"]\s*\)/g)) add(m[1], m[2], '*');
71
+ for (const m of t.matchAll(/\bimport\s+(?:type\s+)?([\w$]+)?\s*,?\s*(?:\{([^}]*)\}|\*\s+as\s+([\w$]+))?\s*from\s*['"]([^'"]+)['"]/g)) {
72
+ if (m[1]) add(m[1], m[4], 'default');
73
+ if (m[2]) for (const n of names(m[2])) add(n.local, m[4], n.imported);
74
+ if (m[3]) add(m[3], m[4], '*');
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function pyImports(file, text, files) {
80
+ const out = [];
81
+ const add = (local, source, member) => out.push({ file, local, source, member, ...resolvePyModule(file, source, files) });
82
+ // the unparenthesised form ends at the line end; only the parenthesised form may span lines
83
+ for (const m of text.matchAll(/^[ \t]*from[ \t]+([.\w]+)[ \t]+import[ \t]+(?:\(([\w\s,]+)\)|([\w \t,]+))/gm)) {
84
+ for (const n of (m[2] ?? m[3]).split(',').map(s => s.trim()).filter(Boolean)) {
85
+ const p = n.split(/\s+as\s+/);
86
+ add(p[1] || p[0], m[1], p[0]);
87
+ }
88
+ }
89
+ for (const m of text.matchAll(/^[ \t]*import\s+([.\w]+)(?:\s+as\s+(\w+))?\s*$/gm)) add(m[2] || m[1].split('.')[0], m[1], '*');
90
+ return out;
91
+ }
92
+
93
+ /** All import entries, deterministically ordered. */
94
+ export function buildImportMap(fileContents) {
95
+ const files = new Set(Object.keys(fileContents));
96
+ const out = [];
97
+ for (const file of [...files].sort()) {
98
+ if (/\.(?:js|jsx|mjs|cjs|ts|tsx)$/i.test(file)) out.push(...jsImports(file, fileContents[file], files));
99
+ else if (/\.py$/i.test(file)) out.push(...pyImports(file, fileContents[file], files));
100
+ }
101
+ return out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || (a.local < b.local ? -1 : a.local > b.local ? 1 : 0) || (a.source < b.source ? -1 : 1));
102
+ }
103
+
104
+ /**
105
+ * Resolve a callee name used in `file` against the import map.
106
+ * Returns { kind:'exact-import', targetFile, member } | { kind:'third-party' } | { kind:'unresolved-import' } | null.
107
+ */
108
+ export function resolveViaImports(imports, file, calleeName) {
109
+ const parts = String(calleeName || '').split('.');
110
+ const head = parts[0];
111
+ const imp = imports.filter(i => i.file === file && i.local === head);
112
+ if (!imp.length) return null;
113
+ const i = imp[0];
114
+ if (i.kind === 'third-party') return { kind: 'third-party' };
115
+ if (i.kind === 'unresolved') return { kind: 'unresolved-import' };
116
+ if (parts.length === 1) {
117
+ const member = i.member === '*' || i.member === 'default' ? head : i.member;
118
+ return { kind: 'exact-import', targetFile: i.target, member, namespaceCall: i.member === '*' || i.member === 'default' };
119
+ }
120
+ if (i.member === '*' || i.member === 'default') return { kind: 'exact-import', targetFile: i.target, member: parts[parts.length - 1], namespaceCall: false };
121
+ // `db.query()` where `db` is a NAMED import: a method of an imported object, not a function of that module
122
+ return { kind: 'imported-object-method' };
123
+ }
@@ -0,0 +1,99 @@
1
+ //
2
+ // context-manifest/1: the inspectable record behind every bundle.
3
+ //
4
+ // A manifest answers, without any model: which entry point, which exact source spans (with hashes), which controls
5
+ // were seen and how they were established, what could not be resolved, what was left out and why, and how complete
6
+ // the bundle is. `validateManifest` enforces the contract's hard rules, so a manifest that over-claims is an error,
7
+ // not a warning.
8
+ import { hashOf, safeRelPath, sha256, sliceLines } from './canonical.js';
9
+ import { evaluateProtection } from './reachability.js';
10
+
11
+ export const MANIFEST_SCHEMA_VERSION = 'context-manifest/1';
12
+ export const INTEGRITY_VALUES = Object.freeze(['complete', 'partial', 'unavailable']);
13
+
14
+ /** Content-addressed key of a bundle set. Depends on what the model would see and how it was built, never on the commit. */
15
+ export function contextHashOf({ entrypoint, pack, graphSchema }) {
16
+ return hashOf({
17
+ algorithm: 'context-hash/1',
18
+ graphSchema,
19
+ entrypoint: { kind: entrypoint.kind, method: entrypoint.method, path: entrypoint.pathTemplate, handler: entrypoint.handlerSymbol },
20
+ profile: pack.profile, maxTokens: pack.maxTokens, prune: pack.pruneLowValue,
21
+ packages: pack.packages.map(p => p.sha256),
22
+ integrity: pack.integrity,
23
+ });
24
+ }
25
+
26
+ export function buildManifest({ entrypoint, reach, pack, graph, fileContents, repository, semanticSource = ['static-ir'] }) {
27
+ const omittedWithControls = pack.omissions.some(o => o.reason === 'omitted_due_to_budget' && o.symbolId && reach.facts.some(f => f.symbolId === o.symbolId));
28
+ const protection = evaluateProtection({ facts: reach.facts, unknowns: reach.unknowns, omittedWithControls, capped: reach.capped });
29
+ const byId = new Map(graph.symbols.map(s => [s.id, s]));
30
+
31
+ const reachableSymbols = reach.visited.map(v => {
32
+ const s = byId.get(v.symbolId);
33
+ return { symbolId: v.symbolId, file: s.file, start: { line: s.startLine, column: 1 }, end: { line: s.endLine, column: 1 }, spanHash: s.spanHash, spanPrecision: s.spanPrecision, reason: v.reason, depth: v.depth, pathFromRoot: v.path, confidence: v.confidence };
34
+ });
35
+ const includedSymbols = new Set(pack.packages.flatMap(p => p.segments.flatMap(s => s.symbolIds)));
36
+ for (const r of reachableSymbols) r.included = includedSymbols.has(r.symbolId) || (reach.root && reach.root.kind === 'inline-block' && r.reason === 'handler');
37
+
38
+ const manifest = {
39
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
40
+ entrypointId: entrypoint.id,
41
+ entrypoint: {
42
+ kind: entrypoint.kind, method: entrypoint.method, path: entrypoint.pathTemplate, framework: entrypoint.framework, language: entrypoint.language,
43
+ handlerSymbol: entrypoint.handlerSymbol, handlerKind: entrypoint.handlerKind, handlerConfidence: entrypoint.handlerConfidence,
44
+ registrationSite: entrypoint.registrationSite, authScheme: entrypoint.authScheme, middleware: entrypoint.middleware,
45
+ },
46
+ repository,
47
+ graph: { schemaVersion: graph.schemaVersion, hash: graph.hash, source: semanticSource },
48
+ context: {
49
+ profile: pack.profile, maxTokens: pack.maxTokens, integrity: pack.integrity, integrityReasons: [...pack.integrityReasons].sort(),
50
+ tokenizer: pack.tokenizer, pruneLowValue: pack.pruneLowValue,
51
+ packages: pack.packages.map(p => ({ index: p.index, total: p.total, setId: p.setId, sha256: p.sha256, tokensEstimated: p.tokensEstimated, standalone: p.standalone, integrity: p.integrity, segments: p.segments })),
52
+ },
53
+ reachableSymbols,
54
+ // observed and inferred are kept apart on purpose: a name is not an observation
55
+ controls: reach.facts.filter(f => f.category === 'control').map(f => ({ symbolId: f.symbolId, file: f.file, line: f.line, kind: f.kind, evidence: f.evidence, basis: f.basis, rejectionSeen: f.rejectionSeen, order: f.effLine === -1 ? 'before-handler' : null, status: 'included' })),
56
+ sensitiveOperations: reach.facts.filter(f => f.category === 'sensitive').map(f => ({ symbolId: f.symbolId, file: f.file, line: f.line, kind: f.kind, basis: f.basis })),
57
+ protection,
58
+ unknowns: reach.unknowns.map(u => ({ type: u.type, site: u.site, calleeName: u.calleeName || null, reason: u.reason, material: u.material, candidates: u.candidates || [] })),
59
+ boundaries: reach.boundaries,
60
+ omissions: pack.omissions,
61
+ traversal: { capped: reach.capped, cycles: reach.cycles, limits: reach.limits, unvisited: reach.unvisited },
62
+ status: { entrypoint: entrypoint.disposition, reachability: reach.capped || reach.unknowns.some(u => u.material) ? 'partial' : (reach.root ? 'complete' : 'unavailable'), bundle: pack.integrity },
63
+ resolutionReasons: entrypoint.resolutionReasons,
64
+ };
65
+ manifest.contextHash = contextHashOf({ entrypoint, pack, graphSchema: graph.schemaVersion });
66
+ manifest.manifestHash = hashOf({ ...manifest, repository: undefined });
67
+ return manifest;
68
+ }
69
+
70
+ /**
71
+ * Contract validation. Returns problems (empty = valid). With `fileContents` it also verifies every span hash against
72
+ * the current source, which is what turns a stale manifest into an error instead of a quiet mismatch.
73
+ */
74
+ export function validateManifest(m, { fileContents = null } = {}) {
75
+ const p = [];
76
+ if (!m || m.schemaVersion !== MANIFEST_SCHEMA_VERSION) return ['unsupported-schema-version'];
77
+ if (!INTEGRITY_VALUES.includes(m.context?.integrity)) p.push('bad-integrity-value');
78
+ const materialUnknowns = (m.unknowns || []).some(u => u.material);
79
+ const budgetOmissions = (m.omissions || []).some(o => o.reason === 'omitted_due_to_budget');
80
+ if (m.context?.integrity === 'complete' && (materialUnknowns || budgetOmissions || m.traversal?.capped)) p.push('complete-with-material-gaps');
81
+ if (m.context?.integrity === 'complete' && m.status?.entrypoint && m.status.entrypoint !== 'resolved') p.push('complete-with-unresolved-entrypoint');
82
+ for (const pk of m.context?.packages || []) {
83
+ if (pk.total > 1 && pk.integrity === 'complete') p.push(`standalone-complete-in-split-set:${pk.index}`);
84
+ for (const s of pk.segments || []) {
85
+ if (!safeRelPath(s.file) || s.file !== safeRelPath(s.file)) p.push(`unsafe-path:${s.file}`);
86
+ if (fileContents) {
87
+ const t = fileContents[s.file];
88
+ if (typeof t !== 'string') { p.push(`missing-file:${s.file}`); continue; }
89
+ if (s.startLine < 1 || s.endLine > t.split('\n').length || s.endLine < s.startLine) { p.push(`span-out-of-range:${s.file}:${s.startLine}`); continue; }
90
+ if (sha256(sliceLines(t, s.startLine, s.endLine)) !== s.spanHash) p.push(`stale-span:${s.file}:${s.startLine}-${s.endLine}`);
91
+ }
92
+ }
93
+ }
94
+ const probe = { ...m };
95
+ delete probe.manifestHash;
96
+ if (hashOf({ ...probe, repository: undefined }) !== m.manifestHash) p.push('manifest-hash-mismatch');
97
+ if (JSON.stringify(m).match(/"(?:file|site)":"(?:\/|[A-Za-z]:)/)) p.push('absolute-host-path');
98
+ return p;
99
+ }