@codedology/code-forge 0.8.0 → 0.9.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,10 +12,23 @@
12
12
  *
13
13
  * B62b: `<repo>` is always the MAIN tree's top level, where the worker runs — a block with its own
14
14
  * worktree still queues there; only the reviewed content is read in the block's tree.
15
+ *
16
+ * B71: an ANSWERS ticket (`kind: 'answers'`, from `review-file <answering file> --answers <ids>
17
+ * --of <source>`) asks the worker whether `by_file` resolves the open findings `answers` of
18
+ * `file` (the source). Its id adds `answers`, `by_file`, `by_hash` and the sorted ids to the
19
+ * normal ticket's parts, so it never collides with a review ticket of the same source content.
20
+ *
21
+ * B75: `review-file --fresh` drops a done marker it classified in place as re-runnable through
22
+ * `dropDoneIf` (an atomic claim by rename, then a byte compare), so two concurrent callers never
23
+ * both drop it. A counted marker the read saw is never moved; only a newer marker the worker wrote
24
+ * between that read and the claim is moved for a moment, then linked straight back (`changed`).
25
+ * B83: a claim that could not be linked back leaves a refused `fresh-restore-failed` marker in its
26
+ * place (or, when that write fails too, the claim itself, which `pendingTickets` treats as done),
27
+ * so a failed restore never hands the worker a ticket to run again; `clearDone` clears both.
15
28
  */
16
29
 
17
- import { randomBytes } from 'node:crypto';
18
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { createHash, randomBytes } from 'node:crypto';
31
+ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
19
32
  import path from 'node:path';
20
33
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
21
34
  import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
@@ -57,12 +70,27 @@ function readJSON(file) {
57
70
  }
58
71
 
59
72
  /**
60
- * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string}} Ticket
73
+ * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
61
74
  * `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
62
75
  * and the worker only checks it against the tree it takes from the run record (a ticket whose
63
76
  * tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
64
77
  */
65
78
 
79
+ /**
80
+ * The review ticket for `file`'s CURRENT content: the one rule `enqueue` and `clearDone` (B72)
81
+ * share — the repo-relative path, the realpath'd tree (or null), the content hash taken under the
82
+ * tree when given (else under `repoRoot`) and the ticket id over all of them.
83
+ * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null}} opts
84
+ * @returns {{rel: string, root: string | null, content_hash: string, ticket: string}}
85
+ * @throws {WorkerError} `bad-path` for a bad file path or a tree that cannot be resolved.
86
+ */
87
+ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
88
+ const rel = assertRowPath(file);
89
+ const root = tree === null ? null : realTree(tree);
90
+ const content_hash = contentHash(root ?? repoRoot, rel);
91
+ return { rel, root, content_hash, ticket: ticketId({ run, block, file: rel, content_hash, tree: root }) };
92
+ }
93
+
66
94
  /**
67
95
  * Enqueue `file` (already repo-root-relative) for review. Idempotent by content hash: the same
68
96
  * content returns the same ticket and never rewrites it. The queue is under `repoRoot`; the
@@ -74,10 +102,7 @@ function readJSON(file) {
74
102
  * @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
75
103
  */
76
104
  export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
77
- const rel = assertRowPath(file);
78
- const root = tree === null ? null : realTree(tree);
79
- const content_hash = contentHash(root ?? repoRoot, rel);
80
- const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
105
+ const { rel, root, content_hash, ticket } = resolveTicket({ repoRoot, run, block, file, tree });
81
106
  const target = path.join(queueDir(repoRoot), `${ticket}.json`);
82
107
  const named = root === null ? {} : { tree: root };
83
108
  if (!existsSync(target)) {
@@ -88,6 +113,67 @@ export function enqueue({ repoRoot, run, block, file, tree = null, now = new Dat
88
113
  return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
89
114
  }
90
115
 
116
+ /** B71: the most finding ids one answers ticket may name. */
117
+ export const MAX_ANSWERS = 10;
118
+
119
+ /** B71: the finding-id shape (the one `block waive` and the review results use). */
120
+ const ANSWER_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
121
+
122
+ /**
123
+ * B71: the id of an answers ticket — the normal ticket's parts (run, block, source, source hash,
124
+ * tree) plus `answers`, the answering file, its hash and the sorted ids, so it differs from the
125
+ * normal ticket of the same source content and from an answer of other ids or another file.
126
+ * @param {{run: string, block: string, file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string | null}} t
127
+ * @returns {string}
128
+ */
129
+ export function answersTicketId({ run, block, file, content_hash, by_file, by_hash, answers, tree = null }) {
130
+ const parts = ['answers', run, block, file, content_hash, by_file, by_hash, [...answers].sort().join(','), ...(tree === null ? [] : [tree])];
131
+ return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 24);
132
+ }
133
+
134
+ /**
135
+ * B71: refuse an id list that is not 1..MAX_ANSWERS finding ids; returns them sorted and unique.
136
+ * @param {unknown} answers @returns {string[]}
137
+ * @throws {WorkerError} `usage`
138
+ */
139
+ export function assertAnswers(answers) {
140
+ if (!Array.isArray(answers) || answers.length === 0) throw new WorkerError('usage', '--answers needs at least one finding id');
141
+ if (answers.some((a) => typeof a !== 'string' || !ANSWER_ID.test(a))) throw new WorkerError('usage', 'a finding id is letters, digits, . _ : - (at most 64)');
142
+ const ids = [...new Set(/** @type {string[]} */ (answers))].sort();
143
+ if (ids.length > MAX_ANSWERS) throw new WorkerError('usage', `--answers takes at most ${MAX_ANSWERS} finding ids`);
144
+ return ids;
145
+ }
146
+
147
+ /**
148
+ * @typedef {Ticket & {kind: 'answers', by_file: string, by_hash: string, answers: string[]}} AnswersTicket
149
+ */
150
+
151
+ /**
152
+ * B71: enqueue an answers ticket: `byFile` answers the findings `answers` of `file` (both
153
+ * repo-root-relative). Both files are hashed now (under `tree` when given); idempotent like
154
+ * {@link enqueue}. The caller has already refused the same file and the id list.
155
+ * @param {{repoRoot: string, run: string, block: string, file: string, byFile: string, answers: string[], tree?: string | null, now?: Date}} opts
156
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string}}
157
+ * @throws {WorkerError} `bad-path`, `usage`
158
+ */
159
+ export function enqueueAnswers({ repoRoot, run, block, file, byFile, answers, tree = null, now = new Date() }) {
160
+ const rel = assertRowPath(file);
161
+ const by_file = assertRowPath(byFile);
162
+ const ids = assertAnswers(answers);
163
+ const root = tree === null ? null : realTree(tree);
164
+ const content_hash = contentHash(root ?? repoRoot, rel);
165
+ const by_hash = contentHash(root ?? repoRoot, by_file);
166
+ const ticket = answersTicketId({ run, block, file: rel, content_hash, by_file, by_hash, answers: ids, tree: root });
167
+ const target = path.join(queueDir(repoRoot), `${ticket}.json`);
168
+ const named = root === null ? {} : { tree: root };
169
+ if (!existsSync(target)) {
170
+ /** @type {AnswersTicket} */
171
+ const body = { ticket, kind: 'answers', run, block, file: rel, content_hash, by_file, by_hash, answers: ids, enqueued_at: now.toISOString(), ...named };
172
+ writeAtomic(target, `${JSON.stringify(body)}\n`);
173
+ }
174
+ return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, by_file, by_hash, answers: ids, ...named };
175
+ }
176
+
91
177
  /**
92
178
  * @param {string} tree @returns {string} `tree` realpath'd.
93
179
  * @throws {WorkerError} `bad-path` when it is relative or cannot be resolved.
@@ -106,9 +192,163 @@ export function isDone(repoRoot, ticket) {
106
192
  return existsSync(path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`));
107
193
  }
108
194
 
195
+ /** B83: a `dropDoneIf` claim of a done marker (`<ticket>.done.<pid>.<hex>.fresh`). */
196
+ const CLAIM = /^([0-9a-f]{24})\.done\.\d+\.[0-9a-f]{8}\.fresh$/;
197
+
198
+ /**
199
+ * B83: an fs error's code when it is a plain code word, else `unknown` (a message names the code
200
+ * only, never a path). @param {unknown} err @returns {string}
201
+ */
202
+ function codeOf(err) {
203
+ const code = /** @type {{code?: unknown}} */ (err)?.code;
204
+ return typeof code === 'string' && /^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(code) ? code : 'unknown';
205
+ }
206
+
207
+ /**
208
+ * B72 (`block reopen`, C11): drop the done marker of the review ticket for the file's CURRENT
209
+ * content, so the next `review-file` on it runs instead of returning the cached result. The ticket
210
+ * id comes from `resolveTicket`, the rule `enqueue` uses; no ticket is written. Synchronous.
211
+ * B83: it also removes the ticket's leftover `dropDoneIf` claims (`<ticket>.done.*.fresh`), which
212
+ * would otherwise keep the ticket off the worker for good; `removed` still names the marker only.
213
+ * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null}} opts
214
+ * @returns {{ticket: string, content_hash: string, removed: boolean}}
215
+ * @throws {WorkerError} `bad-path` for a bad file path or a tree that cannot be resolved.
216
+ */
217
+ export function clearDone({ repoRoot, run, block, file, tree = null }) {
218
+ const { content_hash, ticket } = resolveTicket({ repoRoot, run, block, file, tree });
219
+ const marker = path.join(queueDir(repoRoot), `${ticket}.done`);
220
+ const removed = existsSync(marker);
221
+ rmSync(marker, { force: true });
222
+ let names;
223
+ try {
224
+ names = readdirSync(queueDir(repoRoot));
225
+ } catch {
226
+ names = [];
227
+ }
228
+ // accepted race: a claim a concurrent dropDoneIf still uses may go here, which can leave a refused
229
+ // fresh-restore-failed marker (rare, fail closed; a second `block reopen` clears it)
230
+ for (const name of names) if (CLAIM.exec(name)?.[1] === ticket) rmSync(path.join(queueDir(repoRoot), name), { force: true });
231
+ return { ticket, content_hash, removed };
232
+ }
233
+
234
+ /**
235
+ * B75 (`review-file --fresh`): drop the done marker of `ticket` only while it still holds `seen`,
236
+ * the bytes the caller already read IN PLACE and classified as re-runnable (a marker classified
237
+ * as counted, tampered or unknown is never passed here, so it is never moved). The queue has no
238
+ * lock file; the order is the guarantee:
239
+ * 1. the caller reads the marker in place and classifies it (a refusal touches nothing);
240
+ * 2. only a re-runnable marker is claimed by an atomic rename to a name of this process — of
241
+ * two concurrent callers only one takes it, the other gets `gone`;
242
+ * 3. the claim's bytes are compared with `seen`: equal, the claim is removed (`dropped`) and the
243
+ * caller enqueues; different (a newer result arrived), the marker is restored with
244
+ * `linkSync` only — on EEXIST a newer marker is there and wins, the claim is removed
245
+ * (`changed`); on any other link error `fresh-restore-failed` is thrown (below).
246
+ * B83, fail closed — no failure leaves a ticket the worker runs again by itself:
247
+ * - a link-back that fails with other than EEXIST writes a refused marker in place,
248
+ * `{ticket, status: 'refused', reason: 'fresh-restore-failed'}` (no-overwrite open `wx`, mode
249
+ * 0o600; it names no result, so `--fresh` counts it and refuses, and `block reopen` clears it).
250
+ * EEXIST on that write: a newer marker is in place. Any other write error keeps the claim; a
251
+ * claim (`<ticket>.done.<pid>.<hex>.fresh`) counts as done for `pendingTickets`, and
252
+ * `clearDone` (`block reopen`) removes it;
253
+ * - a claim that cannot be removed after a `dropped` compare is `fresh-claim-failed` (it would
254
+ * hold the re-queued ticket off the worker; `block reopen` clears it); after a good link-back
255
+ * the marker is back, so a claim that cannot be removed is left for `clearDone`.
256
+ * A crash between the drop and the caller's enqueue leaves a re-runnable ticket with no marker:
257
+ * harmless — its ticket file is pending, and the next `review-file` queues it anyway. Synchronous.
258
+ * @param {string} repoRoot @param {string} ticket @param {Buffer} seen
259
+ * @param {{link?: (from: string, to: string) => void, read?: (file: string) => Buffer, rename?: (from: string, to: string) => void, remove?: (file: string) => void, write?: (file: string, text: string) => void}} [io] - for
260
+ * tests (default `linkSync`, `readFileSync`, `renameSync`, `rmSync` with force, `writeFileSync`
261
+ * with flag `wx` and mode 0o600). A claim that cannot be read is restored the same way and
262
+ * refused `fresh-restore-failed`.
263
+ * @returns {'dropped' | 'gone' | 'changed'}
264
+ * @throws {WorkerError} `fresh-claim-failed` (the claim could not be taken, or not removed after
265
+ * the drop; nothing was queued) or `fresh-restore-failed` (the message says which state was left:
266
+ * put back, a newer marker, a refused marker, the claim kept, or nothing). A message names an
267
+ * error code, never a path.
268
+ */
269
+ export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
270
+ const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
271
+ const claim = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.fresh`;
272
+ const remove = io.remove ?? ((/** @type {string} */ file) => rmSync(file, { force: true }));
273
+ try {
274
+ (io.rename ?? renameSync)(marker, claim);
275
+ } catch (err) {
276
+ if (/** @type {{code?: unknown}} */ (err)?.code === 'ENOENT') return 'gone';
277
+ throw new WorkerError('fresh-claim-failed', `the done marker of ticket ${ticket} could not be claimed (${codeOf(err)}); nothing was queued`);
278
+ }
279
+ /** @type {string | null} */
280
+ let readFailed = null;
281
+ /** @type {boolean} */
282
+ let same = false;
283
+ try {
284
+ same = (io.read ?? readFileSync)(claim).equals(seen);
285
+ } catch (err) {
286
+ readFailed = codeOf(err);
287
+ }
288
+ if (same) {
289
+ try {
290
+ remove(claim);
291
+ } catch (err) {
292
+ throw new WorkerError('fresh-claim-failed', `the done marker of ticket ${ticket} was dropped but its claim could not be removed (${codeOf(err)}); block reopen clears it; nothing was queued`);
293
+ }
294
+ return 'dropped';
295
+ }
296
+ /** @type {string | null} */
297
+ let linkFailed = null;
298
+ try {
299
+ (io.link ?? linkSync)(claim, marker);
300
+ } catch (err) {
301
+ linkFailed = codeOf(err);
302
+ }
303
+ const unread = `the claimed done marker of ticket ${ticket} could not be read (${readFailed})`;
304
+ if (linkFailed !== null && linkFailed !== 'EEXIST') {
305
+ const cause = readFailed !== null ? `${unread} and could not be restored (${linkFailed})` : `the done marker of ticket ${ticket} changed and could not be restored (${linkFailed})`;
306
+ throw new WorkerError('fresh-restore-failed', `${cause}; ${refuseInPlace(marker, claim, ticket, io, remove)}; nothing was queued`);
307
+ }
308
+ try {
309
+ remove(claim);
310
+ } catch {
311
+ // the marker is back (or a newer one won), so the ticket is done; `clearDone` removes the claim
312
+ }
313
+ if (readFailed !== null) throw new WorkerError('fresh-restore-failed', `${unread}; ${linkFailed === 'EEXIST' ? 'a newer marker is in place' : 'it was put back'}; nothing was queued`);
314
+ return 'changed';
315
+ }
316
+
317
+ /**
318
+ * B83: after a failed link-back, put a refused `fresh-restore-failed` marker where the done marker
319
+ * was (never over one), so the worker does not run the ticket; the claim is then removed when it
320
+ * can be (`clearDone` removes a leftover one). When the write fails the claim is kept instead.
321
+ * @param {string} marker @param {string} claim @param {string} ticket
322
+ * @param {{write?: (file: string, text: string) => void}} io @param {(file: string) => void} remove
323
+ * @returns {string} which state was left, for the message (codes only, no path).
324
+ */
325
+ function refuseInPlace(marker, claim, ticket, io, remove) {
326
+ const text = `${JSON.stringify({ ticket, status: 'refused', reason: 'fresh-restore-failed' })}\n`;
327
+ /** @type {string | null} */
328
+ let writeFailed = null;
329
+ try {
330
+ (io.write ?? ((/** @type {string} */ file, /** @type {string} */ body) => writeFileSync(file, body, { flag: 'wx', mode: 0o600 })))(marker, text);
331
+ } catch (err) {
332
+ writeFailed = codeOf(err);
333
+ }
334
+ if (writeFailed === null || writeFailed === 'EEXIST') {
335
+ try {
336
+ remove(claim);
337
+ } catch {
338
+ // the marker holds the ticket off the worker; `clearDone` removes the leftover claim
339
+ }
340
+ return writeFailed === null ? 'a refused fresh-restore-failed marker was written in its place, so the worker does not run the ticket' : 'a newer marker is in place';
341
+ }
342
+ // the claim holds the ticket off the worker (`pendingTickets`); a claim that is gone cannot
343
+ return existsSync(claim)
344
+ ? `the refused marker could not be written (${writeFailed}); its claim was kept, so the worker does not run the ticket; block reopen clears it`
345
+ : `the refused marker could not be written (${writeFailed}) and no claim exists`;
346
+ }
347
+
109
348
  /**
110
349
  * Tickets with no done marker, oldest first (enqueue time, then id). Unparseable files are
111
- * skipped here and refused by the worker when it reads them.
350
+ * skipped here and refused by the worker when it reads them. B83: a ticket whose marker is held
351
+ * by a `dropDoneIf` claim is not pending either (a claim kept after a failed restore fails closed).
112
352
  * @param {string} repoRoot
113
353
  * @returns {string[]} ticket ids
114
354
  */
@@ -119,8 +359,9 @@ export function pendingTickets(repoRoot) {
119
359
  } catch {
120
360
  return [];
121
361
  }
362
+ const claimed = new Set(names.map((n) => CLAIM.exec(n)?.[1]));
122
363
  const ids = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => n.slice(0, 24));
123
- const pending = ids.filter((id) => !names.includes(`${id}.done`));
364
+ const pending = ids.filter((id) => !names.includes(`${id}.done`) && !claimed.has(id));
124
365
  const at = (/** @type {string} */ id) => {
125
366
  const t = readJSON(path.join(queueDir(repoRoot), `${id}.json`));
126
367
  return typeof t?.enqueued_at === 'string' ? t.enqueued_at : '';
@@ -139,9 +380,23 @@ export function readTicket(repoRoot, id) {
139
380
  if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
140
381
  assertRowPath(t.file);
141
382
  if (t.tree !== undefined && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
142
- if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string' || ticketId(t) !== id) {
383
+ if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string') {
143
384
  throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
144
385
  }
386
+ if (t.kind !== undefined) {
387
+ // B71: the only other kind is an answers ticket; its id covers the answering file and the ids
388
+ if (t.kind !== 'answers') throw new WorkerError('bad-ticket', `ticket ${id} has an unknown kind`);
389
+ assertRowPath(t.by_file);
390
+ let ids;
391
+ try {
392
+ ids = assertAnswers(t.answers);
393
+ } catch {
394
+ throw new WorkerError('bad-ticket', `ticket ${id} names no valid finding ids`);
395
+ }
396
+ if (typeof t.by_hash !== 'string' || ids.join(',') !== t.answers.join(',') || answersTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
397
+ return /** @type {Ticket} */ (t);
398
+ }
399
+ if (ticketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
145
400
  return /** @type {Ticket} */ (t);
146
401
  }
147
402