@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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The deletion note (block B74, issue #8): a packet for a pure deletion (the file is gone from the
3
+ * reviewed tree, its diff is all `-` lines) gets a `## deleted file` section before the diff. It
4
+ * says the file is deleted and lists the tree paths that still name the file's basename, so the
5
+ * reviewer checks for dangling references.
6
+ *
7
+ * The reviewed tree is the working tree the review engine diffs (`reviewFile`'s `repoRoot`: the
8
+ * block's worktree, or the main tree), never a commit: the paths come from ONE
9
+ * `git grep -l -z --untracked -F -e <basename> -- .` run with that tree as cwd (argv only, the
10
+ * review git env `reviewGitEnv`: no inherited `GIT_*` variable, `GIT_CONFIG_NOSYSTEM=1`,
11
+ * `GIT_CONFIG_GLOBAL` the null device, `GIT_LITERAL_PATHSPECS=1`). `--untracked` adds the files a
12
+ * block created and has not committed yet (never ignored ones). The list is sorted, never names the
13
+ * deleted path itself or a secret-like path (`isSecretLike`), and stops at {@link MAX_REFERENCES}
14
+ * paths; a path with a control character (it could break the packet layout) is never printed, only
15
+ * counted with the paths past the cap (`(<n> more not listed)`). No match ⇒ `no references found`.
16
+ * A basename shorter than {@link MIN_BASENAME} characters is not grepped, nor is a deleted path
17
+ * with a control character (it is printed JSON-escaped), and a grep that fails (or times out) is
18
+ * not trusted: all say `references not checked`. The note is a hint, never an exemption: a
19
+ * deletion is reviewed and needs a signed approval like any other change (a grep cannot prove that
20
+ * nothing depends on a file).
21
+ */
22
+
23
+ import path from 'node:path';
24
+ import { exec } from '../util/exec.mjs';
25
+ import { isSecretLike, reviewGitEnv } from './packet.mjs';
26
+
27
+ /** At most this many referencing paths are listed. */
28
+ export const MAX_REFERENCES = 20;
29
+
30
+ /** A basename shorter than this matches too much to be a useful hint: it is not grepped. */
31
+ export const MIN_BASENAME = 3;
32
+
33
+ export const NO_REFERENCES = 'no references found';
34
+ export const NOT_CHECKED = 'references not checked';
35
+
36
+ const GREP_TIMEOUT_MS = 30000;
37
+
38
+ const CONTROL = /[\x00-\x1f\x7f]/;
39
+
40
+ /** @param {string} p @returns {boolean} whether `p` has a control character. */
41
+ const hasControl = (p) => CONTROL.test(p);
42
+
43
+ /**
44
+ * @typedef {object} References
45
+ * @property {'found' | 'none' | 'not_checked'} status
46
+ * @property {string[]} paths - sorted, at most {@link MAX_REFERENCES}.
47
+ * @property {number} more - matching paths not listed: past the cap, or with a control character.
48
+ */
49
+
50
+ /** @type {References} */
51
+ const NOT_CHECKED_REFS = Object.freeze({ status: 'not_checked', paths: [], more: 0 });
52
+
53
+ /**
54
+ * The paths of the reviewed tree (`repoRoot`, the grep's cwd) that still name `file`'s basename.
55
+ * @param {{repoRoot: string, file: string, run?: typeof exec}} opts - `run`: the child runner
56
+ * (default `exec`; a test seam).
57
+ * @returns {Promise<References>}
58
+ */
59
+ export async function findReferences({ repoRoot, file, run = exec }) {
60
+ const base = path.posix.basename(file);
61
+ if (base.length < MIN_BASENAME || hasControl(file)) return { ...NOT_CHECKED_REFS, paths: [] };
62
+ let res;
63
+ try {
64
+ res = await run(['git', 'grep', '-l', '-z', '--untracked', '-F', '-e', base, '--', '.'], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GREP_TIMEOUT_MS, okExitCodes: [0, 1] });
65
+ } catch {
66
+ return { ...NOT_CHECKED_REFS, paths: [] };
67
+ }
68
+ if (res?.result !== 'ok' || (res.code !== 0 && res.code !== 1) || typeof res.stdout !== 'string') return { ...NOT_CHECKED_REFS, paths: [] };
69
+ const matched = [...new Set(res.stdout.split('\0').filter((p) => p.length > 0 && p !== file && !isSecretLike(p)))];
70
+ const unsafe = matched.filter(hasControl).length;
71
+ const clean = matched.filter((p) => !hasControl(p)).sort();
72
+ if (clean.length === 0 && unsafe === 0) return { status: 'none', paths: [], more: 0 };
73
+ return { status: 'found', paths: clean.slice(0, MAX_REFERENCES), more: Math.max(0, clean.length - MAX_REFERENCES) + unsafe };
74
+ }
75
+
76
+ /**
77
+ * The `## deleted file` section (heading included) for a deleted `file`. A name with a control
78
+ * character is printed JSON-escaped, never raw.
79
+ * @param {string} file @param {References} refs @returns {string}
80
+ */
81
+ export function renderDeletionNote(file, refs) {
82
+ const shown = hasControl(file) ? JSON.stringify(file) : file;
83
+ const out = ['## deleted file', `this file is deleted: ${shown}`, `check that nothing still depends on it. tree paths that still name ${JSON.stringify(path.posix.basename(file))}:`];
84
+ if (refs.status === 'not_checked') out.push(NOT_CHECKED);
85
+ else if (refs.status === 'none') out.push(NO_REFERENCES);
86
+ else {
87
+ out.push(...refs.paths.map((p) => `- ${p}`));
88
+ if (refs.more > 0) out.push(`(${refs.more} more not listed)`);
89
+ }
90
+ return out.join('\n');
91
+ }
92
+
93
+ /**
94
+ * The deletion note for `file`, its references read in `repoRoot` (the reviewed tree). Never
95
+ * rejects: anything that fails is `references not checked`.
96
+ * @param {{repoRoot: string, file: string, run?: typeof exec}} opts @returns {Promise<string>}
97
+ */
98
+ export async function deletionNote(opts) {
99
+ let refs;
100
+ try {
101
+ refs = await findReferences(opts);
102
+ } catch {
103
+ refs = NOT_CHECKED_REFS;
104
+ }
105
+ return renderDeletionNote(opts.file, refs);
106
+ }
@@ -40,12 +40,15 @@
40
40
  * (`moved.mjs`, B56). The peers' diffs are read once per review (`input.peerDiffs`, or a
41
41
  * reader built from `input.peers`). It is extra text: every hunk still goes to the reviewer,
42
42
  * and it counts toward the budget in the same measure.
43
+ * 9. a pure deletion (the file is gone from the reviewed tree) gets a `## deleted file` note in
44
+ * every packet — reviewers and judge — naming up to 20 tree paths that still name its basename
45
+ * (`deletion.mjs`, B74). It is a hint, never an exemption: the deletion still needs approval.
43
46
  * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
44
47
  * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
45
48
  * `warning` finding.
46
49
  */
47
50
 
48
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
51
+ import { lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
49
52
  import { randomBytes } from 'node:crypto';
50
53
  import path from 'node:path';
51
54
  import { resolveLevel } from '../config/known-ids.mjs';
@@ -55,6 +58,7 @@ import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
55
58
  import { tierFor } from '../proof/tiers.mjs';
56
59
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
57
60
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
61
+ import { deletionNote, renderDeletionNote } from './deletion.mjs';
58
62
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
59
63
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
60
64
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
@@ -182,6 +186,8 @@ export function rulesRisk({ file, plusCount, cfg }) {
182
186
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
183
187
  * (`review.plan`, `review.unavailable`, `review.schema_invalid`, `review.schema_fallback`); a
184
188
  * failed write never fails the review (a failed `review.schema_fallback` write means no try).
189
+ * @property {(opts: {repoRoot: string, file: string}) => Promise<string>} [deletionNote] - B74: the
190
+ * deletion note reader (default `deletionNote`, `deletion.mjs`); a throw gives `references not checked`.
185
191
  * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's
186
192
  * signed schema-miss history (the worker's); absent ⇒ no second-level try.
187
193
  */
@@ -233,8 +239,22 @@ export async function reviewFile(input, deps) {
233
239
  } catch {
234
240
  moves = null; // no hint; the review goes on
235
241
  }
242
+ // B74: a pure deletion gets the `## deleted file` note in every packet, read once; it is extra
243
+ // text like the moved code, counted in the same measure. "Deleted" is explicit: the diff is not
244
+ // empty (checked above), the reader found no content (`readFileDiff` gives null ONLY for a path
245
+ // not on disk: a binary file is decoded text, an unreadable one throws) AND the path is absent
246
+ // from the reviewed tree now. The references are read in that same tree (`repoRoot`).
247
+ let deleted = '';
248
+ if (diff.content === null && isAbsent(repoRoot, diff.file)) {
249
+ const note = deps.deletionNote ?? deletionNote;
250
+ try {
251
+ deleted = await note({ repoRoot, file: diff.file });
252
+ } catch {
253
+ deleted = renderDeletionNote(diff.file, { status: 'not_checked', paths: [], more: 0 }); // the review goes on
254
+ }
255
+ }
236
256
  /** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
237
- const movedOf = (keys) => movedText(moves, diff.content, keys);
257
+ const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys)].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
238
258
  const min = minTokensOut(cfg);
239
259
  // B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
240
260
  const schemaFallback = plan.mode !== 'consensus';
@@ -466,6 +486,20 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
466
486
  return { ...meta, status: 'ok', reason: null, review: verdict.review };
467
487
  }
468
488
 
489
+ /**
490
+ * B74: whether `file` is absent from the tree at `repoRoot` (lstat says ENOENT or ENOTDIR).
491
+ * @param {string} repoRoot @param {string} file @returns {boolean}
492
+ */
493
+ function isAbsent(repoRoot, file) {
494
+ try {
495
+ lstatSync(path.join(repoRoot, file));
496
+ return false;
497
+ } catch (err) {
498
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
499
+ return code === 'ENOENT' || code === 'ENOTDIR';
500
+ }
501
+ }
502
+
469
503
  /**
470
504
  * Two token counts added; a missing one (not a number) adds nothing, both missing ⇒ null.
471
505
  * @param {unknown} a @param {unknown} b @returns {number | null}
@@ -24,11 +24,15 @@
24
24
  * a coding level, R1) takes it, so for an L2 block the rung IS the +1 at round 2. After the
25
25
  * patch the block continues at L2 (§3.6, Fable ruling A): `l3_rung_used = true`,
26
26
  * `rounds_at_level = 0`, and the `patch_check` goes through `decideNext` like any recheck —
27
- * clean ⇒ complete; open set not smaller than before the patch ⇒ `stopped: l3_patch_exhausted`
28
- * (`trigger: review_stall`); round ≥ cap ⇒ `stopped: review_cap`; else `fix` at L2 with
29
- * `l3_patch: true` in the brief. A later stall ⇒ `l3_patch_exhausted`; round 4 still open ⇒
30
- * `review_cap` (the rung is spent). Either way the ledger `review.cap` row lists the open
31
- * ids — the human decides (fix by hand, `block waive`, or re-decompose).
27
+ * clean ⇒ complete; the patch closed NO finding that was open before it (ids compared, not
28
+ * counts) ⇒ `stopped: l3_patch_exhausted` (`trigger: review_stall`); round ≥ cap ⇒
29
+ * `stopped: review_cap`; else `fix` at L2 with `l3_patch: true` in the brief. B73 (issue #12):
30
+ * a patch that closed ≥ 1 old id below the cap is progress, not a stall, even when new findings
31
+ * in its fix hunks keep the open set from shrinking. A later stall at L2 ⇒ `l3_patch_exhausted`
32
+ * (the rung is spent, never a second one); round 4 still open ⇒ `review_cap`. Either way the
33
+ * ledger `review.cap` row lists the open ids — the human decides (`STOP_EXITS`,
34
+ * `stop-exits.mjs`): `block reopen` after a fix, `block waive` per open finding,
35
+ * `review-file --answers` for a fix in another owned file, or re-decompose.
32
36
  * 6. Ledger per round: `review.round {file, round, level, kind, open_before, closed,
33
37
  * new_in_hunks, late, open_after, tokens_in, tokens_out}`; a converged file gets
34
38
  * `review.approved {file, content_hash, round}` (the worker signs every row it writes).
@@ -497,17 +501,20 @@ async function recheckSession(packet, deps) {
497
501
  /**
498
502
  * After a counted round with open findings: cap first (rule 6), else stall / ladder (2b, 2).
499
503
  * @param {FileState} state @param {number} openBefore @param {ReturnType<typeof settings>} s
500
- * @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check` compares against the open
501
- * set before the patch (the rung's own shrink test), whatever the round number.
504
+ * @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check` is judged by `closedIds`
505
+ * (B73), whatever the round number.
506
+ * @param {number} closedIds - how many ids open before the round are not open after it.
502
507
  * @returns {NextStep}
503
508
  */
504
- function decideNext(state, openBefore, s, kind) {
509
+ function decideNext(state, openBefore, s, kind, closedIds) {
505
510
  if (state.open.length === 0) return { action: 'complete' };
506
511
  if (kind === 'patch_check') {
507
- // the block's one rung is spent: a patch that did not shrink the set stops FIRST — even when
508
- // the rung was taken at the cap — then the cap; else the block continues at L2 below
509
- if (reviewRoundStalled(openBefore, state.open.length)) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
512
+ // the block's one rung is spent: a patch that closed no old finding stops FIRST — even when
513
+ // the rung was taken at the cap — then the cap; else (B73: ≥ 1 old id closed is progress,
514
+ // whatever new findings its fix hunks brought) the block continues at L2: a fix, no stall
515
+ if (closedIds === 0) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
510
516
  if (state.round >= s.maxRounds) return { action: 'stop', reason: 'review_cap', trigger: 'review_cap' };
517
+ return { action: 'fix', level: state.level, trigger: null };
511
518
  }
512
519
  if (state.round >= s.maxRounds) {
513
520
  const esc = escalationAfterAttempt({ currentLevel: state.level, roundsAtFile: state.round, maxRoundsPerFile: s.maxRounds, openFixNowFinding: true, l3RungAlreadyUsed: state.l3_rung_used });
@@ -515,7 +522,7 @@ function decideNext(state, openBefore, s, kind) {
515
522
  }
516
523
  const esc = escalationAfterAttempt({
517
524
  currentLevel: state.level,
518
- reviewStall: (kind === 'patch_check' || state.round >= 2) && reviewRoundStalled(openBefore, state.open.length),
525
+ reviewStall: state.round >= 2 && reviewRoundStalled(openBefore, state.open.length),
519
526
  openFixNowFinding: true,
520
527
  reviewRoundsAtLevel: state.rounds_at_level,
521
528
  reviewRoundsPerLevel: s.perLevel,
@@ -740,9 +747,12 @@ export async function runRound(state, deps, opts = {}) {
740
747
  draft.status = 'complete';
741
748
  draft.next = { action: 'complete' };
742
749
  } else {
743
- // after a patch_check the block continues at L2 (§3.6): the rung is spent, so a stall now
744
- // stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a shrunk set is fixed at L2
745
- draft.next = decideNext(draft, openBefore, s, kind);
750
+ // after a patch_check the block continues at L2 (§3.6): the rung is spent, so a patch that
751
+ // closed no old id stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a patch that
752
+ // closed one is fixed at L2 (B73). Closed = an id open before the round, not open after it.
753
+ const after = new Set(draft.open.map((f) => f.id));
754
+ const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
755
+ draft.next = decideNext(draft, openBefore, s, kind, closedIds);
746
756
  if (draft.next.action === 'stop') draft.status = 'stopped';
747
757
  }
748
758
  const rows = [...lateRows, roundRow];