@azure-id/orc 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +374 -0
- package/README.md +49 -36
- package/bin/build-agents.js +7 -5
- package/bin/cli.js +1006 -235
- package/bin/fix.js +21 -18
- package/bin/graph-extract.js +314 -6
- package/bin/graph-gain.js +83 -2
- package/bin/graph-map.js +23 -1
- package/bin/graph-notes.js +1 -1
- package/bin/graph-query.js +1362 -37
- package/bin/graph-resolve.js +1 -1
- package/bin/graph-shard.js +29 -6
- package/bin/graph-signals.js +5 -3
- package/bin/graph.js +7 -3
- package/bin/habit.js +1 -1
- package/bin/pricing.json +8 -1
- package/bin/trace-write.js +40 -11
- package/bin/verify-contracts.js +169 -60
- package/bin/verify-package.js +3 -3
- package/bin/webui/api.js +8 -0
- package/bin/webui/fixtures/extra.js +9 -9
- package/bin/webui/fixtures/flow.js +1 -1
- package/bin/webui/fixtures/index.js +2 -2
- package/bin/webui/fixtures/knowledge.js +8 -1
- package/bin/webui/fixtures/pact.js +112 -111
- package/bin/webui/fixtures/stats.js +2 -2
- package/bin/webui/i18n/en/knowledge.json +2 -0
- package/bin/webui/i18n/id/knowledge.json +2 -0
- package/bin/webui/js/panels/knowledge.js +17 -0
- package/guides/code-graph-benefit.md +116 -0
- package/guides/configuration.md +150 -0
- package/guides/documents.md +262 -0
- package/guides/extra-models.md +448 -0
- package/guides/habits-and-gotchas.md +152 -0
- package/guides/knowledge-reads.md +190 -0
- package/guides/model-selection.md +186 -0
- package/guides/rules.md +305 -0
- package/guides/status-line.md +344 -0
- package/mock-run/a-normal-day.md +2 -2
- package/mock-run/extra-slots.md +177 -177
- package/mock-run/orc-cli.md +3 -2
- package/mock-run/orc-doc.md +452 -448
- package/mock-run/orc-fast.md +2 -2
- package/mock-run/orc-quick.md +146 -146
- package/mock-run/orc.md +4 -4
- package/package.json +2 -1
- package/templates/agents/MODEL-MAPPING.md +12 -12
- package/templates/agents/{orc-executor-haiku-4-5.md → orc-executor-haiku-5-high.md} +21 -8
- package/templates/agents/orc-executor-opus-4-7-high.md +17 -5
- package/templates/agents/orc-executor-opus-4-7-med.md +17 -5
- package/templates/agents/orc-executor-opus-4-8-high.md +17 -5
- package/templates/agents/orc-executor-opus-5-high.md +17 -5
- package/templates/agents/orc-executor-opus-5-low.md +17 -5
- package/templates/agents/orc-executor-opus-5-med.md +17 -5
- package/templates/agents/orc-executor-sonnet-4-6-high.md +17 -5
- package/templates/agents/orc-executor-sonnet-5-high.md +17 -5
- package/templates/agents/orc-executor-sonnet-5-low.md +17 -5
- package/templates/agents/orc-executor-sonnet-5-med.md +17 -5
- package/templates/agents/{orc-graph-noter-sonnet-4-6-med.md → orc-graph-noter-haiku-5-high.md} +4 -4
- package/templates/agents/orc-planner-mini-opus-5-med.md +3 -1
- package/templates/agents/orc-planner-mini-sonnet-5-high.md +3 -1
- package/templates/agents/orc-planner-opus-5-med.md +3 -1
- package/templates/agents/{orc-recon-sonnet-4-6-med.md → orc-recon-sonnet-5-med.md} +4 -4
- package/templates/agents/{orc-trace-writer-haiku-4-5.md → orc-trace-writer-haiku-5-high.md} +4 -3
- package/templates/commands/orc-doc.md +128 -128
- package/templates/commands/orc-export.md +51 -46
- package/templates/commands/orc-fast.md +10 -10
- package/templates/hooks/README.md +8 -3
- package/templates/hooks/orc-effort-guard.js +105 -27
- package/templates/hooks/orc-graph-hook.js +252 -4
- package/templates/hooks/orc-statusline.js +13 -6
- package/templates/hooks/orc-trace.js +1 -1
- package/templates/skills/_shared/README.md +2 -2
- package/templates/skills/_shared/code-graph.md +185 -251
- package/templates/skills/_shared/extra-dispatch.md +4 -4
- package/templates/skills/_shared/lane-contract.md +7 -3
- package/templates/skills/_shared/opus5-only.md +3 -3
- package/templates/skills/_shared/phases/execution.md +13 -11
- package/templates/skills/_shared/phases/planning.md +27 -9
- package/templates/skills/_shared/phases/preflight.md +3 -3
- package/templates/skills/_shared/phases/review.md +4 -3
- package/templates/skills/_shared/phases/ship.md +7 -10
- package/templates/skills/_shared/phases/summary.md +2 -3
- package/templates/skills/_shared/phases/trace-verbs.md +21 -16
- package/templates/skills/_shared/phases/trace.md +3 -3
- package/templates/skills/_shared/phases/wiki-consult.md +2 -2
- package/templates/skills/_shared/pr-templates.md +108 -106
- package/templates/skills/_shared/read-ladder.md +8 -2
- package/templates/skills/_shared/return-validation.md +10 -6
- package/templates/skills/_shared/review-slice.md +1 -1
- package/templates/skills/_shared/stack-plan.md +135 -135
- package/templates/skills/_shared/wait.md +14 -2
- package/templates/skills/context-combiner/SKILL.md +2 -2
- package/templates/skills/orc/SKILL.md +7 -7
- package/templates/skills/orc/config.md +2 -2
- package/templates/skills/orc/examples/full-run-mock.md +7 -7
- package/templates/skills/orc/references/effort-and-mode.md +222 -222
- package/templates/skills/orc/schemas/planning-output.md +3 -0
- package/templates/skills/orc-aftermath/SKILL.md +15 -15
- package/templates/skills/orc-analyze/SKILL.md +2 -2
- package/templates/skills/orc-analyze-mini/SKILL.md +2 -2
- package/templates/skills/orc-boundary/SKILL.md +15 -15
- package/templates/skills/orc-boundary/references/card.md +81 -78
- package/templates/skills/orc-boundary/references/gate.md +113 -113
- package/templates/skills/orc-brainstorm/SKILL.md +15 -15
- package/templates/skills/orc-budget/SKILL.md +15 -15
- package/templates/skills/orc-challenge/README.md +1 -1
- package/templates/skills/orc-challenge/SKILL.md +16 -16
- package/templates/skills/orc-challenge/examples/council-full-roster.md +1 -1
- package/templates/skills/orc-claude/SKILL.md +4 -4
- package/templates/skills/orc-claude/examples/claude-run-mock.md +1 -1
- package/templates/skills/orc-diy/SKILL.md +2 -2
- package/templates/skills/orc-diy/references/blocks/wiki.md +26 -26
- package/templates/skills/orc-diy/references/flow-schema.md +2 -2
- package/templates/skills/orc-doc/README.md +2 -2
- package/templates/skills/orc-doc/SKILL.md +501 -490
- package/templates/skills/orc-doc/examples/orc-doc-prd-run.md +12 -1
- package/templates/skills/orc-doc/references/chunking.md +6 -4
- package/templates/skills/orc-doc/references/gates.md +311 -311
- package/templates/skills/orc-doc/references/resume-protocol.md +229 -228
- package/templates/skills/orc-explain/SKILL.md +88 -88
- package/templates/skills/orc-export/SKILL.md +190 -187
- package/templates/skills/orc-fast/SKILL.md +17 -14
- package/templates/skills/orc-grill/SKILL.md +16 -16
- package/templates/skills/orc-grill/references/grill-doc.md +1 -1
- package/templates/skills/orc-handoff/SKILL.md +11 -11
- package/templates/skills/orc-learn/SKILL.md +13 -13
- package/templates/skills/orc-learn/examples/learn-run-mock.md +2 -2
- package/templates/skills/orc-mini/SKILL.md +12 -12
- package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -5
- package/templates/skills/orc-pact/SKILL.md +15 -15
- package/templates/skills/orc-pact/references/gate.md +70 -70
- package/templates/skills/orc-pattern/SKILL.md +3 -3
- package/templates/skills/orc-poly/SKILL.md +4 -4
- package/templates/skills/orc-poly/examples/poly-run-mock.md +51 -51
- package/templates/skills/orc-pr-driver/SKILL.md +4 -3
- package/templates/skills/orc-pr-driver/references/orc-run-split.md +108 -99
- package/templates/skills/orc-pr-setup/SKILL.md +2 -2
- package/templates/skills/orc-quick/README.md +11 -12
- package/templates/skills/orc-quick/SKILL.md +29 -32
- package/templates/skills/orc-quick/references/context-doc.md +5 -5
- package/templates/skills/orc-quick/references/dispatch-gate.md +10 -10
- package/templates/skills/orc-quick/references/gh-mode.md +2 -1
- package/templates/skills/orc-quick/references/look.md +34 -12
- package/templates/skills/orc-retro/SKILL.md +2 -2
- package/templates/skills/orc-retro/examples/retro-mock.md +2 -2
- package/templates/skills/orc-route/SKILL.md +2 -2
- package/templates/skills/orc-test/SKILL.md +6 -3
- package/templates/skills/orc-verify/SKILL.md +2 -2
- package/templates/skills/orc-wait/SKILL.md +10 -2
- package/templates/skills/orc-wiki/SKILL.md +4 -4
- package/templates/skills/orc-wiki/references/pattern-prewarm.md +19 -19
- package/templates/agents/orc-executor-sonnet-4-6-med.md +0 -155
package/bin/graph-query.js
CHANGED
|
@@ -133,12 +133,133 @@ function filesInDir(model, dir, ext) {
|
|
|
133
133
|
return (model.dirs.get(dir) || []).filter((f) => f.endsWith(ext));
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// v2.2.0 (D1): the `@/` alias. A SPA whose code lives under `client/src/`
|
|
137
|
+
// is not served by "`@/` means `src/`". The aliases a project DECLARES win —
|
|
138
|
+
// jsconfig/tsconfig `paths`, a bundler `alias`, a jest `moduleNameMapper` —
|
|
139
|
+
// read from disk once per directory per process (never evaluated). Then the
|
|
140
|
+
// nearest package `src/`, then the old root `src/`.
|
|
141
|
+
const ALIAS_JSON = ["jsconfig.json", "tsconfig.json"];
|
|
142
|
+
const ALIAS_BUNDLER = ["vue.config.js", "webpack.config.js", "vite.config.js", "vite.config.ts", "vite.config.mjs"];
|
|
143
|
+
const ALIAS_RX_RESOLVE = /['"]?(@[\w-]*|~)['"]?\s*:\s*(?:path\.)?(?:resolve|join)\(\s*__dirname\s*,\s*['"]([^'"]+)['"]\s*\)/g;
|
|
144
|
+
const ALIAS_RX_URL = /['"]?(@[\w-]*|~)['"]?\s*:\s*fileURLToPath\(\s*new\s+URL\(\s*['"]([^'"]+)['"]/g;
|
|
145
|
+
const ALIAS_RX_JEST = /['"]\^(@[\w-]*|~)\/\(\.\*\)\$['"]\s*:\s*['"]<rootDir>\/([^'"]*?)\/?\$1['"]/g;
|
|
146
|
+
|
|
147
|
+
const cleanRel = (p) => {
|
|
148
|
+
const n = posix.normalize(p).replace(/^\.\//, "");
|
|
149
|
+
return n === "." ? "" : n;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
function readText(file) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.readFileSync(file, "utf8");
|
|
155
|
+
} catch (_) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stripJsonc(t) {
|
|
161
|
+
return t
|
|
162
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
163
|
+
.replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
|
|
164
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// One table per directory: [{ prefix, target, exact }]. Built once per
|
|
168
|
+
// directory per process; a cache hit makes no fs call.
|
|
169
|
+
function aliasTable(model, dir) {
|
|
170
|
+
if (!model.aliasTables) model.aliasTables = new Map();
|
|
171
|
+
if (model.aliasTables.has(dir)) return model.aliasTables.get(dir);
|
|
172
|
+
const out = [];
|
|
173
|
+
const at = (name) => path.join(model.root || "", dir, name);
|
|
174
|
+
for (const name of ALIAS_JSON) {
|
|
175
|
+
const t = readText(at(name));
|
|
176
|
+
if (t === null) continue;
|
|
177
|
+
let j = null;
|
|
178
|
+
try {
|
|
179
|
+
j = JSON.parse(stripJsonc(t));
|
|
180
|
+
} catch (_) {
|
|
181
|
+
j = null;
|
|
182
|
+
}
|
|
183
|
+
const co = (j && j.compilerOptions) || {};
|
|
184
|
+
const baseUrl = typeof co.baseUrl === "string" ? co.baseUrl : ".";
|
|
185
|
+
for (const [k, v] of Object.entries(co.paths || {})) {
|
|
186
|
+
const first = Array.isArray(v) ? v[0] : v;
|
|
187
|
+
if (typeof first !== "string") continue;
|
|
188
|
+
const target = cleanRel(posix.join(dir, baseUrl, first.replace(/\*$/, "")));
|
|
189
|
+
if (k.endsWith("*")) out.push({ prefix: k.slice(0, -1), target, exact: false });
|
|
190
|
+
else out.push({ prefix: k, target, exact: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const scan = (t, rx) => {
|
|
194
|
+
rx.lastIndex = 0;
|
|
195
|
+
let m;
|
|
196
|
+
while ((m = rx.exec(t))) out.push({ prefix: m[1] + "/", target: cleanRel(posix.join(dir, m[2])), exact: false });
|
|
197
|
+
};
|
|
198
|
+
for (const name of ALIAS_BUNDLER) {
|
|
199
|
+
const t = readText(at(name));
|
|
200
|
+
if (t === null) continue;
|
|
201
|
+
scan(t, ALIAS_RX_RESOLVE);
|
|
202
|
+
scan(t, ALIAS_RX_URL);
|
|
203
|
+
}
|
|
204
|
+
const jt = readText(at("jest.config.js"));
|
|
205
|
+
if (jt !== null) scan(jt, ALIAS_RX_JEST);
|
|
206
|
+
model.aliasTables.set(dir, out);
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Nearest ancestor first, the repository root ("") last.
|
|
211
|
+
function ancestorDirs(from) {
|
|
212
|
+
const out = [];
|
|
213
|
+
let d = posix.dirname(from);
|
|
214
|
+
for (;;) {
|
|
215
|
+
if (d === "." || d === "/" || d === "") {
|
|
216
|
+
out.push("");
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
out.push(d);
|
|
220
|
+
d = posix.dirname(d);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Every directory D for which the index holds a file under `D/src/`.
|
|
226
|
+
function srcRoots(model) {
|
|
227
|
+
if (model.srcRoots) return model.srcRoots;
|
|
228
|
+
const out = new Set();
|
|
229
|
+
for (const f of model.fileSet) {
|
|
230
|
+
const parts = f.split("/");
|
|
231
|
+
for (let i = 0; i < parts.length - 1; i++) if (parts[i] === "src") out.add(parts.slice(0, i).join("/"));
|
|
232
|
+
}
|
|
233
|
+
model.srcRoots = out;
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function aliasBase(model, from, spec) {
|
|
238
|
+
const dirs = ancestorDirs(from);
|
|
239
|
+
for (const d of dirs) {
|
|
240
|
+
let best = null;
|
|
241
|
+
for (const e of aliasTable(model, d)) {
|
|
242
|
+
const ok = e.exact ? spec === e.prefix : spec.startsWith(e.prefix);
|
|
243
|
+
if (ok && (!best || e.prefix.length > best.prefix.length)) best = e;
|
|
244
|
+
}
|
|
245
|
+
if (best) return cleanRel(best.exact ? best.target : posix.join(best.target, spec.slice(best.prefix.length)));
|
|
246
|
+
}
|
|
247
|
+
if (/^[@~]\//.test(spec)) {
|
|
248
|
+
const roots = srcRoots(model);
|
|
249
|
+
for (const d of dirs) if (roots.has(d)) return cleanRel(posix.join(d, "src", spec.slice(2)));
|
|
250
|
+
return "src/" + spec.slice(2);
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
136
255
|
function resolveJs(model, from, spec) {
|
|
137
256
|
let base;
|
|
138
257
|
if (spec.startsWith(".")) base = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
139
|
-
else if (/^[@~]\//.test(spec)) base = "src/" + spec.slice(2);
|
|
140
258
|
else if (spec.startsWith("/")) base = spec.slice(1);
|
|
141
|
-
else
|
|
259
|
+
else {
|
|
260
|
+
base = aliasBase(model, from, spec);
|
|
261
|
+
if (base === null) return [];
|
|
262
|
+
}
|
|
142
263
|
const cands = [base, ...JS_EXT.map((e) => base + e), ...JS_EXT.map((e) => `${base}/index${e}`)];
|
|
143
264
|
// TS ESM writes `./x.js` for a file that is `x.ts` on disk.
|
|
144
265
|
if (/\.(js|jsx|mjs|cjs)$/.test(base)) {
|
|
@@ -378,9 +499,66 @@ function inheritedMember(model, rel, className, member, depth, seen) {
|
|
|
378
499
|
return null;
|
|
379
500
|
}
|
|
380
501
|
|
|
502
|
+
// v2.2.0: SPA route navigation. `{ name: "<route name>", route: true }`, or
|
|
503
|
+
// `{ name: "IDENT.MEMBER", route: true, via: true }` where IDENT is a const
|
|
504
|
+
// object (this file's own, or imported) whose `values[MEMBER]` holds the
|
|
505
|
+
// route name. Never subject to the `call.ref` restriction.
|
|
506
|
+
function routeNameIndex(model) {
|
|
507
|
+
if (model.routeNames) return model.routeNames;
|
|
508
|
+
const idx = new Map();
|
|
509
|
+
for (const s of model.symbols.values()) {
|
|
510
|
+
if (s.kind !== "route" || typeof s.route_name !== "string") continue;
|
|
511
|
+
if (!idx.has(s.route_name)) idx.set(s.route_name, []);
|
|
512
|
+
idx.get(s.route_name).push(s);
|
|
513
|
+
}
|
|
514
|
+
model.routeNames = idx;
|
|
515
|
+
return idx;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function resolveRouteCall(model, rel, call) {
|
|
519
|
+
// The sharded fast model holds no symbol table to search for route names,
|
|
520
|
+
// so it DECLINES and the full model answers.
|
|
521
|
+
if (model.fast) {
|
|
522
|
+
const S = require("./graph-shard.js");
|
|
523
|
+
throw new S.FastUnavailable("route-call");
|
|
524
|
+
}
|
|
525
|
+
let rname = call.name;
|
|
526
|
+
if (call.via) {
|
|
527
|
+
const parts = String(call.name).split(".");
|
|
528
|
+
const id = parts[0];
|
|
529
|
+
const member = parts.slice(1).join(".");
|
|
530
|
+
const f = model.byFile[rel] || {};
|
|
531
|
+
let s = (f.symbols || []).find((x) => x.kind !== "module" && x.qname === id) || null;
|
|
532
|
+
if (!s) {
|
|
533
|
+
const bd = fileBindings(model, rel).map.get(id);
|
|
534
|
+
if (bd && bd.files.length) {
|
|
535
|
+
const wanted = bd.kind === "named" ? bd.orig : bd.local;
|
|
536
|
+
for (const pair of barrelTargets(model, bd.files, wanted, 0, new Set())) {
|
|
537
|
+
const tsyms = ((model.byFile[pair.file] || {}).symbols || []).filter((x) => x.kind !== "module");
|
|
538
|
+
s = tsyms.find((x) => x.qname === pair.name) || null;
|
|
539
|
+
if (s) break;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const values = s && s.values;
|
|
544
|
+
const v = values && Object.prototype.hasOwnProperty.call(values, member) ? values[member] : undefined;
|
|
545
|
+
if (typeof v !== "string") return { state: "UNRESOLVED" };
|
|
546
|
+
rname = v;
|
|
547
|
+
}
|
|
548
|
+
const hits = routeNameIndex(model).get(rname) || [];
|
|
549
|
+
if (hits.length === 1) return { state: "ROUTE", target: hits[0].id };
|
|
550
|
+
if (hits.length > 1) return { state: "AMBIGUOUS", candidates: hits.map((s) => s.id) };
|
|
551
|
+
return { state: "UNRESOLVED", missing_route: rname };
|
|
552
|
+
}
|
|
553
|
+
|
|
381
554
|
function resolveCall(model, rel, caller, call) {
|
|
382
555
|
const cls = caller ? classOf(caller) : null;
|
|
383
|
-
const key = `${rel}\0${cls || ""}\0${call.name}\0${call.self ? 1 : 0}\0${call.ref ? 1 : 0}\0${call.sup ? 1 : 0}\0${call.alias ? 1 : 0}`;
|
|
556
|
+
const key = `${rel}\0${cls || ""}\0${call.name}\0${call.self ? 1 : 0}\0${call.ref ? 1 : 0}\0${call.sup ? 1 : 0}\0${call.alias ? 1 : 0}\0${call.route ? (call.via ? 2 : 1) : 0}`;
|
|
557
|
+
if (call.route) {
|
|
558
|
+
const rr = resolveRouteCall(model, rel, call);
|
|
559
|
+
model.resolved.set(key, rr);
|
|
560
|
+
return rr;
|
|
561
|
+
}
|
|
384
562
|
if (model.resolved.has(key)) return model.resolved.get(key);
|
|
385
563
|
const parts = call.name.split(".");
|
|
386
564
|
const last = parts[parts.length - 1];
|
|
@@ -486,7 +664,10 @@ function resolveCall(model, rel, caller, call) {
|
|
|
486
664
|
// W1: a bare `get(…)` is never a class METHOD — a method is reached through
|
|
487
665
|
// a receiver, `this`, or inheritance. Before this, `request(app).get("/p")`
|
|
488
666
|
// resolved UNIQUE to the only method named `get` in the repository.
|
|
489
|
-
|
|
667
|
+
// v2.2.0 (D5): a source file never resolves into a test helper. A test
|
|
668
|
+
// caller keeps every candidate.
|
|
669
|
+
const named = model.byName.get(last) || [];
|
|
670
|
+
const all = TEST_FILE.test(rel) ? named : named.filter((s) => !TEST_FILE.test(s.file));
|
|
490
671
|
const cands = head || call.self ? all : all.filter((s) => !classOf(s));
|
|
491
672
|
if (head) {
|
|
492
673
|
const hl = head.toLowerCase();
|
|
@@ -766,6 +947,9 @@ function calleesOf(model, sym) {
|
|
|
766
947
|
if (seen.has(c.name)) continue;
|
|
767
948
|
seen.add(c.name);
|
|
768
949
|
if (stored) {
|
|
950
|
+
// v2.2.0: a route call is answered by the full model only (its
|
|
951
|
+
// UNRESOLVED answer names the missing route, which no shard stores).
|
|
952
|
+
if (c.route) throw new (require("./graph-shard.js").FastUnavailable)("route-call");
|
|
769
953
|
const row = stored.get(i);
|
|
770
954
|
const S = require("./graph-shard.js");
|
|
771
955
|
out.push({
|
|
@@ -816,6 +1000,14 @@ function findTarget(model, q) {
|
|
|
816
1000
|
const named = model.byName.get(s) || [];
|
|
817
1001
|
if (named.length === 1) return { kind: "symbol", sym: named[0] };
|
|
818
1002
|
if (named.length > 1) return { kind: "ambiguous", candidates: named };
|
|
1003
|
+
// v2.2.0 (T6): lanes type SHORT file names — `OutwardRemittance.vue`, or a
|
|
1004
|
+
// path suffix. The ONE indexed file whose path ends with `/<it>` is the
|
|
1005
|
+
// target; several is an answer that lists them.
|
|
1006
|
+
if (/\.[A-Za-z0-9]+$/.test(s) || s.includes("/")) {
|
|
1007
|
+
const suf = bySuffix(model, s);
|
|
1008
|
+
if (suf.length === 1) return { kind: "file", rel: suf[0] };
|
|
1009
|
+
if (suf.length > 1) return { kind: "ambiguous-file", files: suf.slice().sort() };
|
|
1010
|
+
}
|
|
819
1011
|
const ql = s.toLowerCase();
|
|
820
1012
|
const nearest = [];
|
|
821
1013
|
for (const [n, arr] of model.byName) if (n.toLowerCase().includes(ql)) nearest.push(...arr);
|
|
@@ -970,7 +1162,7 @@ function fit(items, budget, footer, tail, blocks) {
|
|
|
970
1162
|
// It is a FORMAT, never a filter: the same rows, the same order, the same JSON.
|
|
971
1163
|
// `--format prose` stays the default, because a shorter card that is read wrong
|
|
972
1164
|
// costs more than it saves.
|
|
973
|
-
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" };
|
|
1165
|
+
const KIND_SHORT = { prop: "p", emit: "e", inject: "i", template: "t", function: "fn", method: "me", class: "cl", route: "rt", module: "mo", const: "co", var: "va", type: "ty", interface: "in", enum: "en", struct: "st" };
|
|
974
1166
|
const STATE_SHORT = { LOCAL: "L", IMPORT: "I", UNIQUE: "U", ROUTE: "R", AMBIGUOUS: "A", UNRESOLVED: "-" };
|
|
975
1167
|
const shortKind = (k) => KIND_SHORT[k] || String(k).slice(0, 2);
|
|
976
1168
|
|
|
@@ -1188,6 +1380,7 @@ function ctx(model, query, opts) {
|
|
|
1188
1380
|
const t = findTarget(model, query);
|
|
1189
1381
|
if (t.kind === "none") return { ok: false, state: "not-found", query, nearest: t.nearest.map(brief), exit: 4 };
|
|
1190
1382
|
if (t.kind === "ambiguous") return { ok: false, state: "ambiguous-target", query, candidates: t.candidates.map(brief), exit: 4 };
|
|
1383
|
+
if (t.kind === "ambiguous-file") return { ok: false, state: "ambiguous-target", query, candidates: t.files.map((file) => ({ file })), exit: 4 };
|
|
1191
1384
|
if (t.kind === "file") return fileCard(model, t.rel, budget, opts);
|
|
1192
1385
|
|
|
1193
1386
|
const sym = t.sym;
|
|
@@ -1241,6 +1434,13 @@ function ctx(model, query, opts) {
|
|
|
1241
1434
|
const walk = (s, level, indent) => {
|
|
1242
1435
|
for (const { call, res } of calleesOf(model, s)) {
|
|
1243
1436
|
if (res.state === "UNRESOLVED") {
|
|
1437
|
+
// v2.2.0: a route call whose name no route record carries is SAID,
|
|
1438
|
+
// one line, no guess — it is shown, so it is not counted as hidden.
|
|
1439
|
+
if (level === 1 && call.route && res.missing_route) {
|
|
1440
|
+
const miss = `${indent}→ route ${res.missing_route} NO ROUTE — no record has this name`;
|
|
1441
|
+
push(2, "calls", miss, { item: "call", name: call.name, line: call.line, state: "UNRESOLVED", missing_route: res.missing_route, level }, miss);
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1244
1444
|
if (level === 1) unresolved++;
|
|
1245
1445
|
continue;
|
|
1246
1446
|
}
|
|
@@ -1255,7 +1455,10 @@ function ctx(model, query, opts) {
|
|
|
1255
1455
|
const target = model.symbols.get(res.target);
|
|
1256
1456
|
if (!target) continue;
|
|
1257
1457
|
const mark = res.inherited ? " (inherited)" : call.alias ? ` (via ${call.alias})` : "";
|
|
1258
|
-
const row = (short) =>
|
|
1458
|
+
const row = (short) =>
|
|
1459
|
+
call.route && res.state === "ROUTE"
|
|
1460
|
+
? `${indent}→ route ${call.name} ${target.qname} ${where(target)} ${stateWord(res.state, short)}`
|
|
1461
|
+
: `${indent}→ ${call.ref ? "uses " : "calls"} ${target.qname} ${where(target)} ${stateWord(res.state, short)}${mark}`;
|
|
1259
1462
|
push(level === 1 ? 2 : 3 + level, "calls", row(false), {
|
|
1260
1463
|
item: "call",
|
|
1261
1464
|
name: call.name,
|
|
@@ -1283,6 +1486,39 @@ function ctx(model, query, opts) {
|
|
|
1283
1486
|
if (wiki.length) push(6, "wiki", ` wiki ${wiki.join(", ")}`, { item: "wiki", docs: wiki });
|
|
1284
1487
|
const tests = [...new Set(callers.confident.filter((c) => TEST_FILE.test(c.sym.file)).map((c) => c.sym.file))];
|
|
1285
1488
|
if (tests.length) push(5, "tests", ` tests ${tests.join(", ")}`, { item: "tests", files: tests });
|
|
1489
|
+
// R11 (v2.2.0): a target no test calls DIRECTLY is often reached by a spec
|
|
1490
|
+
// THROUGH one of its callers. One hop, confident callers only, test files
|
|
1491
|
+
// only, max 6 — so the reader does not grep for the tests.
|
|
1492
|
+
const testsVia = [];
|
|
1493
|
+
const plainCallers = callers.confident.filter((c) => !TEST_FILE.test(c.sym.file));
|
|
1494
|
+
if (!tests.length && plainCallers.length) {
|
|
1495
|
+
// R11b: the SAME three rules the file card uses for its tests — a test
|
|
1496
|
+
// that CALLS a symbol of the caller's file, IMPORTS that file, or is
|
|
1497
|
+
// NAMED after it. Ranked call > import > name. Only TEST files are asked
|
|
1498
|
+
// who they import, so the fast model loads test blobs and nothing else.
|
|
1499
|
+
const SIG = require("./graph-signals.js");
|
|
1500
|
+
const seenVia = new Set();
|
|
1501
|
+
const add = (file, via) => {
|
|
1502
|
+
if (testsVia.length >= 6 || seenVia.has(file) || !TEST_FILE.test(file)) return;
|
|
1503
|
+
seenVia.add(file);
|
|
1504
|
+
testsVia.push({ file, via });
|
|
1505
|
+
};
|
|
1506
|
+
const callerFiles = [...new Set(plainCallers.map((c) => c.sym.file))];
|
|
1507
|
+
for (const c of plainCallers) for (const cc of callersOf(model, c.sym).confident) add(cc.sym.file, c.sym.qname);
|
|
1508
|
+
for (const rel of callerFiles)
|
|
1509
|
+
for (const s of ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind !== "module"))
|
|
1510
|
+
for (const cc of callersOf(model, s).confident) add(cc.sym.file, s.qname);
|
|
1511
|
+
for (const rel of callerFiles) for (const f of importersOf(model, rel, true).sort()) add(f, rel);
|
|
1512
|
+
for (const rel of callerFiles) for (const f of SIG.testsByName(model, rel)) add(f, rel);
|
|
1513
|
+
if (testsVia.length) push(5, "tests", ` tests (via callers) ${testsVia.map((x) => `${x.file} ← ${x.via}`).join(", ")}`, { item: "tests_via", rows: testsVia });
|
|
1514
|
+
for (const x of testsVia) if (!tests.includes(x.file)) tests.push(x.file);
|
|
1515
|
+
}
|
|
1516
|
+
// R11: the parse-coverage verdict of the target file + every caller file —
|
|
1517
|
+
// the same per-path reading `orc graph coverage` prints (G.coverageOf).
|
|
1518
|
+
const covFiles = [...new Set([sym.file, ...callers.confident.map((c) => c.sym.file)])];
|
|
1519
|
+
const covPartial = covFiles.filter((rel) => G.coverageOf(model.byFile[rel]).coverage !== "full");
|
|
1520
|
+
const coverage = { verdict: covPartial.length ? "partial" : "full", files: covFiles.length, gaps: covPartial };
|
|
1521
|
+
push(5, "coverage", ` coverage ${coverage.verdict} · ${covFiles.length} files${covPartial.length ? ` · ${covPartial.join(", ")}` : ""}`, { item: "coverage" });
|
|
1286
1522
|
|
|
1287
1523
|
const short = pickShort(items);
|
|
1288
1524
|
// The footer is printed ONLY when the card hid something, dropped an
|
|
@@ -1316,6 +1552,8 @@ function ctx(model, query, opts) {
|
|
|
1316
1552
|
effects: kept.filter((d) => d.item ==="effect"),
|
|
1317
1553
|
wiki,
|
|
1318
1554
|
tests,
|
|
1555
|
+
tests_via: testsVia,
|
|
1556
|
+
coverage,
|
|
1319
1557
|
unresolved,
|
|
1320
1558
|
source: tail && f.source && f.source.kept ? { file: tail.file, from: tail.from, to: tail.from + f.source.kept - 1, text: sourceText(tail, f.source.kept), cut: f.source.cut } : null,
|
|
1321
1559
|
...callersSource,
|
|
@@ -1326,10 +1564,11 @@ function ctx(model, query, opts) {
|
|
|
1326
1564
|
};
|
|
1327
1565
|
}
|
|
1328
1566
|
|
|
1329
|
-
function importersOf(model, rel) {
|
|
1567
|
+
function importersOf(model, rel, testsOnly) {
|
|
1330
1568
|
const out = [];
|
|
1331
1569
|
for (const other of model.fileSet) {
|
|
1332
1570
|
if (other === rel) continue;
|
|
1571
|
+
if (testsOnly && !TEST_FILE.test(other)) continue;
|
|
1333
1572
|
const { map, includes } = fileBindings(model, other);
|
|
1334
1573
|
let hit = includes.includes(rel);
|
|
1335
1574
|
if (!hit) for (const bd of map.values()) if (bd.files.includes(rel)) { hit = true; break; }
|
|
@@ -1355,7 +1594,17 @@ function fileCard(model, rel, budget, opts) {
|
|
|
1355
1594
|
const fanIn = callersOf(model, s).confident.length;
|
|
1356
1595
|
return { s, fanIn, imp: SIG.importance(model, s, fanIn) };
|
|
1357
1596
|
});
|
|
1358
|
-
|
|
1597
|
+
// v2.2.0: a component's interface leads — props, emits and injects in
|
|
1598
|
+
// source order, then its template, then the rest by importance.
|
|
1599
|
+
const GROUP = { prop: 0, emit: 0, inject: 0, template: 1 };
|
|
1600
|
+
const grp = (s) => (GROUP[s.kind] === undefined ? 2 : GROUP[s.kind]);
|
|
1601
|
+
allRows.sort((a, b) => {
|
|
1602
|
+
const ga = grp(a.s);
|
|
1603
|
+
const gb = grp(b.s);
|
|
1604
|
+
if (ga !== gb) return ga - gb;
|
|
1605
|
+
if (ga < 2) return a.s.lines[0] - b.s.lines[0];
|
|
1606
|
+
return b.imp - a.imp || b.fanIn - a.fanIn || a.s.lines[0] - b.s.lines[0];
|
|
1607
|
+
});
|
|
1359
1608
|
// `--offset` pages the SYMBOL rows. Paging is not truncation: `has_more` says
|
|
1360
1609
|
// there is a next page, and the budget footer still says what it hid.
|
|
1361
1610
|
const offset = Math.max(0, Number(opts.offset) || 0);
|
|
@@ -1507,7 +1756,8 @@ function sliceBlock(model, rel, budget, opts) {
|
|
|
1507
1756
|
const importers = importersOf(model, rel);
|
|
1508
1757
|
// Only the importers that call NOTHING — the ones the caller rows already
|
|
1509
1758
|
// name are the same fact printed twice.
|
|
1510
|
-
|
|
1759
|
+
// T6c: a test file is never on the `imp` line — it stays on `tst`.
|
|
1760
|
+
const quiet = importers.filter((x) => !byFile.has(x) && !TEST_FILE.test(x));
|
|
1511
1761
|
if (quiet.length) {
|
|
1512
1762
|
const pre = dirPrefix(quiet);
|
|
1513
1763
|
if (pre) push(1, "importers", ` dir ${pre}`, null);
|
|
@@ -1523,7 +1773,8 @@ function sliceBlock(model, rel, budget, opts) {
|
|
|
1523
1773
|
.filter((r) => r.sym.file === rel)
|
|
1524
1774
|
.map((r) => `${r.method} ${r.full || r.own}${r.mounted === "known" || r.mounted === "none" ? "" : " (mount ambiguous)"}`);
|
|
1525
1775
|
const unreached = routes.filter((x) => !reached.has(x));
|
|
1526
|
-
|
|
1776
|
+
// T6c: at most 8 routes on the line, then `+<n>` — a router answers 85.
|
|
1777
|
+
if (unreached.length) push(1, "routes", ` rt ${unreached.slice(0, 8).join(" ")}${unreached.length > 8 ? ` +${unreached.length - 8}` : ""} (no caller in the graph)`, { item: "routes", routes: unreached });
|
|
1527
1778
|
|
|
1528
1779
|
const shownFiles = new Set(rows.map((r) => r.file));
|
|
1529
1780
|
const allTests = [
|
|
@@ -1624,7 +1875,10 @@ function impact(model, files, opts) {
|
|
|
1624
1875
|
|
|
1625
1876
|
const tree = opts.format === "tree";
|
|
1626
1877
|
const offset = Math.max(0, Number(opts.offset) || 0);
|
|
1627
|
-
|
|
1878
|
+
// T6b: a caller in a TEST file is not a caller row — the budget shows the
|
|
1879
|
+
// CODE that depends on the change, and the tests get one line of their own.
|
|
1880
|
+
const codeHits = hits.filter((h) => !TEST_FILE.test(h.sym.file));
|
|
1881
|
+
const page = codeHits.slice(offset);
|
|
1628
1882
|
const items = [];
|
|
1629
1883
|
const push = (pri, section, text, data) => items.push({ pri, section, text, data });
|
|
1630
1884
|
push(0, "header", `impact of ${rels.join(", ")} — ${targets.length} symbols · depth ${depth}`);
|
|
@@ -1651,7 +1905,7 @@ function impact(model, files, opts) {
|
|
|
1651
1905
|
}
|
|
1652
1906
|
for (const r of callRows) push(r.pri, "callers", useTree ? r.tree : r.prose, r.data);
|
|
1653
1907
|
const j = useTree ? " " : ", ";
|
|
1654
|
-
if (tests.length) push(1, "tests", ` tests${
|
|
1908
|
+
if (tests.length) push(1, "tests", ` tests ${tests.length} files: ${tests.slice(0, 6).join(", ")}${tests.length > 6 ? `, +${tests.length - 6}` : ""}`, { item: "tests" });
|
|
1655
1909
|
if (wiki.length) push(2, "wiki", ` wiki${useTree ? "" : " "} ${wiki.join(j)}`, { item: "wiki" });
|
|
1656
1910
|
if (maybe) push(2, "maybe", useTree ? ` maybe ${maybe} ambiguous, not followed` : ` maybe ${maybe} AMBIGUOUS caller(s) not followed`, { item: "maybe" });
|
|
1657
1911
|
if (missing.length) push(1, "missing", useTree ? ` absent ${missing.join(" ")}` : ` not in the graph: ${missing.join(", ")}`, { item: "missing" });
|
|
@@ -1664,12 +1918,12 @@ function impact(model, files, opts) {
|
|
|
1664
1918
|
state: "found",
|
|
1665
1919
|
format: useTree ? "tree" : "prose",
|
|
1666
1920
|
offset,
|
|
1667
|
-
has_more: offset + keptCallers.length <
|
|
1921
|
+
has_more: offset + keptCallers.length < codeHits.length,
|
|
1668
1922
|
files: rels,
|
|
1669
1923
|
missing,
|
|
1670
1924
|
symbols: targets.length,
|
|
1671
1925
|
callers: kept.filter((d) => d.item ==="caller"),
|
|
1672
|
-
total_callers:
|
|
1926
|
+
total_callers: codeHits.length,
|
|
1673
1927
|
maybe_callers: maybe,
|
|
1674
1928
|
tests,
|
|
1675
1929
|
wiki,
|
|
@@ -1681,15 +1935,31 @@ function impact(model, files, opts) {
|
|
|
1681
1935
|
}
|
|
1682
1936
|
|
|
1683
1937
|
// ── path ────────────────────────────────────────────────────────────────────
|
|
1938
|
+
// T6b: a FILE as an end means ANY symbol in that file — the shortest
|
|
1939
|
+
// confident path to (or from) any of them.
|
|
1940
|
+
function pathEnd(model, q) {
|
|
1941
|
+
const t = findTarget(model, q);
|
|
1942
|
+
if (t.kind === "symbol") return { t, syms: [t.sym], label: t.sym.qname };
|
|
1943
|
+
if (t.kind === "file") {
|
|
1944
|
+
const syms = ((model.byFile[t.rel] || {}).symbols || []).filter((s) => s.kind !== "module");
|
|
1945
|
+
if (syms.length) return { t, syms, label: t.rel, file: t.rel };
|
|
1946
|
+
}
|
|
1947
|
+
return { t, syms: null };
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1684
1950
|
function pathBetween(model, fromQ, toQ) {
|
|
1685
|
-
const
|
|
1686
|
-
const
|
|
1687
|
-
if (
|
|
1951
|
+
const A = pathEnd(model, fromQ);
|
|
1952
|
+
const B = pathEnd(model, toQ);
|
|
1953
|
+
if (!A.syms || !B.syms) {
|
|
1954
|
+
const a = A.t;
|
|
1955
|
+
const b = B.t;
|
|
1688
1956
|
return { ok: false, state: "not-found", from: a.kind, to: b.kind, candidates: [...(a.candidates || []), ...(b.candidates || [])].map(brief), exit: 4 };
|
|
1689
1957
|
}
|
|
1690
|
-
const
|
|
1691
|
-
|
|
1692
|
-
|
|
1958
|
+
const goals = new Set(B.syms.map((x) => x.id));
|
|
1959
|
+
const prev = new Map(A.syms.map((x) => [x.id, null]));
|
|
1960
|
+
let frontier = A.syms.slice();
|
|
1961
|
+
let reached = (A.syms.find((x) => goals.has(x.id)) || {}).id || null;
|
|
1962
|
+
for (let d = 0; d < 8 && frontier.length && !reached; d++) {
|
|
1693
1963
|
const next = [];
|
|
1694
1964
|
for (const s of frontier) {
|
|
1695
1965
|
for (const { call, res } of calleesOf(model, s)) {
|
|
@@ -1698,11 +1968,16 @@ function pathBetween(model, fromQ, toQ) {
|
|
|
1698
1968
|
if (!t) continue;
|
|
1699
1969
|
prev.set(t.id, { from: s, line: call.line, state: res.state });
|
|
1700
1970
|
next.push(t);
|
|
1971
|
+
if (!reached && goals.has(t.id)) reached = t.id;
|
|
1701
1972
|
}
|
|
1702
1973
|
}
|
|
1703
1974
|
frontier = next;
|
|
1704
1975
|
}
|
|
1705
|
-
if (!
|
|
1976
|
+
if (!reached) {
|
|
1977
|
+
const end = (E) => (E.file ? { file: E.file, kind: "file" } : brief(E.syms[0]));
|
|
1978
|
+
return { ok: true, state: "none", from: end(A), to: end(B), exit: 4, card: `no confident call path from ${A.label} to ${B.label} (AMBIGUOUS edges are not followed)` };
|
|
1979
|
+
}
|
|
1980
|
+
const b = { sym: model.symbols.get(reached) };
|
|
1706
1981
|
const chain = [];
|
|
1707
1982
|
for (let id = b.sym.id; id; ) {
|
|
1708
1983
|
const p = prev.get(id);
|
|
@@ -1729,7 +2004,6 @@ function pathBetween(model, fromQ, toQ) {
|
|
|
1729
2004
|
// how the list is cut, which is the same contract every other card keeps: rows
|
|
1730
2005
|
// in a fixed order, the budget cuts from the BOTTOM, and a card that hid
|
|
1731
2006
|
// something says so.
|
|
1732
|
-
const MAP_MAX_ROWS = 200;
|
|
1733
2007
|
const MAP_FOCUS_MASS = 100;
|
|
1734
2008
|
const MAP_CAVEAT = " rank is a HINT about where to look first — it is never proof a file matters to this change";
|
|
1735
2009
|
|
|
@@ -1756,20 +2030,143 @@ function mapRow(model, rel, fan, pre) {
|
|
|
1756
2030
|
// A focus token is a FILE when the graph holds that path, and a NAME otherwise.
|
|
1757
2031
|
// A name contributes every file that DEFINES it — which is how "focus on
|
|
1758
2032
|
// `createOrder`" reaches the file a request never spelled out.
|
|
2033
|
+
//
|
|
2034
|
+
// v2.1.2 (F05): a token that is neither is read as plain WORDS. Two lane spines
|
|
2035
|
+
// pass "<3–6 words from the request>", and before this step every such map was
|
|
2036
|
+
// the plain repository ranking with a trace that still said `focused`. A word
|
|
2037
|
+
// reaches the files whose PATH or whose symbol NAMES hold it.
|
|
2038
|
+
const FOCUS_STOP = new Set(
|
|
2039
|
+
"the and for with from into that this when where what which how why not all any add fix get set new use make src lib index test tests spec file files code".split(" ")
|
|
2040
|
+
);
|
|
2041
|
+
|
|
2042
|
+
// Light on purpose: `rates` → `rate`, `taxes` → `tax`, `orders` → `order`,
|
|
2043
|
+
// `address` stays. A real stemmer would join words a request keeps apart.
|
|
2044
|
+
function focusStem(w) {
|
|
2045
|
+
if (w.length > 3 && w.endsWith("ies")) return w.slice(0, -3) + "y";
|
|
2046
|
+
if (/(s|x|z|ch|sh)es$/.test(w)) return w.slice(0, -2);
|
|
2047
|
+
if (w.length >= 4 && w.endsWith("s") && !w.endsWith("ss")) return w.slice(0, -1);
|
|
2048
|
+
return w;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// camelCase, `_`, `-`, `.`, `/` and whitespace all end a word.
|
|
2052
|
+
function focusWords(text) {
|
|
2053
|
+
return String(text)
|
|
2054
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
2055
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
2056
|
+
.split(/[^A-Za-z0-9]+/)
|
|
2057
|
+
.filter(Boolean)
|
|
2058
|
+
.map((w) => focusStem(w.toLowerCase()));
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// R4: a focus word matches a path/name segment when they are equal, or one is
|
|
2062
|
+
// the other's simple plural (`+s`, `+es`), both ways. Both sides are already
|
|
2063
|
+
// lower case and stemmed by focusWords, so this only adds what the light
|
|
2064
|
+
// stemmer leaves apart.
|
|
2065
|
+
function wordMatch(w, seg) {
|
|
2066
|
+
return seg === w || seg === w + "s" || seg === w + "es" || w === seg + "s";
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// ONE index per call, built only when a token needs it: word → the files whose
|
|
2070
|
+
// path (directories + basename without its extension) or symbol names hold it.
|
|
2071
|
+
// v2.2.0 (D6): word → Map(file → 2 when the word is a PATH segment, 1 when
|
|
2072
|
+
// only a symbol name holds it).
|
|
2073
|
+
function focusWordIndex(model) {
|
|
2074
|
+
const index = new Map();
|
|
2075
|
+
for (const rel of model.fileSet) {
|
|
2076
|
+
const words = new Map();
|
|
2077
|
+
for (const w of focusWords(rel.replace(/\.[^./]+$/, ""))) words.set(w, { path: true, n: 0 });
|
|
2078
|
+
for (const s of (model.byFile[rel] && model.byFile[rel].symbols) || []) {
|
|
2079
|
+
if (s.kind === "module") continue;
|
|
2080
|
+
for (const w of new Set(focusWords(s.name || s.qname || ""))) {
|
|
2081
|
+
const e = words.get(w) || { path: false, n: 0 };
|
|
2082
|
+
e.n++;
|
|
2083
|
+
words.set(w, e);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
for (const [w, x] of words) {
|
|
2087
|
+
if (!index.has(w)) index.set(w, new Map());
|
|
2088
|
+
index.get(w).set(rel, x);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
return index;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// v2.2.0 (D6): a word is never dropped. It is WEIGHTED by how rare it is —
|
|
2095
|
+
// log(1 + N/hits) / log(1 + N), so a rare word is near 1 and a word in every
|
|
2096
|
+
// file is small but above 0. A file named directly is the strongest (3).
|
|
2097
|
+
//
|
|
2098
|
+
// v2.2.0 (T6b): a plain lower-case token is ALWAYS a word — `amount` used to
|
|
2099
|
+
// be read as a NAME (only the files defining it) and vanished from the words
|
|
2100
|
+
// line. A token with `/`, `.` or `#` is a file or a qname; a camelCase,
|
|
2101
|
+
// PascalCase or `_` token is a NAME and a word. A PATH-segment hit is worth
|
|
2102
|
+
// 2w; a symbol-only hit is worth w × min(1, matched / sqrt(total)), so a
|
|
2103
|
+
// constants file with 177 symbols and 6 matches is not a strong hit.
|
|
2104
|
+
// `strong` is every file reached by a path segment, a file token or a name —
|
|
2105
|
+
// the files hub damping never touches.
|
|
2106
|
+
const FOCUS_FILE_MASS = 3;
|
|
1759
2107
|
function focusFiles(model, tokens) {
|
|
1760
|
-
const
|
|
2108
|
+
const mass = new Map();
|
|
2109
|
+
const strong = new Set();
|
|
2110
|
+
const add = (rel, m) => mass.set(rel, (mass.get(rel) || 0) + m);
|
|
1761
2111
|
const missing = [];
|
|
2112
|
+
const common = [];
|
|
2113
|
+
const words = [];
|
|
2114
|
+
const seen = new Set();
|
|
2115
|
+
let index = null;
|
|
2116
|
+
const N = model.fileSet.size;
|
|
2117
|
+
const symCount = (rel) => ((model.byFile[rel] && model.byFile[rel].symbols) || []).filter((s) => s.kind !== "module").length;
|
|
1762
2118
|
for (const t of tokens) {
|
|
1763
|
-
const
|
|
2119
|
+
const raw = String(t);
|
|
2120
|
+
const rel = norm(raw).replace(/:\d+$/, "");
|
|
1764
2121
|
if (model.fileSet.has(rel)) {
|
|
1765
|
-
|
|
2122
|
+
add(rel, FOCUS_FILE_MASS);
|
|
2123
|
+
strong.add(rel);
|
|
1766
2124
|
continue;
|
|
1767
2125
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2126
|
+
if (/[\/.#]/.test(raw)) {
|
|
2127
|
+
const defs = model.byName.get(raw.split(/[.#]/).pop()) || [];
|
|
2128
|
+
for (const s of defs) {
|
|
2129
|
+
add(s.file, 1);
|
|
2130
|
+
strong.add(s.file);
|
|
2131
|
+
}
|
|
2132
|
+
if (!defs.length) missing.push(raw);
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
const isName = /[a-z0-9][A-Z]|^[A-Z]|_/.test(raw);
|
|
2136
|
+
let named = false;
|
|
2137
|
+
if (isName) {
|
|
2138
|
+
for (const s of model.byName.get(raw) || []) {
|
|
2139
|
+
add(s.file, 1);
|
|
2140
|
+
strong.add(s.file);
|
|
2141
|
+
named = true;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
index = index || focusWordIndex(model);
|
|
2145
|
+
const none = [];
|
|
2146
|
+
let held = false;
|
|
2147
|
+
for (const w of focusWords(raw)) {
|
|
2148
|
+
if (w.length < 3 || FOCUS_STOP.has(w) || seen.has(w)) continue;
|
|
2149
|
+
seen.add(w);
|
|
2150
|
+
const hit = index.get(w) || new Map();
|
|
2151
|
+
const weight = hit.size ? Math.log(1 + N / hit.size) / Math.log(1 + N) : 0;
|
|
2152
|
+
words.push({ word: w, files: hit.size, used: hit.size > 0, weight: Math.round(weight * 1000) / 1000, kind: isName ? "name" : "word" });
|
|
2153
|
+
if (!hit.size) none.push(w);
|
|
2154
|
+
else {
|
|
2155
|
+
held = true;
|
|
2156
|
+
for (const [f, x] of hit) {
|
|
2157
|
+
if (x.path) {
|
|
2158
|
+
add(f, weight * 2);
|
|
2159
|
+
strong.add(f);
|
|
2160
|
+
} else add(f, weight * Math.min(1, x.n / Math.sqrt(Math.max(1, symCount(f)))));
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
// A token with no word in the graph is reported WHOLE, as it was typed —
|
|
2165
|
+
// the answer a caller got before the word step, unchanged.
|
|
2166
|
+
if (held) missing.push(...none);
|
|
2167
|
+
else if (!named) missing.push(raw);
|
|
1771
2168
|
}
|
|
1772
|
-
return { files: [...
|
|
2169
|
+
return { files: [...mass.keys()], mass, strong, missing, common, words };
|
|
1773
2170
|
}
|
|
1774
2171
|
|
|
1775
2172
|
function graphMap(claudeDir, model, opts) {
|
|
@@ -1778,7 +2175,7 @@ function graphMap(claudeDir, model, opts) {
|
|
|
1778
2175
|
const { files, edges, base, cached } = M.edgesFor(claudeDir, model);
|
|
1779
2176
|
const idOf = new Map(files.map((f, i) => [f, i]));
|
|
1780
2177
|
const tokens = (opts.focus || []).filter(Boolean);
|
|
1781
|
-
const foc = tokens.length ? focusFiles(model, tokens) : { files: [], missing: [] };
|
|
2178
|
+
const foc = tokens.length ? focusFiles(model, tokens) : { files: [], mass: new Map(), strong: new Set(), missing: [], common: [], words: [] };
|
|
1782
2179
|
|
|
1783
2180
|
let rank;
|
|
1784
2181
|
if (foc.files.length) {
|
|
@@ -1792,19 +2189,37 @@ function graphMap(claudeDir, model, opts) {
|
|
|
1792
2189
|
// The mass is FIXED, so a fifth focus file dilutes the other four rather
|
|
1793
2190
|
// than making the focus louder overall. That is Aider's rule and the reason
|
|
1794
2191
|
// is the same: a focus list is a hint about where to start, not a filter.
|
|
2192
|
+
//
|
|
2193
|
+
// v2.2.0 (D6): the fixed mass is shared by each file's focus MASS (its
|
|
2194
|
+
// weighted word hits), not in equal parts.
|
|
1795
2195
|
const pers = new Float64Array(files.length).fill(1);
|
|
1796
|
-
|
|
2196
|
+
let total = 0;
|
|
2197
|
+
for (const f of foc.files) total += foc.mass.get(f) || 0;
|
|
1797
2198
|
for (const f of foc.files) {
|
|
1798
2199
|
const i = idOf.get(f);
|
|
1799
|
-
if (i !== undefined) pers[i] +=
|
|
2200
|
+
if (i !== undefined && total > 0) pers[i] += (MAP_FOCUS_MASS * (foc.mass.get(f) || 0)) / total;
|
|
1800
2201
|
}
|
|
1801
2202
|
rank = M.pageRank(files.length, edges, pers);
|
|
1802
2203
|
} else rank = base || M.pageRank(files.length, edges, null);
|
|
1803
2204
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
2205
|
+
// v2.2.0 (D6): hub damping, focus only. A file the focus never reached that
|
|
2206
|
+
// is a shared hub — by its path, or by an in-degree in the top 2 % — would
|
|
2207
|
+
// otherwise outrank the files the request names.
|
|
2208
|
+
// T6b: only a file the focus reached by a PATH, a file token or a name is
|
|
2209
|
+
// exempt — a file reached by symbol hits alone is damped when it is a hub.
|
|
2210
|
+
const damp = foc.files.length ? M.hubDamping(files, edges, foc.strong) : null;
|
|
2211
|
+
// Every indexed file is ranked — a file with no edge keeps its teleport share.
|
|
2212
|
+
let scored = files.map((rel, i) => ({ rel, score: (rank[i] || 0) * M.multiplier(model, rel) * (damp ? damp[i] : 1) }));
|
|
2213
|
+
// T6b: personalised PageRank sends rank from the focused files into what
|
|
2214
|
+
// they IMPORT — the hubs. With a focus, the final score is half the walk and
|
|
2215
|
+
// half the file's own focus mass, each normalised to [0, 1].
|
|
2216
|
+
if (foc.files.length) {
|
|
2217
|
+
const maxPr = Math.max(...scored.map((x) => x.score), 0) || 1;
|
|
2218
|
+
const maxMass = Math.max(...foc.files.map((f) => foc.mass.get(f) || 0), 0) || 1;
|
|
2219
|
+
scored = scored.map((x) => ({ rel: x.rel, score: 0.5 * (x.score / maxPr) + 0.5 * ((foc.mass.get(x.rel) || 0) / maxMass) }));
|
|
2220
|
+
}
|
|
2221
|
+
scored.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
|
|
2222
|
+
const candidates = scored;
|
|
1808
2223
|
const fan = M.fanCounts(model);
|
|
1809
2224
|
const pre = dirPrefix(candidates.map((c) => c.rel));
|
|
1810
2225
|
const rows = candidates.map((c, i) => {
|
|
@@ -1829,6 +2244,15 @@ function graphMap(claudeDir, model, opts) {
|
|
|
1829
2244
|
items.push({ pri: 0, section: "header", text: `graph map (gen ${model.meta.generation}) — ${files.length} file(s) by rank${focusNote}`, data: null });
|
|
1830
2245
|
if (pre) items.push({ pri: 0, section: "header", text: ` dir ${pre}`, data: null });
|
|
1831
2246
|
if (foc.missing.length) items.push({ pri: 0, section: "focus", text: ` focus not in the graph: ${foc.missing.join(", ")}`, data: { item: "focus-missing" } });
|
|
2247
|
+
// F05: what each plain word reached, so a reader can tell a focused map
|
|
2248
|
+
// from one whose words all missed.
|
|
2249
|
+
if (foc.words.length)
|
|
2250
|
+
items.push({
|
|
2251
|
+
pri: 0,
|
|
2252
|
+
section: "focus",
|
|
2253
|
+
text: ` focus words: ${foc.words.map((w) => (w.files ? `${w.word} ${w.files}×${w.weight.toFixed(2)}` : `${w.word} ${w.files}`)).join(" · ")}`,
|
|
2254
|
+
data: { item: "focus-words" },
|
|
2255
|
+
});
|
|
1832
2256
|
for (let i = 0; i < k; i++) items.push({ pri: 1, section: "files", text: rows[i].text, data: rows[i].data });
|
|
1833
2257
|
// pri 0: never cut, and CHARGED to the budget like everything else. It is
|
|
1834
2258
|
// the map's whole equivalent of "the graph is a LOCATOR" — a card that
|
|
@@ -1864,6 +2288,8 @@ function graphMap(claudeDir, model, opts) {
|
|
|
1864
2288
|
state: "found",
|
|
1865
2289
|
focus: foc.files,
|
|
1866
2290
|
focus_missing: foc.missing,
|
|
2291
|
+
focus_common: foc.common,
|
|
2292
|
+
focus_words: foc.words,
|
|
1867
2293
|
cached_map: !!cached,
|
|
1868
2294
|
total_files: files.length,
|
|
1869
2295
|
edges: edges.length,
|
|
@@ -1909,4 +2335,903 @@ function nameHits(model, name) {
|
|
|
1909
2335
|
return calls + defs;
|
|
1910
2336
|
}
|
|
1911
2337
|
|
|
1912
|
-
|
|
2338
|
+
// ── v2.2.0 (T6-a): the executor PACK ────────────────────────────────────────
|
|
2339
|
+
// On a real Vue repository the executor never used the graph: it read ~180 KB
|
|
2340
|
+
// of REFERENCE code (the files it copies behaviour from) through `cat` and
|
|
2341
|
+
// `sed`, in about 30 calls. An outside-view card did not change that. The pack
|
|
2342
|
+
// is ONE file that holds the outline of each reference file and the exact
|
|
2343
|
+
// lines of the ranges the executor was about to go looking for.
|
|
2344
|
+
//
|
|
2345
|
+
// It is a LOCATOR, not a copy of the repository: the quoted ranges are charged
|
|
2346
|
+
// to a budget, a range is at most 80 lines, nothing is quoted twice, and a
|
|
2347
|
+
// file whose blob moved since the index says so beside every quote from it.
|
|
2348
|
+
const PACK_BIG_LINES = 150;
|
|
2349
|
+
const PACK_RANGE_LINES = 80;
|
|
2350
|
+
const PACK_MAX_REFS = 8;
|
|
2351
|
+
const PACK_AUTO_REFS = 5;
|
|
2352
|
+
const PACK_OUTLINE_BUDGET = 2200;
|
|
2353
|
+
const PACK_TESTS_BUDGET = 700;
|
|
2354
|
+
// R2 (lean pack v2): outline first. Only a short helper is quoted — whole.
|
|
2355
|
+
const PACK_QUOTE_BUDGET = 600;
|
|
2356
|
+
const PACK_QUOTE_MAX_LINES = 30;
|
|
2357
|
+
const PACK_READ_RANGES_LINES = 300;
|
|
2358
|
+
const PACK_PARENTS_PER_REF = 2;
|
|
2359
|
+
const PACK_PARENTS_MAX = 3;
|
|
2360
|
+
// R3: the component NEIGHBOURHOOD — outline-only refs around the given ones.
|
|
2361
|
+
const PACK_CHILDREN_PER_REF = 3;
|
|
2362
|
+
const PACK_NAME_REFS = 5;
|
|
2363
|
+
const PACK_NEIGH_MAX = 12;
|
|
2364
|
+
const PACK_SLICE_BUDGET = 600;
|
|
2365
|
+
const PACK_QUOTE_KINDS = new Set(["function", "method", "computed", "route"]);
|
|
2366
|
+
const PACK_CONTRACT = [["props", "prop"], ["emits", "emit"], ["inject", "inject"]];
|
|
2367
|
+
const PACK_NEVER_QUOTE = new Set(["prop", "emit", "inject", "template", "module"]);
|
|
2368
|
+
const LANG_FENCE = { vue: "vue", svelte: "svelte", js: "js", mjs: "js", cjs: "js", jsx: "jsx", ts: "ts", tsx: "tsx", mts: "ts", cts: "ts", py: "python", go: "go", java: "java", cs: "csharp", php: "php", rb: "ruby", rs: "rust", kt: "kotlin", kts: "kotlin", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp" };
|
|
2369
|
+
|
|
2370
|
+
// A `--refs=` operand: a file, a path suffix, or a symbol (its file).
|
|
2371
|
+
function resolveRef(model, q) {
|
|
2372
|
+
const s = norm(q).replace(/:\d+$/, "");
|
|
2373
|
+
if (model.fileSet.has(s)) return { file: s };
|
|
2374
|
+
const t = findTarget(model, s);
|
|
2375
|
+
if (t.kind === "file") return { file: t.rel };
|
|
2376
|
+
if (t.kind === "symbol") return { file: t.sym.file };
|
|
2377
|
+
if (t.kind === "ambiguous-file") return { candidates: t.files };
|
|
2378
|
+
if (t.kind === "ambiguous") {
|
|
2379
|
+
const files = [...new Set(t.candidates.map((c) => c.file))].sort();
|
|
2380
|
+
return files.length === 1 ? { file: files[0] } : { candidates: files };
|
|
2381
|
+
}
|
|
2382
|
+
return { missing: String(q) };
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// The top-level blocks of a single-file component, read from the working tree.
|
|
2386
|
+
// A block opens on a line that STARTS with `<template`, `<script` or `<style`
|
|
2387
|
+
// and closes on the first line that starts with its closing tag.
|
|
2388
|
+
function blockRanges(model, rel) {
|
|
2389
|
+
const lines = fileLines(model, rel) || [];
|
|
2390
|
+
const out = [];
|
|
2391
|
+
let open = null;
|
|
2392
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2393
|
+
const l = lines[i];
|
|
2394
|
+
if (!open) {
|
|
2395
|
+
const o = /^<(template|script|style)\b/.exec(l);
|
|
2396
|
+
if (!o) continue;
|
|
2397
|
+
open = { tag: o[1], from: i + 1 };
|
|
2398
|
+
if (new RegExp(`</${open.tag}>\\s*$`).test(l)) {
|
|
2399
|
+
out.push(`${open.tag} ${open.from}-${i + 1}`);
|
|
2400
|
+
open = null;
|
|
2401
|
+
}
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
if (new RegExp(`^</${open.tag}>`).test(l)) {
|
|
2405
|
+
out.push(`${open.tag} ${open.from}-${i + 1}`);
|
|
2406
|
+
open = null;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
return out;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function packTestsOf(model, rel) {
|
|
2413
|
+
const SIG = require("./graph-signals.js");
|
|
2414
|
+
const syms = ((model.byFile[rel] || {}).symbols || []).filter((s) => s.kind !== "module");
|
|
2415
|
+
return [
|
|
2416
|
+
...new Set([
|
|
2417
|
+
...syms.flatMap((s) => callersOf(model, s).confident.map((c) => c.sym.file)).filter((x) => TEST_FILE.test(x)),
|
|
2418
|
+
...importersOf(model, rel).filter((x) => TEST_FILE.test(x)),
|
|
2419
|
+
...SIG.testsByName(model, rel),
|
|
2420
|
+
]),
|
|
2421
|
+
].sort();
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const memberName = (s) => (s.qname.includes(".") ? s.qname.split(".").slice(1).join(".") : s.qname);
|
|
2425
|
+
|
|
2426
|
+
// The OUTLINE of one file: a header, then rows that each name the part they
|
|
2427
|
+
// are, so the size rule can cut `members` first.
|
|
2428
|
+
// T6c: `members` are kept as entries so the OUTLINE budget can drop the
|
|
2429
|
+
// smallest first; `routes` (a router edit target) are their own rows.
|
|
2430
|
+
const VUE_MEMBER_ORDER = { computed: 1, method: 2, watch: 3 };
|
|
2431
|
+
const PACK_ROUTE_ROWS = 40;
|
|
2432
|
+
function packOutline(model, rel, withRoutes, tag, focus) {
|
|
2433
|
+
const f = model.byFile[rel] || {};
|
|
2434
|
+
const syms = (f.symbols || []).filter((s) => s.kind !== "module");
|
|
2435
|
+
const sfc = /\.(vue|svelte)$/.test(rel);
|
|
2436
|
+
const blocks = sfc ? blockRanges(model, rel) : [];
|
|
2437
|
+
const nLines = Number(f.lines) || (fileLines(model, rel) || []).length;
|
|
2438
|
+
const head = `${rel} · ${nLines} lines${blocks.length ? " · " + blocks.join(" · ") : ""}${tag ? ` · ${tag}` : ""}${nLines > PACK_READ_RANGES_LINES ? " · read ranges, not the file" : ""}`;
|
|
2439
|
+
const rows = [];
|
|
2440
|
+
const contract = PACK_CONTRACT.map(([label, kind]) => {
|
|
2441
|
+
const n = syms.filter((s) => s.kind === kind).map((s) => s.name);
|
|
2442
|
+
return n.length ? `${label} ${n.join(", ")}` : null;
|
|
2443
|
+
}).filter(Boolean);
|
|
2444
|
+
if (contract.length) rows.push({ part: "contract", text: ` contract ${contract.join(" · ")}` });
|
|
2445
|
+
const routes = withRoutes ? syms.filter((s) => s.kind === "route").sort((a, b) => a.lines[0] - b.lines[0]) : [];
|
|
2446
|
+
if (routes.length) {
|
|
2447
|
+
// R3: a router-like target (>= 10 routes) keeps the rows whose path or
|
|
2448
|
+
// name holds a focus word, plus 2 rows before and after each hit. No hit
|
|
2449
|
+
// -> the first 10.
|
|
2450
|
+
let keep = routes.slice(0, PACK_ROUTE_ROWS);
|
|
2451
|
+
if (routes.length >= 10) {
|
|
2452
|
+
const words = (focus || []).map((w) => String(w).toLowerCase()).filter((w) => w.length >= 2);
|
|
2453
|
+
const idx = new Set();
|
|
2454
|
+
routes.forEach((r, i) => {
|
|
2455
|
+
const hay = `${r.qname} ${r.route_name || ""}`.toLowerCase();
|
|
2456
|
+
if (words.some((w) => hay.includes(w))) for (let k = i - 2; k <= i + 2; k++) if (k >= 0 && k < routes.length) idx.add(k);
|
|
2457
|
+
});
|
|
2458
|
+
keep = idx.size ? routes.filter((_, i) => idx.has(i)) : routes.slice(0, 10);
|
|
2459
|
+
}
|
|
2460
|
+
for (const s of keep) rows.push({ part: "routes", text: ` ${s.qname}${s.route_name ? ` ${s.route_name}` : ""} ${s.lines[0]}-${s.lines[1]}` });
|
|
2461
|
+
if (routes.length > keep.length) rows.push({ part: "routes", text: ` +${routes.length - keep.length} other routes` });
|
|
2462
|
+
}
|
|
2463
|
+
const contractKinds = new Set(PACK_CONTRACT.map((x) => x[1]));
|
|
2464
|
+
// A component's `data` leads, then computed, methods and watchers.
|
|
2465
|
+
const rank = (s) => (!sfc ? 0 : s.name === "data" ? 0 : VUE_MEMBER_ORDER[s.kind] || 4);
|
|
2466
|
+
const members = syms
|
|
2467
|
+
.filter((s) => !contractKinds.has(s.kind) && s.kind !== "template" && !(routes.length && s.kind === "route") && !(sfc && s.kind === "class" && !s.qname.includes(".")))
|
|
2468
|
+
.sort((a, b) => rank(a) - rank(b) || a.lines[0] - b.lines[0])
|
|
2469
|
+
.map((s) => ({ text: `${memberName(s)} ${s.lines[0]}-${s.lines[1]}`, span: s.lines[1] - s.lines[0], keep: true }));
|
|
2470
|
+
const membersRow = { part: "members", members, text: null };
|
|
2471
|
+
if (members.length) rows.push(membersRow);
|
|
2472
|
+
const firstUse = new Map();
|
|
2473
|
+
for (const s of syms.filter((x) => x.kind === "template"))
|
|
2474
|
+
for (const c of s.calls || []) if (c.ref && /^[A-Z][a-z0-9]/.test(c.name) && (!firstUse.has(c.name) || c.line < firstUse.get(c.name))) firstUse.set(c.name, c.line);
|
|
2475
|
+
if (firstUse.size) rows.push({ part: "uses", text: ` uses ${[...firstUse].map(([n, l]) => `${n} ${l}`).join(", ")}` });
|
|
2476
|
+
const imports = [...new Set((f.imports || []).flatMap((imp) => importTargets(model, rel, f.lang, imp)))].slice(0, 8);
|
|
2477
|
+
if (imports.length) rows.push({ part: "imports", text: ` imports ${imports.join(", ")}` });
|
|
2478
|
+
const used = [];
|
|
2479
|
+
for (const s of syms) for (const c of callersOf(model, s).confident) if (c.sym.file !== rel) used.push(`${c.sym.file}:${c.line}`);
|
|
2480
|
+
const usedBy = [...new Set(used)].sort();
|
|
2481
|
+
if (usedBy.length < 5) for (const imp of importersOf(model, rel).sort()) if (!usedBy.some((u) => u.startsWith(imp + ":"))) usedBy.push(imp);
|
|
2482
|
+
if (usedBy.length) rows.push({ part: "used", text: ` used by ${usedBy.slice(0, 5).join(" ")}` });
|
|
2483
|
+
const tests = packTestsOf(model, rel).slice(0, 3);
|
|
2484
|
+
if (tests.length) rows.push({ part: "tests", text: ` tests ${tests.join(" ")}` });
|
|
2485
|
+
return { head, rows };
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function outlineText(ol) {
|
|
2489
|
+
return ol
|
|
2490
|
+
.map((x) =>
|
|
2491
|
+
[
|
|
2492
|
+
x.head,
|
|
2493
|
+
...x.rows.map((r) => {
|
|
2494
|
+
if (r.part !== "members") return r.text;
|
|
2495
|
+
const kept = r.members.filter((m) => m.keep);
|
|
2496
|
+
const gone = r.members.length - kept.length;
|
|
2497
|
+
return ` members ${kept.map((m) => m.text).join(", ")}${gone ? `${kept.length ? ", " : ""}+${gone} members` : ""}`;
|
|
2498
|
+
}),
|
|
2499
|
+
].join("\n")
|
|
2500
|
+
)
|
|
2501
|
+
.join("\n");
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// §4: the first test file's own rows — describe / it / test names, and its
|
|
2505
|
+
// top-level helpers — read from the working tree.
|
|
2506
|
+
function testRows(model, rel) {
|
|
2507
|
+
const lines = fileLines(model, rel) || [];
|
|
2508
|
+
const out = [];
|
|
2509
|
+
for (let i = 0; i < lines.length && out.length < 12; i++) {
|
|
2510
|
+
const l = lines[i];
|
|
2511
|
+
const t = /^\s*(describe|it|test)(?:\.\w+)?\s*\(\s*(['"`])(.*?)\2/.exec(l);
|
|
2512
|
+
if (t) {
|
|
2513
|
+
out.push(`${t[1]} "${t[3]}" ${i + 1}`);
|
|
2514
|
+
continue;
|
|
2515
|
+
}
|
|
2516
|
+
const h = /^(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)|^(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.exec(l);
|
|
2517
|
+
if (h) out.push(`${h[1] || h[2]}() ${i + 1}`);
|
|
2518
|
+
}
|
|
2519
|
+
return out;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// R2: the files whose `template` uses a child component (the `<Tag>` users).
|
|
2523
|
+
// R2b: through RESOLVED edges first — the `<Owner>.template` symbols with a
|
|
2524
|
+
// confident edge to the child's component class. The tag-name match is the
|
|
2525
|
+
// fallback for a ref with no class symbol.
|
|
2526
|
+
function templateParents(model, child) {
|
|
2527
|
+
const cls = ((model.byFile[child] || {}).symbols || []).find((x) => x.kind === "class" && !x.qname.includes("."));
|
|
2528
|
+
if (cls) {
|
|
2529
|
+
const files = callersOf(model, cls).confident.filter((c) => c.sym.kind === "template" && c.sym.file !== child && !TEST_FILE.test(c.sym.file)).map((c) => c.sym.file);
|
|
2530
|
+
return [...new Set(files)].sort();
|
|
2531
|
+
}
|
|
2532
|
+
const base = child.split("/").pop().replace(/\.(vue|svelte)$/, "");
|
|
2533
|
+
const kebab = base.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
2534
|
+
const out = [];
|
|
2535
|
+
for (const rel of [...model.fileSet].sort()) {
|
|
2536
|
+
if (rel === child || TEST_FILE.test(rel) || !/\.(vue|svelte)$/.test(rel)) continue;
|
|
2537
|
+
const syms = ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind === "template");
|
|
2538
|
+
if (syms.some((t) => (t.calls || []).some((c) => c.name === base || c.name === kebab))) out.push(rel);
|
|
2539
|
+
}
|
|
2540
|
+
return out;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// R3: the in-repo components a file's `template` uses — the resolved `ref`
|
|
2544
|
+
// edges from `<Owner>.template` to another file's component class.
|
|
2545
|
+
function templateChildren(model, rel) {
|
|
2546
|
+
const out = [];
|
|
2547
|
+
for (const t of ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind === "template")) {
|
|
2548
|
+
for (const { res } of calleesOf(model, t)) {
|
|
2549
|
+
if (!CONFIDENT.has(res.state)) continue;
|
|
2550
|
+
const s = model.symbols.get(res.target);
|
|
2551
|
+
if (s && s.kind === "class" && s.file !== rel && !TEST_FILE.test(s.file) && !out.includes(s.file)) out.push(s.file);
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
return out;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// R3b: the CHILDREN of a component — the in-repo `.vue`/`.svelte` files it
|
|
2558
|
+
// IMPORTS (resolved import edges) plus its template-tag edges. A dynamic
|
|
2559
|
+
// `<component :is>` has no tags, so the imports are the only signal there.
|
|
2560
|
+
function componentChildren(model, rel) {
|
|
2561
|
+
const f = model.byFile[rel] || {};
|
|
2562
|
+
const out = [];
|
|
2563
|
+
for (const t of [...new Set((f.imports || []).flatMap((imp) => importTargets(model, rel, f.lang, imp)))])
|
|
2564
|
+
if (/\.(vue|svelte)$/.test(t) && t !== rel && !TEST_FILE.test(t) && !out.includes(t)) out.push(t);
|
|
2565
|
+
for (const t of templateChildren(model, rel)) if (!out.includes(t)) out.push(t);
|
|
2566
|
+
return out;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
function packExcerpt(model, ex, fresh) {
|
|
2570
|
+
const lines = fileLines(model, ex.file) || [];
|
|
2571
|
+
const to = Math.min(ex.b, ex.a + PACK_RANGE_LINES - 1);
|
|
2572
|
+
const ext = (ex.file.split(".").pop() || "").toLowerCase();
|
|
2573
|
+
const body = lines.slice(ex.a - 1, to).join("\n");
|
|
2574
|
+
const more = ex.b > to ? `\n… ${ex.b - to} more lines, read ${to + 1}-${ex.b}` : "";
|
|
2575
|
+
const changed = fresh !== "current" ? " (CHANGED since index — lines may have moved)" : "";
|
|
2576
|
+
return `### ${ex.file}:${ex.a}-${ex.b} · ${ex.names.join(", ")}${changed}\n\`\`\`${LANG_FENCE[ext] || ext}\n${body}\n\`\`\`${more}`;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
// R4: the spec closest to a file. By NAME first (`<Name>*.spec.*` /
|
|
2580
|
+
// `<Name>*.test.*` under a tests folder), then a test that reaches it.
|
|
2581
|
+
const PACK_SPEC_FILES = 2;
|
|
2582
|
+
const PACK_SPEC_SETUP_LINES = 45;
|
|
2583
|
+
function closestSpec(model, rel) {
|
|
2584
|
+
const name = rel.split("/").pop().replace(/\.[^.]+$/, "");
|
|
2585
|
+
const byName = [...model.fileSet]
|
|
2586
|
+
.filter((f) => /(^|\/)(tests?|__tests__)\//.test(f))
|
|
2587
|
+
.filter((f) => {
|
|
2588
|
+
const b = f.split("/").pop();
|
|
2589
|
+
return b.startsWith(name) && /\.(spec|test)\.[^.]+$/.test(b);
|
|
2590
|
+
})
|
|
2591
|
+
.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
2592
|
+
if (byName.length) return byName[0];
|
|
2593
|
+
return packTestsOf(model, rel)[0] || null;
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// The end line of a block that opens on line `i` (0-based): bracket balance,
|
|
2597
|
+
// strings stripped.
|
|
2598
|
+
function blockEnd(lines, i) {
|
|
2599
|
+
let depth = 0;
|
|
2600
|
+
let opened = false;
|
|
2601
|
+
for (let k = i; k < lines.length; k++) {
|
|
2602
|
+
for (const ch of lines[k].replace(/(['"`])(?:\\.|(?!\1).)*\1/g, "")) {
|
|
2603
|
+
if (ch === "{" || ch === "(" || ch === "[") {
|
|
2604
|
+
depth++;
|
|
2605
|
+
opened = true;
|
|
2606
|
+
} else if (ch === "}" || ch === ")" || ch === "]") depth--;
|
|
2607
|
+
}
|
|
2608
|
+
if (opened && depth <= 0) return k;
|
|
2609
|
+
}
|
|
2610
|
+
return i;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
// The spec's top-level outline (helpers and `describe` blocks with ranges)
|
|
2614
|
+
// and its SETUP quoted: line 1 through the end of the last top-level
|
|
2615
|
+
// jest.mock / import / const / let before the first `describe`.
|
|
2616
|
+
function specSetup(model, spec, rel) {
|
|
2617
|
+
const lines = fileLines(model, spec) || [];
|
|
2618
|
+
if (!lines.length) return null;
|
|
2619
|
+
const outline = [];
|
|
2620
|
+
let firstDescribe = -1;
|
|
2621
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2622
|
+
const l = lines[i];
|
|
2623
|
+
const d = /^describe(?:\.\w+)?\s*\(\s*(['"`])(.*?)\1/.exec(l);
|
|
2624
|
+
const h = /^(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)|^(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.exec(l);
|
|
2625
|
+
if (d) {
|
|
2626
|
+
if (firstDescribe < 0) firstDescribe = i;
|
|
2627
|
+
const e = blockEnd(lines, i);
|
|
2628
|
+
outline.push(`describe "${d[2]}" ${i + 1}-${e + 1}`);
|
|
2629
|
+
i = e;
|
|
2630
|
+
} else if (h) {
|
|
2631
|
+
const e = blockEnd(lines, i);
|
|
2632
|
+
outline.push(`${h[1] || h[2]}() ${i + 1}-${e + 1}`);
|
|
2633
|
+
i = e;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
const stop = firstDescribe < 0 ? lines.length : firstDescribe;
|
|
2637
|
+
let last = -1;
|
|
2638
|
+
for (let i = 0; i < stop; i++) {
|
|
2639
|
+
if (!/^(jest\.mock\s*\(|import\b|const\b|let\b)/.test(lines[i])) continue;
|
|
2640
|
+
let e = i;
|
|
2641
|
+
if (/^import\b/.test(lines[i])) {
|
|
2642
|
+
while (e < stop - 1 && !/\bfrom\b|^import\s+['"]|;\s*$/.test(lines[e])) e++;
|
|
2643
|
+
} else e = blockEnd(lines, i);
|
|
2644
|
+
last = Math.max(i, e);
|
|
2645
|
+
i = last;
|
|
2646
|
+
}
|
|
2647
|
+
const head = `closest spec for ${rel.split("/").pop()}: ${spec} · ${lines.length} lines${outline.length ? ` — ${outline.join("; ")}` : ""}`;
|
|
2648
|
+
if (last < 0) return head;
|
|
2649
|
+
const to = Math.min(last + 1, PACK_SPEC_SETUP_LINES);
|
|
2650
|
+
const ext = (spec.split(".").pop() || "").toLowerCase();
|
|
2651
|
+
const more = last + 1 > to ? `\n… setup goes on to line ${last + 1}` : "";
|
|
2652
|
+
return `${head}\nsetup 1-${to}:\n\`\`\`${LANG_FENCE[ext] || ext}\n${lines.slice(0, to).join("\n")}\n\`\`\`${more}`;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// `opts`: { declared, refs (null = pick from the map), focus, budget, sliceCard }
|
|
2656
|
+
function buildPack(claudeDir, model, opts) {
|
|
2657
|
+
const SIG = require("./graph-signals.js");
|
|
2658
|
+
const o = opts || {};
|
|
2659
|
+
const budget = Math.max(500, Math.min(12000, Number(o.budget) || PACK_QUOTE_BUDGET));
|
|
2660
|
+
const declared = [...new Set((o.declared || []).map(norm))];
|
|
2661
|
+
const known = declared.filter((r) => model.fileSet.has(r));
|
|
2662
|
+
const fresh = declared.filter((r) => !model.fileSet.has(r));
|
|
2663
|
+
const declaredSet = new Set(known);
|
|
2664
|
+
const focusList = (o.focus || []).filter(Boolean);
|
|
2665
|
+
|
|
2666
|
+
// ── refs ──
|
|
2667
|
+
let refs = [];
|
|
2668
|
+
const refMissing = [];
|
|
2669
|
+
if (o.refs && o.refs.length) {
|
|
2670
|
+
for (const q of o.refs) {
|
|
2671
|
+
const r = resolveRef(model, q);
|
|
2672
|
+
if (r.candidates) return { ok: false, state: "ambiguous-target", query: String(q), candidates: r.candidates.map((file) => ({ file })), exit: 4 };
|
|
2673
|
+
if (r.missing) refMissing.push(r.missing);
|
|
2674
|
+
else if (!refs.includes(r.file)) refs.push(r.file);
|
|
2675
|
+
}
|
|
2676
|
+
} else {
|
|
2677
|
+
const m = graphMap(claudeDir, model, { focus: [...known, ...focusList], budget: 8000 });
|
|
2678
|
+
refs = (m.files || []).map((x) => x.file).filter((f) => !declaredSet.has(f) && !TEST_FILE.test(f)).slice(0, PACK_AUTO_REFS);
|
|
2679
|
+
}
|
|
2680
|
+
refs = refs.slice(0, PACK_MAX_REFS);
|
|
2681
|
+
// R3: the NEIGHBOURHOOD — outline only, never quoted. Ranked: children of
|
|
2682
|
+
// the given refs (two hops), their parents, FILE-NAME focus matches, then
|
|
2683
|
+
// the children of a declared component. Tests and files already present
|
|
2684
|
+
// are skipped; cap 12.
|
|
2685
|
+
const parents = [];
|
|
2686
|
+
const isSfc = (f) => /\.(vue|svelte)$/.test(f);
|
|
2687
|
+
const base = (f) => f.split("/").pop();
|
|
2688
|
+
const addNeigh = (file, tag) => {
|
|
2689
|
+
if (parents.length >= PACK_NEIGH_MAX || TEST_FILE.test(file) || refs.includes(file) || declaredSet.has(file) || parents.some((x) => x.file === file)) return false;
|
|
2690
|
+
parents.push({ file, tag });
|
|
2691
|
+
return true;
|
|
2692
|
+
};
|
|
2693
|
+
const addChildren = (rel) => {
|
|
2694
|
+
const got = [];
|
|
2695
|
+
for (const c of componentChildren(model, rel)) {
|
|
2696
|
+
if (got.length >= PACK_CHILDREN_PER_REF) break;
|
|
2697
|
+
if (addNeigh(c, `child of ${base(rel)}`)) got.push(c);
|
|
2698
|
+
}
|
|
2699
|
+
return got;
|
|
2700
|
+
};
|
|
2701
|
+
const hop1 = refs.filter(isSfc).flatMap((rel) => addChildren(rel));
|
|
2702
|
+
for (const c of hop1) addChildren(c);
|
|
2703
|
+
for (const rel of refs.filter(isSfc)) {
|
|
2704
|
+
let n = 0;
|
|
2705
|
+
for (const p of templateParents(model, rel)) if (n < PACK_PARENTS_PER_REF && addNeigh(p, `parent of ${base(rel)}`)) n++;
|
|
2706
|
+
}
|
|
2707
|
+
// R3b: name matches — the focus words found in the file's OWN name (no
|
|
2708
|
+
// folder, no extension). Tie-break: the number of files that import it.
|
|
2709
|
+
let multiHits = [];
|
|
2710
|
+
let fw = [...new Set(focusList.flatMap((w) => focusWords(w)).filter((w) => w.length >= 2))];
|
|
2711
|
+
if (fw.length && parents.length < PACK_NEIGH_MAX) {
|
|
2712
|
+
// R3c: each word weighs IDF_name = log(1 + N / n_word) over the FILE
|
|
2713
|
+
// NAMES of the indexed non-test files, so a word in dozens of names
|
|
2714
|
+
// weighs little. Ties: the number of files that import it.
|
|
2715
|
+
const ownWords = new Map();
|
|
2716
|
+
for (const rel of model.fileSet) if (!TEST_FILE.test(rel)) ownWords.set(rel, new Set(focusWords(base(rel).replace(/\.[^.]+$/, ""))));
|
|
2717
|
+
// R3d: a word in a DECLARED file's own name describes the target — it
|
|
2718
|
+
// only finds siblings, so it does not count.
|
|
2719
|
+
const declWords = new Set(known.flatMap((rel) => focusWords(base(rel).replace(/\.[^.]+$/, ""))));
|
|
2720
|
+
fw = fw.filter((w) => !declWords.has(w));
|
|
2721
|
+
const N = ownWords.size;
|
|
2722
|
+
const idf = new Map(fw.map((w) => [w, Math.log(1 + N / Math.max(1, [...ownWords.values()].filter((o) => [...o].some((seg) => wordMatch(w, seg))).length))]));
|
|
2723
|
+
const hits = [];
|
|
2724
|
+
for (const [rel, own] of ownWords) {
|
|
2725
|
+
if (declaredSet.has(rel) || refs.includes(rel)) continue;
|
|
2726
|
+
const words = fw.filter((w) => [...own].some((seg) => wordMatch(w, seg)));
|
|
2727
|
+
// R4: a file whose name holds several focus words is the likely
|
|
2728
|
+
// copy-from file: (sum of IDF) x (1 + 0.5 x (k - 1)), and k >= 2 always
|
|
2729
|
+
// ranks before any single-word hit.
|
|
2730
|
+
if (words.length) hits.push({ rel, words, score: words.reduce((a, w) => a + idf.get(w), 0) * (1 + 0.5 * (words.length - 1)), imp: importersOf(model, rel).length });
|
|
2731
|
+
}
|
|
2732
|
+
hits.sort((a, b) => (b.words.length >= 2) - (a.words.length >= 2) || b.score - a.score || b.imp - a.imp || a.rel.localeCompare(b.rel));
|
|
2733
|
+
multiHits = hits.filter((h) => h.words.length >= 2).map((h) => h.rel);
|
|
2734
|
+
let n = 0;
|
|
2735
|
+
for (const h of hits) {
|
|
2736
|
+
if (n >= PACK_NAME_REFS) break;
|
|
2737
|
+
if (addNeigh(h.rel, `name match: ${h.words.join(", ")} (${h.score.toFixed(2)})`)) n++;
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
for (const rel of known.filter(isSfc)) addChildren(rel);
|
|
2741
|
+
warmFiles(model, new Set([...known, ...refs, ...parents.map((x) => x.file)]));
|
|
2742
|
+
|
|
2743
|
+
// ── §3: the candidates, scored ──
|
|
2744
|
+
const want = new Set(focusList.flatMap((w) => focusWords(w)).filter((w) => w.length >= 2));
|
|
2745
|
+
const cands = new Map();
|
|
2746
|
+
for (const rel of refs) for (const s of model.byFile[rel].symbols || []) if (PACK_QUOTE_KINDS.has(s.kind)) cands.set(s.id, s);
|
|
2747
|
+
for (const s of [...cands.values()]) {
|
|
2748
|
+
for (const { res } of calleesOf(model, s)) {
|
|
2749
|
+
if (!CONFIDENT.has(res.state)) continue;
|
|
2750
|
+
const t = model.symbols.get(res.target);
|
|
2751
|
+
if (t && declaredSet.has(t.file) && !PACK_NEVER_QUOTE.has(t.kind)) cands.set(t.id, t);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
const called = new Set();
|
|
2755
|
+
for (const s of cands.values()) {
|
|
2756
|
+
for (const { res } of calleesOf(model, s)) if (CONFIDENT.has(res.state) && res.target !== s.id && cands.has(res.target)) called.add(res.target);
|
|
2757
|
+
}
|
|
2758
|
+
// R2: only a short helper (<= 30 lines) is quoted, whole; a member of a
|
|
2759
|
+
// big edit target (outlined in §1, read in full anyway) is never quoted.
|
|
2760
|
+
const bigSet = new Set(known.filter((rel) => (Number((model.byFile[rel] || {}).lines) || 0) > PACK_BIG_LINES));
|
|
2761
|
+
for (const [id, s] of [...cands]) if (s.lines[1] - s.lines[0] + 1 > PACK_QUOTE_MAX_LINES || bigSet.has(s.file)) cands.delete(id);
|
|
2762
|
+
const scored = [...cands.values()].map((s) => {
|
|
2763
|
+
const words = new Set(focusWords(s.qname));
|
|
2764
|
+
let score = 0;
|
|
2765
|
+
for (const w of want) if (words.has(w)) score += 3;
|
|
2766
|
+
if (called.has(s.id)) score += 2;
|
|
2767
|
+
if (s.exported || !/^[_#]/.test(s.name)) score += 1;
|
|
2768
|
+
const imp = SIG.importance(model, s, callersOf(model, s).confident.length);
|
|
2769
|
+
const relevance = score - (s.exported || !/^[_#]/.test(s.name) ? 1 : 0);
|
|
2770
|
+
return { s, score, imp, relevance };
|
|
2771
|
+
});
|
|
2772
|
+
scored.sort((a, b) => b.score - a.score || b.imp - a.imp || a.s.file.localeCompare(b.s.file) || a.s.lines[0] - b.s.lines[0]);
|
|
2773
|
+
// T6c: the quotes are SPREAD — round 1 takes the best candidate of EACH ref
|
|
2774
|
+
// file (in ref order), round 2 the second best, and so on. A file whose best
|
|
2775
|
+
// candidate has no focus word and no caller among the candidates gets its
|
|
2776
|
+
// single most important one, in round 1 only.
|
|
2777
|
+
const groupOrder = [...refs, ...known];
|
|
2778
|
+
const groups = groupOrder.map((rel) => {
|
|
2779
|
+
const g = scored.filter((x) => x.s.file === rel);
|
|
2780
|
+
if (g.length && g[0].relevance <= 0) return g.slice().sort((a, b) => b.imp - a.imp || a.s.lines[0] - b.s.lines[0]).slice(0, 1);
|
|
2781
|
+
return g;
|
|
2782
|
+
});
|
|
2783
|
+
const spread = [];
|
|
2784
|
+
for (let round = 0; groups.some((g) => g.length > round); round++) for (const g of groups) if (g[round]) spread.push(g[round]);
|
|
2785
|
+
|
|
2786
|
+
// In score order until the budget is used. A range that overlaps one
|
|
2787
|
+
// already taken is MERGED into it; one that sits inside it is skipped.
|
|
2788
|
+
const chosen = [];
|
|
2789
|
+
let used = 0;
|
|
2790
|
+
let overBudget = 0;
|
|
2791
|
+
const costOf = (ex) => tok(packExcerpt(model, ex, blobState(model, ex.file))) + 1;
|
|
2792
|
+
for (const { s } of spread) {
|
|
2793
|
+
const a = s.lines[0];
|
|
2794
|
+
const b = s.lines[1];
|
|
2795
|
+
const over = chosen.filter((x) => x.file === s.file && x.a <= b && a <= x.b);
|
|
2796
|
+
if (over.some((x) => x.a <= a && b <= x.b)) continue;
|
|
2797
|
+
const merged = {
|
|
2798
|
+
file: s.file,
|
|
2799
|
+
a: Math.min(a, ...over.map((x) => x.a)),
|
|
2800
|
+
b: Math.max(b, ...over.map((x) => x.b)),
|
|
2801
|
+
names: [...over.flatMap((x) => x.names), s.qname],
|
|
2802
|
+
};
|
|
2803
|
+
merged.tokens = costOf(merged);
|
|
2804
|
+
const was = over.reduce((n, x) => n + x.tokens, 0);
|
|
2805
|
+
if (used - was + merged.tokens > budget) {
|
|
2806
|
+
overBudget++;
|
|
2807
|
+
continue;
|
|
2808
|
+
}
|
|
2809
|
+
for (const x of over) chosen.splice(chosen.indexOf(x), 1);
|
|
2810
|
+
chosen.push(merged);
|
|
2811
|
+
used += merged.tokens - was;
|
|
2812
|
+
}
|
|
2813
|
+
const order = new Map([...refs, ...known].map((f, i) => [f, i]));
|
|
2814
|
+
chosen.sort((x, y) => (order.get(x.file) || 0) - (order.get(y.file) || 0) || x.a - y.a);
|
|
2815
|
+
|
|
2816
|
+
// ── §1, §2, §4 ── (T6c: three budgets, never traded)
|
|
2817
|
+
const big = known.filter((rel) => (Number((model.byFile[rel] || {}).lines) || 0) > PACK_BIG_LINES);
|
|
2818
|
+
const outlines1 = big.map((rel) => packOutline(model, rel, true, null, focusList));
|
|
2819
|
+
const outlines2 = [...refs.map((rel) => packOutline(model, rel, false)), ...parents.map((p) => packOutline(model, p.file, false, p.tag))];
|
|
2820
|
+
const outlineBudget = Math.max(200, Math.min(12000, Number(o.outlineBudget) || PACK_OUTLINE_BUDGET));
|
|
2821
|
+
const cut = [];
|
|
2822
|
+
// The OUTLINE budget: outlines are never cut for quotes. Over their own
|
|
2823
|
+
// budget, the SMALLEST members go first, and each row says `+<n> members`.
|
|
2824
|
+
const all = [...outlines1, ...outlines2];
|
|
2825
|
+
let outlineTok = tok(outlineText(all));
|
|
2826
|
+
if (outlineTok > outlineBudget) {
|
|
2827
|
+
// The members get what the fixed rows leave — but never less than 40 % of
|
|
2828
|
+
// the outline budget — shared EQUALLY by the files that have members, so
|
|
2829
|
+
// one long router outline cannot empty every other file's row. Inside a
|
|
2830
|
+
// file the largest members are kept and the smallest go first.
|
|
2831
|
+
const rowsWith = all.flatMap((x) => x.rows.filter((r) => r.part === "members"));
|
|
2832
|
+
const memberTok = rowsWith.reduce((a, r) => a + r.members.reduce((n, m) => n + Math.ceil((m.text.length + 2) / 4), 0), 0);
|
|
2833
|
+
const fixed = outlineTok - memberTok;
|
|
2834
|
+
const share = Math.floor(Math.max(outlineBudget - fixed, Math.ceil(outlineBudget * 0.4)) / Math.max(1, rowsWith.length));
|
|
2835
|
+
let dropped = 0;
|
|
2836
|
+
for (const r of rowsWith) {
|
|
2837
|
+
let used = 0;
|
|
2838
|
+
for (const m of r.members.slice().sort((a, b) => b.span - a.span)) {
|
|
2839
|
+
const c = Math.ceil((m.text.length + 2) / 4);
|
|
2840
|
+
if (used + c <= share) used += c;
|
|
2841
|
+
else {
|
|
2842
|
+
m.keep = false;
|
|
2843
|
+
dropped++;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
if (dropped) cut.push(`§1/§2 ${dropped} smallest member(s) over the ${outlineBudget}-token outline budget`);
|
|
2848
|
+
// R2b: §1 and §2 outlines share ONE hard budget. Still over → route rows
|
|
2849
|
+
// past the first 10, then the smallest kept members, then the
|
|
2850
|
+
// imports / used-by / tests rows (last file first).
|
|
2851
|
+
let hard = 0;
|
|
2852
|
+
const over = () => tok(outlineText(all)) > outlineBudget;
|
|
2853
|
+
while (over()) {
|
|
2854
|
+
const rt = all.find((x) => x.rows.filter((r) => r.part === "routes" && !/^ {2}\+\d+ (?:other )?routes$/.test(r.text)).length > 10);
|
|
2855
|
+
if (rt) {
|
|
2856
|
+
const rows = rt.rows.filter((r) => r.part === "routes" && !/^ {2}\+\d+ (?:other )?routes$/.test(r.text));
|
|
2857
|
+
rt.rows.splice(rt.rows.indexOf(rows[rows.length - 1]), 1);
|
|
2858
|
+
const more = rt.rows.find((r) => r.part === "routes" && /^ {2}\+\d+ (?:other )?routes$/.test(r.text));
|
|
2859
|
+
if (more) more.text = ` +${Number(/\d+/.exec(more.text)[0]) + 1} other routes`;
|
|
2860
|
+
else rt.rows.splice(rt.rows.indexOf(rows[rows.length - 2]) + 1, 0, { part: "routes", text: " +1 other routes" });
|
|
2861
|
+
hard++;
|
|
2862
|
+
continue;
|
|
2863
|
+
}
|
|
2864
|
+
const kept = all.flatMap((x) => x.rows.filter((r) => r.part === "members").flatMap((r) => r.members.filter((m) => m.keep)));
|
|
2865
|
+
if (kept.length) {
|
|
2866
|
+
kept.sort((a, b) => a.span - b.span)[0].keep = false;
|
|
2867
|
+
hard++;
|
|
2868
|
+
continue;
|
|
2869
|
+
}
|
|
2870
|
+
const opt = [...all].reverse().find((x) => x.rows.some((r) => ["imports", "used", "tests"].includes(r.part)));
|
|
2871
|
+
if (!opt) break;
|
|
2872
|
+
const i = opt.rows.map((r) => ["imports", "used", "tests"].includes(r.part)).lastIndexOf(true);
|
|
2873
|
+
opt.rows.splice(i, 1);
|
|
2874
|
+
hard++;
|
|
2875
|
+
}
|
|
2876
|
+
if (hard) cut.push(`§1/§2 ${hard} more row(s) or member(s) to keep the ${outlineBudget}-token outline budget`);
|
|
2877
|
+
}
|
|
2878
|
+
const testFiles = [...new Set([...known, ...refs].flatMap((rel) => packTestsOf(model, rel)))].sort();
|
|
2879
|
+
const quoted = chosen.map((ex) => packExcerpt(model, ex, blobState(model, ex.file)));
|
|
2880
|
+
const gen = model.meta.generation || 0;
|
|
2881
|
+
const nFiles = new Set([...declared, ...refs]).size;
|
|
2882
|
+
|
|
2883
|
+
// R4: the CLOSEST spec's setup. For each given ref and each name match of
|
|
2884
|
+
// >= 2 words (max 2 files): its spec's top-level outline and the setup lines
|
|
2885
|
+
// (top of file through the last top-level jest.mock / import / const before
|
|
2886
|
+
// the first describe), max 45 lines.
|
|
2887
|
+
const specFor = [...(o.refs && o.refs.length ? refs : []), ...multiHits.filter((f) => parents.some((p) => p.file === f))].slice(0, PACK_SPEC_FILES);
|
|
2888
|
+
const specBlocks = [];
|
|
2889
|
+
const specSeen = new Set();
|
|
2890
|
+
for (const rel of specFor) {
|
|
2891
|
+
const spec = closestSpec(model, rel);
|
|
2892
|
+
if (!spec || specSeen.has(spec)) continue;
|
|
2893
|
+
specSeen.add(spec);
|
|
2894
|
+
const b = specSetup(model, spec, rel);
|
|
2895
|
+
if (b) specBlocks.push(b);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// The TESTS budget (700 tokens): rows go from the end.
|
|
2899
|
+
let testLines = [];
|
|
2900
|
+
if (testFiles.length) {
|
|
2901
|
+
const rows = testRows(model, testFiles[0]);
|
|
2902
|
+
testLines = [`${testFiles[0]}${rows.length ? ` — ${rows.join("; ")}` : ""}`, ...testFiles.slice(1, 5)];
|
|
2903
|
+
let n = 0;
|
|
2904
|
+
while (testLines.length > 1 && tok([...specBlocks, ...testLines].join("\n")) > PACK_TESTS_BUDGET) {
|
|
2905
|
+
testLines.pop();
|
|
2906
|
+
n++;
|
|
2907
|
+
}
|
|
2908
|
+
if (tok(testLines.join("\n")) > PACK_TESTS_BUDGET) {
|
|
2909
|
+
testLines = [testFiles[0]];
|
|
2910
|
+
n++;
|
|
2911
|
+
}
|
|
2912
|
+
if (n) cut.push(`§4 ${n} row(s) over the ${PACK_TESTS_BUDGET}-token tests budget`);
|
|
2913
|
+
}
|
|
2914
|
+
while (specBlocks.length && tok([...specBlocks, ...testLines].join("\n")) > PACK_TESTS_BUDGET) {
|
|
2915
|
+
specBlocks.pop();
|
|
2916
|
+
cut.push(`§4 a spec setup over the ${PACK_TESTS_BUDGET}-token tests budget`);
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
const parts = [
|
|
2920
|
+
`# graph pack · gen ${gen} · ${nFiles} files`,
|
|
2921
|
+
"Read this pack ALONE, with the Read tool, before anything else. It is a map: file outlines with line",
|
|
2922
|
+
"ranges, plus a few short helpers quoted whole. Then read ONLY the ranges you need, with Read",
|
|
2923
|
+
"offset/limit or `sed -n a,bp` — one range per member you need, never a whole file over 300 lines",
|
|
2924
|
+
"that the outline covers. A file you will EDIT: read it in full first (read-ladder exception 1).",
|
|
2925
|
+
"",
|
|
2926
|
+
"## 1. Files to change — outside view",
|
|
2927
|
+
];
|
|
2928
|
+
// R2b: inside a pack the outside view gets at most ${PACK_SLICE_BUDGET} tokens in total.
|
|
2929
|
+
if (o.sliceCard) {
|
|
2930
|
+
const sc = known.length ? forSlice(model, known, { budget: PACK_SLICE_BUDGET }) : null;
|
|
2931
|
+
parts.push(sc && sc.ok ? sc.card : o.sliceCard);
|
|
2932
|
+
}
|
|
2933
|
+
if (outlines1.length) parts.push(outlineText(outlines1));
|
|
2934
|
+
if (fresh.length) parts.push(`new (no record yet): ${fresh.join(", ")}`);
|
|
2935
|
+
if (!o.sliceCard && !outlines1.length && !fresh.length) parts.push("(no declared file)");
|
|
2936
|
+
parts.push("", "## 2. Reference files — outline");
|
|
2937
|
+
parts.push(outlines2.length ? outlineText(outlines2) : "(no reference file)");
|
|
2938
|
+
if (refMissing.length) parts.push(`not in the graph: ${refMissing.join(", ")}`);
|
|
2939
|
+
parts.push("", "## 3. Quoted ranges");
|
|
2940
|
+
parts.push(quoted.length ? quoted.join("\n\n") : "(nothing quoted)");
|
|
2941
|
+
parts.push("", "## 4. Tests that reach these files");
|
|
2942
|
+
parts.push(testLines.length ? testLines.join("\n") : "(no test reaches these files)");
|
|
2943
|
+
if (specBlocks.length) parts.push("", specBlocks.join("\n\n"));
|
|
2944
|
+
let text = parts.join("\n");
|
|
2945
|
+
// §3 keeps to its own budget; a range the budget left out is named.
|
|
2946
|
+
if (overBudget) cut.push(`§3 ${overBudget} range(s) over the ${budget}-token quote budget`);
|
|
2947
|
+
if (cut.length) text += `\n\ncut: ${cut.join(", ")}`;
|
|
2948
|
+
text += "\n";
|
|
2949
|
+
return {
|
|
2950
|
+
ok: true,
|
|
2951
|
+
files: nFiles,
|
|
2952
|
+
refs,
|
|
2953
|
+
parents: parents.map((p) => ({ file: p.file, tag: p.tag })),
|
|
2954
|
+
refs_missing: refMissing,
|
|
2955
|
+
excerpts: chosen.length,
|
|
2956
|
+
quoted: chosen.map((x) => ({ file: x.file, from: x.a, to: x.b, names: x.names })),
|
|
2957
|
+
tokens: tok(text),
|
|
2958
|
+
cut,
|
|
2959
|
+
big_declared: big,
|
|
2960
|
+
budget,
|
|
2961
|
+
text,
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
// ── v2.2.0 (R6): the executor ANCHORS ───────────────────────────────────────
|
|
2966
|
+
// The pack file was read first but did not shorten the search: it STEERED the
|
|
2967
|
+
// first calls toward the files it outlined, and it rode along every later
|
|
2968
|
+
// turn. Turns, not bytes, drive cost. The anchors are the opposite shape: at
|
|
2969
|
+
// most 12 `path:a-b` ranges INLINE in the slice, and ONE shell command that
|
|
2970
|
+
// prints them all — one call, then the executor reads on from there.
|
|
2971
|
+
//
|
|
2972
|
+
// Never a whole file, never a test helper, ranked by focus score, overlapping
|
|
2973
|
+
// ranges in one file merged. No file is written.
|
|
2974
|
+
const ANCHOR_MAX = 12;
|
|
2975
|
+
const ANCHOR_REF_MEMBERS = 3;
|
|
2976
|
+
const ANCHOR_DECL_MEMBERS = 2;
|
|
2977
|
+
const ANCHOR_RANGE_LINES = 80;
|
|
2978
|
+
// v2.2.0 (R8): the card carries the code inline, so its cap follows the read cap.
|
|
2979
|
+
const ANCHOR_CARD_BYTES = 11264;
|
|
2980
|
+
// v2.2.0 (R7): five-line ranges were not enough to copy from; executors
|
|
2981
|
+
// `cat` the whole file. Each range is padded, and the read is capped.
|
|
2982
|
+
const ANCHOR_PAD = 6;
|
|
2983
|
+
const ANCHOR_READ_TOKENS = 2500;
|
|
2984
|
+
const ANCHOR_CALLERS = 6;
|
|
2985
|
+
const ANCHOR_CALLER_PAD = 4;
|
|
2986
|
+
const ANCHOR_CALLER_SCORE = 10;
|
|
2987
|
+
// v2.2.0 (R10): the caller cap when a focus word names a declared export.
|
|
2988
|
+
const ANCHOR_FOCUS_CALLERS = 8;
|
|
2989
|
+
const ANCHOR_NEVER = new Set(["module", "template", "prop", "emit", "inject"]);
|
|
2990
|
+
const SAFE_PATH = /^[A-Za-z0-9._@+\/-]+$/;
|
|
2991
|
+
|
|
2992
|
+
// v2.2.0: a declared file with this many route records is a ROUTER.
|
|
2993
|
+
const ANCHOR_ROUTER_MIN = 10;
|
|
2994
|
+
const ANCHOR_COMMENT_REACH = 15;
|
|
2995
|
+
const QUOTED_PATH_COMMENT = /(\/\/|\/\*|^\s*\*).*['"`][^'"`\s]*\/[^'"`\s]*['"`]/;
|
|
2996
|
+
|
|
2997
|
+
// The top feature folder: the first segment after `src/views/`, else after `src/`.
|
|
2998
|
+
function featureOf(rel) {
|
|
2999
|
+
const m = rel.match(/(?:^|\/)src\/views\/([^/]+)\//) || rel.match(/(?:^|\/)src\/([^/]+)\//);
|
|
3000
|
+
return m ? m[1] : "";
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function anchorHits(want, s) {
|
|
3004
|
+
const words = focusWords(`${s.qname} ${s.route_name || ""}`);
|
|
3005
|
+
return [...want].filter((w) => words.some((seg) => wordMatch(w, seg)));
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
function anchorMembers(model, rel) {
|
|
3009
|
+
const f = model.byFile[rel] || {};
|
|
3010
|
+
const nLines = Number(f.lines) || (fileLines(model, rel) || []).length;
|
|
3011
|
+
const sfc = /\.(vue|svelte)$/.test(rel);
|
|
3012
|
+
return (f.symbols || []).filter((s) => {
|
|
3013
|
+
if (ANCHOR_NEVER.has(s.kind)) return false;
|
|
3014
|
+
if (sfc && s.kind === "class" && !s.qname.includes(".")) return false;
|
|
3015
|
+
const span = s.lines[1] - s.lines[0] + 1;
|
|
3016
|
+
return !(nLines && span >= Math.max(10, nLines * 0.9));
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// `opts`: { declared, refs (null = name match), focus }
|
|
3021
|
+
function buildAnchors(claudeDir, model, opts) {
|
|
3022
|
+
const SIG = require("./graph-signals.js");
|
|
3023
|
+
const o = opts || {};
|
|
3024
|
+
const declared = [...new Set((o.declared || []).map(norm))];
|
|
3025
|
+
const known = declared.filter((r) => model.fileSet.has(r) && !TEST_FILE.test(r));
|
|
3026
|
+
const declaredSet = new Set(known);
|
|
3027
|
+
const focusList = (o.focus || []).filter(Boolean);
|
|
3028
|
+
const want = new Set(focusList.flatMap((w) => focusWords(w)).filter((w) => w.length >= 2));
|
|
3029
|
+
const base = (f) => f.split("/").pop();
|
|
3030
|
+
const tokens = new Set(focusList.flatMap((w) => String(w).split(/[\s,]+/)).map((w) => w.toLowerCase()).filter(Boolean));
|
|
3031
|
+
const focusCallees = [];
|
|
3032
|
+
for (const rel of known) {
|
|
3033
|
+
for (const s of (model.byFile[rel] || {}).symbols || []) {
|
|
3034
|
+
// R10b: CALLABLE only — a function or method, or an exported const with a
|
|
3035
|
+
// confident caller (an arrow helper). Never a class (a Vue SFC component).
|
|
3036
|
+
if (!s.exported || !(s.kind === "function" || s.kind === "method" || (s.kind === "const" && callersOf(model, s).confident.length))) continue;
|
|
3037
|
+
const nm = s.name || String(s.qname || "").split(".").pop();
|
|
3038
|
+
const nw = focusWords(nm);
|
|
3039
|
+
if (tokens.has(String(nm).toLowerCase()) || (nw.length >= 2 && nw.every((w) => [...want].some((x) => wordMatch(x, w))))) focusCallees.push({ rel, s, nm });
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
const focusMode = focusCallees.length > 0;
|
|
3043
|
+
|
|
3044
|
+
// ── refs: given, else a name match of >= 2 focus words, else the best one ──
|
|
3045
|
+
let refs = [];
|
|
3046
|
+
const refMissing = [];
|
|
3047
|
+
if (o.refs && o.refs.length) {
|
|
3048
|
+
for (const q of o.refs) {
|
|
3049
|
+
const r = resolveRef(model, q);
|
|
3050
|
+
if (r.candidates) return { ok: false, state: "ambiguous-target", query: String(q), candidates: r.candidates.map((file) => ({ file })), exit: 4 };
|
|
3051
|
+
if (r.missing) refMissing.push(r.missing);
|
|
3052
|
+
else if (!refs.includes(r.file) && !TEST_FILE.test(r.file)) refs.push(r.file);
|
|
3053
|
+
}
|
|
3054
|
+
} else if (want.size && !focusMode) {
|
|
3055
|
+
// R10b: in focus mode the call sites are the work; no auto name-matched refs.
|
|
3056
|
+
const declWords = new Set(known.flatMap((rel) => focusWords(base(rel).replace(/\.[^.]+$/, ""))));
|
|
3057
|
+
const fw = [...want].filter((w) => !declWords.has(w));
|
|
3058
|
+
const hits = [];
|
|
3059
|
+
for (const rel of model.fileSet) {
|
|
3060
|
+
if (TEST_FILE.test(rel) || declaredSet.has(rel)) continue;
|
|
3061
|
+
const own = focusWords(base(rel).replace(/\.[^.]+$/, ""));
|
|
3062
|
+
const words = fw.filter((w) => own.some((seg) => wordMatch(w, seg)));
|
|
3063
|
+
if (words.length) hits.push({ rel, n: words.length, imp: importersOf(model, rel).length });
|
|
3064
|
+
}
|
|
3065
|
+
const featA = new Set(known.map(featureOf).filter(Boolean));
|
|
3066
|
+
const xf = (rel) => (featA.has(featureOf(rel)) ? 0 : 1);
|
|
3067
|
+
hits.sort((a, b) => xf(a.rel) - xf(b.rel) || b.n - a.n || b.imp - a.imp || a.rel.localeCompare(b.rel));
|
|
3068
|
+
const multi = hits.filter((h) => h.n >= 2).map((h) => h.rel);
|
|
3069
|
+
refs = multi.length ? multi.slice(0, PACK_AUTO_REFS) : hits.slice(0, 1).map((h) => h.rel);
|
|
3070
|
+
}
|
|
3071
|
+
refs = refs.slice(0, PACK_MAX_REFS);
|
|
3072
|
+
warmFiles(model, new Set([...known, ...refs]));
|
|
3073
|
+
|
|
3074
|
+
// ── candidates ──
|
|
3075
|
+
const cands = [];
|
|
3076
|
+
const add = (s, score, why) => {
|
|
3077
|
+
const a = s.lines[0];
|
|
3078
|
+
const b = Math.min(s.lines[1], a + ANCHOR_RANGE_LINES - 1);
|
|
3079
|
+
cands.push({ file: s.file, a, b, names: [s.kind === "route" && !/^VIEW\b/.test(s.qname) ? `VIEW ${s.qname}` : s.qname], score, why: [why] });
|
|
3080
|
+
};
|
|
3081
|
+
// v2.2.0: a ROUTER edit target always gives the route record nearest the
|
|
3082
|
+
// change, plus any comment within reach that quotes a path.
|
|
3083
|
+
for (const rel of known) {
|
|
3084
|
+
const routes = ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind === "route");
|
|
3085
|
+
if (routes.length < ANCHOR_ROUTER_MIN) continue;
|
|
3086
|
+
const best = routes
|
|
3087
|
+
.map((r) => ({ r, n: anchorHits(want, r).length }))
|
|
3088
|
+
.sort((x, y) => y.n - x.n || x.r.lines[0] - y.r.lines[0])[0].r;
|
|
3089
|
+
const src = fileLines(model, rel) || [];
|
|
3090
|
+
let a = Math.max(1, best.lines[0] - ANCHOR_PAD);
|
|
3091
|
+
let b = Math.min(src.length || best.lines[1], best.lines[1] + ANCHOR_PAD);
|
|
3092
|
+
const lo = Math.max(1, best.lines[0] - ANCHOR_COMMENT_REACH);
|
|
3093
|
+
const hi = Math.min(src.length, best.lines[1] + ANCHOR_COMMENT_REACH);
|
|
3094
|
+
for (let i = lo; i <= hi; i++) {
|
|
3095
|
+
if (QUOTED_PATH_COMMENT.test(src[i - 1] || "")) { a = Math.min(a, i); b = Math.max(b, i); }
|
|
3096
|
+
}
|
|
3097
|
+
const hits = anchorHits(want, best);
|
|
3098
|
+
cands.push({ file: rel, a, b, names: [/^VIEW\b/.test(best.qname) ? best.qname : `VIEW ${best.qname}`], score: 1000, why: [`router: ${hits.length ? hits.join(", ") : "nearest route"}`], fixed: true });
|
|
3099
|
+
}
|
|
3100
|
+
for (const rel of focusMode ? [] : known) {
|
|
3101
|
+
// R10b: in focus mode the call sites ARE the edit targets — no generic member scan.
|
|
3102
|
+
if ((Number((model.byFile[rel] || {}).lines) || 0) <= PACK_BIG_LINES) continue;
|
|
3103
|
+
const scored = anchorMembers(model, rel)
|
|
3104
|
+
.map((s) => ({ s, hits: anchorHits(want, s) }))
|
|
3105
|
+
.filter((x) => x.hits.length)
|
|
3106
|
+
.sort((x, y) => y.hits.length - x.hits.length || (x.s.kind === "route") - (y.s.kind === "route") || x.s.lines[0] - y.s.lines[0]);
|
|
3107
|
+
for (const { s, hits } of scored.slice(0, ANCHOR_DECL_MEMBERS)) add(s, hits.length * 3 + 1, `edit target: ${hits.join(", ")}`);
|
|
3108
|
+
}
|
|
3109
|
+
// v2.2.0: a declared file's exported symbols called from OTHER files give up
|
|
3110
|
+
// to 6 CALLER anchors (the call line +-4, inside the caller's own symbol) —
|
|
3111
|
+
// the edit sites of a signature change. Ranked by focus words, then file.
|
|
3112
|
+
// v2.2.0 (R10): FOCUS CALLEES — an export of a declared file whose name IS a
|
|
3113
|
+
// focus token, or whose >= 2 name words are all focus words. When one exists,
|
|
3114
|
+
// only its callers count, declared files INCLUDED (those are the call sites a
|
|
3115
|
+
// signature change edits), up to 8, above every edit-target hit, and its own
|
|
3116
|
+
// definition comes first. With none, the rule above holds unchanged.
|
|
3117
|
+
for (const { rel, s, nm } of focusCallees) {
|
|
3118
|
+
const a = s.lines[0];
|
|
3119
|
+
cands.push({ file: rel, a, b: Math.min(s.lines[1], a + ANCHOR_RANGE_LINES - 1), names: [s.qname], score: ANCHOR_CALLER_SCORE + 30, why: [`edit target: ${nm}`], fixed: true });
|
|
3120
|
+
}
|
|
3121
|
+
const callerSrc = focusMode
|
|
3122
|
+
? focusCallees.map((x) => ({ rel: x.rel, syms: [x.s] }))
|
|
3123
|
+
: known.map((rel) => ({ rel, syms: ((model.byFile[rel] || {}).symbols || []).filter((s) => s.exported && !ANCHOR_NEVER.has(s.kind) && s.kind !== "route") }));
|
|
3124
|
+
const callerC = [];
|
|
3125
|
+
for (const { rel, syms } of callerSrc) {
|
|
3126
|
+
for (const s of syms) {
|
|
3127
|
+
for (const c of callersOf(model, s).confident) {
|
|
3128
|
+
const cs = c.sym;
|
|
3129
|
+
if (!cs || !cs.file || cs.file === rel || (!focusMode && declaredSet.has(cs.file)) || TEST_FILE.test(cs.file) || !c.line) continue;
|
|
3130
|
+
const hits = anchorHits(want, cs).concat(focusWords(base(cs.file).replace(/\.[^.]+$/, "")).filter((w) => want.has(w)));
|
|
3131
|
+
const a = Math.max(1, cs.lines[0], c.line - ANCHOR_CALLER_PAD);
|
|
3132
|
+
const b = Math.min(cs.lines[1], c.line + ANCHOR_CALLER_PAD);
|
|
3133
|
+
callerC.push({ file: cs.file, a, b: Math.max(a, b), names: [cs.qname], score: ANCHOR_CALLER_SCORE + (focusMode ? 20 : 0) + new Set(hits).size, why: [`caller of ${s.qname}`], fixed: true });
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
callerC.sort((x, y) => y.score - x.score || x.file.localeCompare(y.file) || x.a - y.a);
|
|
3138
|
+
const callerSeen = new Set();
|
|
3139
|
+
for (const c of callerC) {
|
|
3140
|
+
const k = `${c.file}:${c.a}`;
|
|
3141
|
+
if (callerSeen.has(k) || callerSeen.size >= (focusMode ? ANCHOR_FOCUS_CALLERS : ANCHOR_CALLERS)) continue;
|
|
3142
|
+
callerSeen.add(k);
|
|
3143
|
+
cands.push(c);
|
|
3144
|
+
}
|
|
3145
|
+
const featD = new Set(known.map(featureOf).filter(Boolean));
|
|
3146
|
+
const given = new Set(o.refs && o.refs.length ? refs : []);
|
|
3147
|
+
for (const rel of refs) {
|
|
3148
|
+
// v2.2.0: a cross-feature copy-from ranks after a same-feature one, unless given.
|
|
3149
|
+
const pen = !given.has(rel) && featD.size && !featD.has(featureOf(rel)) ? 100 : 0;
|
|
3150
|
+
const scored = anchorMembers(model, rel)
|
|
3151
|
+
.map((s) => ({ s, hits: anchorHits(want, s), imp: SIG.importance(model, s, callersOf(model, s).confident.length) }))
|
|
3152
|
+
.sort((x, y) => y.hits.length - x.hits.length || y.imp - x.imp || x.s.lines[0] - y.s.lines[0]);
|
|
3153
|
+
for (const { s, hits } of scored.slice(0, ANCHOR_REF_MEMBERS)) add(s, hits.length * 3 - pen, hits.length ? `copy-from: ${hits.join(", ")}` : "copy-from");
|
|
3154
|
+
// v2.2.0 (R7): a .vue ref also gives its CONTRACT, the props/emits lines merged.
|
|
3155
|
+
if (/\.vue$/.test(rel)) {
|
|
3156
|
+
const con = ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind === "prop" || x.kind === "emit");
|
|
3157
|
+
if (con.length) {
|
|
3158
|
+
const a = Math.min(...con.map((x) => x.lines[0]));
|
|
3159
|
+
const b = Math.max(...con.map((x) => x.lines[1]));
|
|
3160
|
+
cands.push({ file: rel, a, b: Math.min(b, a + ANCHOR_RANGE_LINES - 1), names: ["props/emits"], score: 1 - pen, why: ["contract"] });
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// v2.2.0 (R7): pad every range, clipped to the file and to its owner block.
|
|
3166
|
+
for (const c of cands) {
|
|
3167
|
+
if (c.fixed) continue;
|
|
3168
|
+
const f = model.byFile[c.file] || {};
|
|
3169
|
+
const n = Number(f.lines) || (fileLines(model, c.file) || []).length || c.b;
|
|
3170
|
+
let lo = 1;
|
|
3171
|
+
let hi = n;
|
|
3172
|
+
const owner = (f.symbols || [])
|
|
3173
|
+
.filter((x) => x.lines[0] <= c.a && x.lines[1] >= c.b && x.lines[1] - x.lines[0] > c.b - c.a)
|
|
3174
|
+
.sort((x, y) => (x.lines[1] - x.lines[0]) - (y.lines[1] - y.lines[0]))[0];
|
|
3175
|
+
if (owner) { lo = owner.lines[0]; hi = owner.lines[1]; }
|
|
3176
|
+
c.a = Math.max(1, lo, c.a - ANCHOR_PAD);
|
|
3177
|
+
c.b = Math.min(n, hi, c.b + ANCHOR_PAD);
|
|
3178
|
+
if (c.b < c.a) c.b = c.a;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
// Rank, merge overlapping ranges in one file, then keep the best 12.
|
|
3182
|
+
cands.sort((x, y) => y.score - x.score || x.file.localeCompare(y.file) || x.a - y.a);
|
|
3183
|
+
let chosen = [];
|
|
3184
|
+
for (const c of cands) {
|
|
3185
|
+
if (!SAFE_PATH.test(c.file)) continue;
|
|
3186
|
+
const over = chosen.find((x) => x.file === c.file && x.a <= c.b && c.a <= x.b);
|
|
3187
|
+
if (over) {
|
|
3188
|
+
over.a = Math.min(over.a, c.a);
|
|
3189
|
+
over.b = Math.max(over.b, c.b);
|
|
3190
|
+
for (const n of c.names) if (!over.names.includes(n)) over.names.push(n);
|
|
3191
|
+
continue;
|
|
3192
|
+
}
|
|
3193
|
+
if (chosen.length < ANCHOR_MAX) chosen.push({ ...c });
|
|
3194
|
+
}
|
|
3195
|
+
const gen = model.meta.generation || 0;
|
|
3196
|
+
const inlineOf = (x) => /^(edit target|router|caller)/.test(x.why[0]);
|
|
3197
|
+
const render = (list) => {
|
|
3198
|
+
const lines = list.map((x) => {
|
|
3199
|
+
const changed = blobState(model, x.file) !== "current" ? ", CHANGED since index" : "";
|
|
3200
|
+
return `${x.file}:${x.a}-${x.b} ${x.names.join(", ")} (${x.why[0]}${changed})`;
|
|
3201
|
+
});
|
|
3202
|
+
const readCmd = list.length ? `for r in ${list.map((x) => `"${x.file} ${x.a} ${x.b}"`).join(" ")}; do set -- $r; echo "== $1:$2-$3"; sed -n "$2,$3p" "$1"; done` : "";
|
|
3203
|
+
// v2.2.0 (R8): the code of the EDIT-SITE ranges (edit target, router, caller)
|
|
3204
|
+
// INLINE — the same bytes `read all` prints. Copy-from and contract ranges stay
|
|
3205
|
+
// `path:a-b` lines: the executor reads those wider anyway.
|
|
3206
|
+
const code = list.filter(inlineOf).map((x) => `== ${x.file}:${x.a}-${x.b} · ${x.names[0]}\n${(fileLines(model, x.file) || []).slice(x.a - 1, x.b).join("\n")}`).join("\n");
|
|
3207
|
+
const ticks = Math.max(2, ...((code.match(/`+/g) || []).map((t) => t.length))) + 1;
|
|
3208
|
+
const fence = "`".repeat(ticks);
|
|
3209
|
+
const card = list.length ? `${lines.join("\n")}\nread all: ${readCmd}${code ? `\n${fence}\n${code}\n${fence}` : ""}` : "";
|
|
3210
|
+
return { lines, readCmd, code, card };
|
|
3211
|
+
};
|
|
3212
|
+
const readOf = (list) => list.filter(inlineOf).reduce((n, x) => n + tok((fileLines(model, x.file) || []).slice(x.a - 1, x.b).join("\n")), 0);
|
|
3213
|
+
while (chosen.length > 1 && readOf(chosen) > ANCHOR_READ_TOKENS) chosen = chosen.slice(0, -1);
|
|
3214
|
+
let out = render(chosen);
|
|
3215
|
+
while (chosen.length > 1 && Buffer.byteLength(out.card) > ANCHOR_CARD_BYTES) {
|
|
3216
|
+
chosen = chosen.slice(0, -1);
|
|
3217
|
+
out = render(chosen);
|
|
3218
|
+
}
|
|
3219
|
+
const files = new Set(chosen.map((x) => x.file)).size;
|
|
3220
|
+
const readTok = readOf(chosen);
|
|
3221
|
+
return {
|
|
3222
|
+
ok: true,
|
|
3223
|
+
refs,
|
|
3224
|
+
refs_missing: refMissing,
|
|
3225
|
+
ranges: chosen.length,
|
|
3226
|
+
files,
|
|
3227
|
+
lines: out.lines,
|
|
3228
|
+
read_cmd: out.readCmd,
|
|
3229
|
+
code: out.code,
|
|
3230
|
+
read_tokens: readTok,
|
|
3231
|
+
tokens: tok(out.card),
|
|
3232
|
+
card: out.card,
|
|
3233
|
+
gen,
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
module.exports = { buildPack, buildAnchors, loadModel, resolveCall, resolveUrl, routeTable, urlIndex, findTarget, callersOf, nameHits, warmBlobs, ctx, forSlice, impact, graphMap, pathBetween, importsOf, TEST_FILE, SOURCE_DEFAULT, SOURCE_CAP, CALLERS_SOURCE_MAX, CALLERS_SOURCE_LINES };
|