@rigour-labs/core 6.6.4 → 6.6.6

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.
Files changed (70) hide show
  1. package/dist/gates/frontend-secret-exposure.d.ts +1 -1
  2. package/dist/gates/frontend-secret-exposure.js +9 -3
  3. package/dist/gates/frontend-secret-exposure.test.js +4 -1
  4. package/dist/hooks/index.d.ts +1 -1
  5. package/dist/hooks/index.js +1 -1
  6. package/dist/hooks/stop-review.d.ts +6 -9
  7. package/dist/hooks/stop-review.js +5 -10
  8. package/dist/index.d.ts +5 -2
  9. package/dist/index.js +5 -2
  10. package/dist/review/backtest-init.d.ts +8 -0
  11. package/dist/review/backtest-init.js +87 -0
  12. package/dist/review/backtest-init.test.d.ts +1 -0
  13. package/dist/review/backtest-init.test.js +74 -0
  14. package/dist/review/backtest.d.ts +315 -0
  15. package/dist/review/backtest.js +197 -0
  16. package/dist/review/backtest.test.d.ts +1 -0
  17. package/dist/review/backtest.test.js +129 -0
  18. package/dist/review/code-patterns.test.js +58 -0
  19. package/dist/review/dead-code.test.js +8 -0
  20. package/dist/review/duplicate-functions.js +7 -2
  21. package/dist/review/lint-overlay.d.ts +21 -0
  22. package/dist/review/lint-overlay.js +89 -0
  23. package/dist/review/loop-copies.d.ts +2 -0
  24. package/dist/review/loop-copies.js +116 -0
  25. package/dist/review/migration-order.js +5 -1
  26. package/dist/review/orphan-files.js +5 -1
  27. package/dist/review/partial-fixes.d.ts +2 -0
  28. package/dist/review/partial-fixes.js +138 -0
  29. package/dist/review/partial-wiring.d.ts +2 -0
  30. package/dist/review/partial-wiring.js +201 -0
  31. package/dist/review/quiet.d.ts +6 -0
  32. package/dist/review/quiet.js +12 -2
  33. package/dist/review/quiet.test.js +11 -0
  34. package/dist/review/review.d.ts +6 -0
  35. package/dist/review/review.js +12 -2
  36. package/dist/review/reviewer/adapters.d.ts +35 -0
  37. package/dist/review/reviewer/adapters.js +156 -0
  38. package/dist/review/reviewer/background.d.ts +34 -0
  39. package/dist/review/reviewer/background.js +128 -0
  40. package/dist/review/reviewer/background.test.d.ts +1 -0
  41. package/dist/review/reviewer/background.test.js +104 -0
  42. package/dist/review/reviewer/exec.d.ts +17 -0
  43. package/dist/review/reviewer/exec.js +23 -0
  44. package/dist/review/reviewer/inputs.d.ts +37 -0
  45. package/dist/review/reviewer/inputs.js +103 -0
  46. package/dist/review/reviewer/merge-impact.d.ts +2 -0
  47. package/dist/review/reviewer/merge-impact.js +64 -0
  48. package/dist/review/reviewer/prompt.d.ts +21 -0
  49. package/dist/review/reviewer/prompt.js +123 -0
  50. package/dist/review/reviewer/store.d.ts +26 -0
  51. package/dist/review/reviewer/store.js +57 -0
  52. package/dist/review/reviewer/verdict.d.ts +127 -0
  53. package/dist/review/reviewer/verdict.js +156 -0
  54. package/dist/review/reviewer.d.ts +41 -49
  55. package/dist/review/reviewer.js +210 -194
  56. package/dist/review/reviewer.test.js +199 -48
  57. package/dist/review/toolchain.d.ts +5 -2
  58. package/dist/review/toolchain.js +81 -6
  59. package/dist/review/toolchain.test.js +39 -1
  60. package/dist/review/typed/program.d.ts +27 -0
  61. package/dist/review/typed/program.js +90 -0
  62. package/dist/review/typed/redundancy.d.ts +10 -0
  63. package/dist/review/typed/redundancy.js +339 -0
  64. package/dist/review/typed/redundancy.test.d.ts +1 -0
  65. package/dist/review/typed/redundancy.test.js +121 -0
  66. package/dist/review/unused-exports.js +8 -4
  67. package/dist/templates/universal-config.js +11 -1
  68. package/dist/types/index.d.ts +128 -8
  69. package/dist/types/index.js +26 -2
  70. package/package.json +6 -6
@@ -54,5 +54,5 @@ export declare class FrontendSecretExposureGate extends Gate {
54
54
  private isDummyValue;
55
55
  private toFailures;
56
56
  }
57
- /** Imports a Node-only module: server code, whatever its path. */
57
+ /** Imports a Node-only module (a built-in, or a package that spawns processes): server code, whatever its path. */
58
58
  export declare function importsNodeBuiltins(content: string): boolean;
@@ -66,8 +66,14 @@ export class FrontendSecretExposureGate extends Gate {
66
66
  // Any tool config at the repository root runs in Node, never in a browser bundle.
67
67
  '^[^/]+\\.config\\.[cm]?[jt]s$',
68
68
  '\\.server\\.(?:ts|tsx|js|jsx|mjs|cjs)$',
69
- // SvelteKit endpoints (`+server.ts`) are server-only.
69
+ // SvelteKit endpoints (`+server.ts`) and `src/lib/server/` are server-only.
70
70
  '(^|/)\\+server\\.[cm]?[jt]s$',
71
+ '(^|/)src/lib/server/',
72
+ // Serverless functions and workers run on the platform, never in a browser bundle.
73
+ '(^|/)netlify/(?:functions|edge-functions)/',
74
+ '(^|/)supabase/functions/',
75
+ '^api/',
76
+ '(^|/)(?:functions|lambdas?|workers?|cron|jobs)/',
71
77
  ...(config.server_path_patterns ?? []),
72
78
  ],
73
79
  allowlist_env_names: config.allowlist_env_names ?? [],
@@ -254,8 +260,8 @@ export class FrontendSecretExposureGate extends Gate {
254
260
  return failures;
255
261
  }
256
262
  }
257
- const NODE_BUILTINS = /(?:from\s+|require\(\s*|import\(\s*)['"](?:node:[\w/]+|fs|fs\/promises|fs-extra|child_process|os|net|tls|worker_threads|cluster|module)['"]/;
258
- /** Imports a Node-only module: server code, whatever its path. */
263
+ const NODE_BUILTINS = /(?:from\s+|require\(\s*|import\(\s*)['"](?:node:[\w/]+|fs|fs\/promises|fs-extra|child_process|os|net|tls|worker_threads|cluster|module|execa|cross-spawn)['"]/;
264
+ /** Imports a Node-only module (a built-in, or a package that spawns processes): server code, whatever its path. */
259
265
  export function importsNodeBuiltins(content) {
260
266
  return NODE_BUILTINS.test(content);
261
267
  }
@@ -40,15 +40,18 @@ describe('FrontendSecretExposureGate', () => {
40
40
  });
41
41
  it('treats a file outside frontend paths that imports Node built-ins as server code', async () => {
42
42
  const cli = path.join(testDir, 'packages/cli/src/commands/post.ts');
43
+ const spawner = path.join(testDir, 'packages/core/src/exec.ts');
43
44
  const shared = path.join(testDir, 'packages/shared/src/config.ts');
44
45
  const component = path.join(testDir, 'src/components/Admin.tsx');
45
- for (const file of [cli, shared, component])
46
+ for (const file of [cli, spawner, shared, component])
46
47
  fs.mkdirSync(path.dirname(file), { recursive: true });
47
48
  fs.writeFileSync(cli, "import { execFileSync } from 'child_process';\nexport const token = process.env.GITHUB_TOKEN;\n");
49
+ fs.writeFileSync(spawner, "import { execa } from 'execa';\nexport const env = { GH_TOKEN: process.env.GH_TOKEN };\n");
48
50
  fs.writeFileSync(shared, 'export const token = process.env.GITHUB_TOKEN;\n');
49
51
  fs.writeFileSync(component, "import fs from 'node:fs';\nexport const key = process.env.STRIPE_SECRET_KEY;\n");
50
52
  const files = (await new FrontendSecretExposureGate().run({ cwd: testDir })).flatMap(f => f.files ?? []);
51
53
  expect(files).not.toContain('packages/cli/src/commands/post.ts'); // Node-only: never bundled
54
+ expect(files).not.toContain('packages/core/src/exec.ts'); // a package that spawns processes is Node-only too
52
55
  expect(files).toContain('packages/shared/src/config.ts'); // could be bundled: still flagged
53
56
  expect(files).toContain('src/components/Admin.tsx'); // a frontend path stays frontend
54
57
  });
@@ -14,6 +14,6 @@ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearned
14
14
  export type { DLPFeedbackEntry, DLPFeedbackStore, DLPBlockManifest } from './dlp-feedback.js';
15
15
  export { generateDLPHookFiles } from './dlp-templates.js';
16
16
  export type { GeneratedDLPHookFile } from './dlp-templates.js';
17
- export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
17
+ export { stopReview, stopMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
18
18
  export type { StopDecision } from './stop-review.js';
19
19
  export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed } from './session-state.js';
@@ -10,5 +10,5 @@ export { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry } from './
10
10
  export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearnedAllowCount, loadDLPFeedbackStore, loadDLPFeedbackStoreSync, fingerprintDetection, } from './dlp-feedback.js';
11
11
  export { generateDLPHookFiles } from './dlp-templates.js';
12
12
  // Stop review ("before you say done")
13
- export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
13
+ export { stopReview, stopMessage, STOP_MAX_ATTEMPTS } from './stop-review.js';
14
14
  export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts, workFingerprint, alreadyReviewed, recordReviewed } from './session-state.js';
@@ -6,13 +6,12 @@
6
6
  * letting it finish. A review with nothing to look at says so; it is never
7
7
  * reported as a clean pass.
8
8
  *
9
- * Only findings that deserve it block: critical ones, high ones that are proven
10
- * (the semantic engine traced them) or security findings, findings the change added that are
11
- * certain from the code alone (MUST_FIX: dead code, offset paging, an unbounded window, a
12
- * duplicate function), and on a branch a merge conflict with main or a mention of a file the
13
- * branch deleted (review/branch-checks.ts).
14
- * A high heuristic (a regex that sees `fetch` without `.catch`) is not enough to
15
- * hold an agent back; callers also cap the number of attempts.
9
+ * What blocks is what the review itself reports (review/quiet.ts `mustFix`: proven, critical, or
10
+ * high and verified or from a security gate), plus on a branch a merge conflict with main or a
11
+ * mention of a file the branch deleted (review/branch-checks.ts). The same rule decides the push
12
+ * gate, so a stop, a review and a push never disagree about a finding. A high heuristic (a regex
13
+ * that sees `fetch` without `.catch`) is not enough to hold an agent back; callers also cap the
14
+ * number of attempts.
16
15
  */
17
16
  import type { Config, Failure } from '../types/index.js';
18
17
  import { type ReviewTaskItem } from '../review/review-task.js';
@@ -31,6 +30,4 @@ export interface StopDecision {
31
30
  /** `sessionBaseline`: the commit the session started from, used on main (session-state.ts). */
32
31
  export declare function stopReview(cwd: string, config: Config, attempt: number, sessionBaseline?: string): Promise<StopDecision>;
33
32
  export declare function reviewAckMessage(items: ReviewTaskItem[], attempt: number): string;
34
- /** Critical; high and either proven by the semantic engine or a security finding; or a certain, local finding the change added (MUST_FIX). */
35
- export declare function blocksStop(finding: Failure): boolean;
36
33
  export declare function stopMessage(findings: Failure[], attempt: number): string;
@@ -22,7 +22,11 @@ export async function stopReview(cwd, config, attempt, sessionBaseline) {
22
22
  const result = await reviewChange({ cwd, config, diff, source });
23
23
  const branch = branchBase(cwd);
24
24
  const whole = branch && !branch.onMain ? branchFailures(cwd, branch.base, branch.mainRef, config) : [];
25
- const blocking = [...result.findings.filter(blocksStop), ...whole];
25
+ // A check that could not run is never a pass; the attempt cap keeps a broken environment from looping forever.
26
+ const crashed = result.status === 'ERROR'
27
+ ? result.gateErrors.map(id => ({ id, title: 'A check could not run', details: id === 'typed-checks-unavailable' && result.typedError ? result.typedError : `${id} crashed instead of running`, severity: 'high', files: [], hint: 'Fix the environment (dependencies, generated config), then try again.' }))
28
+ : [];
29
+ const blocking = [...result.findings, ...whole, ...crashed];
26
30
  const unreviewed = config.hooks?.require_review_ack ? buildReviewTask(cwd, diff, config.gates.deep?.router).items : [];
27
31
  const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines), against };
28
32
  if (blocking.length === 0 && unreviewed.length === 0)
@@ -41,15 +45,6 @@ export function reviewAckMessage(items, attempt) {
41
45
  ...listed,
42
46
  ].join('\n');
43
47
  }
44
- /** Certain from the code alone, and quick to fix (each measured with no false alarms before it blocks). */
45
- const MUST_FIX = new Set(['unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function']);
46
- /** Critical; high and either proven by the semantic engine or a security finding; or a certain, local finding the change added (MUST_FIX). */
47
- export function blocksStop(finding) {
48
- const severity = (finding.severity || 'medium');
49
- if (severity === 'critical' || MUST_FIX.has(finding.id))
50
- return true;
51
- return severity === 'high' && (finding.verified === true || finding.provenance === 'security');
52
- }
53
48
  export function stopMessage(findings, attempt) {
54
49
  const listed = findings.slice(0, MAX_LISTED).map(f => {
55
50
  const where = `${f.files?.[0] ?? '?'}:${f.line ?? '?'}`;
package/dist/index.d.ts CHANGED
@@ -15,14 +15,17 @@ export { rigourUserDir, repoStateDir } from './utils/user-state.js';
15
15
  export { reviewChange, toReviewFinding, type ReviewInput, type ReviewResult, type ReviewFinding } from './review/review.js';
16
16
  export { splitByChangedLines, type ChangedLineSplit } from './review/changed-lines.js';
17
17
  export { isControlFile, mergeBaseOf, readStateFile } from './review/trusted-state.js';
18
- export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
18
+ export { dismissFinding, dismissedKeys, findingKey, isProven, mustFix, quietSplit, DISMISSED_FILE } from './review/quiet.js';
19
+ export { loadLedger, ledgerProblems, runBacktest, backtestPassed, formatBacktest, LEDGER_PATH, type Ledger, type RoundResult } from './review/backtest.js';
20
+ export { scaffoldLedger } from './review/backtest-init.js';
19
21
  export { isGeneratedFile } from './review/generated-files.js';
20
22
  export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
21
23
  export { buildReviewTask, type ReviewTask, type ReviewTaskItem } from './review/review-task.js';
22
24
  export { buildQualityReceipt, receiptReport, type QualityReceipt, type ReceiptGap, type ReceiptOptions } from './review/receipt.js';
23
25
  export { runToolchain, type ToolResult, type ToolStatus } from './review/toolchain.js';
24
26
  export { branchFailures, mergeConflicts, staleReferences } from './review/branch-checks.js';
25
- export { runReviewer, reviewerBlocks, type ReviewerResult, type ReviewerVerdict } from './review/reviewer.js';
27
+ export { runReviewer, reviewerBlocks, itemLine, type ReviewerOptions, type ReviewerResult, type ReviewerOutcome, type OpenItem } from './review/reviewer.js';
28
+ export { startBackgroundReview, backgroundReview, reviewStatus, type ReviewStatus } from './review/reviewer/background.js';
26
29
  export { branchBase, type BranchBase } from './gates/logic-drift-git-base.js';
27
30
  export { acknowledgeReview, type ReviewAck, type AckResult } from './review/review-ack.js';
28
31
  export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE, type LedgerEntry, type ReviewedKey, type ReviewVerdict } from './review/ledger.js';
package/dist/index.js CHANGED
@@ -15,14 +15,17 @@ export { rigourUserDir, repoStateDir } from './utils/user-state.js';
15
15
  export { reviewChange, toReviewFinding } from './review/review.js';
16
16
  export { splitByChangedLines } from './review/changed-lines.js';
17
17
  export { isControlFile, mergeBaseOf, readStateFile } from './review/trusted-state.js';
18
- export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
18
+ export { dismissFinding, dismissedKeys, findingKey, isProven, mustFix, quietSplit, DISMISSED_FILE } from './review/quiet.js';
19
+ export { loadLedger, ledgerProblems, runBacktest, backtestPassed, formatBacktest, LEDGER_PATH } from './review/backtest.js';
20
+ export { scaffoldLedger } from './review/backtest-init.js';
19
21
  export { isGeneratedFile } from './review/generated-files.js';
20
22
  export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
21
23
  export { buildReviewTask } from './review/review-task.js';
22
24
  export { buildQualityReceipt, receiptReport } from './review/receipt.js';
23
25
  export { runToolchain } from './review/toolchain.js';
24
26
  export { branchFailures, mergeConflicts, staleReferences } from './review/branch-checks.js';
25
- export { runReviewer, reviewerBlocks } from './review/reviewer.js';
27
+ export { runReviewer, reviewerBlocks, itemLine } from './review/reviewer.js';
28
+ export { startBackgroundReview, backgroundReview, reviewStatus } from './review/reviewer/background.js';
26
29
  export { branchBase } from './gates/logic-drift-git-base.js';
27
30
  export { acknowledgeReview } from './review/review-ack.js';
28
31
  export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE } from './review/ledger.js';
@@ -0,0 +1,8 @@
1
+ import type { Config } from '../types/index.js';
2
+ import { type LedgerRound } from './backtest.js';
3
+ import { type Exec } from './reviewer.js';
4
+ export declare function scaffoldLedger(cwd: string, pr: number, config: Config, exec?: Exec): Promise<{
5
+ file: string;
6
+ rounds: LedgerRound[];
7
+ incomplete: number;
8
+ }>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `rigour backtest init --pr <n>`: a ledger scaffolded from a pull request's human reviews, one
3
+ * round per review. An inline comment gives a point its file and a line window for free; a point
4
+ * made in the review's body has neither, so it is written with `needs` set and a person adds the
5
+ * text pattern once. The base of each round is the main branch as it was when the review was
6
+ * posted, so a later merge does not change what the round measures.
7
+ */
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { branchBase } from '../gates/logic-drift-git-base.js';
11
+ import { LEDGER_PATH, LedgerSchema } from './backtest.js';
12
+ import { defaultExec, githubEnv, parseJsonArrays } from './reviewer.js';
13
+ const GH_TIMEOUT_MS = 60_000;
14
+ /** Lines either side of an inline comment that a finding for the same point may land on. */
15
+ const LINE_SLACK = 10;
16
+ const BULLET = /^\s*(?:[-*]|\d+[.)])\s+(.{8,})$/;
17
+ export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
18
+ const env = await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec);
19
+ const gh = (args) => exec('gh', args, { cwd, timeoutMs: GH_TIMEOUT_MS, env });
20
+ const view = await gh(['pr', 'view', String(pr), '--json', 'author', '-q', '.author.login']);
21
+ if (view.exitCode !== 0)
22
+ throw new Error(`could not read pull request ${pr}: ${view.stderr.trim() || 'is gh signed in?'}`);
23
+ const author = view.stdout.trim();
24
+ const reviews = await gh(['api', `repos/{owner}/{repo}/pulls/${pr}/reviews`, '--paginate']);
25
+ if (reviews.exitCode !== 0)
26
+ throw new Error(`could not read the reviews of pull request ${pr}: ${reviews.stderr.trim()}`);
27
+ const humans = parseJsonArrays(reviews.stdout)
28
+ .filter((r) => r?.user && r.user.type !== 'Bot' && !/bot/i.test(r.user.login) && r.user.login !== author && (r.body?.trim() || r.state === 'CHANGES_REQUESTED'));
29
+ if (humans.length === 0)
30
+ throw new Error(`pull request ${pr} has no review by a person yet`);
31
+ const mainRef = branchBase(cwd)?.mainRef ?? 'origin/main';
32
+ const rounds = [];
33
+ for (const [index, review] of humans.entries()) {
34
+ const comments = await gh(['api', `repos/{owner}/{repo}/pulls/${pr}/reviews/${review.id}/comments`, '--paginate']);
35
+ const inline = comments.exitCode === 0 ? parseJsonArrays(comments.stdout) : [];
36
+ const round = index + 1;
37
+ const points = inline.map((c, i) => inlinePoint(`R${round}-${i + 1}`, c));
38
+ for (const [i, line] of bodyPoints(String(review.body ?? '')).entries()) {
39
+ points.push({ id: `R${round}-B${i + 1}`, point: line, file: '', needs: 'a file pattern and a text pattern (this point was made in the review body, not on a line)' });
40
+ }
41
+ const base = await exec('git', ['rev-list', '-1', `--before=${review.submitted_at}`, mainRef], { cwd, timeoutMs: GH_TIMEOUT_MS });
42
+ rounds.push({
43
+ id: `pr${pr}-r${round}`,
44
+ commit: String(review.commit_id),
45
+ base: base.stdout.trim() || mainRef,
46
+ reviewed_at: String(review.submitted_at),
47
+ pr,
48
+ points,
49
+ must_not_flag: [],
50
+ });
51
+ }
52
+ const file = path.join(cwd, LEDGER_PATH);
53
+ const existing = existingLedger(file);
54
+ const merged = { rounds: [...existing.rounds.filter(r => !rounds.some(n => n.id === r.id)), ...rounds] };
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ fs.writeFileSync(file, JSON.stringify(merged, null, 2) + '\n');
57
+ return { file, rounds, incomplete: rounds.flatMap(r => r.points).filter(p => p.needs).length };
58
+ }
59
+ function inlinePoint(id, comment) {
60
+ const line = Number(comment.line ?? comment.original_line ?? 0);
61
+ const body = String(comment.body ?? '').trim();
62
+ return {
63
+ id,
64
+ point: firstLine(body),
65
+ file: escape(String(comment.path ?? '')),
66
+ ...(line > 0 ? { lines: [Math.max(1, line - LINE_SLACK), line + LINE_SLACK] } : { needs: 'a line window or a text pattern (the comment has no line)' }),
67
+ };
68
+ }
69
+ /** The bullet and numbered lines of a review body: the points a reviewer makes outside any one line. */
70
+ function bodyPoints(body) {
71
+ return body.split('\n').map(l => BULLET.exec(l)?.[1]).filter((l) => !!l).map(firstLine);
72
+ }
73
+ function firstLine(text) {
74
+ const line = text.split('\n').find(l => l.trim())?.replace(/[*_`#]/g, '').trim() ?? '';
75
+ return line.length > 140 ? `${line.slice(0, 137)}...` : line;
76
+ }
77
+ function escape(text) {
78
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
79
+ }
80
+ function existingLedger(file) {
81
+ if (!fs.existsSync(file))
82
+ return { rounds: [] };
83
+ const parsed = LedgerSchema.safeParse(JSON.parse(fs.readFileSync(file, 'utf8')));
84
+ if (!parsed.success)
85
+ throw new Error(`${LEDGER_PATH} exists but is not a ledger; fix or remove it first`);
86
+ return parsed.data;
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { ConfigSchema } from '../types/index.js';
7
+ import { ledgerProblems, loadLedger, LEDGER_PATH, LedgerSchema } from './backtest.js';
8
+ import { scaffoldLedger } from './backtest-init.js';
9
+ let repo;
10
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
11
+ const config = ConfigSchema.parse({ version: 1 });
12
+ const REVIEWS = [
13
+ { id: 1, user: { login: 'ci-bot', type: 'Bot' }, body: 'automated', state: 'COMMENTED', submitted_at: '2026-04-12T10:00:00Z', commit_id: 'c1c1c1c1c1' },
14
+ { id: 2, user: { login: 'senior', type: 'User' }, body: '## Review\n\n- A retry posts the invoice twice.\n- **Quadratic** scan in `latestPayment`.\nlooks good otherwise', state: 'CHANGES_REQUESTED', submitted_at: '2026-04-12T12:00:00Z', commit_id: 'c2c2c2c2c2' },
15
+ ];
16
+ const INLINE = [
17
+ { path: 'src/lib/server/invoices.ts', line: 40, body: 'The export reads every line item for ids.\n\nNarrow it.', created_at: '2026-04-12T12:00:00Z' },
18
+ { path: 'src/checkout/total.ts', line: null, original_line: null, body: 'Drops the currency', created_at: '2026-04-12T12:00:00Z' },
19
+ ];
20
+ function fakeExec(calls) {
21
+ return async (command, args, options) => {
22
+ calls.push([command, ...args]);
23
+ if (command === 'git')
24
+ return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8' }), stderr: '' };
25
+ if (args[0] === 'auth')
26
+ return { exitCode: 0, stdout: 'token\n', stderr: '' };
27
+ if (args[0] === 'pr')
28
+ return { exitCode: 0, stdout: 'author\n', stderr: '' };
29
+ if (args[1].endsWith('/reviews'))
30
+ return { exitCode: 0, stdout: JSON.stringify(REVIEWS), stderr: '' };
31
+ return { exitCode: 0, stdout: JSON.stringify(INLINE), stderr: '' };
32
+ };
33
+ }
34
+ beforeEach(() => {
35
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'backtest-init-'));
36
+ git('init', '-q', '-b', 'main');
37
+ git('config', 'user.email', 't@example.com');
38
+ git('config', 'user.name', 't');
39
+ git('config', 'commit.gpgsign', 'false');
40
+ fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
41
+ git('add', '-A');
42
+ execFileSync('git', ['-C', repo, 'commit', '-qm', 'main as of the review'], { env: { ...process.env, GIT_COMMITTER_DATE: '2026-04-12T09:00:00Z', GIT_AUTHOR_DATE: '2026-04-12T09:00:00Z' } });
43
+ });
44
+ afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
45
+ describe('rigour backtest init', () => {
46
+ it('writes one round per human review: inline comments become file-and-window points, body bullets need a pattern', async () => {
47
+ const calls = [];
48
+ const mainAtReview = git('rev-parse', 'HEAD');
49
+ const { rounds, incomplete } = await scaffoldLedger(repo, 212, config, fakeExec(calls));
50
+ expect(rounds).toHaveLength(1);
51
+ expect(rounds[0]).toMatchObject({ id: 'pr212-r1', commit: 'c2c2c2c2c2', base: mainAtReview, reviewed_at: '2026-04-12T12:00:00Z', pr: 212 });
52
+ expect(rounds[0].points).toEqual([
53
+ { id: 'R1-1', point: 'The export reads every line item for ids.', file: 'src/lib/server/invoices\\.ts', lines: [30, 50] },
54
+ { id: 'R1-2', point: 'Drops the currency', file: 'src/checkout/total\\.ts', needs: 'a line window or a text pattern (the comment has no line)' },
55
+ { id: 'R1-B1', point: 'A retry posts the invoice twice.', file: '', needs: 'a file pattern and a text pattern (this point was made in the review body, not on a line)' },
56
+ { id: 'R1-B2', point: 'Quadratic scan in latestPayment.', file: '', needs: 'a file pattern and a text pattern (this point was made in the review body, not on a line)' },
57
+ ]);
58
+ expect(incomplete).toBe(3);
59
+ expect(calls.find(c => c[0] === 'gh' && c[1] === 'pr')).toContain('212');
60
+ const written = LedgerSchema.parse(JSON.parse(fs.readFileSync(path.join(repo, LEDGER_PATH), 'utf8')));
61
+ expect(ledgerProblems(written)).toHaveLength(3);
62
+ expect(() => loadLedger(repo)).toThrow('rows a person still has to complete');
63
+ });
64
+ it('keeps rounds already in the ledger and replaces its own', async () => {
65
+ fs.mkdirSync(path.join(repo, '.rigour'));
66
+ fs.writeFileSync(path.join(repo, LEDGER_PATH), JSON.stringify({ rounds: [
67
+ { id: 'pr212-r1', commit: 'old0000', base: 'main', points: [], must_not_flag: [] },
68
+ { id: 'pr7-r1', commit: 'other00', base: 'main', points: [{ id: 'P', point: 'p', file: 'x', text: 'x' }], must_not_flag: [] },
69
+ ] }));
70
+ await scaffoldLedger(repo, 212, config, fakeExec([]));
71
+ const written = LedgerSchema.parse(JSON.parse(fs.readFileSync(path.join(repo, LEDGER_PATH), 'utf8')));
72
+ expect(written.rounds.map(r => [r.id, r.commit])).toEqual([['pr7-r1', 'other00'], ['pr212-r1', 'c2c2c2c2c2']]);
73
+ });
74
+ });