@codedology/code-forge 0.9.0 → 0.11.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.
- package/CHANGELOG.md +93 -0
- package/README.md +49 -9
- package/docs/reference/blocks.json +587 -1
- package/docs/reference/config.md +36 -0
- package/package.json +2 -2
- package/schema/code-forge.schema.json +89 -0
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +14 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +39 -7
- package/skill/references/security.md +4 -4
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +6 -6
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +262 -31
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +17 -11
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +27 -6
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/engine.mjs +141 -42
- package/src/review/fixloop.mjs +75 -23
- package/src/review/gate-check.mjs +3 -2
- package/src/review/lenses/A.md +2 -0
- package/src/review/lenses/B.md +2 -0
- package/src/review/lenses/full.md +2 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +2 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +80 -0
- package/src/review/reopen.mjs +4 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block-end.mjs +10 -6
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +2 -2
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +98 -15
- package/src/worker/ticket.mjs +9 -1
package/src/review/fixloop.mjs
CHANGED
|
@@ -93,11 +93,12 @@ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
|
93
93
|
import { parseDiff } from './context.mjs';
|
|
94
94
|
import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
95
95
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
96
|
+
import { pairedContext } from './engine.mjs';
|
|
96
97
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
|
|
97
98
|
import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
|
|
98
99
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
99
100
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
100
|
-
import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
|
|
101
|
+
import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
|
|
101
102
|
|
|
102
103
|
/** @typedef {import('./triage.mjs').Finding} Finding */
|
|
103
104
|
/** @typedef {import('./context.mjs').Hunk} Hunk */
|
|
@@ -144,6 +145,9 @@ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
|
|
|
144
145
|
* @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
|
|
145
146
|
* @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
|
|
146
147
|
* @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
|
|
148
|
+
* @property {ReadonlyArray<string>} [ownedFiles] - concrete paths owned by this block.
|
|
149
|
+
* @property {ReadonlyArray<string>} [changedFiles] - concrete changed paths of the same block.
|
|
150
|
+
* @property {ReadonlyArray<string>} [testFiles] - the block's explicit test_files.
|
|
147
151
|
* @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader of their diffs (`peerDiffReader`); wins over `peers`.
|
|
148
152
|
* @property {string} [rulesDigest] @property {string} [factsExcerpt]
|
|
149
153
|
* @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
|
|
@@ -362,11 +366,11 @@ export function recheckTokens(diff, open, moved = '') {
|
|
|
362
366
|
/**
|
|
363
367
|
* The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
|
|
364
368
|
* the open findings by id. Over budget ⇒ `minimal` (see the module doc).
|
|
365
|
-
* @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string}} opts -
|
|
369
|
+
* @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string, paired?: string}} opts -
|
|
366
370
|
* `moved`: the file's `## moved code` section (B56), '' for none.
|
|
367
371
|
*/
|
|
368
|
-
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '' }) {
|
|
369
|
-
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved });
|
|
372
|
+
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '', paired = '' }) {
|
|
373
|
+
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved, paired });
|
|
370
374
|
if (packet.status !== 'ok') return packet;
|
|
371
375
|
const text = `${packet.text}${openFindingsText(open)}`;
|
|
372
376
|
return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
|
|
@@ -431,25 +435,49 @@ export function insideHunks(f, hunks) {
|
|
|
431
435
|
}
|
|
432
436
|
|
|
433
437
|
/**
|
|
434
|
-
* One recheck session through the stub guard — and, on a `schema` miss
|
|
435
|
-
*
|
|
438
|
+
* One recheck session through the stub guard — and, on a `schema` miss or (B91) a timed-out
|
|
439
|
+
* attempt that is the packet's second broken result, the B55 second-level try on the same packet
|
|
440
|
+
* (`schema-fallback.mjs`; rechecks and the patch_check alike).
|
|
436
441
|
* @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
|
|
437
442
|
* @param {LoopDeps} deps
|
|
443
|
+
* @param {ReadonlyArray<Finding>} open - only the findings listed in this packet.
|
|
438
444
|
*/
|
|
439
|
-
async function recheckSession(packet, deps) {
|
|
445
|
+
async function recheckSession(packet, deps, open) {
|
|
440
446
|
mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
|
|
441
447
|
const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
|
|
442
448
|
writeFileSync(promptPath, packet.text, { mode: 0o600 });
|
|
443
449
|
const min = minTokensOut(deps.cfg);
|
|
450
|
+
/** @param {Record<string, any> | null} result @returns {import('./validate-review.mjs').Verdict} */
|
|
451
|
+
const validate = (result) => {
|
|
452
|
+
const verdict = validateReview(result, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
453
|
+
if (!verdict.ok) return verdict;
|
|
454
|
+
const ids = new Set((verdict.review?.resolved ?? []).map((/** @type {{id: string}} */ entry) => entry.id));
|
|
455
|
+
if (open.some((finding) => !ids.has(finding.id))) return { ok: false, reason: 'schema', detail: 'resolved does not cover the packet open findings' };
|
|
456
|
+
return verdict;
|
|
457
|
+
};
|
|
444
458
|
const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
|
|
445
|
-
|
|
459
|
+
// no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
|
|
460
|
+
// (each helper handles its own failed write)
|
|
461
|
+
const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
|
|
462
|
+
const hash = packetHash(packet.text);
|
|
463
|
+
/** @param {Record<string, any> | null} missed @param {'schema' | 'timeout'} trigger */
|
|
464
|
+
const tryNext = (missed, trigger) =>
|
|
465
|
+
afterSchemaMiss({ res: missed, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt, trigger });
|
|
466
|
+
/**
|
|
467
|
+
* @param {Record<string, any> | undefined} cfg - set only for the second-level try (B91: its
|
|
468
|
+
* timeouts never count and never try again).
|
|
469
|
+
* @returns {Promise<{res: Record<string, any> | null, attempts: number, to?: Record<string, any>, broken?: Record<string, any>}>}
|
|
470
|
+
*/
|
|
446
471
|
const attempt = async (cfg) =>
|
|
447
472
|
deps.spawn
|
|
448
|
-
? // a `timeout` is spawned once more on the same packet and level (B30)
|
|
473
|
+
? // a `timeout` is spawned once more on the same packet and level (B30); B91: the packet's
|
|
474
|
+
// second broken result tries the second level instead
|
|
449
475
|
spawnWithTimeoutRetry(
|
|
450
476
|
deps.spawn,
|
|
451
477
|
{ level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn }, ...(cfg ? { cfg } : {}) },
|
|
452
478
|
(row) => note(deps, row),
|
|
479
|
+
undefined,
|
|
480
|
+
cfg ? { packetHash: hash, secondLevel: true } : { packetHash: hash, onBroken: (r) => tryNext(r, 'timeout') },
|
|
453
481
|
)
|
|
454
482
|
: { res: null, attempts: 0 };
|
|
455
483
|
/** @type {Record<string, any> | null} */
|
|
@@ -462,23 +490,40 @@ async function recheckSession(packet, deps) {
|
|
|
462
490
|
let tokensOut = null;
|
|
463
491
|
// the packet file is removed on every path, and only after the second-level try (which reads it)
|
|
464
492
|
try {
|
|
493
|
+
/** @type {{res: Record<string, any> | null, attempts: number, to?: Record<string, any>, broken?: Record<string, any>} | null} */
|
|
494
|
+
let first = null;
|
|
465
495
|
try {
|
|
466
|
-
|
|
496
|
+
first = await attempt(undefined);
|
|
497
|
+
res = first.res;
|
|
467
498
|
} catch {
|
|
468
499
|
res = null; // the helper never throws; if it ever did, it is still an `exit` failure
|
|
469
500
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
501
|
+
verdict = validate(res);
|
|
502
|
+
if (first?.to) {
|
|
503
|
+
// B91: a timed-out attempt was the packet's second broken result; the second level ran. The
|
|
504
|
+
// verdict is already set: whatever throws after it leaves that verdict, never an exception
|
|
505
|
+
// the second level's miss is logged first, in its own try, so no sum error can skip it
|
|
474
506
|
try {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
507
|
+
if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: packet.text, writeRow: write });
|
|
508
|
+
} catch {
|
|
509
|
+
// defensive only: the helper never throws; the verdict above stands (never approval)
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
tokensIn = sum(first.broken?.usage?.tokens_in ?? null, res?.usage?.tokens_in);
|
|
513
|
+
tokensOut = sum(first.broken?.usage?.tokens_out ?? null, res?.usage?.tokens_out);
|
|
514
|
+
} catch {
|
|
515
|
+
// defensive only, as on the schema path below
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
tokensIn = res?.usage?.tokens_in ?? null;
|
|
519
|
+
tokensOut = res?.usage?.tokens_out ?? null;
|
|
520
|
+
}
|
|
521
|
+
if (!first?.to && !verdict.ok && verdict.reason === 'schema') {
|
|
522
|
+
try {
|
|
523
|
+
const second = await tryNext(res, 'schema');
|
|
479
524
|
if (second) {
|
|
480
525
|
// compute everything first, then assign together, so a throw never leaves a mixed state
|
|
481
|
-
const secondVerdict =
|
|
526
|
+
const secondVerdict = validate(second.res);
|
|
482
527
|
const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
|
|
483
528
|
const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
|
|
484
529
|
res = second.res;
|
|
@@ -632,6 +677,11 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
632
677
|
s.scope === 'file'
|
|
633
678
|
? await readFileDiff({ repoRoot: deps.repoRoot, file: current.rel, base: deps.base ?? null })
|
|
634
679
|
: await fixHunkDiff({ file: current.rel, previous: state.reviewed_content ?? '', current: current.content, workDir: deps.workDir });
|
|
680
|
+
if (kind === 'recheck' && (current.content === state.reviewed_content || diff.hunks.length === 0)) {
|
|
681
|
+
state.next = { action: 'fix', level: state.level, trigger: null, reason: 'no-fix-hunks' };
|
|
682
|
+
state.pending_kind = null;
|
|
683
|
+
return state;
|
|
684
|
+
}
|
|
635
685
|
if (diff.hunks.length > 0) {
|
|
636
686
|
/** @type {Finding[]} */
|
|
637
687
|
const pending = [];
|
|
@@ -644,7 +694,8 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
644
694
|
if (pending.length > 0) {
|
|
645
695
|
const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
|
|
646
696
|
const moves = await recheckMoves(deps, current.rel, s.scope === 'file' ? diff : null);
|
|
647
|
-
const
|
|
697
|
+
const paired = pairedContext({ ...deps, file: current.rel });
|
|
698
|
+
const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null), paired });
|
|
648
699
|
/** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[], keys: number[] | null}>} */
|
|
649
700
|
let parts = [{ index: null, heading: null, diff, open: pending, keys: null }];
|
|
650
701
|
if (whole.status !== 'ok') {
|
|
@@ -665,7 +716,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
665
716
|
continue;
|
|
666
717
|
}
|
|
667
718
|
const moved = movedText(moves, diff.content, part.keys);
|
|
668
|
-
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved });
|
|
719
|
+
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved, paired });
|
|
669
720
|
// a packet that fails to build stops with ITS status (and size, when it has one)
|
|
670
721
|
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
|
|
671
722
|
const size = recheckTokens(part.diff, part.open, moved);
|
|
@@ -681,7 +732,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
681
732
|
for (const [i, part] of parts.entries()) {
|
|
682
733
|
const packet = /** @type {any} */ (built[i]);
|
|
683
734
|
packetInfo = mergePacketInfo(packetInfo, packet);
|
|
684
|
-
const session = await recheckSession(packet, deps);
|
|
735
|
+
const session = await recheckSession(packet, deps, part.open);
|
|
685
736
|
tokensIn = sum(tokensIn, session.tokens_in);
|
|
686
737
|
tokensOut = sum(tokensOut, session.tokens_out);
|
|
687
738
|
if (!session.verdict.ok) {
|
|
@@ -711,7 +762,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
711
762
|
draft.open = [...stillOpen, ...triaged.fix_now, ...blocking];
|
|
712
763
|
}
|
|
713
764
|
}
|
|
714
|
-
// no fix hunks
|
|
765
|
+
// A patch_check with no fix hunks closed nothing: B73 still stops it.
|
|
715
766
|
}
|
|
716
767
|
|
|
717
768
|
if (kind !== 'patch_check') {
|
|
@@ -796,6 +847,7 @@ export async function converge(state, deps) {
|
|
|
796
847
|
}
|
|
797
848
|
await deps.fix?.({ file: state.file, round: state.round + 1, level: state.level, open: [...state.open], l3_patch: state.l3_rung_used });
|
|
798
849
|
await runRound(state, deps);
|
|
850
|
+
if (state.next?.reason === 'no-fix-hunks') return state;
|
|
799
851
|
}
|
|
800
852
|
return state;
|
|
801
853
|
}
|
|
@@ -93,6 +93,7 @@ import { tierFor } from '../proof/tiers.mjs';
|
|
|
93
93
|
import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
94
94
|
import { verifyResult } from '../worker/queue.mjs';
|
|
95
95
|
import { stopExitsLine } from './stop-exits.mjs';
|
|
96
|
+
import { checkFileForm, checkFindingId } from './verb-forms.mjs';
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
99
|
* @typedef {{file: string, content_hash: string, status: 'reviewed_by_waiver', findings: string[], by: 'human' | 'autopilot' | null, answered?: string[]}} WaivedAll
|
|
@@ -1055,10 +1056,10 @@ export function reviewGateCheck(load, { onWaivedAll } = {}) {
|
|
|
1055
1056
|
* @returns {Promise<Record<string, any>>} the signed row.
|
|
1056
1057
|
*/
|
|
1057
1058
|
export async function waiveFinding({ runId, id, finding, reason, file, writeRow }) {
|
|
1058
|
-
|
|
1059
|
+
checkFindingId(finding);
|
|
1059
1060
|
if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block waive needs --reason');
|
|
1060
1061
|
if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block waive needs --file <path>');
|
|
1061
|
-
|
|
1062
|
+
checkFileForm(file, { normalized: false });
|
|
1062
1063
|
const record = await readRun(runId);
|
|
1063
1064
|
if (!record.blocks?.[id]) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
1064
1065
|
const row = { event: 'review.waived', block: id, file, finding, reason: reason.trim(), by: 'human' };
|
package/src/review/lenses/A.md
CHANGED
|
@@ -14,9 +14,11 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
14
14
|
## Rules for every answer
|
|
15
15
|
|
|
16
16
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
17
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
17
18
|
- You have no tools and no project files. The packet is everything you get.
|
|
18
19
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
19
20
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
20
21
|
- Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
|
|
21
22
|
- `passed` is true only when you found no `critical` and no `warning`.
|
|
22
23
|
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
24
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
package/src/review/lenses/B.md
CHANGED
|
@@ -15,9 +15,11 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
15
15
|
## Rules for every answer
|
|
16
16
|
|
|
17
17
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
18
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
18
19
|
- You have no tools and no project files. The packet is everything you get.
|
|
19
20
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
20
21
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
21
22
|
- Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
|
|
22
23
|
- `passed` is true only when you found no `critical` and no `warning`.
|
|
23
24
|
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
25
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
|
@@ -15,9 +15,11 @@ Report each real defect once, at the lines where it lives.
|
|
|
15
15
|
## Rules for every answer
|
|
16
16
|
|
|
17
17
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
18
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
18
19
|
- You have no tools and no project files. The packet is everything you get.
|
|
19
20
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
20
21
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
21
22
|
- Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
|
|
22
23
|
- `passed` is true only when you found no `critical` and no `warning`.
|
|
23
24
|
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
25
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
|
@@ -19,3 +19,4 @@ Skip style, naming and structure unless it hides a bug. Keep `findings` short.
|
|
|
19
19
|
- Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
|
|
20
20
|
- `passed` is true only when you found no `critical` and no `warning`.
|
|
21
21
|
- Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
|
|
22
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
|
@@ -18,6 +18,8 @@ that changed since that round, each with a window of the current file around it.
|
|
|
18
18
|
## Rules for every answer
|
|
19
19
|
|
|
20
20
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
21
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
21
22
|
- You have no tools and no project files. The packet is everything you get.
|
|
22
23
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
|
|
23
24
|
- Answer with ONE JSON object that matches the answer schema.
|
|
25
|
+
- A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
|
package/src/review/packet.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* ## project rules the rules digest (≤ 60 lines)
|
|
11
11
|
* ## facts the facts-sheet excerpt
|
|
12
12
|
* ## context the current file (whole, or hunk windows — `context.mjs`)
|
|
13
|
+
* ## paired optional paired-file names and summary (B97), dropped first over budget
|
|
13
14
|
* ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
|
|
14
15
|
* ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
|
|
15
16
|
*
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
* Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
|
|
21
22
|
* bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
|
|
22
23
|
* section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
|
|
23
|
-
* ± `min_context_lines` (`minimal`)
|
|
24
|
+
* ± `min_context_lines` (`minimal`) after dropping paired, then the digest is trimmed from the end; the diff is
|
|
24
25
|
* never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
|
|
25
26
|
* size (`diffOnlyTokens`, `split_required`) and never trimmed.
|
|
26
27
|
*
|
|
@@ -190,11 +191,11 @@ export async function readFileDiff({ repoRoot, file, base }) {
|
|
|
190
191
|
* Assemble a reviewer packet (pure).
|
|
191
192
|
* @param {{
|
|
192
193
|
* diff: FileDiff, lens: string, rulesDigest?: string, factsExcerpt?: string,
|
|
193
|
-
* cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string,
|
|
194
|
+
* cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string, paired?: string,
|
|
194
195
|
* }} opts - `moved`: the file's `## moved code` section (`moved.mjs`), '' for none.
|
|
195
196
|
* @returns {Packet | {status: 'split_required', tokensIn: number, budget: number}}
|
|
196
197
|
*/
|
|
197
|
-
export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '' }) {
|
|
198
|
+
export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '', paired = '' }) {
|
|
198
199
|
const lensText = loadLens(lens);
|
|
199
200
|
const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
|
|
200
201
|
const hunkHeaders = diff.hunks.map((h) => h.header);
|
|
@@ -204,7 +205,12 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
204
205
|
|
|
205
206
|
let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
|
|
206
207
|
let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
|
|
207
|
-
let
|
|
208
|
+
let pairedSection = boundedPaired(paired, budgetFor(cfg, 'full_in'));
|
|
209
|
+
let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection);
|
|
210
|
+
if (estimateTokens(text) > limit && pairedSection.length > 0) {
|
|
211
|
+
pairedSection = '';
|
|
212
|
+
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
213
|
+
}
|
|
208
214
|
if (estimateTokens(text) > limit) {
|
|
209
215
|
ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
|
|
210
216
|
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
@@ -217,6 +223,22 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
217
223
|
return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
|
|
218
224
|
}
|
|
219
225
|
|
|
226
|
+
/** Cap the entire section at full_in / 16 tokens, without cutting a UTF-8 character.
|
|
227
|
+
* @param {string} paired @param {number} fullIn @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
function boundedPaired(paired, fullIn) {
|
|
230
|
+
if (paired.trim().length === 0) return '';
|
|
231
|
+
const heading = '## paired\n';
|
|
232
|
+
let left = Math.floor(fullIn / 16) * 4 - Buffer.byteLength(heading) - 1;
|
|
233
|
+
let body = '';
|
|
234
|
+
for (const char of paired) {
|
|
235
|
+
left -= Buffer.byteLength(char);
|
|
236
|
+
if (left < 0) break;
|
|
237
|
+
body += char;
|
|
238
|
+
}
|
|
239
|
+
return body.length > 0 ? heading + body.trimEnd() : '';
|
|
240
|
+
}
|
|
241
|
+
|
|
220
242
|
/**
|
|
221
243
|
* The tokens of the diff-only packet (lens + moved code + diff, no digest, facts or context): what
|
|
222
244
|
* `assemblePacket` holds against the budget before it says `split_required`. Pass the SAME `moved`
|
|
@@ -244,8 +266,9 @@ function diffSectionOf(diff) {
|
|
|
244
266
|
/**
|
|
245
267
|
* @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
|
|
246
268
|
* @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
|
|
269
|
+
* @param {string} [paired] - the bounded `## paired` section (heading included).
|
|
247
270
|
*/
|
|
248
|
-
function render(lensText, digest, facts, context, diffSection, moved = '') {
|
|
271
|
+
function render(lensText, digest, facts, context, diffSection, moved = '', paired = '') {
|
|
249
272
|
return [
|
|
250
273
|
'# code-forge review packet',
|
|
251
274
|
'## lens',
|
|
@@ -256,6 +279,7 @@ function render(lensText, digest, facts, context, diffSection, moved = '') {
|
|
|
256
279
|
facts.trim().length > 0 ? facts.trimEnd() : '(none)',
|
|
257
280
|
'## context',
|
|
258
281
|
context.length > 0 ? context : '(none)',
|
|
282
|
+
...(paired.length > 0 ? [paired] : []),
|
|
259
283
|
...(moved.length > 0 ? [moved] : []),
|
|
260
284
|
'## diff',
|
|
261
285
|
diffSection,
|
|
@@ -319,9 +343,10 @@ function readRepoText(repoRoot, rel) {
|
|
|
319
343
|
|
|
320
344
|
/**
|
|
321
345
|
* Why `rel` may not be attached, or null.
|
|
322
|
-
* @param {string} repoRoot @param {string} rel @
|
|
346
|
+
* @param {string} repoRoot @param {string} rel @param {ReadonlyArray<string>} ownedFiles
|
|
347
|
+
* @param {ReadonlyArray<string>} changedFiles @returns {Promise<string | null>}
|
|
323
348
|
*/
|
|
324
|
-
async function attachRefusal(repoRoot, rel) {
|
|
349
|
+
async function attachRefusal(repoRoot, rel, ownedFiles, changedFiles) {
|
|
325
350
|
if (isSecretLike(rel)) return 'refused: secret-like path';
|
|
326
351
|
let st;
|
|
327
352
|
try {
|
|
@@ -330,20 +355,29 @@ async function attachRefusal(repoRoot, rel) {
|
|
|
330
355
|
return 'missing';
|
|
331
356
|
}
|
|
332
357
|
if (!st.isFile()) return 'not a file';
|
|
333
|
-
const res = await exec(['git', 'ls-files', '-z', '--
|
|
334
|
-
if (res.result !== 'ok'
|
|
358
|
+
const res = await exec(['git', 'ls-files', '-z', '--', rel], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
359
|
+
if (res.result !== 'ok') return 'refused: git failed';
|
|
360
|
+
if (res.stdout === `${rel}\0`) return null;
|
|
361
|
+
if (res.stdout.length > 0) return 'refused: git failed';
|
|
362
|
+
if (!ownedFiles.includes(rel)) return 'refused: untracked, not owned by this block';
|
|
363
|
+
if (!changedFiles.includes(rel)) return 'refused: untracked, not changed in this block';
|
|
364
|
+
const ignored = await exec(['git', 'check-ignore', '-q', '--', rel], { cwd: repoRoot, env: plainGitEnv(), timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1] });
|
|
365
|
+
if (ignored.result !== 'ok' || (ignored.code !== 0 && ignored.code !== 1)) return 'refused: git failed';
|
|
366
|
+
if (ignored.code === 0) return 'refused: gitignored';
|
|
335
367
|
return null;
|
|
336
368
|
}
|
|
337
369
|
|
|
338
370
|
/**
|
|
339
371
|
* The `needs_file` follow-up (§4.2): the requested files, read-only, appended AFTER the packet
|
|
340
|
-
* (the stable prefix stays stable).
|
|
341
|
-
*
|
|
372
|
+
* (the stable prefix stays stable). Regular tracked files, or untracked files present in BOTH
|
|
373
|
+
* concrete path lists of this block, are attached. Ignored untracked files never reach a provider,
|
|
374
|
+
* and secret-like paths are refused even
|
|
342
375
|
* when tracked; every refusal is named in the section. The attachment is trimmed to `budgetTokens`.
|
|
343
|
-
* @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number}} opts
|
|
376
|
+
* @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number, ownedFiles?: ReadonlyArray<string>, changedFiles?: ReadonlyArray<string>}} opts
|
|
377
|
+
* `ownedFiles` and `changedFiles`: resolved paths from the SAME block in this repoRoot; no globs.
|
|
344
378
|
* @returns {Promise<string>} the section to append.
|
|
345
379
|
*/
|
|
346
|
-
export async function attachFiles({ repoRoot, paths, budgetTokens }) {
|
|
380
|
+
export async function attachFiles({ repoRoot, paths, budgetTokens, ownedFiles = [], changedFiles = [] }) {
|
|
347
381
|
const out = ['## attached files (read-only, requested in needs_file)'];
|
|
348
382
|
let left = budgetTokens * 4;
|
|
349
383
|
for (const raw of paths.slice(0, 10)) {
|
|
@@ -356,7 +390,7 @@ export async function attachFiles({ repoRoot, paths, budgetTokens }) {
|
|
|
356
390
|
out.push(notFile ? `### ${String(raw)} (not a file)` : '### (refused: not a repo-relative path)');
|
|
357
391
|
continue;
|
|
358
392
|
}
|
|
359
|
-
const refusal = await attachRefusal(repoRoot, rel);
|
|
393
|
+
const refusal = await attachRefusal(repoRoot, rel, ownedFiles, changedFiles);
|
|
360
394
|
if (refusal) {
|
|
361
395
|
out.push(`### ${rel} (${refusal})`);
|
|
362
396
|
continue;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Stem pairs and bounded names for closed-book review packets (B97). No file reads. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findSecret } from '../util/gh.mjs';
|
|
4
|
+
import { redact } from '../util/redact.mjs';
|
|
5
|
+
|
|
6
|
+
/** @param {string} file */
|
|
7
|
+
function identity(file) {
|
|
8
|
+
if (typeof file !== 'string' || file.startsWith('/') || /[\\\x00-\x1f]/.test(file) || file.split('/').some((s) => s === '.' || s === '..' || s === '')) return null;
|
|
9
|
+
const ext = path.posix.extname(file);
|
|
10
|
+
let stem = path.posix.basename(file, ext);
|
|
11
|
+
let test = /(^|\/)(?:test|tests)\//.test(file);
|
|
12
|
+
if (ext === '.mjs' || ext === '.ts') {
|
|
13
|
+
if (/\.(test|spec)$/.test(stem)) { test = true; stem = stem.replace(/\.(test|spec)$/, ''); }
|
|
14
|
+
} else if (ext === '.php') {
|
|
15
|
+
if (stem.endsWith('Test')) { test = true; stem = stem.slice(0, -4); }
|
|
16
|
+
} else if (ext === '.go') {
|
|
17
|
+
if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
|
|
18
|
+
} else if (ext === '.py') {
|
|
19
|
+
if (stem.startsWith('test_')) { test = true; stem = stem.slice(5); }
|
|
20
|
+
} else if (ext === '.swift') {
|
|
21
|
+
if (stem.endsWith('Tests')) { test = true; stem = stem.slice(0, -5); }
|
|
22
|
+
} else return null;
|
|
23
|
+
return { ext, stem, test };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} file @param {ReadonlyArray<string>} candidates @returns {string[]} */
|
|
27
|
+
export function pairsFor(file, candidates) {
|
|
28
|
+
const source = identity(file);
|
|
29
|
+
if (!source) return [];
|
|
30
|
+
return [...new Set(candidates)].filter((candidate) => {
|
|
31
|
+
const other = identity(candidate);
|
|
32
|
+
return candidate !== file && other?.ext === source.ext && other.stem === source.stem && other.test !== source.test;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A best-effort list, not a parser or proof of coverage. Names only, never function bodies.
|
|
38
|
+
* Redact before truncating so a token crossing the 120-character boundary cannot leak.
|
|
39
|
+
* @param {string} text @param {string} file @returns {string[]}
|
|
40
|
+
*/
|
|
41
|
+
export function summarize(text, file) {
|
|
42
|
+
const id = identity(file);
|
|
43
|
+
if (!id) return [];
|
|
44
|
+
/** @type {Set<string>} */
|
|
45
|
+
const names = new Set();
|
|
46
|
+
for (const line of text.split(/\r?\n/)) {
|
|
47
|
+
/** @type {RegExpMatchArray | null} */
|
|
48
|
+
let match = null;
|
|
49
|
+
if (id.test) {
|
|
50
|
+
if (id.ext === '.mjs' || id.ext === '.ts' || id.ext === '.php') {
|
|
51
|
+
match = line.match(/^\s*(?:await\s+)?(?:test|it)(?:\.(?:only|skip|todo))?\(\s*(['"`])([^\r\n]+?)\1/);
|
|
52
|
+
if (match) { names.add(match[2]); match = null; }
|
|
53
|
+
else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected)\s+)?function\s+(test\w+)\s*\(/);
|
|
54
|
+
else continue;
|
|
55
|
+
} else if (id.ext === '.go') match = line.match(/^\s*func\s+(Test\w+)\s*\(/);
|
|
56
|
+
else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?def\s+(test_\w+)\s*\(/);
|
|
57
|
+
else if (id.ext === '.swift') match = line.match(/^\s*func\s+(test\w+)\s*\(/);
|
|
58
|
+
} else {
|
|
59
|
+
if (id.ext === '.mjs' || id.ext === '.ts') {
|
|
60
|
+
match = line.match(/^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|interface|type|enum)\s+(\w+)/);
|
|
61
|
+
const list = line.match(/^\s*export\s*\{([^}]+)\}/);
|
|
62
|
+
if (list) {
|
|
63
|
+
for (const item of list[1].split(',')) {
|
|
64
|
+
const symbol = item.trim().match(/^(?:type\s+)?(\w+)(?:\s+as\s+(\w+))?$/);
|
|
65
|
+
if (symbol) names.add(symbol[2] ?? symbol[1]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected|private|static|final|abstract)\s+)*(?:function|class|interface|trait)\s+(\w+)/);
|
|
69
|
+
else if (id.ext === '.go') match = line.match(/^\s*(?:func\s+(?:\([^)]*\)\s+)?|(?:type|var|const)\s+)([A-Z]\w*)/);
|
|
70
|
+
else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?(?:def|class)\s+([a-zA-Z]\w*)/);
|
|
71
|
+
else if (id.ext === '.swift') match = line.match(/^\s*(?:public|open)\s+(?:final\s+)?(?:func|class|struct|enum|protocol|let|var)\s+(\w+)/);
|
|
72
|
+
}
|
|
73
|
+
if (match) names.add(match[1]);
|
|
74
|
+
if (names.size >= 40) break;
|
|
75
|
+
}
|
|
76
|
+
return [...names].slice(0, 40).map((name) => {
|
|
77
|
+
const safe = redact(name);
|
|
78
|
+
return Array.from(findSecret(safe) ? '[REDACTED]' : safe).slice(0, 120).join('');
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/review/reopen.mjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* Human-only like `block waive`: the verb is on `CODER_ONLY_FORBIDDEN` (`src/util/forbidden.mjs`)
|
|
9
9
|
* and its row is signed with the run key, `by: human` (`block waive` has no other in-verb guard,
|
|
10
10
|
* so neither has this). The verb (`reopenFile`) refuses, writing no row, with a stable code:
|
|
11
|
-
* - `usage` (exit 2) — no `--file`, no `--reason`, or a path
|
|
12
|
-
* repo-root-relative path;
|
|
11
|
+
* - `usage` (exit 2) — no `--file`, no `--reason`, or a path with a control character or that is
|
|
12
|
+
* not a normalised repo-root-relative path (`checkFileForm`, shared with `block waive`);
|
|
13
13
|
* - `unknown_block` — the run does not know the block (the code `block waive` uses);
|
|
14
14
|
* - `reopen-block-not-open` — the block is not open (stopped or closed);
|
|
15
15
|
* - `reopen-not-owned` — the block does not own the file;
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
*
|
|
43
43
|
* This file imports nothing from `src/worker/`: the fix-loop state is read through `io`.
|
|
44
44
|
*/
|
|
45
|
-
import path from 'node:path';
|
|
46
45
|
import { StateError } from '../state/paths.mjs';
|
|
47
46
|
import { verifyRow } from '../state/signer.mjs';
|
|
47
|
+
import { checkFileForm } from './verb-forms.mjs';
|
|
48
48
|
|
|
49
49
|
export const REOPENED_EVENT = 'review.reopened';
|
|
50
50
|
|
|
@@ -177,9 +177,7 @@ export function reopenRowFor(rows, { runId, block, file, key, status, state, seq
|
|
|
177
177
|
export async function reopenFile({ runId, id, file, reason, record, rows, key, writeRow, writeSigned, io }) {
|
|
178
178
|
if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block reopen needs --file <path>');
|
|
179
179
|
if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block reopen needs --reason');
|
|
180
|
-
|
|
181
|
-
throw new StateError('usage', '--file must be repo-root-relative');
|
|
182
|
-
}
|
|
180
|
+
checkFileForm(file, { normalized: true });
|
|
183
181
|
const entry = openBlockEntry(record, runId, id);
|
|
184
182
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
|
|
185
183
|
if (!io.owns(owned, file, entry.owned_kinds)) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|