@codedology/code-forge 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,9 @@
12
12
  */
13
13
 
14
14
  import { createHash } from 'node:crypto';
15
+ import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
15
16
  import os from 'node:os';
17
+ import path from 'node:path';
16
18
  import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
17
19
  import { exec } from '../util/exec.mjs';
18
20
  import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
@@ -48,6 +50,133 @@ async function git(argv, cwd, okExitCodes) {
48
50
  return res;
49
51
  }
50
52
 
53
+ /** B52: the stamp of a path whose state could not be read; never equal to anything (see {@link sameStamp}). */
54
+ export const UNREADABLE = 'unreadable';
55
+
56
+ /**
57
+ * B52: a stamp of one path's current state, never following a symlink and never throwing:
58
+ * - `deleted` — nothing at the path;
59
+ * - `file:<x|->:<sha256 of the bytes>` — a regular file, with its executable bit (hash streamed);
60
+ * - `link:<sha256 of the target text>` — a symlink, by where it points;
61
+ * - `repo:<HEAD commit>:<sha256 of its changes>` — a directory holding a git repository (a
62
+ * checked-out submodule, a nested repo): its checked-out commit, and the status, the binary
63
+ * diff against HEAD and every untracked file's content (`repoStamp`) — so a repo already dirty
64
+ * at open and edited again later stamps differently;
65
+ * - `gitlink:<commit>` — a directory with no `.git` that the index records as a gitlink (an
66
+ * uninitialised submodule);
67
+ * - {@link UNREADABLE} — anything else (a plain directory, a FIFO, a read or git failure).
68
+ * Two stamps are the same only when the path did not change between them, and an `unreadable`
69
+ * stamp never matches — a read failure must never make a file look unchanged.
70
+ * @param {string} top - the repository top level (`repoTop`). @param {string} file - top-relative.
71
+ * @returns {Promise<string>}
72
+ */
73
+ export async function treeStamp(top, file) {
74
+ const full = path.join(top, file);
75
+ let stat;
76
+ try {
77
+ stat = lstatSync(full);
78
+ } catch (err) {
79
+ return /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' ? 'deleted' : UNREADABLE;
80
+ }
81
+ try {
82
+ if (stat.isSymbolicLink()) return `link:${sha256(readlinkSync(full))}`;
83
+ if (stat.isFile()) return `file:${(stat.mode & 0o111) !== 0 ? 'x' : '-'}:${await fileSha256(full)}`;
84
+ if (stat.isDirectory() && existsSync(path.join(full, '.git'))) return await repoStamp(full);
85
+ if (stat.isDirectory()) return await gitlinkStamp(top, file);
86
+ } catch {
87
+ return UNREADABLE;
88
+ }
89
+ return UNREADABLE;
90
+ }
91
+
92
+ /** @param {Buffer | string} data @returns {string} */
93
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
94
+
95
+ /**
96
+ * B52: a file's sha256, streamed (a large file is never read whole into memory).
97
+ * @param {string} full @returns {Promise<string>}
98
+ */
99
+ async function fileSha256(full) {
100
+ const hash = createHash('sha256');
101
+ for await (const chunk of createReadStream(full)) hash.update(/** @type {Buffer} */ (chunk));
102
+ return hash.digest('hex');
103
+ }
104
+
105
+ /**
106
+ * B52: a nested repository's stamp — its HEAD, and a sha256 over its own changes: `git status
107
+ * --porcelain=v1 -z`, `git diff HEAD --binary` (the content of every tracked change) and each
108
+ * untracked file's path and sha256. Any git failure is {@link UNREADABLE}.
109
+ * @param {string} dir @returns {Promise<string>}
110
+ */
111
+ async function repoStamp(dir) {
112
+ const run = (/** @type {string[]} */ argv) => exec(argv, { cwd: dir, env: gitEnv(), timeoutMs: 30000 });
113
+ const head = await run(['git', 'rev-parse', '--verify', 'HEAD']);
114
+ const status = await run(['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all']);
115
+ const diff = await run(['git', 'diff', 'HEAD', '--binary']);
116
+ const untracked = await run(['git', 'ls-files', '-z', '--others', '--exclude-standard']);
117
+ if (head.result !== 'ok' || status.result !== 'ok' || diff.result !== 'ok' || untracked.result !== 'ok') return UNREADABLE;
118
+ const hash = createHash('sha256').update(status.stdout).update('\0diff\0').update(diff.stdout);
119
+ for (const rel of untracked.stdout.split('\0').filter((f) => f.length > 0).sort()) {
120
+ const inner = path.join(dir, rel);
121
+ const st = lstatSync(inner);
122
+ hash.update(`\0${rel}\0`).update(st.isFile() ? await fileSha256(inner) : st.isSymbolicLink() ? `link:${sha256(readlinkSync(inner))}` : 'other');
123
+ }
124
+ return `repo:${head.stdout.trim()}:${hash.digest('hex')}`;
125
+ }
126
+
127
+ /**
128
+ * B52: a directory with no `.git` of its own that the index records as a gitlink (an uninitialised
129
+ * submodule): `gitlink:<commit>` from `git ls-files -s`; anything else is {@link UNREADABLE}.
130
+ * @param {string} top @param {string} file @returns {Promise<string>}
131
+ */
132
+ async function gitlinkStamp(top, file) {
133
+ const res = await exec(['git', 'ls-files', '-s', '-z', '--', file], { cwd: top, env: gitEnv(), timeoutMs: 30000 });
134
+ if (res.result !== 'ok') return UNREADABLE;
135
+ const entry = res.stdout.split('\0').find((e) => e.endsWith(`\t${file}`));
136
+ const m = entry ? /^160000 ([0-9a-f]{40,64}) \d+\t/.exec(entry) : null;
137
+ return m ? `gitlink:${m[1]}` : UNREADABLE;
138
+ }
139
+
140
+ /**
141
+ * B52: whether two stamps say the path did not change: equal, and neither {@link UNREADABLE}.
142
+ * @param {unknown} a @param {unknown} b @returns {boolean}
143
+ */
144
+ export const sameStamp = (a, b) => typeof a === 'string' && a === b && a !== UNREADABLE;
145
+
146
+ /**
147
+ * B52: the repository's top level (`git rev-parse --show-toplevel`, realpath'd) — the root both
148
+ * git listings run from and every stamp is taken against, so a workspace in a subdirectory of
149
+ * the repository lines up with the repo-root-relative paths git prints.
150
+ * @param {string} cwd @returns {Promise<string>}
151
+ * @throws {StateError} `git-failed`
152
+ */
153
+ export async function repoTop(cwd) {
154
+ const res = await git(['git', 'rev-parse', '--show-toplevel'], cwd);
155
+ return realpathSync(res.stdout.trim());
156
+ }
157
+
158
+ /**
159
+ * B52: the tree's changes when a block opens — every file changed since `base` (deletions
160
+ * included) and every untracked, not ignored file, listed from the repository top level, each
161
+ * with its {@link treeStamp}. Recorded in the block's entry (`tree_at_open`, top-relative paths):
162
+ * at `block close`, a file outside the block's `owned_files` whose stamp is still the same
163
+ * ({@link sameStamp}) is not the coder's change (plan files, `init`'s config, a skill link…); one
164
+ * that changed since, or could not be read either time, is `unowned_change` (`review/gate-check.mjs`).
165
+ * @param {string} cwd @param {string} base - a full commit sha.
166
+ * @returns {Promise<Record<string, string>>}
167
+ * @throws {StateError} `git-failed`
168
+ */
169
+ export async function treeAtOpen(cwd, base) {
170
+ const top = await repoTop(cwd);
171
+ const changed = await git(['git', 'diff', '--name-only', '-z', '--no-renames', base, '--'], top);
172
+ const untracked = await git(['git', 'ls-files', '-z', '--others', '--exclude-standard'], top);
173
+ const files = [...new Set([...changed.stdout.split('\0'), ...untracked.stdout.split('\0')].filter((f) => f.length > 0))].sort();
174
+ /** @type {Record<string, string>} */
175
+ const out = {};
176
+ for (const file of files) out[file] = await treeStamp(top, file);
177
+ return out;
178
+ }
179
+
51
180
  /** @param {string} ref @param {string} cwd @returns {Promise<string>} the full commit sha */
52
181
  async function resolveCommit(ref, cwd) {
53
182
  if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) {
@@ -130,7 +259,8 @@ function activeBlocks(record) {
130
259
  * block kind (B34: the declared `kind`, else `blockKind` of the owned files). A docs/contract
131
260
  * block asked for below `levels.coder_floor_docs` (default L1) is opened AT the floor: the entry
132
261
  * and the dispatch row carry the floor level, and the row adds `requested_level` and
133
- * `trigger: 'docs_floor'`.
262
+ * `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
263
+ * tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
134
264
  * @param {{
135
265
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
136
266
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
@@ -151,6 +281,12 @@ export async function openBlock(opts) {
151
281
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
152
282
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
153
283
 
284
+ // B52: the tree is stamped BEFORE the run lock (hashing can be slow; other block commands must
285
+ // not wait on it); under the lock the base is resolved again and must be the one stamped against
286
+ const { workspace } = await readRun(runId);
287
+ const stampedBase = await resolveCommit(opts.base ?? 'HEAD', workspace);
288
+ const treeOpen = await treeAtOpen(workspace, stampedBase);
289
+
154
290
  const block = await mutateRun(runId, null, writeRow, async (record) => {
155
291
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
156
292
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
@@ -158,7 +294,8 @@ export async function openBlock(opts) {
158
294
  if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
159
295
  }
160
296
  const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
161
- const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open' };
297
+ if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
298
+ const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
162
299
  record.blocks[id] = entry;
163
300
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
164
301
  return {
@@ -49,6 +49,7 @@ import { logWarning } from '../util/error-log.mjs';
49
49
  import { keyedLock } from '../util/locks.mjs';
50
50
  import { writeSafe } from '../util/redact.mjs';
51
51
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
52
+ import { openFindings } from '../review/gate-check.mjs';
52
53
  import { reviewTicket } from './engine.mjs';
53
54
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
54
55
  import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
@@ -158,6 +159,21 @@ export function loopResult(outcome) {
158
159
  return out;
159
160
  }
160
161
 
162
+ /**
163
+ * B52: the verdict a result is recorded with (its signed file and its ledger row), always with
164
+ * `approved` and `open`: `approved: true, open: []` only when the outcome said so AND it lists no
165
+ * critical finding. Otherwise `approved: false` and `open` = ALL its findings (`openFindings`:
166
+ * criticals, warnings and nits; a missing or malformed id as `X<n>` by position; the worst
167
+ * severity per id; `[]` when it names none, which the gate refuses until a fresh approval) — so
168
+ * an "approved" result that names a critical is downgraded with every one of its findings open.
169
+ * @param {Record<string, any>} result @returns {Record<string, any>}
170
+ */
171
+ export function recordedVerdict(result) {
172
+ const listed = openFindings(result.findings);
173
+ if (result.approved === true && !listed.some((f) => f.severity === 'critical')) return { ...result, approved: true, open: [] };
174
+ return { ...result, approved: false, open: listed };
175
+ }
176
+
161
177
  /**
162
178
  * @typedef {object} WorkerOpts
163
179
  * @property {string} runId
@@ -351,9 +367,10 @@ export async function createWorker(opts, deps = {}) {
351
367
  /**
352
368
  * Sign and write the result + done marker, then the signed ledger row. A ledger failure does not
353
369
  * undo the result: the done marker is what the queue reads.
354
- * @param {string} id @param {Record<string, any>} result
370
+ * @param {string} id @param {Record<string, any>} outcome - recorded through {@link recordedVerdict}.
355
371
  */
356
- async function complete(id, result) {
372
+ async function complete(id, outcome) {
373
+ const result = recordedVerdict(outcome);
357
374
  const signed = writeResult({ repoRoot, runId, ticket: id, result, key });
358
375
  try {
359
376
  await ledger({
@@ -363,6 +380,11 @@ export async function createWorker(opts, deps = {}) {
363
380
  file: result.file ?? null,
364
381
  content_hash: result.content_hash ?? null,
365
382
  status: result.status,
383
+ // B52: the gate reads the verdict from the ledger, never from the result file
384
+ approved: result.approved,
385
+ open: result.open,
386
+ // the gate tells `refused: other-run` (never reviewed) from other refusals by it
387
+ ...(typeof result.reason === 'string' ? { reason: result.reason } : {}),
366
388
  ...(typeof result.trigger === 'string' ? { trigger: result.trigger } : {}),
367
389
  ...(typeof result.stopped === 'string' ? { stopped: result.stopped } : {}),
368
390
  });