@codedology/code-forge 0.15.1 → 0.15.3
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 +62 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +154 -1
- package/docs/reference/config.md +27 -14
- package/package.json +1 -1
- package/schema/code-forge.schema.json +57 -2
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/code.md +15 -0
- package/skill/references/ledger.md +13 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +146 -30
- package/src/cli/context.mjs +17 -7
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +4 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +168 -38
- package/src/cli/run.mjs +3 -2
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/schema-compile.mjs +6 -4
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/local.mjs +22 -4
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +18 -4
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +39 -1
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/packet.mjs +34 -6
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +4 -3
- package/src/review/session-retry.mjs +59 -0
- package/src/session/facts.mjs +18 -4
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +101 -34
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +67 -14
- package/src/worker/queue.mjs +106 -23
- package/src/worker/watch.mjs +2 -2
package/src/cli/review-file.mjs
CHANGED
|
@@ -67,30 +67,85 @@
|
|
|
67
67
|
* needs its own review.
|
|
68
68
|
*/
|
|
69
69
|
|
|
70
|
-
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
70
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
71
71
|
import path from 'node:path';
|
|
72
|
-
import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
72
|
+
import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, enqueueHash, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveHashTicket, resolveTicket, reviewExclusionFor } from '../worker/queue.mjs';
|
|
73
73
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
74
|
-
import {
|
|
74
|
+
import { changedFiles, namedOnHash } from '../review/gate-check.mjs';
|
|
75
75
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
76
76
|
import { budgetFor } from '../review/packet.mjs';
|
|
77
77
|
import { ownsFile } from '../state/registry.mjs';
|
|
78
|
-
import { readAllRows } from '../ledger/write.mjs';
|
|
78
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
79
79
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
80
80
|
import { StateError } from '../state/paths.mjs';
|
|
81
81
|
import { assertRunId } from '../state/paths.mjs';
|
|
82
82
|
import { readRun } from '../state/run.mjs';
|
|
83
|
+
import { rebaseBlock } from '../state/block.mjs';
|
|
83
84
|
import { configInForce } from '../state/config-snapshot.mjs';
|
|
85
|
+
import { reviewExcludeRule } from '../config/validate.mjs';
|
|
84
86
|
import { blockTree } from '../proof/worktree.mjs';
|
|
85
87
|
import { writeSafe } from '../util/redact.mjs';
|
|
86
88
|
|
|
87
89
|
const USAGE =
|
|
88
|
-
'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
90
|
+
'usage: code-forge review-file [--cwd <dir>] <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
89
91
|
' code-forge review-file --wait-block <id> --run <id> [--max <seconds>s]\n' +
|
|
90
|
-
' code-forge review-file
|
|
92
|
+
' code-forge review-file --rebase-check --block <id> --run <id>\n' +
|
|
93
|
+
' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n' +
|
|
94
|
+
' --cwd <dir> applies to queueing and both wait modes\n';
|
|
91
95
|
|
|
92
96
|
export const DEFAULT_WAIT_MS = 90_000;
|
|
93
97
|
const POLL_MS = 200;
|
|
98
|
+
const MAX_IDLE_MS = 5000;
|
|
99
|
+
|
|
100
|
+
/** Metadata only: never open file content during an idle poll.
|
|
101
|
+
* @param {string} file @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function fileStamp(file) {
|
|
104
|
+
try {
|
|
105
|
+
const s = lstatSync(file);
|
|
106
|
+
return `${s.mtimeMs}:${s.size}:${s.mode}:${s.ino}`;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return 'missing';
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Scan literal ownership prefixes, including directory entries so new files are noticed.
|
|
114
|
+
* @param {string} tree @param {string[]} owned @param {string[]} gitPaths
|
|
115
|
+
*/
|
|
116
|
+
function waitTreeStamp(tree, owned, gitPaths) {
|
|
117
|
+
const entries = new Map();
|
|
118
|
+
/** @param {string} full */
|
|
119
|
+
function visit(full) {
|
|
120
|
+
if (entries.has(full)) return;
|
|
121
|
+
const stamp = fileStamp(full);
|
|
122
|
+
entries.set(full, stamp);
|
|
123
|
+
if (stamp !== 'missing' && lstatSync(full).isDirectory()) {
|
|
124
|
+
for (const name of readdirSync(full).sort()) {
|
|
125
|
+
if (name !== '.git' && name !== '.code-forge') visit(path.join(full, name));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const pattern of owned) {
|
|
130
|
+
const segments = pattern.split('/');
|
|
131
|
+
const glob = segments.findIndex((s) => /[*?{]/.test(s));
|
|
132
|
+
visit(path.join(tree, ...(glob < 0 ? segments : segments.slice(0, glob))));
|
|
133
|
+
}
|
|
134
|
+
for (const full of gitPaths) visit(full);
|
|
135
|
+
return JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Git may store worktree metadata outside the content tree.
|
|
139
|
+
* @param {string} tree @returns {string[]}
|
|
140
|
+
*/
|
|
141
|
+
function waitGitPaths(tree) {
|
|
142
|
+
let gitDir = path.join(tree, '.git');
|
|
143
|
+
if (lstatSync(gitDir).isFile()) gitDir = path.resolve(tree, readFileSync(gitDir, 'utf8').trim().replace(/^gitdir: /, ''));
|
|
144
|
+
let common = gitDir;
|
|
145
|
+
try { common = path.resolve(gitDir, readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim()); }
|
|
146
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
147
|
+
return [path.join(tree, '.gitignore'), ...new Set([gitDir, common])].flatMap((dir, i) => i === 0 ? [dir] : ['HEAD', 'index', 'packed-refs', 'refs', 'info/exclude'].map((f) => path.join(dir, f)));
|
|
148
|
+
}
|
|
94
149
|
|
|
95
150
|
/**
|
|
96
151
|
* `90s`, `90`, `1500ms` → milliseconds.
|
|
@@ -394,9 +449,11 @@ export function fixList(result) {
|
|
|
394
449
|
* B100: read block scope from the workspace mirror, never the private run record. This is a
|
|
395
450
|
* progress summary, not an approval: block close still verifies the authoritative signed rows.
|
|
396
451
|
* @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
|
|
397
|
-
* @
|
|
452
|
+
* @param {{scopeFile: string, stamp: string, entry: Record<string, any>, tree: string, cfg: Record<string, any> | null, project: string} | null} [cached]
|
|
453
|
+
* @returns {Promise<{scopeFile: string, stamp: string, entry: Record<string, any>, tree: string, cfg: Record<string, any> | null, project: string}>}
|
|
398
454
|
*/
|
|
399
|
-
async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
455
|
+
async function waitBlockScope(repoRoot, cwd, run, block, cached = null) {
|
|
456
|
+
if (cached && fileStamp(cached.scopeFile) === cached.stamp) return cached;
|
|
400
457
|
const top = await repoRootOf(cwd);
|
|
401
458
|
const candidates = new Set([repoRoot]);
|
|
402
459
|
for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
|
|
@@ -416,7 +473,8 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
|
416
473
|
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
417
474
|
}
|
|
418
475
|
const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
|
|
419
|
-
|
|
476
|
+
const scopeFile = path.join(dir, '.code-forge', 'runs', `${run}.json`);
|
|
477
|
+
return { entry, tree, cfg: configInForce(record), project: record.project, scopeFile, stamp: fileStamp(scopeFile) };
|
|
420
478
|
}
|
|
421
479
|
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
422
480
|
}
|
|
@@ -445,16 +503,32 @@ function blockTicketOutcome(marker) {
|
|
|
445
503
|
return 'unavailable';
|
|
446
504
|
}
|
|
447
505
|
|
|
506
|
+
/** Completion timings are progress metadata; old markers remain readable.
|
|
507
|
+
* @param {Record<string, any> | null} value @returns {Record<string, any>} */
|
|
508
|
+
function ticketTimings(value) {
|
|
509
|
+
return Object.fromEntries(['queued_at', 'started_at', 'finished_at', 'duration_ms']
|
|
510
|
+
.filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]]));
|
|
511
|
+
}
|
|
512
|
+
|
|
448
513
|
/**
|
|
449
514
|
* A block wait uses unsigned results only to classify retries; it never reads the signing key.
|
|
450
|
-
* Re-scan
|
|
515
|
+
* Re-scan metadata each poll and read content only after its stamp changes. Deliver unseen findings of the newest earlier ticket
|
|
451
516
|
* before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
|
|
452
|
-
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
|
|
517
|
+
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number) => Promise<unknown>}} opts
|
|
453
518
|
*/
|
|
454
|
-
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf }) {
|
|
455
|
-
|
|
519
|
+
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
|
|
520
|
+
let scope = await waitBlockScope(repoRoot, cwd, run, block);
|
|
521
|
+
const { tree } = scope;
|
|
522
|
+
const gitPaths = waitGitPaths(tree);
|
|
456
523
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
457
|
-
const deadline =
|
|
524
|
+
const deadline = now() + maxMs;
|
|
525
|
+
let interval = pollMs;
|
|
526
|
+
let seen = '';
|
|
527
|
+
let treeStamp = '';
|
|
528
|
+
/** @type {string[]} */
|
|
529
|
+
let current = [];
|
|
530
|
+
/** @type {Map<string, {stamp: string, content_hash: string}>} */
|
|
531
|
+
const hashes = new Map();
|
|
458
532
|
let downPolls = 0;
|
|
459
533
|
const deliveredHere = new Set();
|
|
460
534
|
for (;;) {
|
|
@@ -462,25 +536,46 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
462
536
|
const live = worker?.run === run;
|
|
463
537
|
downPolls = live ? 0 : downPolls + 1;
|
|
464
538
|
const tickets = blockTickets({ repoRoot, run, block, ...where });
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
539
|
+
scope = await waitBlockScope(repoRoot, cwd, run, block, scope);
|
|
540
|
+
const { entry, cfg, project } = scope;
|
|
541
|
+
const stamp = waitTreeStamp(tree, entry.owned_files, gitPaths) + scope.stamp;
|
|
542
|
+
if (stamp !== treeStamp) {
|
|
543
|
+
current = (await changedFiles({ repoRoot: tree, base: entry.base_sha })).filter((file) => ownsFile(entry.owned_files, file, entry.owned_kinds));
|
|
544
|
+
treeStamp = stamp;
|
|
545
|
+
}
|
|
546
|
+
// Progress only, like answersPrecheck: sandboxed callers may not read the private ledger.
|
|
547
|
+
// Reopens issued here also renew the workspace ticket, which needs no private read to join.
|
|
548
|
+
let rows = [];
|
|
549
|
+
try { rows = await readAllRows(project); } catch { /* The worker verifies the signed row. */ }
|
|
550
|
+
const reopens = new Map();
|
|
551
|
+
for (const row of rows) {
|
|
552
|
+
if (row.event !== 'review.reopened' || row.run !== run || row.block !== block || !Number.isFinite(Date.parse(row.ts))) continue;
|
|
553
|
+
if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree')) continue;
|
|
554
|
+
if (!reopens.has(row.file) || row.ts > reopens.get(row.file)) reopens.set(row.file, row.ts);
|
|
555
|
+
}
|
|
556
|
+
const files = current.map((file) => {
|
|
557
|
+
const rule = reviewExcludeRule(cfg, file);
|
|
558
|
+
if (rule !== null) return { file, ticket: null, status: 'excluded', outcome: null, rule };
|
|
559
|
+
const after = reopens.get(file);
|
|
560
|
+
const stamp = fileStamp(path.join(tree, file));
|
|
561
|
+
if (hashes.get(file)?.stamp !== stamp) hashes.set(file, { stamp, content_hash: contentHash(tree, file) });
|
|
562
|
+
const opts = { repoRoot, run, block, file, ...where, after, content_hash: hashes.get(file).content_hash };
|
|
563
|
+
const { ticket } = resolveHashTicket(opts);
|
|
564
|
+
const previous = tickets.filter((t) => t.file === file && (after === undefined || Date.parse(t.enqueued_at) > Date.parse(after)));
|
|
470
565
|
let present = previous.find((t) => t.ticket === ticket);
|
|
471
566
|
if (!present && previous.length > 0) {
|
|
472
567
|
const newest = previous[previous.length - 1];
|
|
473
568
|
const marker = blockTicketMarker(repoRoot, newest.ticket);
|
|
474
|
-
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker) };
|
|
569
|
+
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
|
|
475
570
|
if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
|
|
476
571
|
if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
|
|
477
572
|
deliveredHere.add(newest.ticket);
|
|
478
|
-
return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker) };
|
|
573
|
+
return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
|
|
479
574
|
}
|
|
480
575
|
}
|
|
481
576
|
}
|
|
482
577
|
if (!present && live) {
|
|
483
|
-
|
|
578
|
+
enqueueHash(opts);
|
|
484
579
|
present = { ...previous[previous.length - 1], ticket };
|
|
485
580
|
}
|
|
486
581
|
let marker = present ? blockTicketMarker(repoRoot, ticket) : null;
|
|
@@ -488,7 +583,7 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
488
583
|
dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
|
|
489
584
|
marker = blockTicketMarker(repoRoot, ticket);
|
|
490
585
|
}
|
|
491
|
-
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker) };
|
|
586
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
|
|
492
587
|
});
|
|
493
588
|
if (downPolls >= 2) {
|
|
494
589
|
say({ block, files, done: false, status: 'worker_down' });
|
|
@@ -499,38 +594,65 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
499
594
|
say({ block, files, done: false, reason: 'split_required', next: `The owner raises review.budgets for ${split.file}, runs run reload, then review-file ${split.file} --fresh.` });
|
|
500
595
|
return 0;
|
|
501
596
|
}
|
|
502
|
-
if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
|
|
597
|
+
if (live && files.every((f) => f.status === 'done' || f.status === 'findings' || f.status === 'excluded')) {
|
|
503
598
|
say({ block, files, done: true });
|
|
504
599
|
return 0;
|
|
505
600
|
}
|
|
506
|
-
if (live && (files.some((f) => f.status === 'not_queued') ||
|
|
601
|
+
if (live && (files.some((f) => f.status === 'not_queued') || now() >= deadline)) {
|
|
507
602
|
say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
|
|
508
603
|
return 0;
|
|
509
604
|
}
|
|
510
|
-
|
|
605
|
+
const ticketIds = [...new Set([...tickets.map((t) => t.ticket), ...files.map((f) => f.ticket).filter(Boolean)])].sort();
|
|
606
|
+
const state = JSON.stringify([stamp, live, ticketIds, files]);
|
|
607
|
+
interval = seen === state ? Math.min(MAX_IDLE_MS, interval * 2) : pollMs;
|
|
608
|
+
seen = state;
|
|
609
|
+
await sleep(Math.min(interval, Math.max(0, deadline - now())));
|
|
511
610
|
}
|
|
512
611
|
}
|
|
513
612
|
|
|
514
613
|
/**
|
|
515
614
|
* @param {string[]} args
|
|
516
|
-
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
|
|
615
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number) => Promise<unknown>}} [deps] - `dropDone`: B75's claim, for tests.
|
|
517
616
|
* @returns {Promise<number>}
|
|
518
617
|
*/
|
|
519
618
|
export async function runReviewFile(args, deps = {}) {
|
|
520
619
|
const stdout = deps.stdout ?? process.stdout;
|
|
521
620
|
const stderr = deps.stderr ?? process.stderr;
|
|
522
621
|
const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
|
|
523
|
-
const cwd = deps.cwd ?? process.cwd();
|
|
524
622
|
try {
|
|
525
|
-
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
623
|
+
const { flags, positionals } = parseFlags(args, { values: ['cwd', 'block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh', 'rebase-check'] });
|
|
624
|
+
let cwd = deps.cwd ?? process.cwd();
|
|
625
|
+
if (typeof flags.cwd === 'string') {
|
|
626
|
+
cwd = path.resolve(cwd, flags.cwd);
|
|
627
|
+
try {
|
|
628
|
+
if (!statSync(cwd).isDirectory()) throw new Error('not a directory');
|
|
629
|
+
} catch {
|
|
630
|
+
throw new WorkerError('usage', '--cwd must name an existing directory');
|
|
631
|
+
}
|
|
632
|
+
}
|
|
526
633
|
// the queue and the worker: the main tree, also from inside a block's worktree
|
|
527
634
|
const repoRoot = await mainRootOf(cwd);
|
|
528
635
|
|
|
636
|
+
if (flags['rebase-check'] === true) {
|
|
637
|
+
if (positionals.length || typeof flags.block !== 'string' || typeof flags.run !== 'string' || ['wait', 'wait-block', 'max', 'answers', 'of', 'fresh'].some((key) => flags[key] !== undefined)) {
|
|
638
|
+
throw new WorkerError('usage', '--rebase-check takes --block and --run only');
|
|
639
|
+
}
|
|
640
|
+
const block = assertBlockId(flags.block);
|
|
641
|
+
const runId = assertRunId(flags.run);
|
|
642
|
+
const worker = liveWorker(repoRoot);
|
|
643
|
+
if (!worker || worker.run !== runId) { say({ status: 'worker_down', block }); return 3; }
|
|
644
|
+
const record = await readRun(runId);
|
|
645
|
+
if (await mainRootOf(record.workspace) !== repoRoot) throw new WorkerError('other-workspace', 'the run belongs to another workspace');
|
|
646
|
+
const result = await rebaseBlock({ runId, id: block, rebaseCheck: true, writeRow: (row) => appendRow(row, { slug: record.project }) });
|
|
647
|
+
say({ block, status: 'queued', ...result, next: `code-forge block rebase ${block} --run ${runId}` });
|
|
648
|
+
return 0;
|
|
649
|
+
}
|
|
650
|
+
|
|
529
651
|
if (flags['wait-block'] !== undefined) {
|
|
530
652
|
if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
|
|
531
653
|
throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
|
|
532
654
|
}
|
|
533
|
-
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone });
|
|
655
|
+
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone, now: deps.now, sleep: deps.sleep });
|
|
534
656
|
}
|
|
535
657
|
|
|
536
658
|
if (typeof flags.wait === 'string') {
|
|
@@ -542,7 +664,7 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
542
664
|
for (;;) {
|
|
543
665
|
if (isDone(repoRoot, ticket)) {
|
|
544
666
|
const result = doneResult(repoRoot, ticket);
|
|
545
|
-
say({ ticket, status: 'done', result, ...fixList(result) });
|
|
667
|
+
say({ ticket, status: 'done', result, ...ticketTimings(result), ...fixList(result) });
|
|
546
668
|
return 0;
|
|
547
669
|
}
|
|
548
670
|
downPolls = liveWorker(repoRoot) ? 0 : downPolls + 1;
|
|
@@ -561,9 +683,23 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
561
683
|
if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
|
|
562
684
|
const block = assertBlockId(flags.block);
|
|
563
685
|
if (flags.fresh === true && (flags.answers !== undefined || flags.of !== undefined)) throw new WorkerError('usage', '--fresh takes no --answers or --of');
|
|
564
|
-
if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
|
|
565
686
|
const file = normalizeRequestPath(positionals[0], cwd, await repoRootOf(cwd));
|
|
566
687
|
const worker = liveWorker(repoRoot);
|
|
688
|
+
let cfg = null;
|
|
689
|
+
const requestedRun = typeof flags.run === 'string' ? flags.run : worker?.run;
|
|
690
|
+
if (requestedRun) {
|
|
691
|
+
try { cfg = configInForce(await readRun(requestedRun)); }
|
|
692
|
+
catch { /* The workspace mirror below also serves sandboxed callers. */ }
|
|
693
|
+
}
|
|
694
|
+
const candidates = [file, ...(typeof flags.of === 'string' ? [normalizeRequestPath(flags.of, cwd, await repoRootOf(cwd))] : [])];
|
|
695
|
+
for (const candidate of candidates) {
|
|
696
|
+
const rule = requestedRun ? reviewExclusionFor(repoRoot, requestedRun, candidate, cfg) : reviewExcludeRule(cfg, candidate);
|
|
697
|
+
if (rule !== null) {
|
|
698
|
+
writeSafe(stdout, `not reviewed (review.exclude ${rule}): ${candidate}\n`);
|
|
699
|
+
return 0;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
|
|
567
703
|
if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
|
|
568
704
|
say({ status: 'worker_down', file });
|
|
569
705
|
return 3;
|
|
@@ -574,12 +710,6 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
574
710
|
const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
|
|
575
711
|
assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
|
|
576
712
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
577
|
-
let cfg = null;
|
|
578
|
-
try {
|
|
579
|
-
cfg = configInForce(await readRun(worker.run));
|
|
580
|
-
} catch {
|
|
581
|
-
// No readable snapshot: retain the existing cache and --fresh rules.
|
|
582
|
-
}
|
|
583
713
|
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
584
714
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
585
715
|
const cached = queued.status === 'done' ? doneResult(repoRoot, queued.ticket) : null;
|
package/src/cli/run.mjs
CHANGED
|
@@ -40,7 +40,8 @@ import { configInForce } from '../state/config-snapshot.mjs';
|
|
|
40
40
|
import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
|
|
41
41
|
import { writeSafe } from '../util/redact.mjs';
|
|
42
42
|
import { withLock } from '../util/file-locks.mjs';
|
|
43
|
-
import {
|
|
43
|
+
import { sweepProcesses } from '../util/reaper.mjs';
|
|
44
|
+
import { tmpBase } from '../util/tmp.mjs';
|
|
44
45
|
import { launchWorker, repinWorker } from '../worker/loop.mjs';
|
|
45
46
|
import { liveWorker, workerRegistry } from '../worker/queue.mjs';
|
|
46
47
|
import { repoRootOf } from '../worker/ticket.mjs';
|
|
@@ -173,7 +174,7 @@ export async function runRun(args, deps = {}) {
|
|
|
173
174
|
const project = slugFor(loaded.config, workspace);
|
|
174
175
|
const engine = typeof flags.engine === 'string' ? flags.engine : undefined;
|
|
175
176
|
const tmpRoot = typeof loaded.config?.tmp?.root === 'string' ? loaded.config.tmp.root : undefined;
|
|
176
|
-
await
|
|
177
|
+
await sweepProcesses({ apply: true, root: tmpBase(tmpRoot), zombies: false });
|
|
177
178
|
let record = await startRun({ workspace, project, config: loaded.config, engine, mode, runId, workerPid, writeRow: ledgerWriter(project), probe });
|
|
178
179
|
if (!record.worker && startWorker) {
|
|
179
180
|
const launched = await startWorker({ runId: record.run_id, workspace });
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseFlags } from '../state/cli-args.mjs';
|
|
2
|
+
import { sweepProcesses } from '../util/reaper.mjs';
|
|
3
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
4
|
+
|
|
5
|
+
const HELP = 'usage: code-forge sweep [--apply] [--json]\nDry run by default: list stale workers, dead-run registry children and marked orphans with RSS and total MB.\n--apply stops proven stale processes with SIGTERM, then SIGKILL after a grace period.\nZombies are reported by parent only; neither they nor their parents are signalled. Live engines have no memory limit.\n';
|
|
6
|
+
|
|
7
|
+
/** @param {string[]} args @param {{sweep?:typeof sweepProcesses}} [deps] */
|
|
8
|
+
export default async function runSweep(args, deps = {}) {
|
|
9
|
+
let flags;
|
|
10
|
+
try {
|
|
11
|
+
const parsed = parseFlags(args, { booleans: ['apply', 'json', 'help'] });
|
|
12
|
+
if (parsed.positionals.length) throw new Error('unexpected positional argument');
|
|
13
|
+
flags = parsed.flags;
|
|
14
|
+
} catch {
|
|
15
|
+
writeSafe(process.stderr, HELP);
|
|
16
|
+
return 2;
|
|
17
|
+
}
|
|
18
|
+
if (flags.help) { writeSafe(process.stdout, HELP); return 0; }
|
|
19
|
+
const report = await (deps.sweep ?? sweepProcesses)({ apply: flags.apply === true });
|
|
20
|
+
if (flags.json) writeSafe(process.stdout, `${JSON.stringify(report)}\n`);
|
|
21
|
+
else {
|
|
22
|
+
for (const item of report.items) writeSafe(process.stdout,
|
|
23
|
+
`${report.apply ? item.action ?? 'stopped' : 'would stop'} ${item.kind} pid ${item.pid} ${item.rssMb.toFixed(1)} MB ${item.command}\n`);
|
|
24
|
+
for (const group of report.zombies) writeSafe(process.stdout, `${group.count} zombies (parent ${group.ppid} ${group.command}) — report only\n`);
|
|
25
|
+
writeSafe(process.stdout, `${report.apply ? 'sweep' : 'dry run'}: ${report.items.length} processes, total ${report.totalRssMb.toFixed(1)} MB; ${report.registryRoots.length} dead-run roots\n`);
|
|
26
|
+
}
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
package/src/cli/validate.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { loadConfigFile, DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
|
|
|
11
11
|
import { loadSeenInCache } from '../config/refresh.mjs';
|
|
12
12
|
import { maskSecretTokens } from '../config/secret-patterns.mjs';
|
|
13
13
|
import { validateConfig } from '../config/validate.mjs';
|
|
14
|
+
import { loadPipeline } from '../pipeline/pipeline.mjs';
|
|
14
15
|
import * as log from '../util/log.mjs';
|
|
15
16
|
|
|
16
17
|
/** Provider -> the CLI binary name `engine: subprocess` would spawn for it. */
|
|
@@ -100,6 +101,11 @@ export default async function validate(args) {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
if (result.valid) {
|
|
104
|
+
const pipeline = loadPipeline(path.dirname(parsed.file));
|
|
105
|
+
if (pipeline.exists && !pipeline.ok) {
|
|
106
|
+
for (const problem of pipeline.problems) log.error(maskSecretTokens(problem));
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
103
109
|
log.info(`${parsed.file}: valid (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})`);
|
|
104
110
|
return 0;
|
|
105
111
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* OpenAI's structured-output "strict" mode has three rules a hand-written schema violates by
|
|
6
6
|
* default: (1) every property under an object must be listed in `required` (optional fields are
|
|
7
|
-
* instead modeled as **nullable** required fields), (2) every object must set
|
|
7
|
+
* instead modeled as **nullable** required fields; source-required keys stay as they are), (2) every object must set
|
|
8
8
|
* `additionalProperties: false`, (3) nothing may be missing from `required`. Claude and Grok take
|
|
9
9
|
* the source schema unchanged (they support ordinary JSON Schema `required`/optional).
|
|
10
10
|
*
|
|
@@ -149,8 +149,9 @@ function childSchemas(node) {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* Recursively rewrites every object node: a `properties` map makes every property `required
|
|
153
|
-
* nullable
|
|
152
|
+
* Recursively rewrites every object node: a `properties` map makes every property `required`;
|
|
153
|
+
* optional keys become nullable; required keys stay as they are. It closes the node (unless it
|
|
154
|
+
* already has an open-ended `additionalProperties`); a
|
|
154
155
|
* bare `{type: 'object'}` is closed to an empty strict object. `visited` guards against revisiting
|
|
155
156
|
* the same shared node twice — not against a genuine schema cycle, which this project's schema
|
|
156
157
|
* does not have.
|
|
@@ -165,12 +166,13 @@ function compileForOpenAI(node, visited) {
|
|
|
165
166
|
|
|
166
167
|
if (node.properties && typeof node.properties === 'object') {
|
|
167
168
|
const keys = Object.keys(node.properties);
|
|
169
|
+
const wasRequired = new Set(Array.isArray(node.required) ? node.required : []);
|
|
168
170
|
node.required = keys;
|
|
169
171
|
if (node.additionalProperties === undefined) {
|
|
170
172
|
node.additionalProperties = false;
|
|
171
173
|
}
|
|
172
174
|
for (const key of keys) {
|
|
173
|
-
makeNullable(node.properties[key]);
|
|
175
|
+
if (!wasRequired.has(key)) makeNullable(node.properties[key]);
|
|
174
176
|
}
|
|
175
177
|
} else if (isObjectTyped(node) && node.additionalProperties === undefined && node.patternProperties === undefined) {
|
|
176
178
|
node.properties = {};
|
package/src/config/validate.mjs
CHANGED
|
@@ -66,7 +66,9 @@
|
|
|
66
66
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
67
67
|
import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
|
|
68
68
|
import { redact } from '../util/redact.mjs';
|
|
69
|
+
import { packageVersion } from '../util/error-log.mjs';
|
|
69
70
|
import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
71
|
+
import { BLOCK_KINDS } from '../decide/escalation.mjs';
|
|
70
72
|
import { checkEffort, displayEffort } from '../engines/efforts.mjs';
|
|
71
73
|
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
|
|
72
74
|
import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
@@ -75,6 +77,7 @@ import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
|
75
77
|
import { ROUTE_LEVELS, ruleProvider } from '../review/route.mjs';
|
|
76
78
|
import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES, sandboxDirRefusals, SANDBOX_DIR_REFUSED } from '../engines/sandbox-dirs.mjs';
|
|
77
79
|
import { tmpBase } from '../util/tmp.mjs';
|
|
80
|
+
import { expandBraces, globMatch } from '../state/registry.mjs';
|
|
78
81
|
import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
|
|
79
82
|
|
|
80
83
|
/**
|
|
@@ -211,7 +214,7 @@ function runSchemaValidation(cfg) {
|
|
|
211
214
|
if (err.keyword === 'propertyNames' && typeof err.params?.propertyName === 'string') {
|
|
212
215
|
detail = `property name "${safeSegment(err.params.propertyName)}" is not allowed (propertyNames)`;
|
|
213
216
|
} else if (err.keyword === 'additionalProperties' && typeof err.params?.additionalProperty === 'string') {
|
|
214
|
-
detail = `must NOT have additional property "${safeSegment(err.params.additionalProperty)}"`;
|
|
217
|
+
detail = `must NOT have additional property "${safeSegment(err.params.additionalProperty)}" (unknown key; if it comes from a newer code-forge, upgrade: this is code-forge ${packageVersion()})`;
|
|
215
218
|
}
|
|
216
219
|
return {
|
|
217
220
|
rule: 'schema',
|
|
@@ -512,12 +515,76 @@ function gateMessages(key, value) {
|
|
|
512
515
|
return messages;
|
|
513
516
|
}
|
|
514
517
|
|
|
518
|
+
/** Custom gate refusals are also checked before running commands or reading evidence.
|
|
519
|
+
* @param {unknown} custom @returns {string[]}
|
|
520
|
+
*/
|
|
521
|
+
export function customGateMessages(custom) {
|
|
522
|
+
if (custom === undefined) return [];
|
|
523
|
+
if (!Array.isArray(custom)) return ['gates.custom must be an array of at most 20 named object gates'];
|
|
524
|
+
const messages = custom.length > 20 ? ['gates.custom must contain at most 20 entries'] : [];
|
|
525
|
+
const names = new Set();
|
|
526
|
+
for (const [i, value] of custom.entries()) {
|
|
527
|
+
const key = `custom[${i}]`;
|
|
528
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
529
|
+
messages.push(`gates.${key} must be a named object gate`);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const { name, evidence, ...command } = value;
|
|
533
|
+
const label = `${key}${typeof name === 'string' ? ` (${safeSegment(name)})` : ''}`;
|
|
534
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(name)) messages.push(`gates.${label}.name must match ^[a-z][a-z0-9_-]{0,31}$`);
|
|
535
|
+
if (GATE_KEYS.includes(name)) messages.push(`gates.${label}.name must not be a fixed gate name`);
|
|
536
|
+
if (names.has(name)) messages.push(`gates.${label}.name must be unique`);
|
|
537
|
+
names.add(name);
|
|
538
|
+
messages.push(...gateMessages(label, command));
|
|
539
|
+
if (Array.isArray(command.argv) && command.argv.some((arg) => typeof arg === 'string' && [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].some((token) => arg.includes(token)))) {
|
|
540
|
+
messages.push(`gates.${label}.argv: {test_files} and {changed_files} are refused in custom gates`);
|
|
541
|
+
}
|
|
542
|
+
if (evidence !== undefined) {
|
|
543
|
+
if (!Array.isArray(evidence)) messages.push(`gates.${label}.evidence must be an array of safe relative paths or simple globs`);
|
|
544
|
+
else for (const [j, pattern] of evidence.entries()) {
|
|
545
|
+
// Backslashes are refused as well so a Windows absolute/traversal path is never
|
|
546
|
+
// interpreted differently by validation and the POSIX evidence matcher.
|
|
547
|
+
if (typeof pattern !== 'string' || pattern.length === 0 || pattern.startsWith('/') || /[\\\0]/.test(pattern) || pattern.split('/').some((segment) => segment === '..' || /^[A-Za-z]:/.test(segment))) {
|
|
548
|
+
messages.push(`gates.${label}.evidence[${j}] must be a non-empty relative path or glob without absolute paths or a .. segment`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return messages;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Gate names in a kind set must resolve to an available configured or detected gate.
|
|
557
|
+
* @param {Record<string, any> | undefined} gates @returns {string[]}
|
|
558
|
+
*/
|
|
559
|
+
export function kindGateMessages(gates) {
|
|
560
|
+
const custom = new Set(Array.isArray(gates?.custom) ? gates.custom.map((gate) => gate?.name) : []);
|
|
561
|
+
const messages = [];
|
|
562
|
+
for (const kind of BLOCK_KINDS) {
|
|
563
|
+
const names = gates?.kinds?.[kind];
|
|
564
|
+
if (!Array.isArray(names)) continue; // The schema handles invalid shapes.
|
|
565
|
+
for (const name of names) {
|
|
566
|
+
if (typeof name !== 'string') continue;
|
|
567
|
+
const label = `gates.kinds.${kind}: ${safeSegment(name)}`;
|
|
568
|
+
const fixed = GATE_KEYS.some((key) => key === name);
|
|
569
|
+
if (!fixed && !custom.has(name)) messages.push(`${label} is an unknown gate name`);
|
|
570
|
+
else if (fixed && gates[name] === null) messages.push(`${label} is a null fixed gate`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return messages;
|
|
574
|
+
}
|
|
575
|
+
|
|
515
576
|
function checkGatesNotArgvArray(cfg) {
|
|
516
577
|
const messages = GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]));
|
|
578
|
+
messages.push(...customGateMessages(cfg?.gates?.custom));
|
|
579
|
+
messages.push(...kindGateMessages(cfg?.gates));
|
|
517
580
|
if (Object.hasOwn(cfg?.gates ?? {}, 'repeat_new_tests')) {
|
|
518
581
|
const n = cfg.gates.repeat_new_tests;
|
|
519
582
|
if (!Number.isInteger(n) || n < 2 || n > 10) messages.push('gates.repeat_new_tests must be an integer 2..10');
|
|
520
583
|
}
|
|
584
|
+
if (Object.hasOwn(cfg?.gates ?? {}, 'timeout_s')) {
|
|
585
|
+
const n = cfg.gates.timeout_s;
|
|
586
|
+
if (!Number.isInteger(n) || n < 30 || n > 7200) messages.push('gates.timeout_s must be an integer 30..7200');
|
|
587
|
+
}
|
|
521
588
|
return messages.map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message }));
|
|
522
589
|
}
|
|
523
590
|
|
|
@@ -1181,3 +1248,18 @@ export function reviewFileLoop(cfg) {
|
|
|
1181
1248
|
export function reviewWorkers(cfg) {
|
|
1182
1249
|
return cfg?.review?.workers ?? 1;
|
|
1183
1250
|
}
|
|
1251
|
+
|
|
1252
|
+
/** Common generated lock files, matched at the root and at any depth. */
|
|
1253
|
+
export const DEFAULT_REVIEW_EXCLUDE = Object.freeze([
|
|
1254
|
+
'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb',
|
|
1255
|
+
'composer.lock', 'Cargo.lock', 'Gemfile.lock', 'poetry.lock', 'uv.lock', 'go.sum',
|
|
1256
|
+
'Package.resolved', 'Podfile.lock', 'pubspec.lock', 'mix.lock', 'flake.lock',
|
|
1257
|
+
].map((name) => `**/${name}`));
|
|
1258
|
+
|
|
1259
|
+
/** The first matching repo-relative rule, or null. A supplied list replaces the defaults.
|
|
1260
|
+
* @param {Record<string, any> | null} cfg @param {string} file @returns {string | null}
|
|
1261
|
+
*/
|
|
1262
|
+
export function reviewExcludeRule(cfg, file) {
|
|
1263
|
+
const rules = cfg?.review?.exclude ?? DEFAULT_REVIEW_EXCLUDE;
|
|
1264
|
+
return rules.find((rule) => expandBraces(rule).some((pattern) => globMatch(file, pattern))) ?? null;
|
|
1265
|
+
}
|