@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.
@@ -12,31 +12,122 @@
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
+ * HC-GF-395: it says "matches HEAD" only when git reported no
26
+ * change at all (`!changed.length`). When git reported changes
27
+ * but none could be sent (`!included.length`), it says "NO
28
+ * CHANGES — the working tree differs from HEAD, but no changed
29
+ * file could be sent as content (N skipped; see ### Not
30
+ * included)", N counting named skips only (symlinks are silent).
21
31
  *
22
- * Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path` only.
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.
37
+ *
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
45
+ * never read. It is named under `### Not included`, so it is absent from the patch and the PR
46
+ * on purpose and visibly, not silently. A deleted one still renders as `DELETE:` (no content).
47
+ *
48
+ * HC-GF-394: a file that cannot be read is named under `### Not included` as `unreadable`. It used
49
+ * to throw, and the whole submission failed with it — every readable change included.
50
+ *
51
+ * HC-GF-390: for step_4 the submit also computes a review-only base diff of the over-cap files this
52
+ * render emitted whole (`cli/lib/review-diff.js`). It is sent as a separate `reviewDiff` field and
53
+ * stored apart; it is never part of this artifact, and nothing here changes because of it.
23
54
  */
24
55
 
25
56
  const fs = require('node:fs');
26
57
  const path = require('node:path');
27
58
  const { execFileSync } = require('node:child_process');
28
59
  const { gitEnv } = require('./git-env');
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');
29
64
 
30
65
  /** Bytes above which we do not attempt to inline a file at all. */
31
66
  const MAX_FILE_BYTES = 1_000_000;
32
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
+
33
83
  /**
34
84
  * SC-002-followup-2: git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE to the processes it
35
85
  * spawns, and those BEAT `cwd`. Without gitEnv() this renders a different repository's tree when
36
86
  * it runs under a git hook — and it would look like a perfectly normal build.
37
87
  */
38
- function gitOut(repoRoot, args) {
39
- 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';
40
131
  }
41
132
 
42
133
  /**
@@ -71,14 +162,15 @@ function looksBinary(buf) {
71
162
  * .gitignore: a node_modules SYMLINK is a file to a naive directory walk
72
163
  * (HC-COMM-010-followup-8), and git excludes it because the adopter said so.
73
164
  */
74
- function collectPaths(repoRoot) {
165
+ function listPaths(repoRoot, { runGit, sha } = {}) {
75
166
  const changed = [];
76
167
  const deleted = [];
77
- let hasHead = true;
78
- 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);
79
171
 
80
- if (hasHead) {
81
- const out = gitOut(repoRoot, ['diff', '--name-status', '--no-renames', 'HEAD']);
172
+ if (headSha) {
173
+ const out = gitOut(repoRoot, ['diff', '--name-status', '--no-renames', headSha]);
82
174
  for (const line of out.split('\n')) {
83
175
  if (!line.trim()) continue;
84
176
  const tab = line.indexOf('\t');
@@ -88,6 +180,7 @@ function collectPaths(repoRoot) {
88
180
  if (!rel) continue;
89
181
  if (status.startsWith('D')) deleted.push(rel);
90
182
  else changed.push(rel);
183
+ if (status.startsWith('M')) modified.push(rel);
91
184
  }
92
185
  }
93
186
  const others = gitOut(repoRoot, ['ls-files', '--others', '--exclude-standard']);
@@ -96,9 +189,154 @@ function collectPaths(repoRoot) {
96
189
  return {
97
190
  changed: [...new Set(changed)].sort(),
98
191
  deleted: [...new Set(deleted)].sort(),
192
+ modified: [...new Set(modified)].sort(),
193
+ headSha,
99
194
  };
100
195
  }
101
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
+
102
340
  /**
103
341
  * Render the artifact.
104
342
  *
@@ -108,59 +346,206 @@ function collectPaths(repoRoot) {
108
346
  * invented: an absent report stays absent, because a fabricated
109
347
  * "N passing" is the anti-fabrication failure the step_4
110
348
  * validator already exists to catch.
111
- * @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.
112
358
  */
113
- function renderFromTree({ repoRoot, testReport } = {}) {
359
+ function renderFromTree({ repoRoot, testReport, contract, stepKey, runGit, now = Date.now, budgetMs = RENDER_BUDGET_MS } = {}) {
114
360
  if (!repoRoot) throw new Error('renderFromTree: repoRoot is required');
115
- const { changed, deleted } = collectPaths(repoRoot);
116
-
117
- const blocks = [];
118
- const included = [];
119
- const skipped = [];
120
-
121
- for (const rel of changed) {
122
- const abs = path.join(repoRoot, rel);
123
- let st;
124
- try { st = fs.lstatSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
125
- // Structural, and deliberately silent: a symlink is not file content. materialize-diff would
126
- // write a REGULAR file holding the link's target bytes, converting it without saying so. Naming
127
- // every one of these would also put `node_modules` in the artifact of any repo whose .gitignore
128
- // says `node_modules/` — a directory pattern that does not match the symlink (HC-COMM-010-followup-8).
129
- if (st.isSymbolicLink() || !st.isFile()) continue;
130
- if (st.size > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: `larger than ${MAX_FILE_BYTES} bytes` }); continue; }
131
-
132
- const buf = fs.readFileSync(abs);
133
- if (looksBinary(buf)) { skipped.push({ path: rel, reason: 'binary' }); continue; }
134
-
135
- const content = buf.toString('utf8');
136
- const fence = fenceFor(content);
137
- const body = content.endsWith('\n') ? content.slice(0, -1) : content;
138
- blocks.push(`${fence}file: ${rel}\n${body}\n${fence}`);
139
- 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.');
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
381
+
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 };
140
413
  }
141
414
 
142
- const lines = ['## Changed Files', ''];
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; }
143
434
 
144
- if (!included.length && !deleted.length) {
145
- // The honest no-op, in the shape the validator and the materialiser both already accept.
146
- lines.push('NO CHANGES — the working tree matches HEAD.');
147
- } else {
148
- for (const b of blocks) { lines.push(b, ''); }
149
- for (const rel of deleted) lines.push(`DELETE: ${rel}`);
150
- if (deleted.length) lines.push('');
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 };
151
514
  }
152
515
 
153
- if (skipped.length) {
154
- // Named, not silent: a file that is in the tree and not in the artifact must be visible as a
155
- // decision. Kept out of the Changed Files blocks so it can never parse as one.
156
- lines.push('### Not included', '');
157
- for (const s of skipped) lines.push(`- \`${s.path}\` — ${s.reason}`);
158
- lines.push('');
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
+ }
159
528
  }
160
529
 
161
- 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
+ }
162
532
 
163
- 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;
164
541
  }
165
542
 
166
- 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.40.0",
3
+ "version": "1.42.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {