@hone-ai/cli 1.41.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.
@@ -12,9 +12,13 @@
12
12
  *
13
13
  * files[] whole-file blocks — HANDLED: created + modified files
14
14
  * deletes[] `DELETE: <path>` — HANDLED: deleted files
15
- * edits[] region edits — DELIBERATELY NOT: from-tree always sends whole files. The
16
- * tree is the truth; an anchor-based edit would reintroduce
17
- * exactly the drift this removes.
15
+ * edits[] region edits — HANDLED under editor contract 3, for step_4 only (HC-GF-399): a
16
+ * modified file whose HEAD blob is over BUNDLE_MAX_PER_FILE_CHARS
17
+ * is sent as region edits only after the rendered text, parsed back
18
+ * by extractFileBlocks(), rebuilds the exact tree bytes from HEAD
19
+ * with applyEdits(), and no OLD survives in the tree. The tree is
20
+ * still the truth: an edit is sent only once it is proven to
21
+ * reproduce it. Under contracts 1 and 2 every file goes whole.
18
22
  * malformed[] unparsable block — DELIBERATELY NOT REACHABLE: every fence we emit is longer
19
23
  * than any fence run inside the file it wraps (see below).
20
24
  * noChanges the honest no-op — HANDLED: a clean tree says NO CHANGES rather than nothing.
@@ -25,11 +29,19 @@
25
29
  * file could be sent as content (N skipped; see ### Not
26
30
  * included)", N counting named skips only (symlinks are silent).
27
31
  *
28
- * Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path`, plus the one
29
- * classifier the bundler already uses — `isSecretPath` from `./bundle-context`, which itself
30
- * requires only `path`.
32
+ * Small dependencies by intent: `git`, `fs` and `path`, the one classifier the bundler already uses
33
+ * (`pathRefusal` from `./path-refusal` in egress mode, which itself requires only `path`), the cap from the leaf
34
+ * `./bundle-limits`, and — for HC-GF-399's proof — the consumers' own parser and applier
35
+ * (`./materialize-diff`) and fence walker (`./block-grammar`). The proof must use the code every patch
36
+ * verb applies the artifact with, or it proves nothing about them.
31
37
  *
32
- * HC-GF-393: a path `isSecretPath()` classifies (`.env*`, `*.pem`, `id_rsa`-style keys, …) is
38
+ * HC-GF-399: under contract 3 the artifact also starts with one `<!-- hone-base: <sha> -->` line, the
39
+ * commit every edit is anchored on, and HEAD is resolved to that sha once per render. The route refuses
40
+ * a contract-3 step_4 output without exactly one, and stores it as sent: no composeRebuild, because a
41
+ * HEAD-anchored edit applied over a previous attempt's file would hit the wrong text.
42
+ *
43
+ * HC-GF-393: a path egress mode refuses (`.env*`, `*.pem`, `id_rsa`-style keys, and since HC-GF-398
44
+ * `*.key`, `*.pfx`, `*.jks`, `*.keystore`, `*.p8`) is
33
45
  * never read. It is named under `### Not included`, so it is absent from the patch and the PR
34
46
  * on purpose and visibly, not silently. A deleted one still renders as `DELETE:` (no content).
35
47
  *
@@ -45,18 +57,77 @@ const fs = require('node:fs');
45
57
  const path = require('node:path');
46
58
  const { execFileSync } = require('node:child_process');
47
59
  const { gitEnv } = require('./git-env');
48
- const { isSecretPath } = require('./bundle-context');
60
+ const { pathRefusal } = require('./path-refusal');
61
+ const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits');
62
+ const { extractFileBlocks, applyEdits, countOccurrences } = require('./materialize-diff');
63
+ const { fencedSpans } = require('./block-grammar');
49
64
 
50
65
  /** Bytes above which we do not attempt to inline a file at all. */
51
66
  const MAX_FILE_BYTES = 1_000_000;
52
67
 
68
+ /** HC-GF-399: every git call has a deadline. A listing call that misses it fails the render. */
69
+ const GIT_LIST_TIMEOUT_MS = 30_000;
70
+ /** Per-file calls (`git show`, `git diff -U0`) that miss it send that file whole. */
71
+ const GIT_FILE_TIMEOUT_MS = 10_000;
72
+ /** The per-render CPU budget for edit work. collectPaths() runs outside it; past it, files go whole. */
73
+ const RENDER_BUDGET_MS = 30_000;
74
+ /** Context lines either side of a hunk are doubled from 1 up to this, then the file goes whole. */
75
+ const MAX_CONTEXT = 512;
76
+ /** k doubles from 1 to MAX_CONTEXT in 10 rounds; the loop is bounded by this whatever the widening does. */
77
+ const MAX_ROUNDS = 11;
78
+ /** Keep in sync with server/src/routes/orchestrate.js HONE_BASE_STAMP_RE (pinned by HC-GF-399's contract test). */
79
+ const HONE_BASE_STAMP_RE = /^<!-- hone-base: (?:[0-9a-f]{40}|[0-9a-f]{64}) -->$/;
80
+ /** The three EDIT_SPLIT_RE delimiters (cli/lib/block-grammar.js). An OLD/NEW line like one cannot be carried. */
81
+ const EDIT_DELIMITER_RE = /^(?:<{3,}[ \t]*OLD|={3,}|>{3,}[ \t]*NEW)[ \t]*$/;
82
+
53
83
  /**
54
84
  * SC-002-followup-2: git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE to the processes it
55
85
  * spawns, and those BEAT `cwd`. Without gitEnv() this renders a different repository's tree when
56
86
  * it runs under a git hook — and it would look like a perfectly normal build.
57
87
  */
58
- function gitOut(repoRoot, args) {
59
- return execFileSync('git', args, { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
88
+ function gitOut(repoRoot, args, { encoding = 'utf8', timeout = GIT_LIST_TIMEOUT_MS } = {}) {
89
+ return execFileSync('git', args, {
90
+ cwd: repoRoot, env: gitEnv(), encoding, timeout, maxBuffer: 64 * 1024 * 1024,
91
+ stdio: ['ignore', 'pipe', 'pipe'],
92
+ });
93
+ }
94
+
95
+ /** HC-GF-399: execFileSync reports a missed deadline as ETIMEDOUT, or a SIGTERM with no exit status. */
96
+ function isGitTimeout(e) {
97
+ return Boolean(e && (e.code === 'ETIMEDOUT' || (e.signal === 'SIGTERM' && e.status == null)));
98
+ }
99
+
100
+ /**
101
+ * HC-GF-399: the commit HEAD names, or null when there is none.
102
+ *
103
+ * null ONLY for a numeric non-zero git exit that is not a timeout — an unborn repository, or a directory
104
+ * that is not a repository (128). A timeout throws: read as "no HEAD", it would render the build as if
105
+ * every tracked file were new and drop every modification without a word.
106
+ */
107
+ function resolveHead(repoRoot, runGit) {
108
+ const git = runGit || ((args, opts) => gitOut(repoRoot, args, opts));
109
+ let out;
110
+ try {
111
+ out = git(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], { timeout: GIT_LIST_TIMEOUT_MS });
112
+ } catch (e) {
113
+ if (isGitTimeout(e)) {
114
+ throw new Error(`git rev-parse HEAD hit its ${GIT_LIST_TIMEOUT_MS} ms timeout — not read as "no HEAD", `
115
+ + 'which would drop every modified file');
116
+ }
117
+ if (e && typeof e.status === 'number' && e.status !== 0) return null;
118
+ throw e;
119
+ }
120
+ const sha = String(out).trim();
121
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha)) throw new Error(`git rev-parse HEAD printed ${JSON.stringify(sha.slice(0, 80))}, not a sha`);
122
+ return sha;
123
+ }
124
+
125
+ /**
126
+ * HC-GF-399: the editor contracts this checkout can honour, for `step-instruction` to advertise.
127
+ * Contract 3 anchors edits on HEAD, so a repository with no commit advertises 1,2 and is served 2.
128
+ */
129
+ function editorContractsFor(repoRoot, { runGit } = {}) {
130
+ return resolveHead(repoRoot, runGit) ? '1,2,3' : '1,2';
60
131
  }
61
132
 
62
133
  /**
@@ -91,14 +162,15 @@ function looksBinary(buf) {
91
162
  * .gitignore: a node_modules SYMLINK is a file to a naive directory walk
92
163
  * (HC-COMM-010-followup-8), and git excludes it because the adopter said so.
93
164
  */
94
- function collectPaths(repoRoot) {
165
+ function listPaths(repoRoot, { runGit, sha } = {}) {
95
166
  const changed = [];
96
167
  const deleted = [];
97
- let hasHead = true;
98
- try { gitOut(repoRoot, ['rev-parse', '--verify', 'HEAD']); } catch { hasHead = false; }
168
+ const modified = [];
169
+ // HC-GF-399: one sha per render, so a commit landing mid-render cannot mix two bases.
170
+ const headSha = sha !== undefined ? sha : resolveHead(repoRoot, runGit);
99
171
 
100
- if (hasHead) {
101
- const out = gitOut(repoRoot, ['diff', '--name-status', '--no-renames', 'HEAD']);
172
+ if (headSha) {
173
+ const out = gitOut(repoRoot, ['diff', '--name-status', '--no-renames', headSha]);
102
174
  for (const line of out.split('\n')) {
103
175
  if (!line.trim()) continue;
104
176
  const tab = line.indexOf('\t');
@@ -108,6 +180,7 @@ function collectPaths(repoRoot) {
108
180
  if (!rel) continue;
109
181
  if (status.startsWith('D')) deleted.push(rel);
110
182
  else changed.push(rel);
183
+ if (status.startsWith('M')) modified.push(rel);
111
184
  }
112
185
  }
113
186
  const others = gitOut(repoRoot, ['ls-files', '--others', '--exclude-standard']);
@@ -116,9 +189,154 @@ function collectPaths(repoRoot) {
116
189
  return {
117
190
  changed: [...new Set(changed)].sort(),
118
191
  deleted: [...new Set(deleted)].sort(),
192
+ modified: [...new Set(modified)].sort(),
193
+ headSha,
119
194
  };
120
195
  }
121
196
 
197
+ /** The `{changed, deleted}` the HC-GF-394/395 suites assert, unchanged in shape (HC-GF-399 adds listPaths beside it). */
198
+ function collectPaths(repoRoot, opts) {
199
+ const { changed, deleted } = listPaths(repoRoot, opts);
200
+ return { changed, deleted };
201
+ }
202
+
203
+ /** `@@ -a[,b] +c[,d] @@` headers of a `git diff -U0`; an omitted count is 1. Nothing else is read from git's text. */
204
+ function parseHunkHeaders(diffText) {
205
+ const hunks = [];
206
+ for (const line of String(diffText).split('\n')) {
207
+ const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
208
+ if (!m) continue;
209
+ hunks.push({ a: Number(m[1]), b: m[2] === undefined ? 1 : Number(m[2]), c: Number(m[3]), d: m[4] === undefined ? 1 : Number(m[4]) });
210
+ }
211
+ return hunks;
212
+ }
213
+
214
+ /**
215
+ * HC-GF-399: the region edits that turn `headText` into `treeText`, or the reason there are none.
216
+ *
217
+ * Each hunk becomes a region of whole lines with k lines of context either side (k = 1, 2, 4, … up to
218
+ * MAX_CONTEXT); regions whose widened ranges touch merge. A region is good when, applied in order to a
219
+ * running copy, its OLD occurs EXACTLY ONCE there (what applyEdit requires) and ZERO times in the tree
220
+ * (so the edit cannot re-apply once the build is committed: a pure insertion at line 1 or at EOF has
221
+ * context on one side only, its NEW contains its OLD, and widening cannot help — it goes whole). OLD and
222
+ * NEW are the lines joined by '\n' with no final newline, exactly what EDIT_SPLIT_RE hands the parser.
223
+ *
224
+ * @returns {{edits: Array<{oldString:string,newString:string}>}|{reason:string}}
225
+ */
226
+ function buildEdits(headText, treeText, hunks, overBudget = () => false) {
227
+ const H = headText.split('\n'); H.pop(); // both end in '\n' (checked by the caller): drop the final ''
228
+ const T = treeText.split('\n'); T.pop();
229
+ const base = hunks.map((h) => {
230
+ const hs = h.b === 0 ? h.a : h.a - 1;
231
+ const ts = h.d === 0 ? h.c : h.c - 1;
232
+ return { hs, he: hs + h.b, ts, te: ts + h.d, k: 1 };
233
+ });
234
+ let lastReason = 'ambiguous';
235
+ for (let round = 0; round < MAX_ROUNDS; round++) {
236
+ // Checked every widening round, not once per file: one file with many hunks in repetitive text can otherwise
237
+ // run its rounds well past the budget the caller relies on.
238
+ if (overBudget()) return { reason: 'budget' };
239
+ const regions = [];
240
+ for (const h of base) {
241
+ let r = { hlo: Math.max(0, h.hs - h.k), hhi: Math.min(H.length, h.he + h.k), members: [h] };
242
+ // Members can widen by different k, so a later region may reach back over several earlier ones: merge them all.
243
+ while (regions.length && r.hlo <= regions[regions.length - 1].hhi) {
244
+ const prev = regions.pop();
245
+ r = { hlo: Math.min(prev.hlo, r.hlo), hhi: Math.max(prev.hhi, r.hhi), members: prev.members.concat(r.members) };
246
+ }
247
+ regions.push(r);
248
+ }
249
+ // Lines outside a region's hunks are unchanged, so they map by the offset in force there: the first member's
250
+ // before it, the last member's after it. (Taking each member's own end would cut NEW short, or overshoot, when
251
+ // a wider member reaches past a later one that inserted or removed lines.)
252
+ for (const r of regions) {
253
+ const first = r.members[0];
254
+ const last = r.members[r.members.length - 1];
255
+ r.tlo = r.hlo + (first.ts - first.hs);
256
+ r.thi = r.hhi + (last.te - last.he);
257
+ }
258
+ let running = headText;
259
+ const edits = [];
260
+ const bad = [];
261
+ for (const r of regions) {
262
+ const oldLines = H.slice(r.hlo, r.hhi);
263
+ const newLines = T.slice(r.tlo, r.thi);
264
+ if (oldLines.some((l) => EDIT_DELIMITER_RE.test(l)) || newLines.some((l) => EDIT_DELIMITER_RE.test(l))) return { reason: 'delimiter' };
265
+ const oldString = oldLines.join('\n');
266
+ const newString = newLines.join('\n');
267
+ if (!oldString) { bad.push({ r, reason: 'ambiguous' }); continue; }
268
+ if (countOccurrences(running, oldString) !== 1) { bad.push({ r, reason: 'ambiguous' }); continue; }
269
+ if (countOccurrences(treeText, oldString) !== 0) { bad.push({ r, reason: 'reapplicable' }); continue; }
270
+ const at = running.indexOf(oldString);
271
+ running = running.slice(0, at) + newString + running.slice(at + oldString.length);
272
+ edits.push({ oldString, newString });
273
+ }
274
+ if (!bad.length) return { edits };
275
+ for (const { r, reason } of bad) {
276
+ const whole = r.hlo === 0 && r.hhi === H.length;
277
+ if (whole || r.members.every((h) => h.k >= MAX_CONTEXT)) return { reason };
278
+ for (const h of r.members) h.k = Math.min(MAX_CONTEXT, h.k * 2);
279
+ lastReason = reason;
280
+ }
281
+ }
282
+ return { reason: lastReason }; // not reached by the doubling above; a hard bound, not a guess
283
+ }
284
+
285
+ function wholeBlock(rel, content) {
286
+ const fence = fenceFor(content);
287
+ const body = content.endsWith('\n') ? content.slice(0, -1) : content;
288
+ return `${fence}file: ${rel}\n${body}\n${fence}`;
289
+ }
290
+
291
+ function renderEditBlock(rel, e) {
292
+ const body = `<<<<<<< OLD\n${e.oldString}\n=======\n${e.newString}\n>>>>>>> NEW`;
293
+ const fence = fenceFor(body);
294
+ return `${fence}edit: ${rel}\n${body}\n${fence}`;
295
+ }
296
+
297
+ /**
298
+ * HC-GF-399: the proof, on the TEXT that is sent — parsed back by the consumers' own parser and applied with
299
+ * their own applier. Proving the edit objects would not prove the fence, the OLD/NEW split or the newline
300
+ * stripping. True only when the blocks parse to exactly these edits for `rel`, rebuild `treeText` from
301
+ * `headText`, and leave no OLD in the tree.
302
+ */
303
+ function proveEdits(rel, blocks, headText, treeText) {
304
+ try {
305
+ const p = extractFileBlocks(`## Changed Files\n\n${blocks.join('\n\n')}`);
306
+ if (p.files.length || p.deletes.length || p.malformed.length || p.noChanges) return false;
307
+ if (p.edits.length !== blocks.length || p.edits.some((e) => e.path !== rel)) return false;
308
+ if (applyEdits(headText, p.edits, rel) !== treeText) return false;
309
+ return p.edits.every((e) => countOccurrences(treeText, e.oldString) === 0);
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ /** `a.txt, not b.txt`: what the render sent that the parse did not read, and the reverse. */
316
+ function listDiff(sent, read) {
317
+ const missing = sent.filter((x) => !read.includes(x));
318
+ const extra = read.filter((x) => !sent.includes(x));
319
+ return [missing.length ? `${missing.slice(0, 3).join(', ')}, read back without it` : '', extra.length ? `read back with ${extra.slice(0, 3).join(', ')}` : '']
320
+ .filter(Boolean).join('; ') || 'the same paths in another order';
321
+ }
322
+
323
+ /** Stamp lines outside every fenced block (an unclosed fence runs to the end). Mirrors the route's countBaseStamps. */
324
+ function countStampsOutsideFences(text) {
325
+ const { blocks, open } = fencedSpans(text);
326
+ let n = 0;
327
+ let b = 0;
328
+ for (let i = 0; i <= text.length;) {
329
+ const nl = text.indexOf('\n', i);
330
+ const end = nl === -1 ? text.length : nl;
331
+ while (b < blocks.length && blocks[b].end < i) b++;
332
+ const inside = (b < blocks.length && blocks[b].start <= i && i <= blocks[b].end) || (open && i >= open.start);
333
+ if (!inside && HONE_BASE_STAMP_RE.test(text.slice(i, end))) n++;
334
+ if (nl === -1) break;
335
+ i = nl + 1;
336
+ }
337
+ return n;
338
+ }
339
+
122
340
  /**
123
341
  * Render the artifact.
124
342
  *
@@ -128,72 +346,206 @@ function collectPaths(repoRoot) {
128
346
  * invented: an absent report stays absent, because a fabricated
129
347
  * "N passing" is the anti-fabrication failure the step_4
130
348
  * validator already exists to catch.
131
- * @returns {{ text: string, files: string[], deletes: string[], skipped: Array<{path,reason}> }}
349
+ * @param {string} [opts.contract] HC-GF-399: the editor contract the submit uses. Only '3' changes the
350
+ * render; with it absent the output is byte-identical to before.
351
+ * @param {string} [opts.stepKey] edits are sent only for step_4
352
+ * @param {Function} [opts.runGit] test seam for the per-file git calls: (args, {encoding, timeout}) → output
353
+ * @param {Function} [opts.now] test seam for the render budget
354
+ * @param {number} [opts.budgetMs]
355
+ * @returns {{ text: string, files: string[], edited: string[], fallbacks: Array<{path,reason}>,
356
+ * deletes: string[], skipped: Array<{path,reason}>, headSha: string|null }}
357
+ * `files` stays whole-file blocks only, so computeReviewDiff() keeps keying on them.
132
358
  */
133
- function renderFromTree({ repoRoot, testReport } = {}) {
359
+ function renderFromTree({ repoRoot, testReport, contract, stepKey, runGit, now = Date.now, budgetMs = RENDER_BUDGET_MS } = {}) {
134
360
  if (!repoRoot) throw new Error('renderFromTree: repoRoot is required');
135
- const { changed, deleted } = collectPaths(repoRoot);
136
-
137
- const blocks = [];
138
- const included = [];
139
- const skipped = [];
140
-
141
- for (const rel of changed) {
142
- // First, before lstat or read: the bytes of a secret path never enter this process. The name
143
- // is the signal, so a symlink or an oversized file with a secret name is listed as a secret.
144
- if (isSecretPath(rel)) { skipped.push({ path: rel, reason: 'secret path — not sent' }); continue; }
145
- const abs = path.join(repoRoot, rel);
146
- let st;
147
- try { st = fs.lstatSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
148
- // Structural, and deliberately silent: a symlink is not file content. materialize-diff would
149
- // write a REGULAR file holding the link's target bytes, converting it without saying so. Naming
150
- // every one of these would also put `node_modules` in the artifact of any repo whose .gitignore
151
- // says `node_modules/` — a directory pattern that does not match the symlink (HC-COMM-010-followup-8).
152
- if (st.isSymbolicLink() || !st.isFile()) continue;
153
- if (st.size > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: `larger than ${MAX_FILE_BYTES} bytes` }); continue; }
154
-
155
- let buf;
156
- try { buf = fs.readFileSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
157
- if (looksBinary(buf)) { skipped.push({ path: rel, reason: 'binary' }); continue; }
158
-
159
- const content = buf.toString('utf8');
160
- const fence = fenceFor(content);
161
- const body = content.endsWith('\n') ? content.slice(0, -1) : content;
162
- blocks.push(`${fence}file: ${rel}\n${body}\n${fence}`);
163
- included.push(rel);
361
+ const c3 = String(contract) === '3';
362
+ const stamped = c3 && stepKey === 'step_4';
363
+ const editMode = stamped;
364
+ const git = runGit || ((args, opts) => gitOut(repoRoot, args, opts));
365
+ // The listing runs OUTSIDE the budget, and its failures throw: a timed-out listing caught as "nothing
366
+ // changed" would send NO CHANGES for a real build.
367
+ const { changed, deleted, modified, headSha } = listPaths(repoRoot, { runGit });
368
+ if (c3 && !headSha) {
369
+ throw new Error('--contract 3 sends region edits anchored on HEAD, and this repository has no commit (no HEAD). '
370
+ + 'Re-run with --contract 2.');
164
371
  }
372
+ if (stamped && testReport && String(testReport).split('\n').some((l) => HONE_BASE_STAMP_RE.test(l.trim()))) {
373
+ throw new Error('the test report contains a `<!-- hone-base: … -->` line; remove it — a contract-3 build carries exactly one.');
374
+ }
375
+ const modifiedSet = new Set(modified);
376
+ const start = now();
377
+
378
+ const fallbacks = [];
379
+ const proven = new Map(); // rel → { headText, treeText }
380
+ const tried = new Map(); // rel → tryEdits() result, computed once per render
165
381
 
166
- const lines = ['## Changed Files', ''];
167
-
168
- if (!changed.length && !deleted.length) {
169
- // The honest no-op, in the shape the validator and the materialiser both already accept.
170
- lines.push('NO CHANGES — the working tree matches HEAD.');
171
- } else if (!included.length && !deleted.length) {
172
- // HC-GF-395: still a no-op, but not a clean tree. One line with the same `NO CHANGES —` prefix,
173
- // which every parser keys on; the rest of the line is the reason stored in the artifact (the
174
- // CLI's own no-op messages are fixed text). Only a
175
- // named skip has a list to point at; a symlink or non-regular file is skipped silently.
176
- const why = skipped.length
177
- ? `${skipped.length} skipped; see ### Not included`
178
- : 'symlinks and non-regular files are not sent';
179
- lines.push(`NO CHANGES — the working tree differs from HEAD, but no changed file could be sent as content (${why}).`);
180
- } else {
181
- for (const b of blocks) { lines.push(b, ''); }
182
- for (const rel of deleted) lines.push(`DELETE: ${rel}`);
183
- if (deleted.length) lines.push('');
382
+ /** @returns {{blocks: string[]}|{reason: string|null}} — reason null: under the cap, whole with nothing to say */
383
+ function tryEdits(rel, buf) {
384
+ if (now() - start > budgetMs) return { reason: buf.length > BUNDLE_MAX_PER_FILE_CHARS ? 'budget' : null };
385
+ let headBuf;
386
+ try { headBuf = git(['show', `${headSha}:${rel}`], { encoding: 'buffer', timeout: GIT_FILE_TIMEOUT_MS }); } catch { return { reason: 'git' }; }
387
+ headBuf = Buffer.isBuffer(headBuf) ? headBuf : Buffer.from(String(headBuf), 'utf8');
388
+ const headText = headBuf.toString('utf8');
389
+ if (headText.length <= BUNDLE_MAX_PER_FILE_CHARS) return { reason: null };
390
+ const treeText = buf.toString('utf8');
391
+ // Byte-exact, not string-exact: invalid UTF-8 decodes to U+FFFD and would "prove" a file the verbs then corrupt.
392
+ if (!Buffer.from(headText, 'utf8').equals(headBuf) || !Buffer.from(treeText, 'utf8').equals(buf)) return { reason: 'encoding' };
393
+ if (headText.includes('\r') || treeText.includes('\r')) return { reason: 'cr' };
394
+ if (!headText.endsWith('\n') || !treeText.endsWith('\n')) return { reason: 'no-final-newline' };
395
+ let diff;
396
+ try {
397
+ // Pinned so no user config changes what is read: only the `@@` ranges are parsed, never git's paths or text.
398
+ // `:(literal)`: `src/[id].js` is a path, not a glob that also matches `src/i.js`.
399
+ diff = git(['diff', '-U0', '--no-color', '--no-ext-diff', '--no-textconv', '--src-prefix=a/', '--dst-prefix=b/',
400
+ headSha, '--', `:(literal)${rel}`], { encoding: 'utf8', timeout: GIT_FILE_TIMEOUT_MS });
401
+ } catch { return { reason: 'git' }; }
402
+ const hunks = parseHunkHeaders(diff);
403
+ if (!hunks.length) return { reason: 'no-hunks' };
404
+ const built = buildEdits(headText, treeText, hunks, () => now() - start > budgetMs);
405
+ if (built.reason) return { reason: built.reason };
406
+ const blocks = built.edits.map((e) => renderEditBlock(rel, e));
407
+ if (!proveEdits(rel, blocks, headText, treeText)) return { reason: 'proof' };
408
+ // Edits exist to make the artifact SMALLER. A heavy rewrite renders as OLD plus NEW, about twice the file:
409
+ // then the whole file is the smaller form, and contract 3 must never be the one that hits the 1 MB cap.
410
+ if (Buffer.byteLength(blocks.join('\n\n'), 'utf8') >= Buffer.byteLength(wholeBlock(rel, treeText), 'utf8')) return { reason: 'larger' };
411
+ proven.set(rel, { headText, treeText });
412
+ return { blocks };
184
413
  }
185
414
 
186
- if (skipped.length) {
187
- // Named, not silent: a file that is in the tree and not in the artifact must be visible as a
188
- // decision. Kept out of the Changed Files blocks so it can never parse as one.
189
- lines.push('### Not included', '');
190
- for (const s of skipped) lines.push(`- \`${s.path}\` — ${s.reason}`);
191
- lines.push('');
415
+ // One pass over the tree; `demote` names edited paths the final-artifact proof sent back to whole.
416
+ function renderOnce(demote) {
417
+ const blocks = [];
418
+ const included = [];
419
+ const edited = [];
420
+ const skipped = [];
421
+ for (const rel of changed) {
422
+ // First, before lstat or read: the bytes of a secret path never enter this process. The name
423
+ // is the signal, so a symlink or an oversized file with a secret name is listed as a secret.
424
+ if (pathRefusal(rel, { mode: 'egress' })) { skipped.push({ path: rel, reason: 'secret path — not sent' }); continue; }
425
+ const abs = path.join(repoRoot, rel);
426
+ let st;
427
+ try { st = fs.lstatSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
428
+ // Structural, and deliberately silent: a symlink is not file content. materialize-diff would
429
+ // write a REGULAR file holding the link's target bytes, converting it without saying so. Naming
430
+ // every one of these would also put `node_modules` in the artifact of any repo whose .gitignore
431
+ // says `node_modules/` — a directory pattern that does not match the symlink (HC-COMM-010-followup-8).
432
+ if (st.isSymbolicLink() || !st.isFile()) continue;
433
+ if (st.size > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: `larger than ${MAX_FILE_BYTES} bytes` }); continue; }
434
+
435
+ let buf;
436
+ try { buf = fs.readFileSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
437
+ if (looksBinary(buf)) { skipped.push({ path: rel, reason: 'binary' }); continue; }
438
+
439
+ if (editMode && modifiedSet.has(rel) && !demote.has(rel)) {
440
+ if (!tried.has(rel)) {
441
+ const got = tryEdits(rel, buf);
442
+ tried.set(rel, got);
443
+ if (got.reason) fallbacks.push({ path: rel, reason: got.reason });
444
+ }
445
+ const got = tried.get(rel);
446
+ if (got.blocks) { blocks.push(...got.blocks); edited.push(rel); continue; }
447
+ }
448
+
449
+ blocks.push(wholeBlock(rel, buf.toString('utf8')));
450
+ included.push(rel);
451
+ }
452
+
453
+ const lines = stamped ? [`<!-- hone-base: ${headSha} -->`, '', '## Changed Files', ''] : ['## Changed Files', ''];
454
+
455
+ if (!changed.length && !deleted.length) {
456
+ // The honest no-op, in the shape the validator and the materialiser both already accept.
457
+ lines.push('NO CHANGES — the working tree matches HEAD.');
458
+ } else if (!included.length && !edited.length && !deleted.length) {
459
+ // HC-GF-395: still a no-op, but not a clean tree. One line with the same `NO CHANGES —` prefix,
460
+ // which every parser keys on; the rest of the line is the reason stored in the artifact (the
461
+ // CLI's own no-op messages are fixed text). Only a
462
+ // named skip has a list to point at; a symlink or non-regular file is skipped silently.
463
+ // HC-GF-399: an edits-only build is a build, so `edited` counts here too.
464
+ const why = skipped.length
465
+ ? `${skipped.length} skipped; see ### Not included`
466
+ : 'symlinks and non-regular files are not sent';
467
+ lines.push(`NO CHANGES — the working tree differs from HEAD, but no changed file could be sent as content (${why}).`);
468
+ } else {
469
+ for (const b of blocks) { lines.push(b, ''); }
470
+ for (const rel of deleted) lines.push(`DELETE: ${rel}`);
471
+ if (deleted.length) lines.push('');
472
+ }
473
+
474
+ if (skipped.length) {
475
+ // Named, not silent: a file that is in the tree and not in the artifact must be visible as a
476
+ // decision. Kept out of the Changed Files blocks so it can never parse as one.
477
+ lines.push('### Not included', '');
478
+ for (const s of skipped) lines.push(`- \`${s.path}\` — ${s.reason}`);
479
+ lines.push('');
480
+ }
481
+
482
+ if (testReport) lines.push('### Test run', '', String(testReport).trim(), '');
483
+
484
+ return { text: lines.join('\n'), included, edited, skipped };
485
+ }
486
+
487
+ /**
488
+ * HC-GF-399: the whole artifact, parsed ONCE, so interactions between blocks are caught too. `ok` only when it
489
+ * parses to exactly what was rendered — these whole files, these deletes, one stamp, no edit for a path that was
490
+ * not sent as edits (a test report can carry a block) — and every edited path rebuilds its tree bytes. `failing`
491
+ * names the edited paths to demote: every one of them when the artifact itself is wrong.
492
+ */
493
+ function checkArtifact(out) {
494
+ let p;
495
+ try { p = extractFileBlocks(out.text); } catch (e) { return { ok: false, failing: out.edited, why: `it does not parse (${e.message})` }; }
496
+ const parsedFiles = p.files.map((f) => f.path);
497
+ const why = p.malformed.length ? `an unreadable block for ${p.malformed[0].path}`
498
+ : parsedFiles.join('\n') !== out.included.join('\n')
499
+ ? `the whole files read back differently (sent ${listDiff(out.included, parsedFiles)})`
500
+ : p.deletes.join('\n') !== deleted.join('\n')
501
+ ? `the deletes read back differently (sent ${listDiff(deleted, p.deletes)})`
502
+ : p.edits.some((e) => !out.edited.includes(e.path))
503
+ ? `it carries an edit for ${p.edits.find((e) => !out.edited.includes(e.path)).path}, which was not sent as edits`
504
+ : countStampsOutsideFences(out.text) !== 1 ? 'it does not carry exactly one hone-base line' : null;
505
+ if (why) return { ok: false, failing: out.edited, why };
506
+ const failing = out.edited.filter((rel) => {
507
+ const { headText, treeText } = proven.get(rel);
508
+ const mine = p.edits.filter((e) => e.path === rel);
509
+ try {
510
+ return applyEdits(headText, mine, rel) !== treeText || mine.some((e) => countOccurrences(treeText, e.oldString) !== 0);
511
+ } catch { return true; }
512
+ });
513
+ return { ok: failing.length === 0, failing, why: failing.length ? `the edits for ${failing[0]} do not rebuild it` : null };
514
+ }
515
+
516
+ let out = renderOnce(new Set());
517
+ if (editMode) {
518
+ let verdict = checkArtifact(out);
519
+ if (!verdict.ok && verdict.failing.length) {
520
+ for (const rel of verdict.failing) fallbacks.push({ path: rel, reason: 'proof' });
521
+ out = renderOnce(new Set(verdict.failing));
522
+ verdict = checkArtifact(out);
523
+ }
524
+ if (!verdict.ok) {
525
+ throw new Error(`from-tree: the rendered artifact does not rebuild the working tree; nothing was sent — ${verdict.why}. `
526
+ + 'The patch verbs would read it the same way. Re-run with --contract 2 to send every file whole.');
527
+ }
192
528
  }
193
529
 
194
- if (testReport) lines.push('### Test run', '', String(testReport).trim(), '');
530
+ return { text: out.text, files: out.included, edited: out.edited, fallbacks, deletes: deleted, skipped: out.skipped, headSha };
531
+ }
195
532
 
196
- return { text: lines.join('\n'), files: included, deletes: deleted, skipped };
533
+ /**
534
+ * HC-GF-399: whether a review diff describes the same base as this render. review-diff.js resolves HEAD itself;
535
+ * if that is not the commit the render was taken against, HEAD moved during the submit and the diff describes a
536
+ * different base. A render with no sha (a repository with no commit) cannot disagree.
537
+ */
538
+ function reviewDiffBaseMatches(reviewDiff, rendered) {
539
+ if (!reviewDiff || !rendered || !rendered.headSha) return true;
540
+ return reviewDiff.headSha === rendered.headSha;
197
541
  }
198
542
 
199
- module.exports = { renderFromTree, _internal: { fenceFor, looksBinary, collectPaths, MAX_FILE_BYTES } };
543
+ module.exports = {
544
+ renderFromTree,
545
+ editorContractsFor,
546
+ reviewDiffBaseMatches,
547
+ _internal: {
548
+ fenceFor, looksBinary, collectPaths, listPaths, MAX_FILE_BYTES, resolveHead, isGitTimeout, HONE_BASE_STAMP_RE,
549
+ parseHunkHeaders, buildEdits, proveEdits, countStampsOutsideFences, MAX_CONTEXT, RENDER_BUDGET_MS,
550
+ },
551
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.41.0",
3
+ "version": "1.42.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {