@hone-ai/cli 1.40.0 → 1.42.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/README.md +7 -0
- package/hone-cli.js +191 -55
- package/lib/admin-request.js +49 -0
- package/lib/architect-config.js +5 -0
- package/lib/batch-follow.js +23 -7
- package/lib/block-grammar.js +35 -0
- package/lib/bundle-context.js +7 -12
- package/lib/bundle-limits.js +10 -0
- package/lib/call-sites.js +213 -29
- package/lib/context-reserve.js +13 -26
- package/lib/default-api.js +109 -0
- package/lib/editor-park.js +8 -2
- package/lib/materialize-diff.js +32 -13
- package/lib/mcp-tools.js +17 -5
- package/lib/operator-question.js +9 -4
- package/lib/path-refusal.js +79 -0
- package/lib/review-diff-check.js +262 -0
- package/lib/review-diff.js +188 -0
- package/lib/run-context.js +18 -41
- package/lib/run-report.js +165 -20
- package/lib/tree-artifact.js +440 -55
- package/package.json +1 -1
package/lib/block-grammar.js
CHANGED
|
@@ -167,6 +167,39 @@ const EDIT_SPLIT_RE = /^<{3,}[ \t]*OLD[ \t]*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n
|
|
|
167
167
|
/** A `DELETE: <path>` line. */
|
|
168
168
|
const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* HC-GF-404: the [from, to) of every `file:` block FILE_BLOCK_RE matches, rejected or duplicate
|
|
172
|
+
* paths included, in ascending order. A whole file's content is data: a line-start `edit:` opener
|
|
173
|
+
* or `DELETE:` line inside one is an example the file holds, never a block of its own.
|
|
174
|
+
*/
|
|
175
|
+
function fileBlockSpans(text) {
|
|
176
|
+
const spans = [];
|
|
177
|
+
for (const m of String(text == null ? '' : text).matchAll(FILE_BLOCK_RE)) {
|
|
178
|
+
spans.push({ from: m.index, to: m.index + m[0].length });
|
|
179
|
+
}
|
|
180
|
+
return spans;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* HC-GF-404: every match of `re` over `text` that does not start inside one of `spans`
|
|
185
|
+
* (fileBlockSpans() of the same text). A match that starts inside a span is not dropped after the
|
|
186
|
+
* fact: the scan resumes at that span's end, because a phantom match can run past the span (a nested
|
|
187
|
+
* edit with a fence wider than the file's) and swallow a real block after it. `re` is cloned, so the
|
|
188
|
+
* shared /g regexes above are never advanced.
|
|
189
|
+
*/
|
|
190
|
+
function* matchesOutside(re, text, spans) {
|
|
191
|
+
const r = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
192
|
+
const s = String(text == null ? '' : text);
|
|
193
|
+
let i = 0;
|
|
194
|
+
let m;
|
|
195
|
+
while ((m = r.exec(s)) !== null) {
|
|
196
|
+
while (i < spans.length && spans[i].to <= m.index) i++;
|
|
197
|
+
if (i < spans.length && m.index >= spans[i].from) { r.lastIndex = spans[i].to; continue; }
|
|
198
|
+
if (m[0].length === 0) r.lastIndex++;
|
|
199
|
+
yield m;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
170
203
|
module.exports = {
|
|
171
204
|
fenceWidth,
|
|
172
205
|
closesFence,
|
|
@@ -178,4 +211,6 @@ module.exports = {
|
|
|
178
211
|
EDIT_OPEN_RE,
|
|
179
212
|
EDIT_SPLIT_RE,
|
|
180
213
|
DELETE_RE,
|
|
214
|
+
fileBlockSpans,
|
|
215
|
+
matchesOutside,
|
|
181
216
|
};
|
package/lib/bundle-context.js
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
const path = require('path');
|
|
38
|
+
const { pathRefusal } = require('./path-refusal');
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Repo-level files worth bundling regardless of what the story says. Deliberately
|
|
@@ -50,18 +51,12 @@ const SIGNAL_FILES = Object.freeze([
|
|
|
50
51
|
]);
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* Is this path a credential file that must never be sent as whole content? `.env.example` is not;
|
|
55
|
+
* `.env.production` and `keys/deploy.key` are. HC-GF-398: the patterns live in `./path-refusal`
|
|
56
|
+
* (egress mode); this name stays for the callers that still use it.
|
|
56
57
|
*/
|
|
57
|
-
const SECRET_BASENAMES = [/^\.env($|\.)/i, /^secrets?\./i, /\.pem$/i, /\.p12$/i, /(^|[._-])id_(rsa|dsa|ecdsa|ed25519)$/i];
|
|
58
|
-
const SECRET_EXEMPT = /\.(example|sample|template|dist)$/i;
|
|
59
|
-
|
|
60
|
-
/** Is this path a credential file? `.env.example` is not; `.env.production` is. */
|
|
61
58
|
function isSecretPath(p) {
|
|
62
|
-
|
|
63
|
-
if (SECRET_EXEMPT.test(base)) return false;
|
|
64
|
-
return SECRET_BASENAMES.some((re) => re.test(base));
|
|
59
|
+
return pathRefusal(p, { mode: 'egress' }) !== null;
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
/**
|
|
@@ -146,7 +141,7 @@ function nearestPopulatedAncestor(pendingPath, repoFiles) {
|
|
|
146
141
|
function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSiblings = 6 } = {}) {
|
|
147
142
|
const present = [], pending = [], refused = [];
|
|
148
143
|
for (const p of named) {
|
|
149
|
-
if (
|
|
144
|
+
if (pathRefusal(p, { mode: 'egress' })) { refused.push(p); continue; }
|
|
150
145
|
(exists(p) ? present : pending).push(p);
|
|
151
146
|
}
|
|
152
147
|
|
|
@@ -168,7 +163,7 @@ function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSib
|
|
|
168
163
|
(path.posix.extname(b) === ext) - (path.posix.extname(a) === ext));
|
|
169
164
|
for (const k of ranked) {
|
|
170
165
|
if (siblings.length >= maxSiblings) break;
|
|
171
|
-
if (taken.has(k) ||
|
|
166
|
+
if (taken.has(k) || pathRefusal(k, { mode: 'egress' }) || isGeneratedPath(k)) continue;
|
|
172
167
|
taken.add(k); siblings.push(k);
|
|
173
168
|
}
|
|
174
169
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-390: the leaf home of the bundle's per-file cap. run-context.js windows a file whose text is
|
|
4
|
+
* longer than this (bundle-paths.js `planFileExcerpt`: `text.length <= limit` is sent whole), and
|
|
5
|
+
* review-diff.js diffs exactly the files a reviewer saw only windows of, so both read it from here.
|
|
6
|
+
* run-context.js re-exports it under the same name for the tests that import it from there.
|
|
7
|
+
*/
|
|
8
|
+
const BUNDLE_MAX_PER_FILE_CHARS = 25_000;
|
|
9
|
+
|
|
10
|
+
module.exports = { BUNDLE_MAX_PER_FILE_CHARS };
|
package/lib/call-sites.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - A search that did not run is 'not_searched', never zero callers. `git grep` exits 1 for "no match" and 128
|
|
18
18
|
* for a fatal error; only the first is an answer. A timeout, a missing git and an output overflow are
|
|
19
19
|
* classified failures, not empty results.
|
|
20
|
-
* - A line from a hidden or credential-looking path is never sent (cli/lib/
|
|
20
|
+
* - A line from a hidden or credential-looking path is never sent (cli/lib/path-refusal.js in content mode,
|
|
21
21
|
* the rule a re-serve uses); it is counted, so the planner knows callers exist that it cannot see. The check
|
|
22
22
|
* is made on the disk as it is now: `git grep` follows a DIRECTORY that was replaced by a symlink after the
|
|
23
23
|
* commit (measured with git 2.50.1: a tracked `d/secret.txt` read from outside the checkout), so a path that
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
const { execFile } = require('node:child_process');
|
|
66
66
|
const path = require('node:path');
|
|
67
67
|
const { gitEnv } = require('./git-env');
|
|
68
|
-
const {
|
|
68
|
+
const { repoRealPath } = require('./context-reserve');
|
|
69
|
+
const { pathRefusal } = require('./path-refusal');
|
|
69
70
|
|
|
70
71
|
const CALL_SITES_VERSION = 1;
|
|
71
72
|
/** The only scope id today; the server writes its own description of it. */
|
|
@@ -73,9 +74,22 @@ const SCOPE = 'tracked-repo';
|
|
|
73
74
|
|
|
74
75
|
// The caps. server/src/services/call-sites.js accepts at least these (pinned by a cross-package test): a server
|
|
75
76
|
// that took less would quietly turn a complete list into an incomplete one.
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
// HC-GF-385: 16, up from 6. Six slots spent on string keys (`step_0b`, `config`) left the functions a story changes
|
|
78
|
+
// NOT SEARCHED and hard-stopped step_1 four times on one story. The wire budget is held by MAX_TOTAL_HITS, not by
|
|
79
|
+
// the symbol count: a symbol past it is sent as its per-file list (FILES ONLY), so 16 costs what 6 did.
|
|
80
|
+
const MAX_SYMBOLS = 16;
|
|
81
|
+
/** Secret-looking names are reported, not searched; they take no slot of the 16 but are reported up to this many. */
|
|
78
82
|
const MAX_REFUSED = 2;
|
|
83
|
+
/**
|
|
84
|
+
* HC-GF-385: the hit lines ALL symbols together may send, spent in rank order: today's 6 x 40, so the 448 KiB
|
|
85
|
+
* per-story wire budget (server/src/lib/batch-body-limit.js) and its test keep their meaning. A symbol reached after
|
|
86
|
+
* it is spent sends `hits: []` plus its per-file list.
|
|
87
|
+
*/
|
|
88
|
+
const MAX_TOTAL_HITS = 240;
|
|
89
|
+
/** HC-GF-385: the definition probe ranks at most this many candidates, best-ranked first. */
|
|
90
|
+
const MAX_PROBE_CANDIDATES = 32;
|
|
91
|
+
/** HC-GF-385: the probe's own share of TOTAL_BUDGET_MS; past it the story's own order is kept, and said. */
|
|
92
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
79
93
|
/** Past MAX_HITS_PER_SYMBOL a symbol also carries a per-file list (path + hit count), up to this many files... */
|
|
80
94
|
const MAX_FILE_LIST_FILES = 300;
|
|
81
95
|
/** ...and all the symbols' file lists together stay under this many bytes of JSON (server/src/lib/batch-body-limit.js budgets it). */
|
|
@@ -97,7 +111,38 @@ const PROSE_EXTENSIONS = Object.freeze(['md', 'mdx', 'markdown', 'rst', 'adoc',
|
|
|
97
111
|
|
|
98
112
|
// ── Choosing the symbols ────────────────────────────────────────────────────────────────────────
|
|
99
113
|
|
|
100
|
-
|
|
114
|
+
// Backticked spans are PAIRED first, by the CommonMark rule, and the 2-to-200 rule is applied to each paired span afterwards.
|
|
115
|
+
// A bound INSIDE a regex decides which backticks pair: at `x`, and `foo` the opening backtick of `x` cannot start a 2-character
|
|
116
|
+
// match, so its closing one does, the "span" is `, and`, and `foo` is lost (measured 2026-10-10 over the roadmap rows: 68 of 939
|
|
117
|
+
// extracted a wrong span set). A plain /`([^`\n]*)`/ fixed that and made the next one: after a run of TWO OR MORE backticks
|
|
118
|
+
// (a ``double`` span, an inline ```fence) the pairing never recovered for the rest of the line, and 7 rows lost symbols the old
|
|
119
|
+
// pattern found. The rule that has no such failure is the one Markdown itself uses: a run of N backticks opens a span that the
|
|
120
|
+
// next run of EXACTLY N backticks closes, and a run with no such closer is literal text. Per line (a span does not cross a
|
|
121
|
+
// newline here, as before). Linear: the next run of the same length is computed once per run, not searched for.
|
|
122
|
+
// The same defect as BACKTICKED_RE in bundle-paths.js (HC-GF-188-followup-2), which has the first fix only.
|
|
123
|
+
function backtickedSpans(text) {
|
|
124
|
+
const spans = [];
|
|
125
|
+
for (const line of String(text || '').split('\n')) {
|
|
126
|
+
if (!line.includes('`')) continue;
|
|
127
|
+
const runs = [];
|
|
128
|
+
for (const m of line.matchAll(/`+/g)) runs.push({ at: m.index, len: m[0].length });
|
|
129
|
+
const nextSame = new Array(runs.length).fill(-1);
|
|
130
|
+
const lastOfLen = new Map();
|
|
131
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
132
|
+
nextSame[i] = lastOfLen.has(runs[i].len) ? lastOfLen.get(runs[i].len) : -1;
|
|
133
|
+
lastOfLen.set(runs[i].len, i);
|
|
134
|
+
}
|
|
135
|
+
for (let i = 0; i < runs.length; i++) {
|
|
136
|
+
const j = nextSame[i];
|
|
137
|
+
if (j < 0) continue; // no closer of this length: the run is literal text
|
|
138
|
+
let span = line.slice(runs[i].at + runs[i].len, runs[j].at);
|
|
139
|
+
if (span.length > 2 && span.startsWith(' ') && span.endsWith(' ') && /[^ ]/.test(span)) span = span.slice(1, -1); // CommonMark: one space each side
|
|
140
|
+
spans.push(span);
|
|
141
|
+
i = j; // resume after the closer; the runs between are inside the span
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return spans;
|
|
145
|
+
}
|
|
101
146
|
// One identifier, optionally qualified (`a.b`, `A::b`, `a->b`, `A#b`) and optionally called (`f()`, `f(x)`). The LAST
|
|
102
147
|
// segment is the symbol: `this.ensureConnected` is a reference to `ensureConnected` wherever it is called from.
|
|
103
148
|
// Group 1 is the qualifier (empty when there is none), 2 the name, 3 the call's parentheses (undefined when not called).
|
|
@@ -158,25 +203,129 @@ function symbolOfSpan(span) {
|
|
|
158
203
|
* @returns {{symbols: string[], omitted: string[], omittedTotal: number, refused: string[]}}
|
|
159
204
|
*/
|
|
160
205
|
function pickSymbols(storyText, { max = MAX_SYMBOLS } = {}) {
|
|
206
|
+
const { ranked, refusedAll } = rankCandidates(storyText);
|
|
207
|
+
return sliceRanked(ranked.map((c) => c.name), refusedAll, max);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Every candidate the story names, in the story's order (explicitly written first, then by how often it is named,
|
|
212
|
+
* ties in the order first named), with the facts that order was made from. Secret-looking names are split off.
|
|
213
|
+
* Synchronous and text-only: `pickSymbols` keeps that contract for callers this file cannot see (HC-GF-385).
|
|
214
|
+
* @returns {{ranked: Array<{name: string, explicit: boolean, count: number}>, refusedAll: string[]}}
|
|
215
|
+
*/
|
|
216
|
+
function rankCandidates(storyText) {
|
|
161
217
|
const counts = new Map();
|
|
162
218
|
const explicitNames = new Set();
|
|
163
|
-
for (const
|
|
164
|
-
|
|
219
|
+
for (const span of backtickedSpans(storyText)) {
|
|
220
|
+
if (span.length < 2 || span.length > 200) continue; // the length rule, after pairing
|
|
221
|
+
const sym = symbolOfSpan(span);
|
|
165
222
|
if (!sym) continue;
|
|
166
223
|
counts.set(sym.name, (counts.get(sym.name) || 0) + 1);
|
|
167
224
|
if (sym.explicit) explicitNames.add(sym.name);
|
|
168
225
|
}
|
|
169
226
|
// Array.prototype.sort is stable (ES2019), and a Map iterates in insertion order: ties keep the order the story first names them.
|
|
170
227
|
const all = [...counts.keys()].sort((a, b) => (explicitNames.has(b) - explicitNames.has(a)) || (counts.get(b) - counts.get(a)));
|
|
171
|
-
const refusedAll = all.filter((n) => SECRET_NAME.test(n));
|
|
172
|
-
const ranked = all.filter((n) => !SECRET_NAME.test(n));
|
|
173
|
-
const refused = refusedAll.slice(0, MAX_REFUSED);
|
|
174
|
-
const beyond = [...ranked.slice(max), ...refusedAll.slice(MAX_REFUSED)];
|
|
175
228
|
return {
|
|
176
|
-
|
|
229
|
+
ranked: all.filter((n) => !SECRET_NAME.test(n)).map((name) => ({ name, explicit: explicitNames.has(name), count: counts.get(name) })),
|
|
230
|
+
refusedAll: all.filter((n) => SECRET_NAME.test(n)),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The first `max` names are searched; the rest, and the refused names past MAX_REFUSED, are reported as omitted. */
|
|
235
|
+
function sliceRanked(names, refusedAll, max) {
|
|
236
|
+
const beyond = [...names.slice(max), ...refusedAll.slice(MAX_REFUSED)];
|
|
237
|
+
return {
|
|
238
|
+
symbols: names.slice(0, max),
|
|
177
239
|
omitted: beyond.slice(0, MAX_OMITTED_NAMES),
|
|
178
240
|
omittedTotal: beyond.length,
|
|
179
|
-
refused,
|
|
241
|
+
refused: refusedAll.slice(0, MAX_REFUSED),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Ranking by definition (HC-GF-385) ───────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** Files whose matches never define code: documentation, and data formats (an object or YAML key is not a symbol). */
|
|
248
|
+
const DATA_EXTENSIONS = Object.freeze(['json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'lock', 'csv', 'tsv', 'xml', 'ini', 'env', 'properties']);
|
|
249
|
+
/** A word before `name(` that makes it a call or an expression, never a C-family definition. */
|
|
250
|
+
const NOT_A_RETURN_TYPE = new Set(['return', 'await', 'new', 'throw', 'yield', 'else', 'case', 'typeof', 'delete', 'void', 'in', 'of', 'do',
|
|
251
|
+
'if', 'while', 'for', 'switch', 'catch', 'and', 'or', 'not', 'is', 'as', 'echo', 'print', 'puts', 'go', 'defer', 'export', 'default']);
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* How strongly one line defines `name`: 2 for a function, method, class or type; 1 for a top-level or exported
|
|
255
|
+
* constant or variable; 0 for anything else (a call, a key, a mention). Language-neutral patterns, covering
|
|
256
|
+
* JavaScript and TypeScript, Python, Go, Ruby, Java and the C family, and `fn`/`fun`/`func` (Rust, Kotlin, Swift).
|
|
257
|
+
* `name` is an identifier ([A-Za-z0-9_]), so it is safe inside a RegExp.
|
|
258
|
+
* @param {string} line
|
|
259
|
+
* @param {string} name
|
|
260
|
+
* @returns {0|1|2}
|
|
261
|
+
*/
|
|
262
|
+
function definitionTier(line, name) {
|
|
263
|
+
const n = `${name}(?![A-Za-z0-9_$])`;
|
|
264
|
+
const t = String(line);
|
|
265
|
+
// A keyword that introduces a definition: `function f`, `class C`, `def f`, `def self.f`, `func (r *T) f`, `fn f`, `fun f`.
|
|
266
|
+
if (new RegExp(`(?:^|[^A-Za-z0-9_$.])(?:function\\*?|class|def|func|fn|fun|interface|enum|struct|trait|type|module|record|protocol)\\s+(?:self\\.)?(?:\\([^)]*\\)\\s*)?${n}`).test(t)) return 2;
|
|
267
|
+
// A function value bound at top level or exported: `export const f = async (a) =>`, `const f = function`.
|
|
268
|
+
if (new RegExp(`^(?:export\\s+)?(?:const|let|var)\\s+${n}\\s*=\\s*(?:async\\b\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][A-Za-z0-9_$]*\\s*=>)`).test(t)) return 2;
|
|
269
|
+
// A method, in a class body or an object literal: ` async f(a, b) {`, ` static get f() {`.
|
|
270
|
+
if (new RegExp(`^\\s*(?:(?:static|async|get|set|public|private|protected|readonly|override|abstract|final)\\s+)*${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^={;]+)?\\{`).test(t)) return 2;
|
|
271
|
+
// A C-family signature: one or more type words, then `name(`, and no `;` (a prototype or a call statement).
|
|
272
|
+
const sig = new RegExp(`^\\s*((?:[A-Za-z_][A-Za-z0-9_<>\\[\\],:*&?]*\\s+)+)\\*?${n}\\s*\\([^;]*$`).exec(t);
|
|
273
|
+
if (sig) {
|
|
274
|
+
const words = sig[1].trim().split(/\s+/);
|
|
275
|
+
if (!NOT_A_RETURN_TYPE.has(words[words.length - 1])) return 2;
|
|
276
|
+
}
|
|
277
|
+
// A top-level or exported binding of anything else: `const f =`, `export let f`, `module.exports.f =`, Go `var f`.
|
|
278
|
+
if (new RegExp(`^(?:export\\s+)?(?:const|let|var|val)\\s+${n}`).test(t)) return 1;
|
|
279
|
+
if (new RegExp(`^(?:module\\.)?exports\\.${n}\\s*=`).test(t)) return 1;
|
|
280
|
+
// Python and Ruby top-level assignment: `f = ...` at column 0, not `f == x`.
|
|
281
|
+
if (new RegExp(`^${n}\\s*=(?!=)`).test(t)) return 1;
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Rank candidates by whether the repository defines them, with ONE batched `git grep -F -w` over all of them.
|
|
287
|
+
* Functions, methods, classes and types come first, then top-level or exported constants, then names with no
|
|
288
|
+
* definition (string keys, config fields, words). Within a tier the story's own order holds. Any failure, timeout
|
|
289
|
+
* or output this cannot read keeps the story's order, and the note says so: a probe never empties the search.
|
|
290
|
+
* @param {Array<{name: string}>} ranked the story's order
|
|
291
|
+
* @returns {Promise<{ranked: Array<{name: string}>, fallback: string|null, undefinedNames: string[]}>}
|
|
292
|
+
*/
|
|
293
|
+
async function rankByDefinition(ranked, { run, realPath }) {
|
|
294
|
+
if (ranked.length === 0) return { ranked, fallback: null, undefinedNames: [] };
|
|
295
|
+
const probed = ranked.slice(0, MAX_PROBE_CANDIDATES);
|
|
296
|
+
const argv = ['grep', '-n', '-z', '-w', '-F', '-I', '--no-color', '--no-recurse-submodules'];
|
|
297
|
+
for (const c of probed) argv.push('-e', c.name);
|
|
298
|
+
let r;
|
|
299
|
+
try { r = await run(argv, { timeoutMs: PROBE_TIMEOUT_MS }); } catch { r = { failure: 'the probe could not be run' }; }
|
|
300
|
+
const problem = grepProblem(r || {});
|
|
301
|
+
if (problem) return { ranked, fallback: `the definition probe gave no answer (${problem}), so the story's own order was used`, undefinedNames: [] };
|
|
302
|
+
const lines = r.status === 1 ? [] : parseGrepOutput(r.stdout);
|
|
303
|
+
if (lines === null) return { ranked, fallback: 'the definition probe printed output that could not be read, so the story\'s own order was used', undefinedNames: [] };
|
|
304
|
+
|
|
305
|
+
const tier = new Map(probed.map((c) => [c.name, 0]));
|
|
306
|
+
const kinds = new Map();
|
|
307
|
+
const wordRe = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
308
|
+
for (const hit of lines) {
|
|
309
|
+
let kind = kinds.get(hit.path);
|
|
310
|
+
if (kind === undefined) {
|
|
311
|
+
const ext = path.posix.extname(hit.path).slice(1).toLowerCase();
|
|
312
|
+
kind = DATA_EXTENSIONS.includes(ext) ? 'data' : classifyPath(hit.path, realPath);
|
|
313
|
+
kinds.set(hit.path, kind);
|
|
314
|
+
}
|
|
315
|
+
if (kind !== 'ok') continue; // documentation, data and withheld paths define nothing here
|
|
316
|
+
for (const w of new Set(String(hit.text).match(wordRe) || [])) {
|
|
317
|
+
if (!tier.has(w) || tier.get(w) === 2) continue;
|
|
318
|
+
const d = definitionTier(hit.text, w);
|
|
319
|
+
if (d > tier.get(w)) tier.set(w, d);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Stable: within a tier the story's order is kept. Candidates past the probe cap keep their place after the probed.
|
|
323
|
+
const order = probed.map((c, i) => ({ c, i, t: tier.get(c.name) }))
|
|
324
|
+
.sort((a, b) => (b.t - a.t) || (a.i - b.i)).map((x) => x.c);
|
|
325
|
+
return {
|
|
326
|
+
ranked: [...order, ...ranked.slice(MAX_PROBE_CANDIDATES)],
|
|
327
|
+
fallback: null,
|
|
328
|
+
undefinedNames: probed.filter((c) => tier.get(c.name) === 0).map((c) => c.name),
|
|
180
329
|
};
|
|
181
330
|
}
|
|
182
331
|
|
|
@@ -239,7 +388,7 @@ function classifyPath(rel, realPath) {
|
|
|
239
388
|
const ext = path.posix.extname(rel).slice(1).toLowerCase();
|
|
240
389
|
if (PROSE_EXTENSIONS.includes(ext)) return 'prose';
|
|
241
390
|
if (rel.length > MAX_PATH_CHARS || /[\x00-\x1f\x7f]/.test(rel)) return 'withheld';
|
|
242
|
-
if (
|
|
391
|
+
if (pathRefusal(rel, { mode: 'content' })) return 'withheld';
|
|
243
392
|
// A directory above the file may have been replaced by a link since the commit; git reads through it.
|
|
244
393
|
let real = null;
|
|
245
394
|
try { real = realPath(rel); } catch { real = null; }
|
|
@@ -288,7 +437,7 @@ function parseFileList(out) {
|
|
|
288
437
|
* - 'not_searched': no answer, and `reason` says why. Never to be read as "no callers".
|
|
289
438
|
* `binary` is how many files match only when git's binary-file skip (-I) is off; they cannot be listed.
|
|
290
439
|
*/
|
|
291
|
-
async function searchSymbol(symbol, { run, realPath }) {
|
|
440
|
+
async function searchSymbol(symbol, { run, realPath, maxHits = MAX_HITS_PER_SYMBOL }) {
|
|
292
441
|
// Defence in depth: pickSymbols only yields identifiers, but this is exported, and the symbol reaches an argv and a RegExp.
|
|
293
442
|
if (typeof symbol !== 'string' || !/^[A-Za-z0-9_]+$/.test(symbol)) {
|
|
294
443
|
return { symbol: String(symbol).slice(0, 80), status: 'not_searched', reason: 'not an identifier' };
|
|
@@ -323,7 +472,9 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
323
472
|
else sendable.push(hit);
|
|
324
473
|
}
|
|
325
474
|
const total = sendable.length + withheld;
|
|
326
|
-
|
|
475
|
+
// HC-GF-385: `maxHits` is what is left of the story's MAX_TOTAL_HITS (0 once it is spent), never more than the per-symbol cap.
|
|
476
|
+
const cap = Math.max(0, Math.min(MAX_HITS_PER_SYMBOL, Number.isSafeInteger(maxHits) ? maxHits : MAX_HITS_PER_SYMBOL));
|
|
477
|
+
const hits = sendable.slice(0, cap).map((h) => ({ path: h.path, line: h.line, text: lineText(h.text, symbol) }));
|
|
327
478
|
const perFile = new Map();
|
|
328
479
|
for (const h of sendable) perFile.set(h.path, (perFile.get(h.path) || 0) + 1);
|
|
329
480
|
const entry = {
|
|
@@ -336,7 +487,10 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
336
487
|
binary,
|
|
337
488
|
hits,
|
|
338
489
|
};
|
|
339
|
-
|
|
490
|
+
// HC-GF-385: every searched symbol with a sendable hit carries its per-file list, not only one past the hit cap.
|
|
491
|
+
// A symbol cut by the total-hit budget needs it to be FILES ONLY, and a whole symbol whose hit lines the server's
|
|
492
|
+
// render budget drops needs it to stay an enumeration at file level.
|
|
493
|
+
if (sendable.length > 0) {
|
|
340
494
|
const all = [...perFile].map(([p, n]) => ({ path: p, hits: n }));
|
|
341
495
|
entry.file_list = all.slice(0, MAX_FILE_LIST_FILES);
|
|
342
496
|
entry.file_list_complete = all.length <= MAX_FILE_LIST_FILES;
|
|
@@ -344,11 +498,17 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
344
498
|
return entry;
|
|
345
499
|
}
|
|
346
500
|
|
|
347
|
-
/**
|
|
501
|
+
/**
|
|
502
|
+
* Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so.
|
|
503
|
+
* HC-GF-385: the bytes go first to the symbols whose hit lines were cut (by the per-symbol cap or the total-hit
|
|
504
|
+
* budget), in rank order, because for them the list IS the enumeration; then to the rest, in rank order. Spent
|
|
505
|
+
* in plain rank order, whole symbols at the top starved the FILES ONLY ones below them into INCOMPLETE.
|
|
506
|
+
*/
|
|
348
507
|
function fitFileLists(symbols) {
|
|
349
508
|
let room = MAX_FILE_LIST_BYTES;
|
|
350
|
-
|
|
351
|
-
|
|
509
|
+
const cut = (s) => Array.isArray(s.hits) && Number.isSafeInteger(s.total) && s.hits.length < s.total - (s.withheld || 0);
|
|
510
|
+
const ordered = [...symbols.filter((s) => s.file_list && cut(s)), ...symbols.filter((s) => s.file_list && !cut(s))];
|
|
511
|
+
for (const s of ordered) {
|
|
352
512
|
const kept = [];
|
|
353
513
|
for (const f of s.file_list) {
|
|
354
514
|
const size = Buffer.byteLength(JSON.stringify(f), 'utf8') + 1;
|
|
@@ -379,8 +539,9 @@ const warn = (text) => ({ level: 'warn', text });
|
|
|
379
539
|
*/
|
|
380
540
|
async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now, maxSymbols = MAX_SYMBOLS }) {
|
|
381
541
|
const notes = [];
|
|
382
|
-
const
|
|
383
|
-
|
|
542
|
+
const { ranked: storyOrder, refusedAll } = rankCandidates(storyText);
|
|
543
|
+
// The early return comes BEFORE the probe: a story that names nothing searchable runs no git at all.
|
|
544
|
+
if (storyOrder.length === 0 && refusedAll.length === 0) return { callSites: null, notes };
|
|
384
545
|
|
|
385
546
|
// From the repository's top level, wherever `hone` ran: `git grep` from a subdirectory searches only that subtree.
|
|
386
547
|
let root = cwd;
|
|
@@ -389,17 +550,24 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
|
|
|
389
550
|
const dir = top.failure || top.status !== 0 ? '' : top.stdout.trim();
|
|
390
551
|
if (dir) root = dir;
|
|
391
552
|
}
|
|
392
|
-
const git = run || ((argv) => runGit(argv, { cwd: root }));
|
|
553
|
+
const git = run || ((argv, opts = {}) => runGit(argv, { cwd: root, ...opts }));
|
|
393
554
|
const real = realPath || ((rel) => repoRealPath(root, rel));
|
|
394
555
|
const commit = await headCommit(git);
|
|
395
|
-
|
|
556
|
+
// HC-GF-385: the clock starts before the probe, so the probe's time comes out of the same TOTAL_BUDGET_MS.
|
|
396
557
|
const started = now();
|
|
558
|
+
// Rank BEFORE the slice and compute what was omitted AFTER it, or the functions never reach the slice.
|
|
559
|
+
const probe = await rankByDefinition(storyOrder, { run: git, realPath: real });
|
|
560
|
+
const picked = sliceRanked(probe.ranked.map((c) => c.name), refusedAll, maxSymbols);
|
|
561
|
+
const symbols = [];
|
|
562
|
+
let hitsLeft = MAX_TOTAL_HITS;
|
|
397
563
|
for (const symbol of picked.symbols) {
|
|
398
564
|
if (now() - started > TOTAL_BUDGET_MS) {
|
|
399
565
|
symbols.push({ symbol, status: 'not_searched', reason: `the ${TOTAL_BUDGET_MS / 1000}s search budget for this story was spent on earlier symbols` });
|
|
400
566
|
continue;
|
|
401
567
|
}
|
|
402
|
-
|
|
568
|
+
const entry = await searchSymbol(symbol, { run: git, realPath: real, maxHits: hitsLeft });
|
|
569
|
+
if (Array.isArray(entry.hits)) hitsLeft = Math.max(0, hitsLeft - entry.hits.length);
|
|
570
|
+
symbols.push(entry);
|
|
403
571
|
}
|
|
404
572
|
for (const symbol of picked.refused) symbols.push({ symbol, status: 'not_searched', reason: SECRET_REASON });
|
|
405
573
|
fitFileLists(symbols);
|
|
@@ -413,12 +581,17 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
|
|
|
413
581
|
omitted: picked.omitted,
|
|
414
582
|
omitted_total: picked.omittedTotal,
|
|
415
583
|
};
|
|
416
|
-
notes.push(
|
|
584
|
+
if (probe.fallback) notes.push(warn(` ⚠ call sites: ${probe.fallback}`));
|
|
585
|
+
const searchedUndefined = probe.undefinedNames.filter((n) => picked.symbols.includes(n));
|
|
586
|
+
if (!probe.fallback && searchedUndefined.length > 0) {
|
|
587
|
+
notes.push(info(` no definition found for ${searchedUndefined.join(', ')} (ranked after defined code; it may be a key, a field, or one this story creates)`));
|
|
588
|
+
}
|
|
589
|
+
notes.push(...describeCallSites(callSites, { maxSymbols }));
|
|
417
590
|
return { callSites, notes };
|
|
418
591
|
}
|
|
419
592
|
|
|
420
593
|
/** The operator's lines for a result: one info line per symbol that is whole, a warning for each that is not. */
|
|
421
|
-
function describeCallSites(callSites) {
|
|
594
|
+
function describeCallSites(callSites, { maxSymbols = MAX_SYMBOLS } = {}) {
|
|
422
595
|
const notes = [];
|
|
423
596
|
const where = callSites.commit ? `tracked files at ${callSites.commit.slice(0, 12)}` : 'tracked files (commit unknown)';
|
|
424
597
|
notes.push(info(` → call sites: searched ${callSites.symbols.length} symbol(s) the story names, in ${where}`));
|
|
@@ -437,7 +610,10 @@ function describeCallSites(callSites) {
|
|
|
437
610
|
binary ? `${binary} binary file${binary === 1 ? '' : 's'} match and cannot be listed` : '',
|
|
438
611
|
].filter(Boolean).join('; ');
|
|
439
612
|
const fileLevel = s.status === 'incomplete' && binary === 0 && s.file_list_complete === true && Array.isArray(s.file_list);
|
|
440
|
-
if (s.status === 'incomplete' && fileLevel) {
|
|
613
|
+
if (s.status === 'incomplete' && fileLevel && s.hits.length === 0) {
|
|
614
|
+
// HC-GF-385: reached after the total-hit budget was spent. The server renders it FILES ONLY.
|
|
615
|
+
notes.push(info(` ${s.symbol}: FILES ONLY — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; every file with a hit is named with its count, individual lines not sent (the ${MAX_TOTAL_HITS}-hit budget was spent on higher-ranked symbols)${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
|
|
616
|
+
} else if (s.status === 'incomplete' && fileLevel) {
|
|
441
617
|
notes.push(info(` ${s.symbol}: COMPLETE AT FILE LEVEL — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; the first ${s.hits.length} are listed and every file with a hit is named with its count${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
|
|
442
618
|
} else if (s.status === 'incomplete') {
|
|
443
619
|
notes.push(warn(` ⚠ call sites for ${s.symbol}: INCOMPLETE — ${s.hits.length} of ${listable} hits listed (cap ${MAX_HITS_PER_SYMBOL}${s.file_list ? `, ${s.file_list.length} of ${s.files} files named` : ''}); the planner cannot claim an enumeration${extra ? ` (${extra})` : ''}`));
|
|
@@ -454,16 +630,21 @@ function describeCallSites(callSites) {
|
|
|
454
630
|
const omittedTotal = callSites.omitted_total || 0;
|
|
455
631
|
if (omittedTotal > 0) {
|
|
456
632
|
const shown = callSites.omitted.join(', ') + (omittedTotal > callSites.omitted.length ? ', …' : '');
|
|
457
|
-
notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${
|
|
633
|
+
notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${maxSymbols}-symbol cap — NOT SEARCHED: ${shown}`));
|
|
458
634
|
}
|
|
459
635
|
return notes;
|
|
460
636
|
}
|
|
461
637
|
|
|
462
638
|
module.exports = {
|
|
639
|
+
backtickedSpans,
|
|
463
640
|
CALL_SITES_VERSION,
|
|
464
641
|
SCOPE,
|
|
465
642
|
MAX_SYMBOLS,
|
|
466
643
|
MAX_REFUSED,
|
|
644
|
+
MAX_TOTAL_HITS,
|
|
645
|
+
MAX_PROBE_CANDIDATES,
|
|
646
|
+
PROBE_TIMEOUT_MS,
|
|
647
|
+
DATA_EXTENSIONS,
|
|
467
648
|
MAX_FILE_LIST_FILES,
|
|
468
649
|
MAX_FILE_LIST_BYTES,
|
|
469
650
|
SECRET_NAME,
|
|
@@ -478,6 +659,9 @@ module.exports = {
|
|
|
478
659
|
MAX_OUTPUT_BYTES,
|
|
479
660
|
isCompoundIdentifier,
|
|
480
661
|
pickSymbols,
|
|
662
|
+
rankCandidates,
|
|
663
|
+
definitionTier,
|
|
664
|
+
rankByDefinition,
|
|
481
665
|
fitFileLists,
|
|
482
666
|
runGit,
|
|
483
667
|
parseGrepOutput,
|
package/lib/context-reserve.js
CHANGED
|
@@ -31,7 +31,7 @@ const fs = require('node:fs');
|
|
|
31
31
|
const path = require('node:path');
|
|
32
32
|
const { remedyFor, partitionSafePaths, tokenOf, isSafeToken } = require('./hard-pause-remedy');
|
|
33
33
|
const { parseIncludeTarget } = require('./include-paths');
|
|
34
|
-
const {
|
|
34
|
+
const { pathRefusal, structuralRefusal } = require('./path-refusal');
|
|
35
35
|
const { isBatchId } = require('./run-target');
|
|
36
36
|
|
|
37
37
|
/** The start of the reason a re-served run is killed with. The loop reads it back to find the new batch. */
|
|
@@ -67,18 +67,6 @@ function hardPauseRequest(run) {
|
|
|
67
67
|
return remedy && remedy.stepKey === at[1] ? remedy : null;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Credential-looking names the bundler's own refusal (isSecretPath) does not cover: a public half of a key pair,
|
|
71
|
-
// key containers, anything the author named a secret or a credential.
|
|
72
|
-
const SECRET_LOOKING = [/(^|[._-])id_(rsa|dsa|ecdsa|ed25519)/i, /\.(key|pfx|jks|keystore|p8)$/i, /(secret|credential)/i];
|
|
73
|
-
|
|
74
|
-
/** Why a path is not a plain relative path, or null. `file` has no `:line` or `:symbol` suffix. */
|
|
75
|
-
function structuralRefusal(file) {
|
|
76
|
-
if (typeof file !== 'string' || file === '') return 'empty';
|
|
77
|
-
if (/^[-/~]/.test(file)) return 'absolute, home-relative or option-like';
|
|
78
|
-
if (/[\\\0]/.test(file)) return 'not a plain relative path';
|
|
79
|
-
if (file.split('/').some((s) => s === '' || s === '.' || s === '..')) return 'not a plain relative path';
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
70
|
|
|
83
71
|
/**
|
|
84
72
|
* Why the CONTENT of a path (a plain file path, no `:line`) must not be sent to a model, or null: the three
|
|
@@ -87,11 +75,7 @@ function structuralRefusal(file) {
|
|
|
87
75
|
* @param {string} file
|
|
88
76
|
*/
|
|
89
77
|
function contentRefusal(file) {
|
|
90
|
-
|
|
91
|
-
if (plain) return plain;
|
|
92
|
-
if (isSecretPath(file) || SECRET_LOOKING.some((re) => re.test(path.posix.basename(file)))) return 'looks like a credential file';
|
|
93
|
-
if (file.split('/').some((s) => s.startsWith('.'))) return 'a hidden file or directory';
|
|
94
|
-
return null;
|
|
78
|
+
return pathRefusal(file, { mode: 'content' });
|
|
95
79
|
}
|
|
96
80
|
|
|
97
81
|
/**
|
|
@@ -208,21 +192,24 @@ function batchIdFromQueueOutput(stdout) {
|
|
|
208
192
|
* status, created_at, error_message), newest first.
|
|
209
193
|
* - `later`: a run of the story in this repository, created AFTER the paused one and not killed. A re-serve that
|
|
210
194
|
* queued but whose kill failed, or that died between the two, leaves exactly this and no marker.
|
|
211
|
-
* - `earlier`: a run of the story killed with `re-served as batch <id>`
|
|
212
|
-
* re-serve (the loop was started on the new batch alone) and a second
|
|
213
|
-
*
|
|
214
|
-
*
|
|
195
|
+
* - `earlier`: a run of the story, older than this one, killed with `re-served as batch <id>` where <id> is the batch
|
|
196
|
+
* THIS run is in (`batchId`): this run is that re-serve (the loop was started on the new batch alone), and a second
|
|
197
|
+
* pause here is the second. A story re-served into some OTHER batch, however long ago, is history and not this
|
|
198
|
+
* run's lineage: it neither blocks nor justifies anything (HC-GF-351-followup-1).
|
|
199
|
+
* - `unknown`: the paused run's creation time is not a date or its batch id is not one, so neither can be told.
|
|
200
|
+
* @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string, why?: string}|null}
|
|
215
201
|
*/
|
|
216
|
-
function serveHistory(rows, { runId, storyId, repoName, since }) {
|
|
202
|
+
function serveHistory(rows, { runId, storyId, repoName, since, batchId: ownBatch }) {
|
|
217
203
|
const t0 = Date.parse(since);
|
|
218
|
-
if (!Number.isFinite(t0)) return { kind: 'unknown' };
|
|
204
|
+
if (!Number.isFinite(t0)) return { kind: 'unknown', why: 'its creation time is not known' };
|
|
205
|
+
if (!isBatchId(ownBatch)) return { kind: 'unknown', why: 'the batch it is in is not known' };
|
|
219
206
|
for (const row of Array.isArray(rows) ? rows : []) {
|
|
220
207
|
if (!row || row.id === runId || row.story_id !== storyId || row.repo_name !== repoName) continue;
|
|
221
208
|
const t = Date.parse(row.created_at);
|
|
222
209
|
if (!Number.isFinite(t)) continue;
|
|
223
210
|
if (t > t0 && row.status !== 'killed') return { kind: 'later', runId: row.id };
|
|
224
|
-
const
|
|
225
|
-
if (
|
|
211
|
+
const reserved = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
|
|
212
|
+
if (reserved && reserved === ownBatch) return { kind: 'earlier', runId: row.id, batchId: reserved };
|
|
226
213
|
}
|
|
227
214
|
return null;
|
|
228
215
|
}
|