@hone-ai/cli 1.38.1 → 1.40.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/hone-cli.js +218 -157
- package/lib/anthropic-response.js +58 -0
- package/lib/batch-follow.js +641 -53
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-paths.js +77 -13
- package/lib/call-sites.js +489 -0
- package/lib/classify-spec.js +68 -0
- package/lib/context-reserve.js +270 -0
- package/lib/detect-test-framework.js +54 -0
- package/lib/hard-pause-remedy.js +1 -1
- package/lib/judge-provider.js +20 -11
- package/lib/learnings-sync.js +2 -1
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/run-context.js +21 -0
- package/lib/security-scanner.js +7 -4
- package/lib/story-line.js +48 -12
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
package/lib/block-grammar.js
CHANGED
|
@@ -74,6 +74,60 @@ function fencedSpans(text) {
|
|
|
74
74
|
return { blocks, open: cur };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The inline code spans of ONE line, as ranges (HC-GF-341 rules, extracted by HC-GF-292 so there is ONE walker).
|
|
79
|
+
*
|
|
80
|
+
* CommonMark's rule, on a single line: a run of N backticks opens a span that closes at the next run of EXACTLY
|
|
81
|
+
* N backticks; a run with no equal-length closer on the line is literal text, masks nothing and does not stop the
|
|
82
|
+
* scan. OUTSIDE a span a backtick after an odd number of backslashes is escaped (it cannot open, nor be part of an
|
|
83
|
+
* opening run); INSIDE a span a backslash is only a character. A span never crosses a line break.
|
|
84
|
+
*
|
|
85
|
+
* LINEAR: the line is cut into backtick runs once, the runs of each length are listed in order, and an opener asks
|
|
86
|
+
* for the first later run of its length with a cursor that only moves forward. A backreference regex backtracks
|
|
87
|
+
* across every longer run and is quadratic on a line of mixed runs, and the line is agent-written text.
|
|
88
|
+
*
|
|
89
|
+
* `from`/`to` address `line`: [from, to) covers the delimiters too; the content is
|
|
90
|
+
* `line.slice(from + openLen, to - openLen)`.
|
|
91
|
+
*
|
|
92
|
+
* @returns {Array<{from:number, to:number, openLen:number}>} in ascending order, disjoint
|
|
93
|
+
*/
|
|
94
|
+
function inlineCodeSpans(line) {
|
|
95
|
+
const spans = [];
|
|
96
|
+
if (typeof line !== 'string' || line.indexOf('`') === -1) return spans;
|
|
97
|
+
const starts = [];
|
|
98
|
+
const lens = [];
|
|
99
|
+
for (let i = line.indexOf('`'); i !== -1;) {
|
|
100
|
+
let j = i + 1;
|
|
101
|
+
while (j < line.length && line.charCodeAt(j) === 96) j++;
|
|
102
|
+
starts.push(i);
|
|
103
|
+
lens.push(j - i);
|
|
104
|
+
i = line.indexOf('`', j);
|
|
105
|
+
}
|
|
106
|
+
const runsOfLength = new Map(); // length -> { at: ascending run indices, p: cursor }
|
|
107
|
+
for (let k = 0; k < starts.length; k++) {
|
|
108
|
+
let entry = runsOfLength.get(lens[k]);
|
|
109
|
+
if (!entry) { entry = { at: [], p: 0 }; runsOfLength.set(lens[k], entry); }
|
|
110
|
+
entry.at.push(k);
|
|
111
|
+
}
|
|
112
|
+
const closerFor = (width, after) => {
|
|
113
|
+
const entry = runsOfLength.get(width);
|
|
114
|
+
if (!entry) return -1;
|
|
115
|
+
while (entry.p < entry.at.length && entry.at[entry.p] <= after) entry.p++;
|
|
116
|
+
return entry.p < entry.at.length ? entry.at[entry.p] : -1;
|
|
117
|
+
};
|
|
118
|
+
for (let k = 0; k < starts.length;) {
|
|
119
|
+
let slashes = 0;
|
|
120
|
+
for (let q = starts[k] - 1; q >= 0 && line.charCodeAt(q) === 92; q--) slashes++;
|
|
121
|
+
const escaped = slashes & 1; // 1: the run's first backtick is a literal one
|
|
122
|
+
const width = lens[k] - escaped;
|
|
123
|
+
const closer = width > 0 ? closerFor(width, k) : -1;
|
|
124
|
+
if (closer === -1) { k++; continue; }
|
|
125
|
+
spans.push({ from: starts[k] + escaped, to: starts[closer] + lens[closer], openLen: width });
|
|
126
|
+
k = closer + 1;
|
|
127
|
+
}
|
|
128
|
+
return spans;
|
|
129
|
+
}
|
|
130
|
+
|
|
77
131
|
/**
|
|
78
132
|
* A `file:` block as one regex: group 1 the fence, 2 the path, 3 the body.
|
|
79
133
|
*
|
|
@@ -117,6 +171,7 @@ module.exports = {
|
|
|
117
171
|
fenceWidth,
|
|
118
172
|
closesFence,
|
|
119
173
|
fencedSpans,
|
|
174
|
+
inlineCodeSpans,
|
|
120
175
|
DIRECTIVE_RE,
|
|
121
176
|
FILE_BLOCK_RE,
|
|
122
177
|
EDIT_BLOCK_RE,
|
package/lib/branch-protection.js
CHANGED
|
@@ -17,14 +17,24 @@
|
|
|
17
17
|
* mergeContexts(existing, added)
|
|
18
18
|
* Pure. Dedupes context names and preserves first-seen order so a
|
|
19
19
|
* pre-existing custom check (e.g. 'lint') is preserved when Hone
|
|
20
|
-
* appends
|
|
20
|
+
* appends the checks it requires.
|
|
21
|
+
*
|
|
22
|
+
* pruneLegacyChecks(contexts, { reportedChecks, keep })
|
|
23
|
+
* Pure. Removes LEGACY_REQUIRED_CHECK ('ai-code-review'), which earlier
|
|
24
|
+
* setups required and no served workflow reports (HC-GF-348), unless a
|
|
25
|
+
* workflow in the repository reports it or the caller requires it.
|
|
26
|
+
* Removes nothing else.
|
|
21
27
|
*
|
|
22
28
|
* parseGhError(stderr, exitCode)
|
|
23
29
|
* Pure. Classifies a `gh` invocation failure into a stable kind:
|
|
24
30
|
* no-gh / no-auth / no-admin / plan-restricted / org-ruleset / unknown.
|
|
25
31
|
*
|
|
26
32
|
* installBranchProtection({ exec?, log?, opts? })
|
|
27
|
-
* Orchestrator.
|
|
33
|
+
* Orchestrator. opts.requiredChecks is the list `hone setup` reads from
|
|
34
|
+
* the installed workflow (cli/lib/required-checks.js) and may be empty;
|
|
35
|
+
* opts.reportedChecks is every check name the repository's workflows
|
|
36
|
+
* report (null = unknown, which keeps the legacy check).
|
|
37
|
+
* Returns { status, reason?, branch?, manualCommand? }
|
|
28
38
|
* where status ∈ 'installed' | 'skipped' | 'failed'. Never throws,
|
|
29
39
|
* never exits — degrades gracefully so `hone setup` always returns 0.
|
|
30
40
|
*
|
|
@@ -34,7 +44,14 @@
|
|
|
34
44
|
* invocations during testing.
|
|
35
45
|
*/
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
// HC-GF-348: no check is required by default. Setup passes the checks the repository's own workflow
|
|
48
|
+
// reports (cli/lib/required-checks.js); an empty list is valid — the PUT still requires a review and
|
|
49
|
+
// sets enforce_admins. This used to be ['ai-code-review'], a check nothing reports, which held every PR.
|
|
50
|
+
const DEFAULT_REQUIRED_CHECKS = [];
|
|
51
|
+
|
|
52
|
+
// The one context an earlier `hone setup` installed that no served workflow reports. Re-running setup
|
|
53
|
+
// removes it (pruneLegacyChecks), so a repository already held by it is released.
|
|
54
|
+
const LEGACY_REQUIRED_CHECK = 'ai-code-review';
|
|
38
55
|
|
|
39
56
|
// ─────────────────────────────────────────────────────────────────────
|
|
40
57
|
// defaultExec — production exec, wraps spawnSync
|
|
@@ -59,7 +76,8 @@ function buildPayload({ contexts = [], requireReviews } = {}) {
|
|
|
59
76
|
for (const c of contexts) {
|
|
60
77
|
if (!seen.has(c)) { seen.add(c); dedupedContexts.push(c); }
|
|
61
78
|
}
|
|
62
|
-
|
|
79
|
+
// 0 is a real answer since HC-GF-353 (a repository with one writer); anything that is not a count is 1.
|
|
80
|
+
const reviewCount = Number.isInteger(requireReviews) && requireReviews >= 0
|
|
63
81
|
? requireReviews
|
|
64
82
|
: 1;
|
|
65
83
|
return {
|
|
@@ -90,13 +108,27 @@ function mergeContexts(existing, added) {
|
|
|
90
108
|
return out;
|
|
91
109
|
}
|
|
92
110
|
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
112
|
+
// pruneLegacyChecks — pure (HC-GF-348)
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
114
|
+
function pruneLegacyChecks(contexts, { reportedChecks = [], keep = [] } = {}) {
|
|
115
|
+
const arr = Array.isArray(contexts) ? contexts : [];
|
|
116
|
+
// null: the repository's workflows could not all be read, so whether one reports it is unknown.
|
|
117
|
+
if (reportedChecks == null) return arr.slice();
|
|
118
|
+
const reported = Array.isArray(reportedChecks) ? reportedChecks : [];
|
|
119
|
+
const kept = Array.isArray(keep) ? keep : [];
|
|
120
|
+
if (reported.includes(LEGACY_REQUIRED_CHECK) || kept.includes(LEGACY_REQUIRED_CHECK)) return arr.slice();
|
|
121
|
+
return arr.filter((c) => c !== LEGACY_REQUIRED_CHECK);
|
|
122
|
+
}
|
|
123
|
+
|
|
93
124
|
// ─────────────────────────────────────────────────────────────────────
|
|
94
125
|
// parseGhError — pure
|
|
95
126
|
// ─────────────────────────────────────────────────────────────────────
|
|
96
127
|
function parseGhError(stderr = '', exitCode = 0) {
|
|
97
128
|
const s = String(stderr || '');
|
|
98
129
|
// gh binary missing entirely
|
|
99
|
-
|
|
130
|
+
// Not GitHub's own `gh: Not Found (HTTP 404)`, which the shell's wording also matched.
|
|
131
|
+
if (exitCode === 127 || /command not found/i.test(s) || (/gh:.*not found/i.test(s) && !/\(HTTP \d{3}\)/.test(s))) {
|
|
100
132
|
return { kind: 'no-gh', message: 'gh CLI not installed (or not on PATH)' };
|
|
101
133
|
}
|
|
102
134
|
// gh installed but not authenticated
|
|
@@ -128,28 +160,67 @@ function parseGhError(stderr = '', exitCode = 0) {
|
|
|
128
160
|
// ─────────────────────────────────────────────────────────────────────
|
|
129
161
|
// buildManualCommand — produces a copy-paste shell snippet for the user
|
|
130
162
|
// ─────────────────────────────────────────────────────────────────────
|
|
131
|
-
function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main' } = {}) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.join('\n');
|
|
163
|
+
function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main', requireReviews } = {}) {
|
|
164
|
+
// HC-GF-348: the exact payload setup PUTs, sent as JSON. `-f` fields cannot carry an empty contexts
|
|
165
|
+
// list (now the default) or a null `restrictions` — `-f 'restrictions='` sent the empty string.
|
|
135
166
|
return [
|
|
136
|
-
`gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
` -f 'enforce_admins=true' \\`,
|
|
140
|
-
` -f 'required_pull_request_reviews[required_approving_review_count]=1' \\`,
|
|
141
|
-
` -f 'restrictions='`,
|
|
167
|
+
`gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection --input - <<'JSON'`,
|
|
168
|
+
JSON.stringify(buildPayload({ contexts, requireReviews }), null, 2),
|
|
169
|
+
'JSON',
|
|
142
170
|
].join('\n');
|
|
143
171
|
}
|
|
144
172
|
|
|
173
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
174
|
+
// resolveChecks — the required list, which may depend on the branch (HC-GF-354)
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Setup's list depends on the protected branch, which only `gh repo view` names. On the paths that return
|
|
177
|
+
// before that, the printed manual command is a template, so the list is resolved with branch null. A
|
|
178
|
+
// resolver that throws requires nothing rather than failing the install.
|
|
179
|
+
function resolveChecks(requiredChecks, branch) {
|
|
180
|
+
if (typeof requiredChecks === 'function') {
|
|
181
|
+
try {
|
|
182
|
+
const r = requiredChecks(branch);
|
|
183
|
+
return Array.isArray(r) ? r : [];
|
|
184
|
+
} catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return Array.isArray(requiredChecks) ? requiredChecks : DEFAULT_REQUIRED_CHECKS;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
192
|
+
// countWriters — how many people can push (HC-GF-353)
|
|
193
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
194
|
+
function countWriters(exec, owner, repo) {
|
|
195
|
+
let r;
|
|
196
|
+
try {
|
|
197
|
+
r = exec('gh', ['api', '--paginate', `repos/${owner}/${repo}/collaborators?per_page=100`]);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return { count: null, reason: `gh could not be run: ${e && e.message}` };
|
|
200
|
+
}
|
|
201
|
+
if (r.status !== 0) return { count: null, reason: parseGhError(r.stderr, r.status).message };
|
|
202
|
+
let list;
|
|
203
|
+
try {
|
|
204
|
+
// --paginate concatenates one JSON array per page: `[...][...]`.
|
|
205
|
+
const pages = JSON.parse(`[${String(r.stdout || '').trim().replace(/\]\s*\[/g, '],[')}]`);
|
|
206
|
+
list = pages.length && pages.every(Array.isArray) ? pages.flat() : null;
|
|
207
|
+
} catch {
|
|
208
|
+
list = null;
|
|
209
|
+
}
|
|
210
|
+
if (!Array.isArray(list) || list.length === 0) return { count: null, reason: 'the collaborator list could not be read' };
|
|
211
|
+
const n = list.filter((c) => c && c.permissions && (c.permissions.push === true || c.permissions.admin === true)).length;
|
|
212
|
+
// Nobody with a readable push or admin permission is an answer that could not be read, not "zero
|
|
213
|
+
// writers": it must never remove the review.
|
|
214
|
+
if (n === 0) return { count: null, reason: 'no collaborator in the list has a readable push or admin permission' };
|
|
215
|
+
return { count: n };
|
|
216
|
+
}
|
|
217
|
+
|
|
145
218
|
// ─────────────────────────────────────────────────────────────────────
|
|
146
219
|
// installBranchProtection — orchestrator
|
|
147
220
|
// ─────────────────────────────────────────────────────────────────────
|
|
148
221
|
async function installBranchProtection({ exec = defaultExec, log = () => {}, opts = {} } = {}) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
: DEFAULT_REQUIRED_CHECKS;
|
|
152
|
-
const requireReviews = opts.requireReviews || 1;
|
|
222
|
+
// Not given: unknown, which keeps the legacy check. Only a caller that read the workflows can prune it.
|
|
223
|
+
const reportedChecks = opts.reportedChecks === undefined ? null : opts.reportedChecks;
|
|
153
224
|
|
|
154
225
|
// 1. Honor opt-out — short-circuit before any subprocess call
|
|
155
226
|
if (opts.skip) {
|
|
@@ -159,6 +230,12 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
159
230
|
};
|
|
160
231
|
}
|
|
161
232
|
|
|
233
|
+
// An explicit list — empty included — is what the caller requires (HC-GF-348); a function of the
|
|
234
|
+
// protected branch (HC-GF-354); none given: the default. `requiredChecks` is the answer before the
|
|
235
|
+
// branch is known: for a function that is nothing, since a check it would name might never report on
|
|
236
|
+
// the real branch, and the manual command printed on those paths is a template the user edits.
|
|
237
|
+
const requiredChecks = typeof opts.requiredChecks === 'function' ? [] : resolveChecks(opts.requiredChecks, null);
|
|
238
|
+
|
|
162
239
|
// 2. Resolve git remote
|
|
163
240
|
const remote = exec('git', ['remote', 'get-url', 'origin']);
|
|
164
241
|
if (remote.status !== 0) {
|
|
@@ -206,10 +283,23 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
206
283
|
};
|
|
207
284
|
}
|
|
208
285
|
|
|
286
|
+
const branchChecks = resolveChecks(opts.requiredChecks, branch);
|
|
287
|
+
|
|
209
288
|
// 4. GET existing protection (404 = none, 200 = merge)
|
|
210
289
|
const protPath = `repos/${owner}/${repo}/branches/${branch}/protection`;
|
|
211
290
|
const existing = exec('gh', ['api', protPath]);
|
|
212
291
|
let existingContexts = [];
|
|
292
|
+
// HC-GF-348: only "Branch not protected" (HTTP 404) means there is nothing to merge. Any other failure
|
|
293
|
+
// to read the protection must not be taken for "none": the PUT replaces the contexts, so it would drop
|
|
294
|
+
// every check the repository already requires — and with this story the computed list can be empty.
|
|
295
|
+
if (existing.status !== 0 && !/Branch not protected/i.test(`${existing.stderr || ''}${existing.stdout || ''}`)) {
|
|
296
|
+
const err = parseGhError(existing.stderr, existing.status);
|
|
297
|
+
return {
|
|
298
|
+
status: 'failed',
|
|
299
|
+
reason: `could not read the existing branch protection, so it was left unchanged: ${err.message}`,
|
|
300
|
+
manualCommand: buildManualCommand({ contexts: branchChecks, owner, repo, branch }),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
213
303
|
if (existing.status === 0) {
|
|
214
304
|
try {
|
|
215
305
|
const e = JSON.parse(existing.stdout || '{}');
|
|
@@ -220,11 +310,32 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
220
310
|
existingContexts = [];
|
|
221
311
|
}
|
|
222
312
|
}
|
|
223
|
-
// (non-zero exit is treated as "no existing protection" — the PUT below will succeed if the user has permissions)
|
|
224
313
|
|
|
225
|
-
// 5. Build merged payload
|
|
226
|
-
const mergedContexts = mergeContexts(existingContexts,
|
|
227
|
-
|
|
314
|
+
// 5. Build merged payload; drop the legacy check an earlier setup installed (HC-GF-348)
|
|
315
|
+
const mergedContexts = pruneLegacyChecks(mergeContexts(existingContexts, branchChecks), {
|
|
316
|
+
reportedChecks, keep: branchChecks,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// HC-GF-353: GitHub does not count a PR author's approval of their own PR, so one required review on a
|
|
320
|
+
// repository with a single writer makes every PR unmergeable. An explicit opts.requireReviews wins. A
|
|
321
|
+
// count that could not be established keeps 1: failing open on human review would be the regression.
|
|
322
|
+
let reviewCount = 1;
|
|
323
|
+
let reviewReason;
|
|
324
|
+
if (Number.isInteger(opts.requireReviews) && opts.requireReviews >= 0) {
|
|
325
|
+
reviewCount = opts.requireReviews;
|
|
326
|
+
reviewReason = 'set by the caller';
|
|
327
|
+
} else {
|
|
328
|
+
const w = countWriters(exec, owner, repo);
|
|
329
|
+
if (w.count == null) {
|
|
330
|
+
reviewReason = `the number of people who can push could not be counted (${w.reason}), so one review is still required`;
|
|
331
|
+
} else if (w.count === 1) {
|
|
332
|
+
reviewCount = 0;
|
|
333
|
+
reviewReason = 'one person can push, and GitHub does not count a pull request author\'s own approval; a pull request is still required';
|
|
334
|
+
} else {
|
|
335
|
+
reviewReason = `${w.count} people can push`;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const payload = buildPayload({ contexts: mergedContexts, requireReviews: reviewCount });
|
|
228
339
|
|
|
229
340
|
// 6. PUT
|
|
230
341
|
const put = exec(
|
|
@@ -233,7 +344,7 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
233
344
|
{ input: JSON.stringify(payload) }
|
|
234
345
|
);
|
|
235
346
|
if (put.status === 0) {
|
|
236
|
-
return { status: 'installed', branch };
|
|
347
|
+
return { status: 'installed', branch, requiredChecks: mergedContexts, reviewCount, reviewReason };
|
|
237
348
|
}
|
|
238
349
|
|
|
239
350
|
// 7. PUT failed — classify and return graceful failure
|
|
@@ -241,16 +352,21 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
|
|
|
241
352
|
return {
|
|
242
353
|
status: 'failed',
|
|
243
354
|
reason: err.message,
|
|
244
|
-
manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch }),
|
|
355
|
+
manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch, requireReviews: reviewCount }),
|
|
245
356
|
};
|
|
246
357
|
}
|
|
247
358
|
|
|
248
359
|
module.exports = {
|
|
249
360
|
buildPayload,
|
|
250
361
|
mergeContexts,
|
|
362
|
+
pruneLegacyChecks,
|
|
363
|
+
resolveChecks,
|
|
364
|
+
countWriters,
|
|
251
365
|
parseGhError,
|
|
252
366
|
installBranchProtection,
|
|
253
367
|
// Internal helpers exposed for inspection / future reuse
|
|
254
368
|
buildManualCommand,
|
|
369
|
+
defaultExec,
|
|
255
370
|
DEFAULT_REQUIRED_CHECKS,
|
|
371
|
+
LEGACY_REQUIRED_CHECK,
|
|
256
372
|
};
|
package/lib/bundle-paths.js
CHANGED
|
@@ -256,8 +256,20 @@ function extractPathRefs(text) {
|
|
|
256
256
|
return out;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
/**
|
|
260
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Backticked spans in a story, PAIRED FIRST and length-unbounded; `storyTermsOf` applies the
|
|
261
|
+
* 3-to-200 rule to each paired span. HC-GF-188-followup-2.
|
|
262
|
+
*
|
|
263
|
+
* A BOUND INSIDE THE MATCH DECIDES WHICH BACKTICKS PAIR. At `` `x`, and `foo` `` the opening
|
|
264
|
+
* backtick of `x` could not start a 3-character match, so its CLOSING backtick did: the span ran
|
|
265
|
+
* to `foo`'s OPENING backtick, the "term" was `, and`, and `foo` was lost because no backtick was
|
|
266
|
+
* left to open it. A span over 200 characters, and an empty pair, shift the pairing the same way.
|
|
267
|
+
*
|
|
268
|
+
* The pairing is the one `maskCodeSpans` uses below: one rule for what a code span is, and the
|
|
269
|
+
* filter decides which spans can anchor. Fences are NOT masked here, which would widen the change
|
|
270
|
+
* past what the row measured.
|
|
271
|
+
*/
|
|
272
|
+
const BACKTICKED_RE = /`([^`\n]*)`/g;
|
|
261
273
|
|
|
262
274
|
/**
|
|
263
275
|
* The terms a story quotes that could locate code, longest first — one copy of the filter.
|
|
@@ -269,7 +281,8 @@ const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
|
|
|
269
281
|
function storyTermsOf(storyText) {
|
|
270
282
|
return [...String(storyText || '').matchAll(BACKTICKED_RE)]
|
|
271
283
|
.map((m) => m[1].trim())
|
|
272
|
-
|
|
284
|
+
// Both bounds read the TRIMMED span; the ceiling used to live in the regex, on the raw span.
|
|
285
|
+
.filter((c) => c.length >= 3 && c.length <= 200
|
|
273
286
|
&& !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
|
|
274
287
|
&& !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
|
|
275
288
|
.sort((a, b) => b.length - a.length);
|
|
@@ -325,6 +338,17 @@ function anchorLineForStory(content, storyText) {
|
|
|
325
338
|
*
|
|
326
339
|
* The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
|
|
327
340
|
* gets the operator a window near it, which beats none.
|
|
341
|
+
*
|
|
342
|
+
* HC-GF-203-followup-1: a MENTION is the term standing whole, not the term's letters inside a
|
|
343
|
+
* longer identifier. The fallback was a bare `indexOf`, so `info` landed in `information`,
|
|
344
|
+
* `auth` in `authorize` and `Promise.all` in `Promise.allSettled` — and each such offset became
|
|
345
|
+
* a site in `anchorSitesForStory`, spending one of its 6 slots and a window on text that does
|
|
346
|
+
* not contain the term. Measured 2026-10-07 at the production cap over every roadmap row
|
|
347
|
+
* against cli/hone-cli.js and server/src/services/orchestrator.js: 187 of 6,210 sites, in 161
|
|
348
|
+
* of 1,224 windowed excerpts, 106 of them at the 6-site limit, where the wasted slot cost
|
|
349
|
+
* another term its window. A term that never stands whole is now -1, exactly like a term
|
|
350
|
+
* the file lacks: HC-GF-188's rule, "could not anchor" stays distinguishable from "anchored
|
|
351
|
+
* here", applies to a window around `information` as much as to one around line 1.
|
|
328
352
|
*/
|
|
329
353
|
/**
|
|
330
354
|
* HC-GF-206-followup-1: a collapsed hit is a real site — the collapse only means it EXPECTS to
|
|
@@ -355,6 +379,11 @@ function anchorSitesForStory(content, storyText, max = 6) {
|
|
|
355
379
|
if (def === -1) continue;
|
|
356
380
|
const t = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
357
381
|
const offsets = [def];
|
|
382
|
+
// The STRICT form on purpose, unlike `wholeTerm`. HC-GF-206-followup-5 measured `wholeTerm` here:
|
|
383
|
+
// a term with a punctuation edge (`/prompts/`, `.json`) then takes every occurrence as a site and
|
|
384
|
+
// crowds the rest out of the 6 — 74 of 1,218 excerpts changed, 9 terms gained a window, 28 lost one.
|
|
385
|
+
// With the strict form such a term gets its first whole occurrence (`def`), and the marker names
|
|
386
|
+
// it when no window shows it.
|
|
358
387
|
for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
|
|
359
388
|
for (const off of offsets) {
|
|
360
389
|
const line = text.slice(0, off).split('\n').length;
|
|
@@ -370,8 +399,29 @@ function anchorSitesForStory(content, storyText, max = 6) {
|
|
|
370
399
|
return { sites, collapsed };
|
|
371
400
|
}
|
|
372
401
|
|
|
402
|
+
/**
|
|
403
|
+
* A term standing WHOLE — HC-GF-203-followup-1: no identifier character touches an edge of the term
|
|
404
|
+
* that is itself an identifier character. `checkBudget(` stands whole in `checkBudget(orgId)` — its
|
|
405
|
+
* own `(` is the boundary on that side — and `info` does not in `information`.
|
|
406
|
+
*
|
|
407
|
+
* HC-GF-206-followup-5: one copy, read by `definitionIndexOf` and `unanchoredTerms`. With the strict
|
|
408
|
+
* `(?<![\w$])…(?![\w$])` in `unanchoredTerms`, the two disagreed on every term with a punctuation
|
|
409
|
+
* edge: `definitionIndexOf` found `checkBudget(` and the marker said nothing, because the strict
|
|
410
|
+
* form calls it absent. `anchorSitesForStory`'s scan for further hits deliberately keeps the strict
|
|
411
|
+
* form; see there.
|
|
412
|
+
*
|
|
413
|
+
* @returns {{t: string, before: string, after: string}} the escaped term and the two lookarounds
|
|
414
|
+
*/
|
|
415
|
+
function wholeTerm(term) {
|
|
416
|
+
return {
|
|
417
|
+
t: term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
|
418
|
+
before: /^[\w$]/.test(term) ? '(?<![\\w$])' : '',
|
|
419
|
+
after: /[\w$]$/.test(term) ? '(?![\\w$])' : '',
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
373
423
|
function definitionIndexOf(text, term) {
|
|
374
|
-
const t = term
|
|
424
|
+
const { t, before, after } = wholeTerm(term);
|
|
375
425
|
// Ordered by how strongly each shape asserts "this is where it lives".
|
|
376
426
|
const DEFINITION_FORMS = [
|
|
377
427
|
`\\.command\\(\\s*['"\`]${t}\\b`, // CLI command registration
|
|
@@ -379,13 +429,16 @@ function definitionIndexOf(text, term) {
|
|
|
379
429
|
`class\\s+${t}\\b`, // class declaration
|
|
380
430
|
`(?:const|let|var)\\s+${t}\\s*=`, // binding
|
|
381
431
|
`exports\\.${t}\\s*=`, // named export
|
|
382
|
-
|
|
432
|
+
// The one form with nothing in front of the term: without `before`, `name` is "defined"
|
|
433
|
+
// by `basename = (` and `run` by `rerun: function`.
|
|
434
|
+
`${before}${t}\\s*[:=]\\s*(?:async\\s*)?(?:function|\\()`, // property holding a function
|
|
383
435
|
];
|
|
384
436
|
for (const form of DEFINITION_FORMS) {
|
|
385
437
|
const m = new RegExp(form).exec(text);
|
|
386
438
|
if (m) return m.index;
|
|
387
439
|
}
|
|
388
|
-
|
|
440
|
+
const mention = new RegExp(`${before}${t}${after}`).exec(text);
|
|
441
|
+
return mention ? mention.index : -1;
|
|
389
442
|
}
|
|
390
443
|
|
|
391
444
|
/**
|
|
@@ -402,9 +455,17 @@ function definitionIndexOf(text, term) {
|
|
|
402
455
|
* Judged against the FINAL windows, not the site list, so it holds whatever dropped the term — the
|
|
403
456
|
* site limit, the budget, a collapse — and needs no knowledge of how the layout was chosen.
|
|
404
457
|
*
|
|
405
|
-
* A term counts as occurring only where it stands
|
|
406
|
-
*
|
|
407
|
-
*
|
|
458
|
+
* A term counts as occurring only where it stands whole (`wholeTerm`); reporting `info` inside
|
|
459
|
+
* `information` as "`info` is at line N" would send an agent to a line that does not contain it.
|
|
460
|
+
* `definitionIndexOf` applies the same rule since HC-GF-203-followup-1 (its fallback was a bare
|
|
461
|
+
* `indexOf`); the check here stays, because this is what names a term to the agent.
|
|
462
|
+
*
|
|
463
|
+
* HC-GF-206-followup-5: this read the strict form, which calls every term with a punctuation edge
|
|
464
|
+
* absent unless that edge happens to sit next to a non-identifier character. Measured 2026-10-07 at
|
|
465
|
+
* the production cap over every roadmap row against cli/hone-cli.js and orchestrator.js: 44 such
|
|
466
|
+
* terms in 40 of 1,218 windowed excerpts were in the file, in no window, and not named; and 42
|
|
467
|
+
* were named as outside every window while a window showed them (`.md` in `README.md`). After: 0 and
|
|
468
|
+
* 0, with every window byte-identical and no term pushed out of the marker or the notice.
|
|
408
469
|
*
|
|
409
470
|
* @param {string} content full file contents
|
|
410
471
|
* @param {string} storyText
|
|
@@ -428,11 +489,10 @@ function unanchoredTerms(content, storyText, ranges) {
|
|
|
428
489
|
for (const term of new Set(storyTermsOf(storyText))) {
|
|
429
490
|
const def = definitionIndexOf(text, term);
|
|
430
491
|
if (def === -1) continue;
|
|
431
|
-
const t = term
|
|
432
|
-
const mentions = [...text.matchAll(new RegExp(
|
|
492
|
+
const { t, before, after } = wholeTerm(term);
|
|
493
|
+
const mentions = [...text.matchAll(new RegExp(`${before}${t}${after}`, 'g'))].map((m) => lineOf(m.index));
|
|
433
494
|
if (!mentions.length) continue;
|
|
434
|
-
// Covered only by a whole
|
|
435
|
-
// when `def` is the indexOf fallback that window shows `information`, not `info`.
|
|
495
|
+
// Covered only by a whole occurrence.
|
|
436
496
|
if (mentions.some(covered)) continue;
|
|
437
497
|
const defLine = lineOf(def);
|
|
438
498
|
out.push({ term, line: mentions.includes(defLine) ? defLine : mentions[0] });
|
|
@@ -877,6 +937,10 @@ const LABEL_RE = /\*\*([A-Z][^*\n]{0,80}?):\*\*/g;
|
|
|
877
937
|
* Fences are masked FIRST so a backtick inside a fence cannot pair with one
|
|
878
938
|
* outside it.
|
|
879
939
|
*
|
|
940
|
+
* HC-GF-188-followup-2: `BACKTICKED_RE` (read by `storyTermsOf`) pairs backticks the same way,
|
|
941
|
+
* `[^`\n]*` between two of them. Change the pairing in one place and change it in the other, or
|
|
942
|
+
* the two will disagree about what a code span is.
|
|
943
|
+
*
|
|
880
944
|
* @param {string} text
|
|
881
945
|
* @returns {string} same length as the input
|
|
882
946
|
*/
|