@azure-id/orc 1.7.0 → 1.8.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 +3649 -3339
- package/README-id.md +923 -844
- package/README.md +836 -860
- package/bin/build-agents.js +43 -27
- package/bin/cli.js +701 -3
- package/bin/graph-extract.js +927 -0
- package/bin/graph-notes.js +188 -0
- package/bin/graph-query.js +808 -0
- package/bin/graph-resolve.js +178 -0
- package/bin/graph-signals.js +277 -0
- package/bin/graph.js +605 -0
- package/bin/verify-contracts.js +4669 -4527
- package/bin/verify-package.js +626 -616
- package/bin/webui/api.js +1419 -1414
- package/bin/webui/fixtures/index.js +579 -576
- package/bin/webui/fixtures/knowledge.js +316 -291
- package/bin/webui/i18n/en/knowledge.json +167 -151
- package/bin/webui/i18n/en/overview.json +101 -100
- package/bin/webui/i18n/id/knowledge.json +167 -151
- package/bin/webui/i18n/id/overview.json +101 -100
- package/bin/webui/js/panels/knowledge.js +1065 -1006
- package/bin/webui/js/panels/overview.js +492 -488
- package/package.json +39 -39
- package/templates/agents/MODEL-MAPPING.md +163 -158
- package/templates/agents/orc-executor-haiku-4-5.md +133 -113
- package/templates/agents/orc-executor-opus-4-7-high.md +134 -114
- package/templates/agents/orc-executor-opus-4-7-med.md +134 -114
- package/templates/agents/orc-executor-opus-4-8-high.md +134 -114
- package/templates/agents/orc-executor-opus-5-high.md +134 -114
- package/templates/agents/orc-executor-opus-5-low.md +134 -114
- package/templates/agents/orc-executor-opus-5-med.md +134 -114
- package/templates/agents/orc-executor-sonnet-4-6-high.md +134 -114
- package/templates/agents/orc-executor-sonnet-4-6-med.md +134 -114
- package/templates/agents/orc-executor-sonnet-5-high.md +134 -114
- package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +86 -0
- package/templates/hooks/README.md +444 -396
- package/templates/hooks/orc-graph-hook.js +336 -0
- package/templates/hooks/orc-statusline-render.js +922 -921
- package/templates/hooks/orc-statusline.js +1596 -1545
- package/templates/skills/_shared/README.md +4 -0
- package/templates/skills/_shared/code-graph.md +220 -0
- package/templates/skills/_shared/extra-dispatch.md +1 -1
- package/templates/skills/_shared/opus5-only.md +4 -0
- package/templates/skills/_shared/phases/execution.md +166 -145
- package/templates/skills/_shared/phases/planning.md +142 -135
- package/templates/skills/_shared/phases/preflight.md +132 -118
- package/templates/skills/_shared/phases/review.md +63 -53
- package/templates/skills/_shared/phases/rules.md +5 -0
- package/templates/skills/_shared/phases/ship.md +96 -88
- package/templates/skills/_shared/phases/trace.md +6 -0
- package/templates/skills/_shared/phases/wiki-consult.md +194 -189
- package/templates/skills/_shared/read-ladder.md +124 -102
- package/templates/skills/_shared/return-validation.md +259 -250
- package/templates/skills/orc/SKILL.md +255 -254
- package/templates/skills/orc/references/preflight-report.md +6 -0
- package/templates/skills/orc/subskills/orc-execution/SKILL.md +2 -1
- package/templates/skills/orc/subskills/orc-execution/core.md +9 -1
- package/templates/skills/orc-diy/references/flow-schema.md +101 -100
- package/templates/skills/orc-fast/SKILL.md +236 -229
- package/templates/skills/orc-mini/SKILL.md +267 -259
- package/templates/skills/orc-quick/SKILL.md +378 -360
- package/templates/skills/orc-quick/references/dispatch-gate.md +6 -0
- package/templates/skills/orc-wiki/references/staleness.md +294 -288
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ── orc graph — RESOLUTION and the READ commands (v1.8.0 W3) ────────────────
|
|
3
|
+
//
|
|
4
|
+
// `ctx`, `impact` and `path` answer from the stored records. Resolution is
|
|
5
|
+
// COMPUTED ON READ, never stored — a deliberate change from plan §3 step 5.
|
|
6
|
+
// W0 measured the whole resolve pass at 175 ms on Django (7K files); storing
|
|
7
|
+
// edges would add ~12 MB to every parse AND an invalidation path that can go
|
|
8
|
+
// stale in silence. The standing rule wins: a state is computed, not kept.
|
|
9
|
+
//
|
|
10
|
+
// THE STATES — never a fuzzy guess
|
|
11
|
+
// LOCAL defined in the same file (a `this.`/`self.` call prefers the caller's class)
|
|
12
|
+
// IMPORT the name, or its receiver, is bound by an import whose path resolves here
|
|
13
|
+
// UNIQUE exactly one symbol in the repo has that name (and the receiver agrees)
|
|
14
|
+
// AMBIGUOUS two or more candidates, OR a receiver that no candidate confirms — all listed
|
|
15
|
+
// UNRESOLVED nothing in this repo (a builtin, an external package, dynamic dispatch)
|
|
16
|
+
// (EXACT is reserved for a type-checker-backed resolution; nothing emits it yet.)
|
|
17
|
+
//
|
|
18
|
+
// Every read is BUDGETED. A card never exceeds its budget and always says what
|
|
19
|
+
// it hid — a graph tool that returned 33K tokens is the failure this prevents.
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const posix = path.posix;
|
|
24
|
+
const { spawnSync } = require("child_process");
|
|
25
|
+
const G = require("./graph.js");
|
|
26
|
+
const N = require("./graph-notes.js");
|
|
27
|
+
|
|
28
|
+
const CONFIDENT = new Set(["LOCAL", "IMPORT", "UNIQUE"]);
|
|
29
|
+
const TEST_FILE = /(^|\/)(tests?|__tests__|specs?)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py)$|(^|\/)test_[^/]*\.py$|Tests?\.(java|cs)$|Test\.php$/;
|
|
30
|
+
const JS_EXT = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
|
|
31
|
+
const tok = (s) => Math.ceil(String(s).length / 4);
|
|
32
|
+
const norm = (p) => String(p).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
33
|
+
|
|
34
|
+
function readJson(file) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
37
|
+
} catch (_) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// EW2: `opts.noCache` is for the WRITER — `graph-resolve.build` must compute,
|
|
43
|
+
// never read back the cache it is about to replace.
|
|
44
|
+
function loadModel(claudeDir, root, opts) {
|
|
45
|
+
const p = G.graphPaths(claudeDir);
|
|
46
|
+
const meta = readJson(p.meta);
|
|
47
|
+
const index = readJson(p.index);
|
|
48
|
+
if (!meta || !index || !index.by_file) return null;
|
|
49
|
+
// The stored resolution, and ONLY when it names this exact generation.
|
|
50
|
+
// Anything else — a crash between two writes, an older engine, a hand
|
|
51
|
+
// deletion — leaves `cached` null and every answer is computed as before.
|
|
52
|
+
const cached = opts && opts.noCache ? null : require("./graph-resolve.js").load(claudeDir, meta);
|
|
53
|
+
const byFile = index.by_file;
|
|
54
|
+
const symbols = new Map();
|
|
55
|
+
const byName = new Map();
|
|
56
|
+
for (const [rel, f] of Object.entries(byFile)) {
|
|
57
|
+
for (const s of f.symbols || []) {
|
|
58
|
+
s.file = rel;
|
|
59
|
+
symbols.set(s.id, s);
|
|
60
|
+
if (s.kind === "module") continue;
|
|
61
|
+
const a = byName.get(s.name);
|
|
62
|
+
if (a) a.push(s);
|
|
63
|
+
else byName.set(s.name, [s]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
root,
|
|
68
|
+
meta,
|
|
69
|
+
notes: N.noteIndex(claudeDir),
|
|
70
|
+
byFile,
|
|
71
|
+
fileSet: new Set(Object.keys(byFile)),
|
|
72
|
+
symbols,
|
|
73
|
+
byName,
|
|
74
|
+
bindings: new Map(),
|
|
75
|
+
resolved: new Map(),
|
|
76
|
+
// The REVERSE direction only. EW2 measured storing the whole memo as a net
|
|
77
|
+
// loss (21.7 MB to parse, to save less than that), while this half is the
|
|
78
|
+
// one the 437 ms walk was computing.
|
|
79
|
+
callersCache: cached ? cached.callers || {} : null,
|
|
80
|
+
maybeCache: cached ? cached.maybe || {} : null,
|
|
81
|
+
generation: meta.generation || 0,
|
|
82
|
+
suffix: new Map(),
|
|
83
|
+
dirs: null,
|
|
84
|
+
goModule: undefined,
|
|
85
|
+
callIndex: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── import targets ──────────────────────────────────────────────────────────
|
|
90
|
+
function bySuffix(model, suffix) {
|
|
91
|
+
if (model.suffix.has(suffix)) return model.suffix.get(suffix);
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const f of model.fileSet) if (f === suffix || f.endsWith("/" + suffix)) out.push(f);
|
|
94
|
+
model.suffix.set(suffix, out);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function filesInDir(model, dir, ext) {
|
|
99
|
+
if (!model.dirs) {
|
|
100
|
+
model.dirs = new Map();
|
|
101
|
+
for (const f of model.fileSet) {
|
|
102
|
+
const d = posix.dirname(f);
|
|
103
|
+
const a = model.dirs.get(d);
|
|
104
|
+
if (a) a.push(f);
|
|
105
|
+
else model.dirs.set(d, [f]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return (model.dirs.get(dir) || []).filter((f) => f.endsWith(ext));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveJs(model, from, spec) {
|
|
112
|
+
let base;
|
|
113
|
+
if (spec.startsWith(".")) base = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
114
|
+
else if (/^[@~]\//.test(spec)) base = "src/" + spec.slice(2);
|
|
115
|
+
else if (spec.startsWith("/")) base = spec.slice(1);
|
|
116
|
+
else return [];
|
|
117
|
+
const cands = [base, ...JS_EXT.map((e) => base + e), ...JS_EXT.map((e) => `${base}/index${e}`)];
|
|
118
|
+
// TS ESM writes `./x.js` for a file that is `x.ts` on disk.
|
|
119
|
+
if (/\.(js|jsx|mjs|cjs)$/.test(base)) {
|
|
120
|
+
const stem = base.replace(/\.(js|jsx|mjs|cjs)$/, "");
|
|
121
|
+
cands.push(...JS_EXT.map((e) => stem + e));
|
|
122
|
+
}
|
|
123
|
+
for (const c of cands) if (model.fileSet.has(c)) return [c];
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolvePy(model, from, spec) {
|
|
128
|
+
const lvl = /^\.*/.exec(spec)[0].length;
|
|
129
|
+
const parts = spec.slice(lvl).split(".").filter(Boolean);
|
|
130
|
+
let dir = "";
|
|
131
|
+
if (lvl) {
|
|
132
|
+
dir = posix.dirname(from);
|
|
133
|
+
for (let i = 1; i < lvl; i++) dir = posix.dirname(dir);
|
|
134
|
+
if (dir === ".") dir = "";
|
|
135
|
+
}
|
|
136
|
+
const p = [dir, ...parts].filter(Boolean).join("/");
|
|
137
|
+
for (const c of p ? [`${p}.py`, `${p}/__init__.py`] : [`${dir ? dir + "/" : ""}__init__.py`]) if (model.fileSet.has(c)) return [c];
|
|
138
|
+
if (!lvl && parts.length) {
|
|
139
|
+
const a = bySuffix(model, `${parts.join("/")}.py`);
|
|
140
|
+
if (a.length) return a;
|
|
141
|
+
return bySuffix(model, `${parts.join("/")}/__init__.py`);
|
|
142
|
+
}
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveGo(model, spec) {
|
|
147
|
+
if (model.goModule === undefined) {
|
|
148
|
+
model.goModule = null;
|
|
149
|
+
try {
|
|
150
|
+
const mm = /^module\s+(\S+)/m.exec(fs.readFileSync(path.join(model.root, "go.mod"), "utf8"));
|
|
151
|
+
if (mm) model.goModule = mm[1];
|
|
152
|
+
} catch (_) {}
|
|
153
|
+
}
|
|
154
|
+
if (!model.goModule || !spec.startsWith(model.goModule + "/")) return [];
|
|
155
|
+
return filesInDir(model, spec.slice(model.goModule.length + 1), ".go").filter((f) => !f.endsWith("_test.go"));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function importTargets(model, from, lang, imp) {
|
|
159
|
+
const spec = imp.from || "";
|
|
160
|
+
if (lang === "js" || lang === "ts") return resolveJs(model, from, spec);
|
|
161
|
+
if (lang === "py") return resolvePy(model, from, spec);
|
|
162
|
+
if (lang === "go") return resolveGo(model, spec);
|
|
163
|
+
if (lang === "java") {
|
|
164
|
+
// A wildcard import names a package, not a file; the UNIQUE rung covers it.
|
|
165
|
+
if (imp.star) return [];
|
|
166
|
+
return bySuffix(model, `${spec.replace(/\./g, "/")}.java`);
|
|
167
|
+
}
|
|
168
|
+
if (lang === "php") {
|
|
169
|
+
if (imp.include) {
|
|
170
|
+
const rel = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
171
|
+
return model.fileSet.has(rel) ? [rel] : bySuffix(model, spec.replace(/^(\.\.?\/)+/, ""));
|
|
172
|
+
}
|
|
173
|
+
return bySuffix(model, `${spec.replace(/\\/g, "/")}.php`);
|
|
174
|
+
}
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function fileBindings(model, rel) {
|
|
179
|
+
if (model.bindings.has(rel)) return model.bindings.get(rel);
|
|
180
|
+
const b = new Map();
|
|
181
|
+
const includes = [];
|
|
182
|
+
const f = model.byFile[rel] || {};
|
|
183
|
+
for (const imp of f.imports || []) {
|
|
184
|
+
const files = importTargets(model, rel, f.lang, imp);
|
|
185
|
+
if (imp.include) includes.push(...files);
|
|
186
|
+
for (const bd of imp.bindings || []) b.set(bd.local, { ...bd, from: imp.from, files });
|
|
187
|
+
}
|
|
188
|
+
const out = { map: b, includes };
|
|
189
|
+
model.bindings.set(rel, out);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── resolution ──────────────────────────────────────────────────────────────
|
|
194
|
+
function classOf(sym) {
|
|
195
|
+
const q = sym.qname.split(".");
|
|
196
|
+
return q.length > 1 ? q.slice(0, -1).join(".") : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resolveCall(model, rel, caller, call) {
|
|
200
|
+
const cls = caller ? classOf(caller) : null;
|
|
201
|
+
const key = `${rel}\0${cls || ""}\0${call.name}\0${call.self ? 1 : 0}\0${call.ref ? 1 : 0}`;
|
|
202
|
+
if (model.resolved.has(key)) return model.resolved.get(key);
|
|
203
|
+
const parts = call.name.split(".");
|
|
204
|
+
const last = parts[parts.length - 1];
|
|
205
|
+
const head = parts.length > 1 ? parts[0] : null;
|
|
206
|
+
const f = model.byFile[rel] || {};
|
|
207
|
+
const locals = (f.symbols || []).filter((s) => s.kind !== "module" && s.name === last);
|
|
208
|
+
const { map: binds, includes } = fileBindings(model, rel);
|
|
209
|
+
const lang = f.lang;
|
|
210
|
+
let r = null;
|
|
211
|
+
const hit = (state, s) => ({ state, target: s.id });
|
|
212
|
+
|
|
213
|
+
if (!head && locals.length) {
|
|
214
|
+
const same = call.self && cls ? locals.find((s) => classOf(s) === cls) : null;
|
|
215
|
+
r = hit("LOCAL", same || locals.find((s) => !classOf(s) || !call.self) || locals[0]);
|
|
216
|
+
}
|
|
217
|
+
if (!r && head) {
|
|
218
|
+
const m2 = locals.filter((s) => s.qname === `${head}.${last}` || s.qname.endsWith(`.${head}.${last}`));
|
|
219
|
+
if (m2.length) r = hit("LOCAL", m2[0]);
|
|
220
|
+
}
|
|
221
|
+
if (!r) {
|
|
222
|
+
const bd = binds.get(head || last);
|
|
223
|
+
if (bd && !bd.files.length && lang !== "cs") {
|
|
224
|
+
// Bound by an import that does not resolve to a file here — an external
|
|
225
|
+
// package. Matching it against a same-named repo function would invent
|
|
226
|
+
// an edge (axios.get → some repo `get`).
|
|
227
|
+
if (!(lang === "py" && bd.kind === "named")) r = { state: "UNRESOLVED", external: bd.from };
|
|
228
|
+
}
|
|
229
|
+
if (!r && bd && bd.files.length) {
|
|
230
|
+
const tsyms = bd.files.flatMap((t) => ((model.byFile[t] || {}).symbols || []).filter((s) => s.kind !== "module"));
|
|
231
|
+
let s = null;
|
|
232
|
+
if (head) {
|
|
233
|
+
s =
|
|
234
|
+
tsyms.find((x) => x.qname === `${bd.orig}.${last}` || x.qname === `${bd.local}.${last}`) ||
|
|
235
|
+
(bd.kind === "namespace" || bd.kind === "default" ? tsyms.find((x) => x.name === last && !classOf(x)) : null) ||
|
|
236
|
+
tsyms.find((x) => x.name === last && x.qname.startsWith(bd.orig + "."));
|
|
237
|
+
} else if (bd.kind === "named") {
|
|
238
|
+
s = tsyms.find((x) => x.qname === bd.orig) || tsyms.find((x) => x.name === bd.orig && !classOf(x));
|
|
239
|
+
} else {
|
|
240
|
+
const exported = tsyms.filter((x) => x.exported && !classOf(x));
|
|
241
|
+
s = tsyms.find((x) => x.qname === bd.local) || (exported.length === 1 ? exported[0] : null);
|
|
242
|
+
}
|
|
243
|
+
// `from pkg import module` — the name is a module, not a symbol in pkg.
|
|
244
|
+
if (!s && lang === "py" && bd.kind === "named") {
|
|
245
|
+
const sub = resolvePy(model, rel, `${bd.from}.${bd.orig}`);
|
|
246
|
+
const ss = sub.flatMap((t) => ((model.byFile[t] || {}).symbols || []).filter((x) => x.kind !== "module"));
|
|
247
|
+
s = head ? ss.find((x) => x.name === last && !classOf(x)) : null;
|
|
248
|
+
}
|
|
249
|
+
if (s) r = hit("IMPORT", s);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!r && !head && includes.length) {
|
|
253
|
+
const s = includes.flatMap((t) => ((model.byFile[t] || {}).symbols || []).filter((x) => x.kind !== "module" && x.name === last && !classOf(x)))[0];
|
|
254
|
+
if (s) r = hit("IMPORT", s);
|
|
255
|
+
}
|
|
256
|
+
if (!r) {
|
|
257
|
+
const cands = model.byName.get(last) || [];
|
|
258
|
+
if (head) {
|
|
259
|
+
const hl = head.toLowerCase();
|
|
260
|
+
const byClass = cands.filter((s) => {
|
|
261
|
+
const c = classOf(s);
|
|
262
|
+
return c && c.split(".").pop().toLowerCase() === hl;
|
|
263
|
+
});
|
|
264
|
+
if (byClass.length === 1) r = hit("UNIQUE", byClass[0]);
|
|
265
|
+
else if (byClass.length > 1) r = { state: "AMBIGUOUS", candidates: byClass.map((s) => s.id) };
|
|
266
|
+
else if (cands.length) r = { state: "AMBIGUOUS", candidates: cands.map((s) => s.id) };
|
|
267
|
+
} else if (cands.length === 1) r = hit("UNIQUE", cands[0]);
|
|
268
|
+
else if (cands.length > 1) r = { state: "AMBIGUOUS", candidates: cands.map((s) => s.id) };
|
|
269
|
+
}
|
|
270
|
+
if (!r) r = { state: "UNRESOLVED" };
|
|
271
|
+
// A name passed as an argument is only an edge when this file defines it or
|
|
272
|
+
// imports it. `res.json(order)` must never find a repo function named `order`.
|
|
273
|
+
if (call.ref && r.state !== "LOCAL" && r.state !== "IMPORT") r = { state: "UNRESOLVED" };
|
|
274
|
+
model.resolved.set(key, r);
|
|
275
|
+
return r;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function callIndex(model) {
|
|
279
|
+
if (model.callIndex) return model.callIndex;
|
|
280
|
+
const idx = new Map();
|
|
281
|
+
for (const [rel, f] of Object.entries(model.byFile)) {
|
|
282
|
+
for (const s of f.symbols || []) {
|
|
283
|
+
for (const c of s.calls || []) {
|
|
284
|
+
const last = c.name.split(".").pop();
|
|
285
|
+
const a = idx.get(last);
|
|
286
|
+
const row = { rel, sym: s, call: c };
|
|
287
|
+
if (a) a.push(row);
|
|
288
|
+
else idx.set(last, [row]);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
model.callIndex = idx;
|
|
293
|
+
return idx;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Who calls `target`: confident callers, and a count of AMBIGUOUS ones that
|
|
297
|
+
// list it among their candidates.
|
|
298
|
+
function callersOf(model, target) {
|
|
299
|
+
const confident = [];
|
|
300
|
+
const seen = new Set();
|
|
301
|
+
let maybe = 0;
|
|
302
|
+
// The stored reverse map. EW0 measured this walk at 437 ms for one file card
|
|
303
|
+
// on django/django; the lookup is a property read.
|
|
304
|
+
const cachedRows = model.callersCache && Object.prototype.hasOwnProperty.call(model.callersCache, target.id) ? model.callersCache[target.id] : model.callersCache ? [] : null;
|
|
305
|
+
if (cachedRows) {
|
|
306
|
+
for (const r of cachedRows) {
|
|
307
|
+
const sym = model.symbols.get(r[0]);
|
|
308
|
+
if (!sym) continue;
|
|
309
|
+
const k = `${r[0]}${r[1]}`;
|
|
310
|
+
if (seen.has(k)) continue;
|
|
311
|
+
seen.add(k);
|
|
312
|
+
confident.push({ sym, line: r[1], state: r[2], ...(r[3] ? { ref: true } : {}) });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (model.callersCache) {
|
|
316
|
+
stableCallers(confident);
|
|
317
|
+
const m = model.maybeCache;
|
|
318
|
+
if (m && Object.prototype.hasOwnProperty.call(m, target.id)) for (const row of m[target.id]) maybe += row[1];
|
|
319
|
+
return { confident, maybe };
|
|
320
|
+
}
|
|
321
|
+
for (const row of callIndex(model).get(target.name) || []) {
|
|
322
|
+
const r = resolveCall(model, row.rel, row.sym, row.call);
|
|
323
|
+
if (r.target === target.id && CONFIDENT.has(r.state)) {
|
|
324
|
+
const k = `${row.sym.id}\0${row.call.line}`;
|
|
325
|
+
if (seen.has(k)) continue;
|
|
326
|
+
seen.add(k);
|
|
327
|
+
confident.push({ sym: row.sym, line: row.call.line, state: r.state, ...(row.call.ref ? { ref: true } : {}) });
|
|
328
|
+
} else if (r.state === "AMBIGUOUS" && r.candidates.includes(target.id)) maybe++;
|
|
329
|
+
}
|
|
330
|
+
stableCallers(confident);
|
|
331
|
+
return { confident, maybe };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// EW2: the stored reverse map and the on-read walk visit callers in different
|
|
335
|
+
// orders. Same rows, and a card that reorders itself depending on whether a
|
|
336
|
+
// cache file happens to exist is a card nobody can diff. One total order, both
|
|
337
|
+
// ways.
|
|
338
|
+
function stableCallers(rows) {
|
|
339
|
+
rows.sort((a, b) => a.sym.file.localeCompare(b.sym.file) || a.line - b.line || String(a.sym.id).localeCompare(String(b.sym.id)));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function calleesOf(model, sym) {
|
|
343
|
+
const out = [];
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
for (const c of sym.calls || []) {
|
|
346
|
+
if (seen.has(c.name)) continue;
|
|
347
|
+
seen.add(c.name);
|
|
348
|
+
out.push({ call: c, res: resolveCall(model, sym.file, sym, c) });
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── targets ─────────────────────────────────────────────────────────────────
|
|
354
|
+
function findTarget(model, q) {
|
|
355
|
+
const s = norm(q);
|
|
356
|
+
const fl = /^(.+?):(\d+)$/.exec(s);
|
|
357
|
+
if (fl && model.fileSet.has(fl[1])) {
|
|
358
|
+
const line = Number(fl[2]);
|
|
359
|
+
const inside = (model.byFile[fl[1]].symbols || [])
|
|
360
|
+
.filter((x) => x.kind !== "module" && x.lines[0] <= line && line <= x.lines[1])
|
|
361
|
+
.sort((a, b) => a.lines[1] - a.lines[0] - (b.lines[1] - b.lines[0]));
|
|
362
|
+
return inside.length ? { kind: "symbol", sym: inside[0] } : { kind: "file", rel: fl[1] };
|
|
363
|
+
}
|
|
364
|
+
if (model.fileSet.has(s)) return { kind: "file", rel: s };
|
|
365
|
+
if (model.symbols.has(s)) return { kind: "symbol", sym: model.symbols.get(s) };
|
|
366
|
+
const exact = [];
|
|
367
|
+
for (const x of model.symbols.values()) if (x.kind !== "module" && x.qname === s) exact.push(x);
|
|
368
|
+
if (exact.length === 1) return { kind: "symbol", sym: exact[0] };
|
|
369
|
+
// Same qname, same file: one symbol declared more than once (a Go build tag,
|
|
370
|
+
// a Python redefinition). The widest one is the implementation.
|
|
371
|
+
if (exact.length > 1 && exact.every((x) => x.file === exact[0].file)) {
|
|
372
|
+
return { kind: "symbol", sym: exact.slice().sort((a, b) => b.lines[1] - b.lines[0] - (a.lines[1] - a.lines[0]))[0] };
|
|
373
|
+
}
|
|
374
|
+
if (exact.length > 1) return { kind: "ambiguous", candidates: exact };
|
|
375
|
+
const named = model.byName.get(s) || [];
|
|
376
|
+
if (named.length === 1) return { kind: "symbol", sym: named[0] };
|
|
377
|
+
if (named.length > 1) return { kind: "ambiguous", candidates: named };
|
|
378
|
+
const ql = s.toLowerCase();
|
|
379
|
+
const nearest = [];
|
|
380
|
+
for (const [n, arr] of model.byName) if (n.toLowerCase().includes(ql)) nearest.push(...arr);
|
|
381
|
+
return { kind: "none", nearest: nearest.slice(0, 5) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const where = (sym, line) => `${sym.file}:${line || sym.lines[0]}`;
|
|
385
|
+
const brief = (sym) => ({ id: sym.id, qname: sym.qname, kind: sym.kind, file: sym.file, lines: sym.lines });
|
|
386
|
+
|
|
387
|
+
function blobState(model, rel) {
|
|
388
|
+
if (!fs.existsSync(path.join(model.root, ...rel.split("/")))) return "deleted";
|
|
389
|
+
const r = spawnSync("git", ["hash-object", "--", rel], { cwd: model.root, encoding: "utf8" });
|
|
390
|
+
if (r.status !== 0) return "unknown";
|
|
391
|
+
return r.stdout.trim() === (model.byFile[rel] || {}).blob ? "current" : "changed";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// EW1: what the extractor saw of this file, for the card header. A card that
|
|
395
|
+
// hides a gap is worse than no card — the reader takes silence for absence.
|
|
396
|
+
function coverageTag(model, rel) {
|
|
397
|
+
const f = model.byFile[rel] || {};
|
|
398
|
+
if (f.skipped) return ` · coverage skipped:${f.skipped}`;
|
|
399
|
+
if (f.coverage !== "partial") return "";
|
|
400
|
+
const r = (f.partial || []).map((g) => `${g[0]}-${g[1]}`).join(",");
|
|
401
|
+
return ` · coverage partial ${r}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const BLOB_LABEL = {
|
|
405
|
+
current: "current",
|
|
406
|
+
changed: "CHANGED since index — hints only (run: orc graph update)",
|
|
407
|
+
deleted: "DELETED since index (run: orc graph update)",
|
|
408
|
+
unknown: "freshness unknown",
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Pick lines by priority until the budget is spent, then print them in display
|
|
412
|
+
// order. The footer is always kept, so the card always says what it hid.
|
|
413
|
+
function fit(items, budget, footer) {
|
|
414
|
+
const reserve = tok(footer(0, {})) + 12;
|
|
415
|
+
let used = 0;
|
|
416
|
+
const keep = new Set();
|
|
417
|
+
const hidden = {};
|
|
418
|
+
const order = items.map((it, i) => i).sort((a, b) => items[a].pri - items[b].pri || a - b);
|
|
419
|
+
for (const i of order) {
|
|
420
|
+
const t = tok(items[i].text) + 1;
|
|
421
|
+
if (items[i].pri === 0 || used + t + reserve <= budget) {
|
|
422
|
+
keep.add(i);
|
|
423
|
+
used += t;
|
|
424
|
+
} else hidden[items[i].section] = (hidden[items[i].section] || 0) + 1;
|
|
425
|
+
}
|
|
426
|
+
const lines = items.filter((_, i) => keep.has(i)).map((it) => it.text);
|
|
427
|
+
const foot = footer(used, hidden);
|
|
428
|
+
lines.push(foot);
|
|
429
|
+
return { card: lines.join("\n"), kept: keep, hidden, used: used + tok(foot) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── E6: the tree format ─────────────────────────────────────────────────────
|
|
433
|
+
// The prose card repeats a label on every row — `function`, `:12-40`,
|
|
434
|
+
// `exported`, `fan-in 3`, `(via X)`. A card is sent again on every later turn of
|
|
435
|
+
// the agent that received it, so a word repeated on forty rows is paid forty
|
|
436
|
+
// times, forty turns over. The tree format names each column ONCE in a header
|
|
437
|
+
// and then prints only values.
|
|
438
|
+
//
|
|
439
|
+
// It is a FORMAT, never a filter: the same rows, the same order, the same JSON.
|
|
440
|
+
// `--format prose` stays the default, because a shorter card that is read wrong
|
|
441
|
+
// costs more than it saves.
|
|
442
|
+
const KIND_SHORT = { function: "fn", method: "me", class: "cl", route: "rt", module: "mo", const: "co", var: "va", type: "ty", interface: "in", enum: "en", struct: "st" };
|
|
443
|
+
const STATE_SHORT = { LOCAL: "L", IMPORT: "I", UNIQUE: "U", AMBIGUOUS: "A", UNRESOLVED: "-" };
|
|
444
|
+
const shortKind = (k) => KIND_SHORT[k] || String(k).slice(0, 2);
|
|
445
|
+
|
|
446
|
+
// The longest directory every path shares. A prefix line is only worth writing
|
|
447
|
+
// when it saves REAL bytes — CBM's rule, and the reason is that a header nobody
|
|
448
|
+
// needs is a header everybody still reads.
|
|
449
|
+
function dirPrefix(paths) {
|
|
450
|
+
if (paths.length < 2) return "";
|
|
451
|
+
const parts = paths.map((p) => p.split("/").slice(0, -1));
|
|
452
|
+
let n = 0;
|
|
453
|
+
outer: for (; n < parts[0].length; n++) {
|
|
454
|
+
for (const q of parts) if (q[n] !== parts[0][n]) break outer;
|
|
455
|
+
}
|
|
456
|
+
if (!n) return "";
|
|
457
|
+
const pre = parts[0].slice(0, n).join("/") + "/";
|
|
458
|
+
const saved = pre.length * paths.length - (pre.length + 6); // " dir " + the prefix
|
|
459
|
+
const total = paths.reduce((a, p) => a + p.length, 0);
|
|
460
|
+
return saved >= 64 && saved >= total * 0.15 ? pre : "";
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function hiddenText(hidden) {
|
|
464
|
+
const parts = Object.entries(hidden).map(([k, v]) => `${v} ${k}`);
|
|
465
|
+
return parts.length ? ` · hidden: ${parts.join(", ")} (raise --budget)` : "";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── ctx ─────────────────────────────────────────────────────────────────────
|
|
469
|
+
function ctx(model, query, opts) {
|
|
470
|
+
const depth = Math.max(1, Math.min(4, Number(opts.depth) || 2));
|
|
471
|
+
const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1200));
|
|
472
|
+
const t = findTarget(model, query);
|
|
473
|
+
if (t.kind === "none") return { ok: false, state: "not-found", query, nearest: t.nearest.map(brief), exit: 4 };
|
|
474
|
+
if (t.kind === "ambiguous") return { ok: false, state: "ambiguous-target", query, candidates: t.candidates.map(brief), exit: 4 };
|
|
475
|
+
if (t.kind === "file") return fileCard(model, t.rel, budget, opts);
|
|
476
|
+
|
|
477
|
+
const sym = t.sym;
|
|
478
|
+
const fresh = blobState(model, sym.file);
|
|
479
|
+
const items = [];
|
|
480
|
+
const push = (pri, section, text, data) => items.push({ pri, section, text, data });
|
|
481
|
+
push(0, "header", `${sym.qname} ${sym.file}:${sym.lines[0]}-${sym.lines[1]} [blob ${String((model.byFile[sym.file] || {}).blob || "").slice(0, 7)} · ${BLOB_LABEL[fresh]}${coverageTag(model, sym.file)}]`);
|
|
482
|
+
// A note is shown ONLY while the body still hashes the same. An old
|
|
483
|
+
// sentence about code that has since changed is never printed.
|
|
484
|
+
const note = N.noteFor(model.notes, sym);
|
|
485
|
+
if (note) push(1, "note", note.current ? ` note ${note.text} (${note.model} · current)` : " note: stale (body changed) — it is re-noted at the next batch", { item: "note", ...note });
|
|
486
|
+
|
|
487
|
+
const callers = callersOf(model, sym);
|
|
488
|
+
callers.confident.sort((a, b) => (a.sym.file + a.line).localeCompare(b.sym.file + b.line));
|
|
489
|
+
for (const c of callers.confident)
|
|
490
|
+
push(2, "callers", ` ← ${c.ref ? "used by " : "called by"} ${c.sym.qname} ${where(c.sym, c.line)} ${c.state}`, { item: "caller", id: c.sym.id, qname: c.sym.qname, file: c.sym.file, line: c.line, state: c.state, ...(c.ref ? { ref: true } : {}) });
|
|
491
|
+
if (callers.maybe) push(4, "callers", ` ← maybe ${callers.maybe} more caller(s) name it among AMBIGUOUS candidates`, { item: "maybe_callers", n: callers.maybe });
|
|
492
|
+
|
|
493
|
+
let unresolved = 0;
|
|
494
|
+
const visited = new Set([sym.id]);
|
|
495
|
+
const walk = (s, level, indent) => {
|
|
496
|
+
for (const { call, res } of calleesOf(model, s)) {
|
|
497
|
+
if (res.state === "UNRESOLVED") {
|
|
498
|
+
if (level === 1) unresolved++;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (res.state === "AMBIGUOUS") {
|
|
502
|
+
if (level > 1) continue;
|
|
503
|
+
const cs = res.candidates.map((id) => model.symbols.get(id)).filter(Boolean);
|
|
504
|
+
const shown = cs.slice(0, 3).map((x) => where(x)).join(", ");
|
|
505
|
+
push(4, "calls", `${indent}→ calls ${call.name} AMBIGUOUS (${cs.length}): ${shown}${cs.length > 3 ? `, +${cs.length - 3}` : ""}`, { item: "call", name: call.name, line: call.line, state: "AMBIGUOUS", candidates: cs.map((x) => x.id) });
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const target = model.symbols.get(res.target);
|
|
509
|
+
if (!target) continue;
|
|
510
|
+
push(level === 1 ? 2 : 3 + level, "calls", `${indent}→ ${call.ref ? "uses " : "calls"} ${target.qname} ${where(target)} ${res.state}`, { item: "call", name: call.name, line: call.line, state: res.state, target: brief(target), level, ...(call.ref ? { ref: true } : {}) });
|
|
511
|
+
if (level < depth && !visited.has(target.id)) {
|
|
512
|
+
visited.add(target.id);
|
|
513
|
+
const cn = N.noteFor(model.notes, target);
|
|
514
|
+
if (cn && cn.current) push(2 + level, "notes", `${indent} └ note ${cn.text}`, { item: "callee_note", of: target.id, text: cn.text });
|
|
515
|
+
for (const e of target.effects || []) push(2 + level, "effects", `${indent} └ effect ${e.type} "${e.text}" ${target.file}:${e.line}`, { item: "effect", of: target.id, ...e });
|
|
516
|
+
walk(target, level + 1, indent + " ");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
walk(sym, 1, " ");
|
|
521
|
+
for (const e of sym.effects || []) push(1, "effects", ` effect ${e.type} "${e.text}" ${sym.file}:${e.line}`, { item: "effect", of: sym.id, ...e });
|
|
522
|
+
|
|
523
|
+
const wiki = opts.wikiDocsFor ? opts.wikiDocsFor([sym.file]) : [];
|
|
524
|
+
if (wiki.length) push(6, "wiki", ` wiki ${wiki.join(", ")}`, { item: "wiki", docs: wiki });
|
|
525
|
+
const tests = [...new Set(callers.confident.filter((c) => TEST_FILE.test(c.sym.file)).map((c) => c.sym.file))];
|
|
526
|
+
if (tests.length) push(5, "tests", ` tests ${tests.join(", ")}`, { item: "tests", files: tests });
|
|
527
|
+
|
|
528
|
+
const footer = (used, hidden) => ` budget ${used}/${budget} tokens${unresolved ? ` · ${unresolved} unresolved call(s) not shown` : ""}${hiddenText(hidden)}`;
|
|
529
|
+
const f = fit(items, budget, footer);
|
|
530
|
+
const kept = items.filter((_, i) => f.kept.has(i)).map((it) => it.data).filter(Boolean);
|
|
531
|
+
return {
|
|
532
|
+
ok: true,
|
|
533
|
+
state: "found",
|
|
534
|
+
target: brief(sym),
|
|
535
|
+
blob: fresh,
|
|
536
|
+
exported: sym.exported,
|
|
537
|
+
note: note || null,
|
|
538
|
+
callers: kept.filter((d) => d.item ==="caller"),
|
|
539
|
+
maybe_callers: callers.maybe,
|
|
540
|
+
calls: kept.filter((d) => d.item ==="call"),
|
|
541
|
+
effects: kept.filter((d) => d.item ==="effect"),
|
|
542
|
+
wiki,
|
|
543
|
+
tests,
|
|
544
|
+
unresolved,
|
|
545
|
+
hidden: f.hidden,
|
|
546
|
+
budget: { used: f.used, max: budget },
|
|
547
|
+
card: f.card,
|
|
548
|
+
exit: 0,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function importersOf(model, rel) {
|
|
553
|
+
const out = [];
|
|
554
|
+
for (const other of model.fileSet) {
|
|
555
|
+
if (other === rel) continue;
|
|
556
|
+
const { map, includes } = fileBindings(model, other);
|
|
557
|
+
let hit = includes.includes(rel);
|
|
558
|
+
if (!hit) for (const bd of map.values()) if (bd.files.includes(rel)) { hit = true; break; }
|
|
559
|
+
if (!hit) for (const imp of (model.byFile[other].imports || [])) if (!(imp.bindings || []).length && importTargets(model, other, model.byFile[other].lang, imp).includes(rel)) { hit = true; break; }
|
|
560
|
+
if (hit) out.push(other);
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function fileCard(model, rel, budget, opts) {
|
|
566
|
+
const f = model.byFile[rel];
|
|
567
|
+
const fresh = blobState(model, rel);
|
|
568
|
+
const items = [];
|
|
569
|
+
const push = (pri, section, text, data) => items.push({ pri, section, text, data });
|
|
570
|
+
const tree = opts.format === "tree";
|
|
571
|
+
const allSyms = (f.symbols || []).filter((s) => s.kind !== "module");
|
|
572
|
+
push(0, "header", `${rel} (${allSyms.length} symbols) [blob ${String(f.blob).slice(0, 7)} · ${BLOB_LABEL[fresh]}${coverageTag(model, rel)}]`);
|
|
573
|
+
// E7: ordered by IMPORTANCE, not by raw fan-in. A private helper called from
|
|
574
|
+
// forty places is not more useful to a reader than the exported entry point
|
|
575
|
+
// called from three, and a card that leads with the helper buries the answer.
|
|
576
|
+
const SIG = require("./graph-signals.js");
|
|
577
|
+
const allRows = allSyms.map((s) => {
|
|
578
|
+
const fanIn = callersOf(model, s).confident.length;
|
|
579
|
+
return { s, fanIn, imp: SIG.importance(model, s, fanIn) };
|
|
580
|
+
});
|
|
581
|
+
allRows.sort((a, b) => b.imp - a.imp || b.fanIn - a.fanIn || a.s.lines[0] - b.s.lines[0]);
|
|
582
|
+
// `--offset` pages the SYMBOL rows. Paging is not truncation: `has_more` says
|
|
583
|
+
// there is a next page, and the budget footer still says what it hid.
|
|
584
|
+
const offset = Math.max(0, Number(opts.offset) || 0);
|
|
585
|
+
const rows = allRows.slice(offset);
|
|
586
|
+
const syms = rows.map((r) => r.s);
|
|
587
|
+
// Both bodies are built, and the SMALLER one is printed. A column header and
|
|
588
|
+
// a directory line are FIXED costs: on a three-row card they cost more than
|
|
589
|
+
// the columns save, which is exactly what the first measurement showed. So
|
|
590
|
+
// `--format tree` means "use the tree WHEN it is smaller", and the answer says
|
|
591
|
+
// which one it used. It can never be worse than the prose card.
|
|
592
|
+
const symRows = rows.map(({ s, fanIn, imp }) => ({
|
|
593
|
+
prose: ` ${s.kind.padEnd(8)} ${s.qname} :${s.lines[0]}-${s.lines[1]}${s.exported ? " exported" : ""} fan-in ${fanIn}`,
|
|
594
|
+
tree: ` ${shortKind(s.kind)} ${s.qname} ${s.lines[0]}-${s.lines[1]} ${s.exported ? "E" : "-"} ${fanIn}`,
|
|
595
|
+
data: { item: "symbol", ...brief(s), fan_in: fanIn, importance: imp },
|
|
596
|
+
}));
|
|
597
|
+
const SYM_HEAD = " kind name lines exp fan-in";
|
|
598
|
+
const useTree =
|
|
599
|
+
tree && symRows.length > 0 &&
|
|
600
|
+
SYM_HEAD.length + symRows.reduce((a, r) => a + r.tree.length, 0) < symRows.reduce((a, r) => a + r.prose.length, 0);
|
|
601
|
+
if (useTree) push(1, "symbols", SYM_HEAD);
|
|
602
|
+
for (const r of symRows) push(2, "symbols", useTree ? r.tree : r.prose, r.data);
|
|
603
|
+
const imports = [...new Set((f.imports || []).flatMap((imp) => importTargets(model, rel, f.lang, imp)))];
|
|
604
|
+
const importers = importersOf(model, rel);
|
|
605
|
+
if (useTree) {
|
|
606
|
+
const pre = dirPrefix([...imports, ...importers]);
|
|
607
|
+
const strip = (a) => a.map((x) => (pre && x.startsWith(pre) ? x.slice(pre.length) : x));
|
|
608
|
+
if (pre) push(3, "imports", ` dir ${pre}`, null);
|
|
609
|
+
if (imports.length) push(3, "imports", ` in ${strip(imports).join(" ")}`, { item: "imports", files: imports });
|
|
610
|
+
if (importers.length) push(3, "importers", ` out ${strip(importers).join(" ")}`, { item: "importers", files: importers });
|
|
611
|
+
} else {
|
|
612
|
+
if (imports.length) push(3, "imports", ` imports ${imports.join(", ")}`, { item: "imports", files: imports });
|
|
613
|
+
if (importers.length) push(3, "importers", ` imported by ${importers.join(", ")}`, { item: "importers", files: importers });
|
|
614
|
+
}
|
|
615
|
+
// E7: the tests that reach this file — by CALL (an edge into one of its
|
|
616
|
+
// symbols from a test file) and by NAME (`x.test.js` beside `x.js`). Both,
|
|
617
|
+
// because a test that imports a module without naming a symbol is still its
|
|
618
|
+
// test, and the graph alone would never find it.
|
|
619
|
+
const fileTests = [
|
|
620
|
+
...new Set([
|
|
621
|
+
...allSyms.flatMap((sym) => callersOf(model, sym).confident.map((c) => c.sym.file)).filter((x) => TEST_FILE.test(x)),
|
|
622
|
+
...importers.filter((x) => TEST_FILE.test(x)),
|
|
623
|
+
...SIG.testsByName(model, rel),
|
|
624
|
+
]),
|
|
625
|
+
].sort();
|
|
626
|
+
if (fileTests.length) push(4, "tests", ` tests ${fileTests.join(useTree ? " " : ", ")}`, { item: "tests", files: fileTests });
|
|
627
|
+
const wiki = opts.wikiDocsFor ? opts.wikiDocsFor([rel]) : [];
|
|
628
|
+
if (wiki.length) push(5, "wiki", ` wiki ${wiki.join(useTree ? " " : ", ")}`, { item: "wiki", docs: wiki });
|
|
629
|
+
const footer = (used, hidden) => ` budget ${used}/${budget} tokens${hiddenText(hidden)}`;
|
|
630
|
+
const fitted = fit(items, budget, footer);
|
|
631
|
+
const kept = items.filter((_, i) => fitted.kept.has(i)).map((it) => it.data).filter(Boolean);
|
|
632
|
+
const keptSymbols = kept.filter((d) => d.item === "symbol");
|
|
633
|
+
return {
|
|
634
|
+
ok: true,
|
|
635
|
+
state: "found",
|
|
636
|
+
format: useTree ? "tree" : "prose",
|
|
637
|
+
offset,
|
|
638
|
+
has_more: offset + keptSymbols.length < allRows.length,
|
|
639
|
+
total_symbols: allRows.length,
|
|
640
|
+
file: rel,
|
|
641
|
+
blob: fresh,
|
|
642
|
+
symbols: kept.filter((d) => d.item ==="symbol"),
|
|
643
|
+
tests: fileTests,
|
|
644
|
+
imports,
|
|
645
|
+
importers,
|
|
646
|
+
wiki,
|
|
647
|
+
hidden: fitted.hidden,
|
|
648
|
+
budget: { used: fitted.used, max: budget },
|
|
649
|
+
card: fitted.card,
|
|
650
|
+
exit: 0,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── impact ──────────────────────────────────────────────────────────────────
|
|
655
|
+
function impact(model, files, opts) {
|
|
656
|
+
const depth = Math.max(1, Math.min(6, Number(opts.depth) || 3));
|
|
657
|
+
const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1500));
|
|
658
|
+
const rels = [...new Set(files.map(norm))].filter((f) => model.fileSet.has(f));
|
|
659
|
+
const missing = files.map(norm).filter((f) => !model.fileSet.has(f));
|
|
660
|
+
if (!rels.length) return { ok: false, state: "not-found", missing, exit: 4 };
|
|
661
|
+
const targets = rels.flatMap((r) => (model.byFile[r].symbols || []).filter((s) => s.kind !== "module"));
|
|
662
|
+
const inTargets = new Set(targets.map((s) => s.id));
|
|
663
|
+
const seen = new Map();
|
|
664
|
+
let frontier = targets;
|
|
665
|
+
let maybe = 0;
|
|
666
|
+
for (let d = 1; d <= depth && frontier.length; d++) {
|
|
667
|
+
const next = [];
|
|
668
|
+
for (const t of frontier) {
|
|
669
|
+
const c = callersOf(model, t);
|
|
670
|
+
if (d === 1) maybe += c.maybe;
|
|
671
|
+
for (const row of c.confident) {
|
|
672
|
+
if (inTargets.has(row.sym.id) || seen.has(row.sym.id)) continue;
|
|
673
|
+
seen.set(row.sym.id, { sym: row.sym, depth: d, line: row.line, state: row.state, via: t.qname });
|
|
674
|
+
if (row.sym.kind !== "module") next.push(row.sym);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
frontier = next;
|
|
678
|
+
}
|
|
679
|
+
const hits = [...seen.values()].sort(
|
|
680
|
+
(a, b) => a.depth - b.depth || a.sym.file.localeCompare(b.sym.file) || a.line - b.line || String(a.sym.id).localeCompare(String(b.sym.id))
|
|
681
|
+
);
|
|
682
|
+
const importerTests = rels.flatMap((r) => importersOf(model, r)).filter((f) => TEST_FILE.test(f));
|
|
683
|
+
const SIG = require("./graph-signals.js");
|
|
684
|
+
const tests = [
|
|
685
|
+
...new Set([
|
|
686
|
+
...hits.filter((h) => TEST_FILE.test(h.sym.file)).map((h) => h.sym.file),
|
|
687
|
+
...importerTests,
|
|
688
|
+
...rels.flatMap((r) => SIG.testsByName(model, r)),
|
|
689
|
+
]),
|
|
690
|
+
].sort();
|
|
691
|
+
const touched = [...new Set([...rels, ...hits.map((h) => h.sym.file)])];
|
|
692
|
+
const wiki = opts.wikiDocsFor ? opts.wikiDocsFor(touched) : [];
|
|
693
|
+
|
|
694
|
+
const tree = opts.format === "tree";
|
|
695
|
+
const offset = Math.max(0, Number(opts.offset) || 0);
|
|
696
|
+
const page = hits.slice(offset);
|
|
697
|
+
const items = [];
|
|
698
|
+
const push = (pri, section, text, data) => items.push({ pri, section, text, data });
|
|
699
|
+
push(0, "header", `impact of ${rels.join(", ")} — ${targets.length} symbols · depth ${depth}`);
|
|
700
|
+
// The same never-worse rule as the file card. The fixed cost here is a column
|
|
701
|
+
// header PLUS a directory line, so a two-row impact card stays prose.
|
|
702
|
+
const pre = dirPrefix(page.map((h) => h.sym.file));
|
|
703
|
+
const CALL_HEAD = " d qname file:line state via";
|
|
704
|
+
const callRows = page.map((h) => {
|
|
705
|
+
const file = pre && h.sym.file.startsWith(pre) ? h.sym.file.slice(pre.length) : h.sym.file;
|
|
706
|
+
return {
|
|
707
|
+
pri: h.depth,
|
|
708
|
+
prose: ` d${h.depth} ${h.sym.qname} ${where(h.sym, h.line)} ${h.state} (via ${h.via})`,
|
|
709
|
+
tree: ` ${h.depth} ${h.sym.qname} ${file}:${h.line || h.sym.lines[0]} ${STATE_SHORT[h.state] || h.state} ${h.via}`,
|
|
710
|
+
data: { item: "caller", depth: h.depth, ...brief(h.sym), line: h.line, state: h.state, via: h.via },
|
|
711
|
+
};
|
|
712
|
+
});
|
|
713
|
+
const fixed = CALL_HEAD.length + (pre ? pre.length + 6 : 0);
|
|
714
|
+
const useTree =
|
|
715
|
+
tree && callRows.length > 0 &&
|
|
716
|
+
fixed + callRows.reduce((a, r) => a + r.tree.length, 0) < callRows.reduce((a, r) => a + r.prose.length, 0);
|
|
717
|
+
if (useTree) {
|
|
718
|
+
if (pre) push(1, "callers", ` dir ${pre}`, null);
|
|
719
|
+
push(1, "callers", CALL_HEAD);
|
|
720
|
+
}
|
|
721
|
+
for (const r of callRows) push(r.pri, "callers", useTree ? r.tree : r.prose, r.data);
|
|
722
|
+
const j = useTree ? " " : ", ";
|
|
723
|
+
if (tests.length) push(1, "tests", ` tests${useTree ? "" : " "} ${tests.join(j)}`, { item: "tests" });
|
|
724
|
+
if (wiki.length) push(2, "wiki", ` wiki${useTree ? "" : " "} ${wiki.join(j)}`, { item: "wiki" });
|
|
725
|
+
if (maybe) push(2, "maybe", useTree ? ` maybe ${maybe} ambiguous, not followed` : ` maybe ${maybe} AMBIGUOUS caller(s) not followed`, { item: "maybe" });
|
|
726
|
+
if (missing.length) push(1, "missing", useTree ? ` absent ${missing.join(" ")}` : ` not in the graph: ${missing.join(", ")}`, { item: "missing" });
|
|
727
|
+
const footer = (used, hidden) => ` budget ${used}/${budget} tokens${hiddenText(hidden)}`;
|
|
728
|
+
const f = fit(items, budget, footer);
|
|
729
|
+
const kept = items.filter((_, i) => f.kept.has(i)).map((it) => it.data).filter(Boolean);
|
|
730
|
+
const keptCallers = kept.filter((d) => d.item === "caller");
|
|
731
|
+
return {
|
|
732
|
+
ok: true,
|
|
733
|
+
state: "found",
|
|
734
|
+
format: useTree ? "tree" : "prose",
|
|
735
|
+
offset,
|
|
736
|
+
has_more: offset + keptCallers.length < hits.length,
|
|
737
|
+
files: rels,
|
|
738
|
+
missing,
|
|
739
|
+
symbols: targets.length,
|
|
740
|
+
callers: kept.filter((d) => d.item ==="caller"),
|
|
741
|
+
total_callers: hits.length,
|
|
742
|
+
maybe_callers: maybe,
|
|
743
|
+
tests,
|
|
744
|
+
wiki,
|
|
745
|
+
hidden: f.hidden,
|
|
746
|
+
budget: { used: f.used, max: budget },
|
|
747
|
+
card: f.card,
|
|
748
|
+
exit: 0,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ── path ────────────────────────────────────────────────────────────────────
|
|
753
|
+
function pathBetween(model, fromQ, toQ) {
|
|
754
|
+
const a = findTarget(model, fromQ);
|
|
755
|
+
const b = findTarget(model, toQ);
|
|
756
|
+
if (a.kind !== "symbol" || b.kind !== "symbol") {
|
|
757
|
+
return { ok: false, state: "not-found", from: a.kind, to: b.kind, candidates: [...(a.candidates || []), ...(b.candidates || [])].map(brief), exit: 4 };
|
|
758
|
+
}
|
|
759
|
+
const prev = new Map([[a.sym.id, null]]);
|
|
760
|
+
let frontier = [a.sym];
|
|
761
|
+
for (let d = 0; d < 8 && frontier.length && !prev.has(b.sym.id); d++) {
|
|
762
|
+
const next = [];
|
|
763
|
+
for (const s of frontier) {
|
|
764
|
+
for (const { call, res } of calleesOf(model, s)) {
|
|
765
|
+
if (!CONFIDENT.has(res.state) || prev.has(res.target)) continue;
|
|
766
|
+
const t = model.symbols.get(res.target);
|
|
767
|
+
if (!t) continue;
|
|
768
|
+
prev.set(t.id, { from: s, line: call.line, state: res.state });
|
|
769
|
+
next.push(t);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
frontier = next;
|
|
773
|
+
}
|
|
774
|
+
if (!prev.has(b.sym.id)) return { ok: true, state: "none", from: brief(a.sym), to: brief(b.sym), exit: 4, card: `no confident call path from ${a.sym.qname} to ${b.sym.qname} (AMBIGUOUS edges are not followed)` };
|
|
775
|
+
const chain = [];
|
|
776
|
+
for (let id = b.sym.id; id; ) {
|
|
777
|
+
const p = prev.get(id);
|
|
778
|
+
chain.unshift({ sym: model.symbols.get(id), step: p });
|
|
779
|
+
id = p ? p.from.id : null;
|
|
780
|
+
}
|
|
781
|
+
const lines = chain.map((c, i) => (i === 0 ? `${c.sym.qname} ${where(c.sym)}` : ` → ${c.sym.qname} ${where(c.sym)} ${c.step.state} (called at ${where(c.step.from, c.step.line)})`));
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
state: "found",
|
|
785
|
+
hops: chain.length - 1,
|
|
786
|
+
chain: chain.map((c) => ({ ...brief(c.sym), state: c.step ? c.step.state : null, called_at: c.step ? where(c.step.from, c.step.line) : null })),
|
|
787
|
+
card: lines.join("\n"),
|
|
788
|
+
exit: 0,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// EW2, for the resolution cache only: which files this one's import specifiers
|
|
793
|
+
// actually reach, and whether any RELATIVE specifier reaches nothing. A file
|
|
794
|
+
// added later can only ever satisfy a relative specifier, so that flag is the
|
|
795
|
+
// complete set of files an addition can move.
|
|
796
|
+
function importsOf(model, rel) {
|
|
797
|
+
const f = model.byFile[rel] || {};
|
|
798
|
+
const files = new Set();
|
|
799
|
+
let unresolvedRelative = false;
|
|
800
|
+
for (const imp of f.imports || []) {
|
|
801
|
+
const t = importTargets(model, rel, f.lang, imp);
|
|
802
|
+
for (const x of t) files.add(x);
|
|
803
|
+
if (!t.length && /^\.{1,2}\//.test(String(imp.from || ""))) unresolvedRelative = true;
|
|
804
|
+
}
|
|
805
|
+
return { files: [...files].sort(), unresolvedRelative };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
module.exports = { loadModel, resolveCall, findTarget, callersOf, ctx, impact, pathBetween, importsOf, TEST_FILE };
|