@hone-ai/cli 1.34.0 → 1.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hone-mcp.js +1 -0
- package/hone-cli.js +1341 -795
- package/lib/agent-eval-pacing.js +102 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-stateful.js +72 -0
- package/lib/agent-eval-score.js +70 -0
- package/lib/agent-executor.js +26 -4
- package/lib/asset-fetch-list.js +72 -0
- package/lib/auto-verify.js +67 -3
- package/lib/batch-follow.js +333 -0
- package/lib/block-grammar.js +126 -0
- package/lib/bundle-context.js +43 -4
- package/lib/bundle-paths.js +698 -1
- package/lib/bundle-source-dirs.js +79 -0
- package/lib/doctor-bind-default.js +192 -18
- package/lib/doctor-local-run.js +139 -1
- package/lib/emit-pr.js +39 -6
- package/lib/git-helpers.js +19 -8
- package/lib/hard-pause-remedy.js +101 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +97 -7
- package/lib/materialize-diff.js +131 -73
- package/lib/mcp-tools.js +20 -14
- package/lib/pipeline-config.js +182 -0
- package/lib/private-temp.js +52 -0
- package/lib/promote-payload.js +77 -0
- package/lib/refresh-knowledge.js +15 -6
- package/lib/repo-tree.js +22 -3
- package/lib/run-context.js +698 -0
- package/lib/run-target.js +66 -1
- package/lib/sandbox-lifecycle.js +357 -0
- package/lib/schedule-cron.js +1 -1
- package/lib/skill-assertions.js +61 -7
- package/lib/skill-audit-render.js +1 -1
- package/lib/story-classifier-extract.js +16 -8
- package/lib/story-classifier.js +64 -0
- package/lib/story-compose.js +69 -0
- package/lib/story-line.js +40 -0
- package/lib/story-provenance.js +267 -0
- package/lib/timeout-option.js +42 -0
- package/lib/untrusted-code-env.js +51 -0
- package/lib/verdict-env.js +31 -0
- package/lib/verify-patch.js +74 -4
- package/lib/verify-pr.js +2 -1
- package/lib/worktree-provision.js +62 -2
- package/package.json +1 -1
package/lib/bundle-paths.js
CHANGED
|
@@ -32,7 +32,36 @@
|
|
|
32
32
|
* Extensions mirror FILE_PATH_RE exactly. The trailing `\b` mirrors the
|
|
33
33
|
* followup-13c fix so `.jsonl` is not truncated to `.js`.
|
|
34
34
|
*/
|
|
35
|
-
|
|
35
|
+
// ── HC-GF-202: what counts as a file reference ───────────────────────────────
|
|
36
|
+
// Widening this is deliberate and load-bearing: it decides what EVERY run
|
|
37
|
+
// bundles. It was measured, not guessed — three live runs of a story whose whole
|
|
38
|
+
// deliverable was a `Makefile` recipe bundled the Makefile zero times, because
|
|
39
|
+
// every pattern here required an extension from CODE_EXT.
|
|
40
|
+
//
|
|
41
|
+
// CONFIG_EXT and EXTENSIONLESS are the files a build story actually names.
|
|
42
|
+
// DOTFILES are listed explicitly rather than by a leading-dot rule, which would
|
|
43
|
+
// match `.env` and every other secret-shaped name.
|
|
44
|
+
// Markdown is deliberately absent: docs are not the source context an agent
|
|
45
|
+
// needs to write code, and every story mentions a `.md` file.
|
|
46
|
+
const CODE_EXT = 'py|js|ts|jsx|tsx|go|rs|java|rb|sh|sql';
|
|
47
|
+
// `yml`, `yaml` and `json` are deliberately ABSENT. HC-019n-followup-12 excluded
|
|
48
|
+
// them with a stated reason — bundling prose blows the context window — and a
|
|
49
|
+
// regression pins `ops/vals.yml` as not-detected. HC-GF-202's row asks for YAML
|
|
50
|
+
// config to be visible; that collides with a decision made for a measured cost,
|
|
51
|
+
// and the row's own acceptance is the EXTENSIONLESS half. `json` is excluded on
|
|
52
|
+
// the same logic plus a second: lockfiles are the largest files in most repos.
|
|
53
|
+
// A story that genuinely needs one names it with --include-paths, which HC-GF-202
|
|
54
|
+
// also repairs. Revisit only with a measurement of what it costs.
|
|
55
|
+
const CONFIG_EXT = 'toml|cfg|ini|conf|mk';
|
|
56
|
+
const FILE_EXT = `${CODE_EXT}|${CONFIG_EXT}`;
|
|
57
|
+
// Case-sensitive on purpose: `Makefile` is a filename, `makefile approach` is prose.
|
|
58
|
+
const EXTENSIONLESS = 'Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Jenkinsfile|Vagrantfile|Justfile|Brewfile';
|
|
59
|
+
const DOTFILES = '\\.(?:gitignore|dockerignore|gitattributes|gitmodules|editorconfig|npmrc|nvmrc)';
|
|
60
|
+
|
|
61
|
+
const BARE_FILE_RE = new RegExp(
|
|
62
|
+
`(?<![\\w/.-])((?:[a-zA-Z_][\\w-]*\\.(?:${FILE_EXT})|(?:${EXTENSIONLESS})|(?:${DOTFILES})))\\b(?::(\\d+))?`,
|
|
63
|
+
'g',
|
|
64
|
+
);
|
|
36
65
|
|
|
37
66
|
/**
|
|
38
67
|
* Extract bare filename references (no directory segment) with an optional
|
|
@@ -102,6 +131,60 @@ function windowAroundLine(content, line, radiusChars) {
|
|
|
102
131
|
return { excerpt, startLine, endLine, totalLines: total };
|
|
103
132
|
}
|
|
104
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A window over explicit line bounds, for merging two that intersect.
|
|
136
|
+
* `windowAroundLine` centres on a line with a CHAR radius; a merge needs the
|
|
137
|
+
* union of two LINE ranges instead. HC-GF-206.
|
|
138
|
+
*/
|
|
139
|
+
function windowBetweenLines(content, startLine, endLine) {
|
|
140
|
+
const all = String(content).split('\n');
|
|
141
|
+
const total = all.length;
|
|
142
|
+
const s = Math.max(1, Math.min(total, Number(startLine) || 1));
|
|
143
|
+
const e = Math.max(s, Math.min(total, Number(endLine) || s));
|
|
144
|
+
const excerpt = all.slice(s - 1, e).join('\n');
|
|
145
|
+
return { excerpt, startLine: s, endLine: e, totalLines: total };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Collapse candidate windows whose LINE RANGES intersect into their union.
|
|
150
|
+
*
|
|
151
|
+
* HC-GF-206: the near-duplicate collapse upstream compares ANCHOR distance
|
|
152
|
+
* (< 40 lines), but window width is set by `radius`, far larger at a realistic
|
|
153
|
+
* cap — so two anchors 50 lines apart pass that check and produce windows that
|
|
154
|
+
* overlap almost entirely. The budget then buys the same bytes twice while
|
|
155
|
+
* distant regions are dropped for want of room, which is the exact failure
|
|
156
|
+
* HC-GF-204 was written to prevent. Cap-dependent and perverse: radius grows
|
|
157
|
+
* with the budget, so a larger cap wastes proportionally more of it.
|
|
158
|
+
*
|
|
159
|
+
* Merge rather than drop: the second window usually extends past the first, so
|
|
160
|
+
* discarding it would lose real ground. The union keeps both and costs less
|
|
161
|
+
* than the two separately.
|
|
162
|
+
*
|
|
163
|
+
* The while-loop matters. Merging A and B can produce a range that now
|
|
164
|
+
* intersects C, accepted earlier; a single pass would leave that pair behind.
|
|
165
|
+
*/
|
|
166
|
+
function mergeIntersectingWindows(content, entries) {
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
let cur = entry;
|
|
170
|
+
let i;
|
|
171
|
+
while ((i = out.findIndex((o) =>
|
|
172
|
+
cur.w.startLine <= o.w.endLine && o.w.startLine <= cur.w.endLine)) !== -1) {
|
|
173
|
+
const o = out.splice(i, 1)[0];
|
|
174
|
+
cur = {
|
|
175
|
+
w: windowBetweenLines(content,
|
|
176
|
+
Math.min(o.w.startLine, cur.w.startLine),
|
|
177
|
+
Math.max(o.w.endLine, cur.w.endLine)),
|
|
178
|
+
// Keep the earlier-listed (better-ranked) anchor as the region's label.
|
|
179
|
+
anchor: o.anchor != null ? o.anchor : cur.anchor,
|
|
180
|
+
line: o.line,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
out.push(cur);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
105
188
|
/**
|
|
106
189
|
* Resolve a bare filename against the repo's tracked file list.
|
|
107
190
|
*
|
|
@@ -136,6 +219,620 @@ function alreadyCovered(candidate, existing) {
|
|
|
136
219
|
return false;
|
|
137
220
|
}
|
|
138
221
|
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Full paths WITH an optional `:line` — HC-GF-188.
|
|
225
|
+
*
|
|
226
|
+
* The CLI's own FILE_PATH_RE ends at the extension, so a story writing
|
|
227
|
+
* `server/src/routes/_all.js:712` had its `:712` discarded and the windowing
|
|
228
|
+
* HC-019n-followup-27 built could never fire for it. A bare ref already carried its line
|
|
229
|
+
* (BARE_FILE_RE above captures one); a full path did not, and full paths are what stories
|
|
230
|
+
* normally write — so the capability fired on the rarer shape and never on the usual one.
|
|
231
|
+
*
|
|
232
|
+
* The path half of the pattern is UNCHANGED from the CLI's regex, deliberately. This decides
|
|
233
|
+
* what every run bundles, and widening it silently changes the context every agent receives.
|
|
234
|
+
* Only the optional `(?::(\d+))?` tail is new.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} text
|
|
237
|
+
* @returns {Array<{path: string, line: number|null}>} order-preserving, de-duplicated by path
|
|
238
|
+
*/
|
|
239
|
+
// HC-GF-202: the first segment may now start with a dot (`.github/...`) and the
|
|
240
|
+
// last may be extensionless (`scripts/Makefile`). `.github/.pipeline-config.yml`
|
|
241
|
+
// drives every run and was invisible to this pattern.
|
|
242
|
+
const PATH_REF_RE = new RegExp(
|
|
243
|
+
`(?<![\\w/])(\\.?[a-zA-Z_][\\w-]*\\/(?:[\\w.-]+\\/)*(?:[\\w.-]+\\.(?:${FILE_EXT})|(?:${EXTENSIONLESS})))\\b(?::(\\d+))?`,
|
|
244
|
+
'g',
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
function extractPathRefs(text) {
|
|
248
|
+
if (!text || typeof text !== 'string') return [];
|
|
249
|
+
const seen = new Set();
|
|
250
|
+
const out = [];
|
|
251
|
+
for (const m of text.matchAll(PATH_REF_RE)) {
|
|
252
|
+
if (seen.has(m[1])) continue;
|
|
253
|
+
seen.add(m[1]);
|
|
254
|
+
out.push({ path: m[1], line: m[2] ? Number(m[2]) : null });
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Backticked spans in a story, longest first — the longest is the most specific anchor. */
|
|
260
|
+
const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The line of the first thing the STORY quoted that actually occurs in this file — HC-GF-188.
|
|
264
|
+
*
|
|
265
|
+
* A story names its target in two ways. `path:line` states it outright; more often the story
|
|
266
|
+
* quotes the code, as HC-GF-126 does — "`server/src/routes/_all.js` reports
|
|
267
|
+
* `Math.ceil((expiresMs - Date.now()) / 86400000)`". Both name the same region, and only the
|
|
268
|
+
* first was usable.
|
|
269
|
+
*
|
|
270
|
+
* RETURNS null RATHER THAN A DEFAULT. Falling back to line 1 would produce a confident window
|
|
271
|
+
* around the wrong region, which reads to every downstream agent exactly like a correct one —
|
|
272
|
+
* and the operator would be told the file was anchored. "Could not anchor" has to stay
|
|
273
|
+
* distinguishable from "anchored here".
|
|
274
|
+
*
|
|
275
|
+
* Candidates are tried longest-first: a quoted expression is a far stronger locator than a
|
|
276
|
+
* quoted identifier that may appear in a hundred places. Path-shaped and whitespace-only spans
|
|
277
|
+
* are skipped — a story always quotes the file's own path, and matching that would anchor on
|
|
278
|
+
* whatever line happened to mention it.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} content full file contents
|
|
281
|
+
* @param {string} storyText
|
|
282
|
+
* @returns {{line: number, anchor: string}|null}
|
|
283
|
+
*/
|
|
284
|
+
function anchorLineForStory(content, storyText) {
|
|
285
|
+
const text = String(content || '');
|
|
286
|
+
const story = String(storyText || '');
|
|
287
|
+
if (!text || !story) return null;
|
|
288
|
+
|
|
289
|
+
const candidates = [...story.matchAll(BACKTICKED_RE)]
|
|
290
|
+
.map((m) => m[1].trim())
|
|
291
|
+
.filter((c) => c.length >= 3
|
|
292
|
+
&& !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
|
|
293
|
+
&& !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
|
|
294
|
+
.sort((a, b) => b.length - a.length);
|
|
295
|
+
|
|
296
|
+
for (const anchor of candidates) {
|
|
297
|
+
const at = definitionIndexOf(text, anchor);
|
|
298
|
+
if (at === -1) continue;
|
|
299
|
+
return { line: text.slice(0, at).split('\n').length, anchor };
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Where a term is DEFINED, falling back to where it is first mentioned — HC-GF-203.
|
|
306
|
+
*
|
|
307
|
+
* `indexOf` alone was the whole bug. A term that names something appears once at its
|
|
308
|
+
* definition and many times in prose, comments and call sites, so the first occurrence is
|
|
309
|
+
* the least likely of those to be the one a story means — and the gap grows with the file.
|
|
310
|
+
* Measured 2026-09-20: `verify-patch` occurs 22 times in `cli/hone-cli.js`; the first is a
|
|
311
|
+
* comment at line 5744, the `.command(…)` definition is at 7849, and the 400-line window
|
|
312
|
+
* around the comment carried neither the command nor the call site the story named. The
|
|
313
|
+
* planner refused, correctly, and no flag could fix it.
|
|
314
|
+
*
|
|
315
|
+
* The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
|
|
316
|
+
* gets the operator a window near it, which beats none.
|
|
317
|
+
*/
|
|
318
|
+
function anchorSitesForStory(content, storyText, max = 6) {
|
|
319
|
+
// HC-GF-204: every region a story's terms point at, best-first. `anchorLineForStory`
|
|
320
|
+
// answers "where does this live"; a story also needs "where is it USED", and on run
|
|
321
|
+
// ad4a561f the planner had the definition and refused for want of the call sites.
|
|
322
|
+
const text = String(content || '');
|
|
323
|
+
const story = String(storyText || '');
|
|
324
|
+
if (!text || !story) return [];
|
|
325
|
+
const candidates = [...story.matchAll(BACKTICKED_RE)]
|
|
326
|
+
.map((m) => m[1].trim())
|
|
327
|
+
.filter((c) => c.length >= 3
|
|
328
|
+
&& !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
|
|
329
|
+
&& !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
|
|
330
|
+
.sort((a, b) => b.length - a.length);
|
|
331
|
+
|
|
332
|
+
const seenLine = new Set();
|
|
333
|
+
const sites = [];
|
|
334
|
+
for (const anchor of candidates) {
|
|
335
|
+
const def = definitionIndexOf(text, anchor);
|
|
336
|
+
if (def === -1) continue;
|
|
337
|
+
const t = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
338
|
+
const offsets = [def];
|
|
339
|
+
for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
|
|
340
|
+
for (const off of offsets) {
|
|
341
|
+
const line = text.slice(0, off).split('\n').length;
|
|
342
|
+
// Collapse near-duplicates: two hits 20 lines apart share one window.
|
|
343
|
+
if ([...seenLine].some((l) => Math.abs(l - line) < 40)) continue;
|
|
344
|
+
seenLine.add(line);
|
|
345
|
+
sites.push({ line, anchor });
|
|
346
|
+
if (sites.length >= max) return sites;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return sites;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function definitionIndexOf(text, term) {
|
|
353
|
+
const t = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
354
|
+
// Ordered by how strongly each shape asserts "this is where it lives".
|
|
355
|
+
const DEFINITION_FORMS = [
|
|
356
|
+
`\\.command\\(\\s*['"\`]${t}\\b`, // CLI command registration
|
|
357
|
+
`(?:async\\s+)?function\\s+${t}\\b`, // function declaration
|
|
358
|
+
`class\\s+${t}\\b`, // class declaration
|
|
359
|
+
`(?:const|let|var)\\s+${t}\\s*=`, // binding
|
|
360
|
+
`exports\\.${t}\\s*=`, // named export
|
|
361
|
+
`${t}\\s*[:=]\\s*(?:async\\s*)?(?:function|\\()`, // property holding a function
|
|
362
|
+
];
|
|
363
|
+
for (const form of DEFINITION_FORMS) {
|
|
364
|
+
const m = new RegExp(form).exec(text);
|
|
365
|
+
if (m) return m.index;
|
|
366
|
+
}
|
|
367
|
+
return text.indexOf(term);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Decide what a bundled file should actually contain — HC-GF-188.
|
|
372
|
+
*
|
|
373
|
+
* One function so the DECISION is testable and mutable. Inline in the bundler it was three
|
|
374
|
+
* branches nobody could reach from a test, which is how the blind head-slice survived.
|
|
375
|
+
*
|
|
376
|
+
* `notice` is the operator's half and is the point of this story as much as the excerpt is. The
|
|
377
|
+
* run that exposed this printed `bundled 4 codebase file(s) … (35383 chars)` and nothing else,
|
|
378
|
+
* while the payload carried a truncation marker that only the AGENT could read. All three agents
|
|
379
|
+
* diagnosed the cut correctly and refused; the person watching had no idea it had happened.
|
|
380
|
+
*
|
|
381
|
+
* HC-GF-288: `lines` carries EVERY stated line for the file, best-first; `line` is the
|
|
382
|
+
* one-line form and is appended to it. One line per file could not express the operator's
|
|
383
|
+
* `--include-paths f.js:N` for a file the story had already cited as `f.js:M` — the CLI kept
|
|
384
|
+
* M, discarded N, and printed nothing. Each stated line is a floor, and one this cannot
|
|
385
|
+
* honour comes back in `unhonoured` so the caller can say which, and why.
|
|
386
|
+
*
|
|
387
|
+
* @param {{content: string, cap: number, line?: number|null, lines?: number[], storyText?: string, path?: string}} input
|
|
388
|
+
* @returns {{mode: 'full'|'window'|'truncate', content: string, notice: string|null, anchor?: string,
|
|
389
|
+
* unhonoured: Array<{line: number, reason: 'past-eof'|'budget', totalLines: number}>}}
|
|
390
|
+
*/
|
|
391
|
+
function planFileExcerpt({ content, cap, line = null, lines = null, storyText = '', path: filePath = 'file' } = {}) {
|
|
392
|
+
const text = String(content || '');
|
|
393
|
+
const limit = Number(cap);
|
|
394
|
+
const totalLines = text.split('\n').length;
|
|
395
|
+
const requested = [...new Set([...(Array.isArray(lines) ? lines : []), line]
|
|
396
|
+
.map(Number).filter((n) => Number.isInteger(n) && n > 0))];
|
|
397
|
+
// A line past the end is not clamped to the last line: `windowAroundLine` would do exactly
|
|
398
|
+
// that, and a window at EOF presented as "centered on referenced line 9000" is a confident
|
|
399
|
+
// wrong region. It is dropped and reported instead.
|
|
400
|
+
const stated = requested.filter((n) => n <= totalLines);
|
|
401
|
+
const unhonoured = requested.filter((n) => n > totalLines)
|
|
402
|
+
.map((n) => ({ line: n, reason: 'past-eof', totalLines }));
|
|
403
|
+
if (text.length <= limit) return { mode: 'full', content: text, notice: null, unhonoured };
|
|
404
|
+
|
|
405
|
+
// An explicit line wins: the author stated it, the anchor is inferred from prose.
|
|
406
|
+
const hit = stated.length ? { line: stated[0], anchor: null } : anchorLineForStory(text, storyText);
|
|
407
|
+
|
|
408
|
+
if (hit) {
|
|
409
|
+
// The 800 is headroom for the marker text. Clamped: `cap` is a parameter now, and the
|
|
410
|
+
// inherited formula goes NEGATIVE below 800, which collapses the window to nothing — an
|
|
411
|
+
// empty excerpt presented as a successful anchor. A window that slightly overruns a very
|
|
412
|
+
// small cap is strictly better than one that shows nothing at the site it claims to show.
|
|
413
|
+
// HC-GF-204: a story's code sits in SEVERAL regions of a large file, so inferred
|
|
414
|
+
// anchors spread the budget across every region found — "one window that is merely
|
|
415
|
+
// the BEST region still leaves the planner refusing for want of the others".
|
|
416
|
+
//
|
|
417
|
+
// HC-GF-216: that sentence is true of an EXPLICIT line too, and treating "stated"
|
|
418
|
+
// as "sufficient" is what made `line` exclusive. Measured 2026-09-20 on run
|
|
419
|
+
// de47094f, server/src/services/orchestrator.js (218,011 chars, cap 25,000): with
|
|
420
|
+
// the explicit line, ONE window [1,281] carrying the declaration and NEITHER
|
|
421
|
+
// consumer; without it, FIVE windows [13,95] [3876,3938] [576,648] [1606,1735]
|
|
422
|
+
// [3768,3852] carrying the declaration at 64, the `EDITOR_SIDE_STEPS.includes`
|
|
423
|
+
// consumer at 1701 and the export at 3923. step_1 then refused for call sites the
|
|
424
|
+
// UN-hinted bundle would have supplied — naming the line made the bundle strictly
|
|
425
|
+
// worse, and the shortfall notice below is what tells operators to name it.
|
|
426
|
+
//
|
|
427
|
+
// So the stated line SEEDS the site list rather than replacing it: it is at index 0,
|
|
428
|
+
// hence budgeted first, and `mergeIntersectingWindows` unions it with any inferred
|
|
429
|
+
// window it overlaps. No sort, no priority field — array order already says it.
|
|
430
|
+
// HC-GF-288: every stated line seeds it, in the caller's order.
|
|
431
|
+
const inferred = anchorSitesForStory(text, storyText);
|
|
432
|
+
const sites = [...stated.map((l) => ({ line: l, anchor: null })), ...inferred];
|
|
433
|
+
const chosen = sites.length ? sites : [hit];
|
|
434
|
+
const why = (a, l) => (a ? `anchored on \`${a}\`` : `centered on referenced line ${l}`);
|
|
435
|
+
const header = (w, a, l) =>
|
|
436
|
+
`# [HC-019n-followup-27: windowed excerpt, lines ${w.startLine}-${w.endLine} of ${w.totalLines} `
|
|
437
|
+
+ `(${why(a, l)}); you were NOT shown the whole file — emit an \`edit:\` block `
|
|
438
|
+
+ `with a unique OLD anchor from this excerpt, NOT a whole-file \`file:\` block]\n`;
|
|
439
|
+
const footer = `\n# [end windowed excerpt of ${filePath}]`;
|
|
440
|
+
// Budget the MARKER too. Each window carries a header and a footer, and counting only
|
|
441
|
+
// the excerpt overran a 25,000 cap by 1,647 chars across six regions — measured. The cap
|
|
442
|
+
// is a promise to the caller about total size.
|
|
443
|
+
// HC-GF-288 review: at its REAL length. A flat 280 undercounted it — the header alone is
|
|
444
|
+
// 232 chars for `centered on referenced line N`, more for a long anchor, and the footer
|
|
445
|
+
// carries the path — so six windows of server/src/services/orchestrator.js came to
|
|
446
|
+
// 25,039 chars against a 25,000 cap once the radius stopped leaving slack to hide it.
|
|
447
|
+
// The +1 is the newline that joins windows.
|
|
448
|
+
const costOf = (e) => header(e.w, e.anchor, e.line).length + e.w.excerpt.length + footer.length + 1;
|
|
449
|
+
// The radius is sized before any window exists, so it prices the marker at its widest:
|
|
450
|
+
// the longest label among the sites, with line numbers at the file's own width.
|
|
451
|
+
const widest = { startLine: totalLines, endLine: totalLines, totalLines };
|
|
452
|
+
const markerCost = Math.max(...chosen.map((s) => header(widest, s.anchor, totalLines).length))
|
|
453
|
+
+ footer.length + 1;
|
|
454
|
+
// HC-GF-288: the 800 covers ONE window's marker, and every further window brings its
|
|
455
|
+
// own. Sized from `limit - 800` alone, n windows overran the cap from n = 3 — so the
|
|
456
|
+
// LAST region was always dropped however much room the rest left. Measured on
|
|
457
|
+
// cli/hone-cli.js at cap 25,000 with stated lines [6180, 2000, 6580] and no inferred
|
|
458
|
+
// sites: 16,836 chars spent and 6580 dropped. `(n - 1)` keeps the single-window size
|
|
459
|
+
// exactly what it was.
|
|
460
|
+
let radius = Math.max(200,
|
|
461
|
+
Math.floor((limit - 800 - (chosen.length - 1) * markerCost) / (2 * chosen.length)));
|
|
462
|
+
|
|
463
|
+
const windows = [];
|
|
464
|
+
// HC-GF-216 review: a STATED line is a FLOOR, not a preference.
|
|
465
|
+
//
|
|
466
|
+
// Seeding it at index 0 was not enough — `mergeIntersectingWindows` returns its
|
|
467
|
+
// own order, so index 0 never reached the budget loop. Measured at cap 900 with
|
|
468
|
+
// line 7: 476 chars of 900 spent and the stated region dropped anyway, failing
|
|
469
|
+
// HC-GF-203 (`cli/lib/bundle-anchor.test.js:109`).
|
|
470
|
+
//
|
|
471
|
+
// Floor over preference because the two failures are not symmetric. A dropped
|
|
472
|
+
// INFERRED region surfaces as a planner refusal the operator can act on. A dropped
|
|
473
|
+
// STATED region is silent: the bundle is well-formed, the notice says a region was
|
|
474
|
+
// dropped, and nothing says the dropped one is the one that was asked for. That is
|
|
475
|
+
// HC-GF-205's lesson — the silence was the expensive part.
|
|
476
|
+
//
|
|
477
|
+
// No conflict with HC-GF-203's other pin (`:117`, a definition beats a mention):
|
|
478
|
+
// that ranks INFERRED sites against each other. This governs stated vs inferred.
|
|
479
|
+
// Different layers, so both hold.
|
|
480
|
+
//
|
|
481
|
+
// Bounded by construction: `radius` already shrinks with site count and floors at
|
|
482
|
+
// 200 chars, so the reserved window takes its share plus its marker, never the cap.
|
|
483
|
+
// Identify a stated entry by the RANGE it covers, not its anchor: mergeIntersectingWindows
|
|
484
|
+
// unions an overlapping inferred window into it and the merged entry inherits the
|
|
485
|
+
// OTHER site's anchor label, so `anchor === null` no longer finds it. Measured:
|
|
486
|
+
// candidate {line:7, anchor:null, 1-15} merges with {line:1, "thing", 1-10} and
|
|
487
|
+
// comes back as {line:7, anchor:"thing", 1-15}. HC-GF-288: by range, because with
|
|
488
|
+
// several stated lines a merge can also carry one stated line under ANOTHER's `line`.
|
|
489
|
+
const covers = (w, l) => w.startLine <= l && l <= w.endLine;
|
|
490
|
+
const rankOf = (c) => stated.findIndex((l) => covers(c.w, l));
|
|
491
|
+
// Restore the stated identity the merge erased. `anchor: null` is what makes the
|
|
492
|
+
// notice read "centered on referenced line N" rather than "anchored on `term`"
|
|
493
|
+
// (see `why` below), and an operator who named a line needs to see it was honoured.
|
|
494
|
+
// Reporting someone else's anchor for the window they asked for is the silence
|
|
495
|
+
// HC-GF-205 was filed about. Stated entries go first, in the caller's order; the
|
|
496
|
+
// rest keep merge order.
|
|
497
|
+
const layout = (r) => {
|
|
498
|
+
// HC-GF-206: merge intersecting windows BEFORE budgeting. The old dedupe was
|
|
499
|
+
// `usedLines.has(w.startLine)` — an exact start-line match, which two windows
|
|
500
|
+
// overlapping by all but a few lines never trigger.
|
|
501
|
+
const candidates = chosen.map((site) => ({
|
|
502
|
+
w: windowAroundLine(text, site.line, r),
|
|
503
|
+
anchor: site.anchor,
|
|
504
|
+
line: site.line,
|
|
505
|
+
}));
|
|
506
|
+
return mergeIntersectingWindows(text, candidates)
|
|
507
|
+
.map((c, i) => {
|
|
508
|
+
const k = rankOf(c);
|
|
509
|
+
return k === -1 ? { c, k: Infinity, i } : { c: { ...c, anchor: null, line: stated[k] }, k, i };
|
|
510
|
+
})
|
|
511
|
+
.sort((a, b) => (a.k - b.k) || (a.i - b.i))
|
|
512
|
+
.map(({ c, k }) => ({ ...c, stated: k !== Infinity }));
|
|
513
|
+
};
|
|
514
|
+
// Spend the budget in priority order. Stated entries come first, so a stated region is
|
|
515
|
+
// only ever dropped when the stated regions alone exceed the cap.
|
|
516
|
+
const budget = (ord) => {
|
|
517
|
+
const kept = [];
|
|
518
|
+
let spent = 0;
|
|
519
|
+
let skipped = 0;
|
|
520
|
+
for (const site of ord) {
|
|
521
|
+
const cost = costOf(site);
|
|
522
|
+
if (spent + cost > limit) {
|
|
523
|
+
// A dropped stated region is reported by LINE below, not counted here: the
|
|
524
|
+
// advice attached to this count — "name them as path:<line>" — is advice the
|
|
525
|
+
// operator has already followed for those.
|
|
526
|
+
if (!site.stated) skipped += 1;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
spent += cost;
|
|
530
|
+
kept.push({ w: site.w, anchor: site.anchor, line: site.line });
|
|
531
|
+
}
|
|
532
|
+
return { kept, skipped };
|
|
533
|
+
};
|
|
534
|
+
// HC-GF-288 review: the radius is an ESTIMATE. Each window also snaps outward to whole
|
|
535
|
+
// lines — 50-180 chars per window on ordinary source, up to 10K on a file of long lines
|
|
536
|
+
// — which is not known until the window exists. Measured: six stated lines of
|
|
537
|
+
// cli/hone-cli.js left 4,192 chars unspent and still dropped one whose window needed
|
|
538
|
+
// 4,203. A stated line is a floor, so when the estimate loses one, search for the WIDEST
|
|
539
|
+
// radius that keeps them all.
|
|
540
|
+
//
|
|
541
|
+
// Of the layouts tried, keep the one that honours more stated lines — never "the last
|
|
542
|
+
// one tried". When even the narrowest windows cannot hold every stated line, narrowing
|
|
543
|
+
// frees room that an earlier window can take, and it lost 5 stated lines where the
|
|
544
|
+
// estimate lost 2 (a review probe, cap 12,000, seven lines of a file with 10K-char
|
|
545
|
+
// lines). A fixed number of shrink passes was also too few there: it stopped short and
|
|
546
|
+
// the fallback below overran a 25,000 cap by 409 chars. Only the estimate and the
|
|
547
|
+
// minimum are compared in that case; a radius between them can occasionally keep one
|
|
548
|
+
// more, which a scan would find at the cost of more layouts.
|
|
549
|
+
//
|
|
550
|
+
// On a tie, a layout that kept ANY window beats one that kept none, before width is
|
|
551
|
+
// considered: a layout with no window sends the result to the fallback, which overruns
|
|
552
|
+
// the cap by construction. Third review: preferring width alone discarded a 1,066-char
|
|
553
|
+
// layout for a 33,002-char fallback at cap 12,000.
|
|
554
|
+
let best = null;
|
|
555
|
+
const better = (a, b) => (a.honoured - b.honoured)
|
|
556
|
+
|| (Number(a.plan.kept.length > 0) - Number(b.plan.kept.length > 0))
|
|
557
|
+
|| (a.r - b.r);
|
|
558
|
+
const tryRadius = (r) => {
|
|
559
|
+
const plan = budget(layout(r));
|
|
560
|
+
const honoured = stated.filter((l) => plan.kept.some(({ w }) => covers(w, l))).length;
|
|
561
|
+
const cand = { r, plan, honoured };
|
|
562
|
+
if (!best || better(cand, best) > 0) best = cand;
|
|
563
|
+
return honoured === stated.length;
|
|
564
|
+
};
|
|
565
|
+
if (!tryRadius(radius) && radius > 200 && tryRadius(200)) {
|
|
566
|
+
// All stated lines fit at 200 but not at the estimate: binary-search between them.
|
|
567
|
+
// Bounded by log2(radius) layouts — measured at most 12 layouts and 228 ms on a
|
|
568
|
+
// 1.6 MB file of long lines, a few ms on ordinary source.
|
|
569
|
+
let lo = 200;
|
|
570
|
+
let hi = radius - 1;
|
|
571
|
+
while (lo < hi) {
|
|
572
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
573
|
+
if (tryRadius(mid)) lo = mid; else hi = mid - 1;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
radius = best.r;
|
|
577
|
+
windows.push(...best.plan.kept);
|
|
578
|
+
const dropped = best.plan.skipped;
|
|
579
|
+
if (!windows.length) {
|
|
580
|
+
// Nothing fits at any radius tried — one line alone is longer than the cap. Build the
|
|
581
|
+
// window at the MINIMUM radius, so the overrun is that line and no more: at the
|
|
582
|
+
// estimate, a 30,000-char line at cap 25,000 came back as 42,380 chars.
|
|
583
|
+
const w = windowAroundLine(text, chosen[0].line, 200);
|
|
584
|
+
windows.push({ w, anchor: chosen[0].anchor, line: chosen[0].line });
|
|
585
|
+
}
|
|
586
|
+
// HC-GF-288: which stated lines no kept window contains. Only the budget can do that
|
|
587
|
+
// here — every stated line seeded a window, and a merge never shrinks one.
|
|
588
|
+
const lost = stated.filter((l) => !windows.some(({ w }) => covers(w, l)));
|
|
589
|
+
for (const l of lost) unhonoured.push({ line: l, reason: 'budget', totalLines });
|
|
590
|
+
|
|
591
|
+
const ranges = windows.map(({ w }) => `${w.startLine}-${w.endLine}`).join(', ');
|
|
592
|
+
const because = why(windows[0].anchor, windows[0].line);
|
|
593
|
+
// HC-CI-016: a partial result reported as complete is the defect. If the budget
|
|
594
|
+
// could not cover every region found, the operator is TOLD how many were left out.
|
|
595
|
+
const shortfall = (dropped > 0
|
|
596
|
+
// HC-GF-216: "to bundle one directly" described the pre-fix behaviour and was the
|
|
597
|
+
// mechanism that taught operators to narrow their own bundles. A stated line now
|
|
598
|
+
// ANCHORS the spread, so the advice says so.
|
|
599
|
+
? ` — ${dropped} further region(s) DROPPED for budget; name them as ${filePath}:<line> to anchor a region directly`
|
|
600
|
+
: '')
|
|
601
|
+
+ (lost.length > 0
|
|
602
|
+
? ` — stated line(s) ${lost.join(', ')} DROPPED for budget; name fewer regions in ${filePath}`
|
|
603
|
+
: '');
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
mode: 'window',
|
|
607
|
+
unhonoured,
|
|
608
|
+
anchor: windows[0].anchor || null,
|
|
609
|
+
content: windows.map(({ w, anchor, line: siteLine }) =>
|
|
610
|
+
header(w, anchor, siteLine) + w.excerpt + footer).join('\n'),
|
|
611
|
+
notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — bundled lines `
|
|
612
|
+
+ `${ranges} of ${windows[0].w.totalLines}, ${because}${shortfall}`,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const dropped = text.length - limit;
|
|
617
|
+
return {
|
|
618
|
+
mode: 'truncate',
|
|
619
|
+
unhonoured,
|
|
620
|
+
content: text.slice(0, limit)
|
|
621
|
+
+ `\n\n# [HC-019n-followup-12: truncated at ${limit} chars; full file is ${text.length} chars]`,
|
|
622
|
+
notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — head-truncated, ${dropped} chars `
|
|
623
|
+
+ `DROPPED and nothing in the story text matched inside the file, so the region you care about `
|
|
624
|
+
+ `may not be in the bundle. Name it as ${filePath}:<line>, or quote the code, to anchor it.`,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── HC-GF-229: is a path a story CITES, or one it intends to CHANGE? ─────────
|
|
629
|
+
//
|
|
630
|
+
// DELIBERATE SECOND COPY. The identical block lives in
|
|
631
|
+
// server/src/services/story-context.js. The CLI cannot read the roadmap and the
|
|
632
|
+
// server cannot read the adopter's disk, and there is no shared lib between the
|
|
633
|
+
// two packages — so the classification is duplicated on purpose, exactly as the
|
|
634
|
+
// extraction layer beneath it (HC-GF-247) already is. The agreement pin in
|
|
635
|
+
// tests/regression/HC-019n-followup-18-story-context-and-patch.test.js is what
|
|
636
|
+
// keeps them equal: widening one side alone turns it red.
|
|
637
|
+
//
|
|
638
|
+
// THE RULE IS DECLARATION, NOT POSITION. A row may name its change targets
|
|
639
|
+
// outright with a `**Targets:**` label; everything else the row names is
|
|
640
|
+
// evidence. Absent a declaration the answer is byte-identical to today's
|
|
641
|
+
// behaviour, and that is what makes the lost-file class structurally
|
|
642
|
+
// impossible: a row that declares nothing cannot lose anything.
|
|
643
|
+
//
|
|
644
|
+
// Classifying by POSITION — a path appearing only inside the `**Premise:**`
|
|
645
|
+
// span is evidence — was built, measured and REJECTED: 28 losing rows and 32
|
|
646
|
+
// lost files over 261 completed rows, including cli/lib/bundle-paths.js for
|
|
647
|
+
// HC-GF-203/204/206, the file all three exist to edit, while 5,000 regression
|
|
648
|
+
// tests passed over it. See docs/architecture/HC-GF-229-classifier-design.md.
|
|
649
|
+
// Do not re-attempt it.
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Inline bold labels. WIDENED on purpose: the rejected build matched the exact
|
|
653
|
+
* literal `**Premise:**` and missed 11 of 144 labels in use
|
|
654
|
+
* (`**Premise (measured 2026-09-18):**`, `**Premise for the correction:**`).
|
|
655
|
+
* The leading `[A-Z]` is deliberate — `**targets:**` in lowercase prose is a
|
|
656
|
+
* mention, not a declaration, and loosening case widens the false-positive
|
|
657
|
+
* surface onto every bold span in the corpus.
|
|
658
|
+
*/
|
|
659
|
+
const LABEL_RE = /\*\*([A-Z][^*\n]{0,80}?):\*\*/g;
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Blank out inline `code` spans and fenced blocks, PRESERVING OFFSETS.
|
|
663
|
+
*
|
|
664
|
+
* The known trap for this story, and this story's own row is the instance: it
|
|
665
|
+
* contains `` `**Targets:**` `` inside a code span while documenting the
|
|
666
|
+
* convention, and a naive detector reads that as a real declaration — the
|
|
667
|
+
* defect this story exists to fix, reproduced one level up on labels instead
|
|
668
|
+
* of paths.
|
|
669
|
+
*
|
|
670
|
+
* MASKING RATHER THAN DELETING IS LOAD-BEARING. Every non-newline character is
|
|
671
|
+
* replaced by a space, so `maskCodeSpans(t).length === t.length` and every
|
|
672
|
+
* index computed on the masked text addresses the same character of the
|
|
673
|
+
* original. That is what lets span bounds and token occurrences share one
|
|
674
|
+
* coordinate system with no second index map. Newlines survive so paragraph
|
|
675
|
+
* structure — which ends a span, below — is unchanged.
|
|
676
|
+
*
|
|
677
|
+
* Fences are masked FIRST so a backtick inside a fence cannot pair with one
|
|
678
|
+
* outside it.
|
|
679
|
+
*
|
|
680
|
+
* @param {string} text
|
|
681
|
+
* @returns {string} same length as the input
|
|
682
|
+
*/
|
|
683
|
+
function maskCodeSpans(text) {
|
|
684
|
+
const src = String(text || '');
|
|
685
|
+
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
686
|
+
return src.replace(/```[\s\S]*?```/g, blank).replace(/`[^`\n]*`/g, blank);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Index of the blank line that ends the paragraph starting at `from`. */
|
|
690
|
+
function paragraphEnd(text, from) {
|
|
691
|
+
const m = /\n[ \t]*\n/.exec(text.slice(from));
|
|
692
|
+
return m ? from + m.index : text.length;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Spans of text governed by a label whose name satisfies `namePredicate`.
|
|
697
|
+
*
|
|
698
|
+
* A span ends at the NEXT label or at the next BLANK LINE, whichever comes
|
|
699
|
+
* first. The next-label half is inherited from the replay harness; the blank
|
|
700
|
+
* line half is the correction. A roadmap row is one line, so the two rules are
|
|
701
|
+
* identical over the corpus — but a story BODY is not, and a declaration that
|
|
702
|
+
* ran to end-of-text because no second label followed would silently
|
|
703
|
+
* re-classify the next paragraph's prose citations as change targets, which is
|
|
704
|
+
* the widening this story exists to stop.
|
|
705
|
+
*
|
|
706
|
+
* @param {string} masked output of maskCodeSpans — offsets match the original
|
|
707
|
+
* @param {(name: string) => boolean} namePredicate
|
|
708
|
+
* @returns {Array<[number, number]>} half-open [start, end) ranges
|
|
709
|
+
*/
|
|
710
|
+
function labelSpans(masked, namePredicate) {
|
|
711
|
+
const labels = [...masked.matchAll(new RegExp(LABEL_RE.source, 'g'))];
|
|
712
|
+
const spans = [];
|
|
713
|
+
labels.forEach((lab, i) => {
|
|
714
|
+
if (!namePredicate(lab[1])) return;
|
|
715
|
+
const nextLabel = i + 1 < labels.length ? labels[i + 1].index : masked.length;
|
|
716
|
+
const para = paragraphEnd(masked, lab.index + lab[0].length);
|
|
717
|
+
spans.push([lab.index, Math.min(nextLabel, para)]);
|
|
718
|
+
});
|
|
719
|
+
return spans;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** The `**Targets…:**` spans of a story, code spans already excluded. */
|
|
723
|
+
function targetSpans(text) {
|
|
724
|
+
return labelSpans(maskCodeSpans(String(text || '')), (n) => /^Targets\b/.test(n));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Every index at which `token` literally occurs. */
|
|
728
|
+
function occurrencesOf(text, token) {
|
|
729
|
+
const at = [];
|
|
730
|
+
if (!token) return at;
|
|
731
|
+
let i = text.indexOf(token);
|
|
732
|
+
while (i !== -1) { at.push(i); i = text.indexOf(token, i + 1); }
|
|
733
|
+
return at;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function indexInSpans(i, spans) {
|
|
737
|
+
return spans.some(([a, b]) => i >= a && i < b);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* The literal the ROW wrote, not the path it resolved to.
|
|
742
|
+
*
|
|
743
|
+
* Load-bearing for bare references. `resolveBareName` turns `orchestrator.js`
|
|
744
|
+
* into `server/src/services/orchestrator.js`, and that resolved path occurs
|
|
745
|
+
* nowhere in the row text — so matching on it would find zero occurrences and
|
|
746
|
+
* silently make every bare-ref citation evidence regardless of what the row
|
|
747
|
+
* declared.
|
|
748
|
+
*/
|
|
749
|
+
function tokenOf(entry) {
|
|
750
|
+
if (typeof entry === 'string') return entry;
|
|
751
|
+
if (!entry || typeof entry !== 'object') return '';
|
|
752
|
+
return String(entry.token || entry.path || '');
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Partition the paths a story names into change targets and evidence.
|
|
757
|
+
*
|
|
758
|
+
* ADDITIVE. This wraps extraction rather than replacing it: extractPathRefs /
|
|
759
|
+
* extractBareRefs return exactly what they returned before, so every existing
|
|
760
|
+
* consumer of the shared chokepoint is untouched by construction. A caller
|
|
761
|
+
* that never calls this function behaves exactly as today.
|
|
762
|
+
*
|
|
763
|
+
* `named` carries whatever the caller resolved — `{path, token}` entries, or
|
|
764
|
+
* plain path strings. Entries are returned BY IDENTITY, so the caller keeps
|
|
765
|
+
* any fields (`line`, `via`) it attached.
|
|
766
|
+
*
|
|
767
|
+
* THREE OUTCOMES:
|
|
768
|
+
*
|
|
769
|
+
* no declaration targets = named, declared false. Today's behaviour,
|
|
770
|
+
* byte for byte. This is the structural guarantee.
|
|
771
|
+
* declaration resolves targets = the declared paths, evidence = the rest.
|
|
772
|
+
* One in-span occurrence is enough, so a path named in
|
|
773
|
+
* both the declaration and the premise is a TARGET.
|
|
774
|
+
* declaration resolves
|
|
775
|
+
* to nothing targets = named, declared false, plus a diagnostic.
|
|
776
|
+
* Returning [] here would hand back an empty Scope —
|
|
777
|
+
* the HC-GF-073 blind-bundle failure, and the one
|
|
778
|
+
* outcome the acceptance forbids outright. A malformed
|
|
779
|
+
* declaration is an AUTHORING error, and the costs are
|
|
780
|
+
* asymmetric: degrading to the status quo loses
|
|
781
|
+
* nothing, bundling nothing loses everything. The
|
|
782
|
+
* diagnostic exists so that degradation is not silent —
|
|
783
|
+
* callers should surface it.
|
|
784
|
+
*
|
|
785
|
+
* Pure and I/O-free.
|
|
786
|
+
*
|
|
787
|
+
* @param {string} text the story/row text, as written
|
|
788
|
+
* @param {Array<{path: string, token?: string}|string>} named
|
|
789
|
+
* @returns {{targets: Array, evidence: Array, declared: boolean, diagnostic: string|null}}
|
|
790
|
+
*/
|
|
791
|
+
function classifyStoryPaths(text, named) {
|
|
792
|
+
const list = Array.isArray(named) ? named.slice() : [];
|
|
793
|
+
const raw = String(text || '');
|
|
794
|
+
const masked = maskCodeSpans(raw);
|
|
795
|
+
const spans = labelSpans(masked, (n) => /^Targets\b/.test(n));
|
|
796
|
+
if (!spans.length) return { targets: list, evidence: [], declared: false, diagnostic: null };
|
|
797
|
+
|
|
798
|
+
const targets = [];
|
|
799
|
+
const evidence = [];
|
|
800
|
+
for (const entry of list) {
|
|
801
|
+
// SPANS FROM THE MASKED TEXT, OCCURRENCES FROM THE ORIGINAL. This is the whole
|
|
802
|
+
// reason maskCodeSpans preserves offsets, and getting it wrong made the feature
|
|
803
|
+
// inert on the corpus's actual writing convention: roadmap rows write paths
|
|
804
|
+
// inside backticks, masking blanks those to spaces, so every declared path
|
|
805
|
+
// scored ZERO occurrences, fell to evidence, and the declared-but-empty branch
|
|
806
|
+
// handed back `named` with declared:false. A row declaring
|
|
807
|
+
// `**Targets:** \`cli/lib/bundle-paths.js\`` bundled exactly what it bundles
|
|
808
|
+
// today — a sanitizer applied one layer too wide, silently disabling the
|
|
809
|
+
// capability it was protecting.
|
|
810
|
+
//
|
|
811
|
+
// The mask exists to stop a LABEL inside a code span reading as a declaration.
|
|
812
|
+
// It was never meant to hide PATHS. Residual, and accepted: a path written
|
|
813
|
+
// inside a code span that sits INSIDE a real Targets span now counts as a
|
|
814
|
+
// target, which is what the author meant by writing it there.
|
|
815
|
+
const hit = occurrencesOf(raw, tokenOf(entry)).some((i) => indexInSpans(i, spans));
|
|
816
|
+
(hit ? targets : evidence).push(entry);
|
|
817
|
+
}
|
|
818
|
+
if (!targets.length) {
|
|
819
|
+
return {
|
|
820
|
+
targets: list,
|
|
821
|
+
evidence: [],
|
|
822
|
+
declared: false,
|
|
823
|
+
diagnostic: list.length
|
|
824
|
+
? `row declares Targets but none of its ${list.length} named path(s) resolved inside the `
|
|
825
|
+
+ 'declaration — bundling all of them (status quo)'
|
|
826
|
+
: 'row declares Targets and names no paths',
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
return { targets, evidence, declared: true, diagnostic: null };
|
|
830
|
+
}
|
|
831
|
+
|
|
139
832
|
module.exports = {
|
|
140
833
|
BARE_FILE_RE, extractBareNames, extractBareRefs, resolveBareName, alreadyCovered, windowAroundLine,
|
|
834
|
+
PATH_REF_RE, extractPathRefs, anchorLineForStory, planFileExcerpt, definitionIndexOf, anchorSitesForStory,
|
|
835
|
+
windowBetweenLines, mergeIntersectingWindows,
|
|
836
|
+
// HC-GF-229 — classification. Pinned byte-for-byte against the server copy.
|
|
837
|
+
LABEL_RE, maskCodeSpans, targetSpans, classifyStoryPaths,
|
|
141
838
|
};
|