@bongos/core 1.19.1033 → 1.19.1034

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 (100) hide show
  1. package/.bongos-core.json +186 -86
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-recover/SKILL.md +2 -1
  4. package/.claude/skills/grader-health/SKILL.md +4 -0
  5. package/.claude/skills/merge-mode/SKILL.md +1 -1
  6. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  7. package/docs/adr/README.md +1 -1
  8. package/docs/api/openapi.json +1 -1
  9. package/docs/architecture.md +3 -2
  10. package/docs/canonical-permissions.md +2 -2
  11. package/docs/copy-inventory.md +34 -30
  12. package/docs/copy-registry.json +69 -33
  13. package/docs/module-api-changelog.md +3 -1
  14. package/docs/recipes/local-dev.md +45 -3
  15. package/docs/recipes/packaging-the-core.md +1 -1
  16. package/modules/autonomy/cadence.js +52 -0
  17. package/modules/government/protected-surfaces.json +1 -0
  18. package/modules/grading/grade-engine.js +267 -0
  19. package/modules/grading/grader-subagent.js +4 -3
  20. package/modules/grading/grader-workers/index.js +67 -11
  21. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  22. package/modules/grading/grader.js +109 -88
  23. package/modules/hall-ui/public/deploy.js +35 -0
  24. package/modules/hall-ui/public/project-settings.css +7 -0
  25. package/modules/lifecycle/routes/tasks.js +19 -4
  26. package/modules/provisioning/control-plane-guard.js +14 -13
  27. package/modules/provisioning/core-upgrade.js +30 -17
  28. package/modules/provisioning/public-refusal.js +44 -0
  29. package/modules/provisioning/routes/disconnect.js +2 -1
  30. package/modules/provisioning/routes/env-manifest.js +5 -4
  31. package/modules/provisioning/routes/provisioning.js +16 -15
  32. package/modules/provisioning/routes/repo-private.js +2 -1
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/release-notes.json +70 -0
  36. package/scripts/gds/autobongos-run.js +10 -2
  37. package/scripts/gds/backfill-cost.js +20 -9
  38. package/scripts/gds/claim.js +2 -2
  39. package/scripts/gds/credential-presence.js +281 -0
  40. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  41. package/scripts/gds/dev-lib.js +212 -5
  42. package/scripts/gds/dev.js +102 -50
  43. package/scripts/gds/doctor.js +3 -2
  44. package/scripts/gds/grade-correlation-audit.js +280 -0
  45. package/scripts/gds/grade-replay.js +237 -0
  46. package/scripts/gds/hooks/pre-push.js +7 -3
  47. package/scripts/gds/main-audit.js +19 -10
  48. package/scripts/gds/memory-map.js +3 -1
  49. package/scripts/gds/onboarding-config.js +3 -2
  50. package/scripts/gds/plain-cards.js +4 -0
  51. package/scripts/gds/preflight.js +12 -4
  52. package/scripts/gds/provision-core-upgrade.js +155 -4
  53. package/scripts/gds/provision-repo-auth.js +15 -0
  54. package/scripts/gds/provision.js +1 -1
  55. package/scripts/gds/publish-credential-check.js +3 -0
  56. package/scripts/gds/push-main.js +14 -3
  57. package/scripts/gds/push-path-brief.js +3 -2
  58. package/scripts/gds/run-routine.js +18 -5
  59. package/scripts/gds/run-unit-tests.js +14 -3
  60. package/scripts/gds/session-cost.js +12 -3
  61. package/scripts/gds/ship-deploy-target.js +11 -6
  62. package/scripts/gds/ship-grade-steps.js +33 -0
  63. package/scripts/gds/ship-land.js +157 -11
  64. package/scripts/gds/ship-merge.js +3 -2
  65. package/scripts/gds/ship-preflight-steps.js +30 -8
  66. package/scripts/gds/ship.js +1 -1
  67. package/scripts/gds/upgrade.js +3 -3
  68. package/scripts/hall-preview/README.md +20 -3
  69. package/scripts/hall-preview/live-identity.js +1 -1
  70. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  71. package/scripts/hall-preview/scrub-identity.js +296 -0
  72. package/src/bongos/routes/security.js +3 -3
  73. package/src/build-info.js +4 -2
  74. package/src/module-api.js +1 -1
  75. package/tests/adr_0089_scope.mjs +70 -0
  76. package/tests/autobongos_cadence.mjs +74 -0
  77. package/tests/consumer_layout_boot.mjs +363 -31
  78. package/tests/core_upgrade_door.mjs +40 -6
  79. package/tests/core_upgrade_runner.mjs +240 -4
  80. package/tests/credential_presence.mjs +153 -0
  81. package/tests/deploy_audit_gate.mjs +5 -2
  82. package/tests/deploy_page_projects.mjs +63 -2
  83. package/tests/dev_lib.mjs +2 -1
  84. package/tests/dev_windows.mjs +324 -0
  85. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  86. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  87. package/tests/government_protected_surfaces.mjs +10 -0
  88. package/tests/grade_correlation_audit.mjs +159 -0
  89. package/tests/grade_engine.mjs +249 -0
  90. package/tests/grade_engine_replay.mjs +99 -0
  91. package/tests/grader_prompt_window_bound.mjs +132 -0
  92. package/tests/grader_unbacked_score.mjs +194 -0
  93. package/tests/hall_preview_scrub.mjs +214 -0
  94. package/tests/integration_parked_ratchet.mjs +24 -1
  95. package/tests/provisioning_public_refusal.mjs +195 -0
  96. package/tests/publish_status_branch.mjs +128 -0
  97. package/tests/ship_land_red_pr.mjs +236 -0
  98. package/tests/ship_preflight.mjs +67 -1
  99. package/tests/start_env_guard.mjs +16 -0
  100. package/tests/upgrade.mjs +20 -1
@@ -0,0 +1,99 @@
1
+ // tests/grade_engine_replay.mjs — the differential replay over RECORDED grades
2
+ // (task 1002839, ADR 0089 §1).
3
+ //
4
+ // The corpus (tests/fixtures/grade-replay/recorded-panel-grades.json) is real:
5
+ // production grade rows pulled by `scripts/gds/grade-replay.js --live
6
+ // --write-fixture`, one per panel shape, with review prose hashed. Each row
7
+ // carries the judges' persisted verdicts and the decision the server stored.
8
+ // Replaying the verdicts through the grade engine's aggregate + gate stages must
9
+ // reproduce that decision exactly — that is what "the extraction changed no
10
+ // grade" means, measured on grades that actually happened rather than asserted.
11
+ //
12
+ // A future change to aggregation or the gate (task 1002841's normalization is
13
+ // the next) that moves any of these recorded decisions fails here, and says
14
+ // which task's grade moved and on which field.
15
+ //
16
+ // Run: node --test tests/grade_engine_replay.mjs
17
+
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const grader = require('../modules/grading/grader.js');
24
+ const replay = require('../scripts/gds/grade-replay.js');
25
+ const { grades: corpus } = require('./fixtures/grade-replay/recorded-panel-grades.json');
26
+
27
+ const clone = (v) => JSON.parse(JSON.stringify(v));
28
+
29
+ test('the corpus is real and varied, not a pile of all-pass panels', () => {
30
+ assert.ok(corpus.length >= 20, `expected a corpus of 20+ recorded grades, got ${corpus.length}`);
31
+ const outcomes = new Set(corpus.map((g) => g.recorded.panel_outcome));
32
+ for (const o of ['pass', 'concerns', 'fail', 'unavailable']) assert.ok(outcomes.has(o), `no recorded '${o}' grade in the corpus`);
33
+ const workers = corpus.flatMap((g) => g.workers);
34
+ assert.ok(workers.some((w) => w.kind === 'route-rank'), 'a deterministic pseudo-judge');
35
+ assert.ok(workers.some((w) => w.kind === 'reader'), 'the non-gating reader');
36
+ assert.ok(workers.some((w) => w.retried), 'a retried worker');
37
+ assert.ok(workers.some((w) => w.error), 'an errored worker');
38
+ assert.equal(new Set(corpus.map(replay.shapeKey)).size, corpus.length, 'one row per panel shape');
39
+ });
40
+
41
+ test('every recorded grade replays to the decision production stored', () => {
42
+ for (const row of corpus) {
43
+ const { mismatches } = replay.replayRow(row);
44
+ assert.deepEqual(mismatches, [], `task ${row.task_id} (${row.graded_at}) replays differently: ${JSON.stringify(mismatches)}`);
45
+ }
46
+ });
47
+
48
+ test('the engine gate changes nothing for the code panel on any recorded grade', () => {
49
+ for (const row of corpus) {
50
+ const viaEngine = grader.gradeEngine.replayRecorded({ signals: { workers: row.workers } });
51
+ assert.deepEqual(viaEngine, grader.aggregatePanel(row.workers, {}), `task ${row.task_id}`);
52
+ }
53
+ });
54
+
55
+ test('the replay is not vacuous: a changed record, or a changed verdict, is flagged', () => {
56
+ const passing = corpus.find((g) => g.recorded.panel_outcome === 'pass' && g.workers.some((w) => w.kind === 'quality'));
57
+ const edited = clone(passing);
58
+ edited.recorded.panel_outcome = 'concerns';
59
+ assert.deepEqual(replay.replayRow(edited).mismatches.map((m) => m.field), ['panel_outcome']);
60
+
61
+ const flipped = clone(passing);
62
+ const q = flipped.workers.find((w) => w.kind === 'quality');
63
+ q.verdict = 'fail';
64
+ q.issues = [{ severity: 'major', file: 'h:x', summary: 'h:y' }];
65
+ const fields = replay.replayRow(flipped).mismatches.map((m) => m.field);
66
+ for (const f of ['panel_outcome', 'pass_count', 'fail_count', 'confidence']) assert.ok(fields.includes(f), `${f} not flagged: ${fields}`);
67
+ });
68
+
69
+ test('compareDecision: a stored 0 against an axis the panel left null is the old server coercion — nothing else is', () => {
70
+ const rec = { panel_outcome: 'unavailable', rubric: { code_quality: 0, functional_fidelity: 7 } };
71
+ const rep = { panel_outcome: 'unavailable', rubric: { code_quality: null, functional_fidelity: null } };
72
+ assert.deepEqual(replay.compareDecision(rec, rep), [{ field: 'rubric.functional_fidelity', recorded: 7, replayed: null }]);
73
+ assert.deepEqual(replay.compareDecision({ confidence: undefined, rubric: {} }, { confidence: 0.3, rubric: {} }), [],
74
+ 'a confidence the server guards rewrote is recorded as undefined and not compared');
75
+ });
76
+
77
+ test('trimRecordedGrade keeps what aggregate + gate read and hashes the prose', () => {
78
+ const grade = {
79
+ rubric_scores: { functional_fidelity: '9', code_quality: 8 },
80
+ signals: {
81
+ panel_outcome: 'pass', pass_count: 1, worker_count: 1, panel_cost_usd: '0.5', confidence: 0.8,
82
+ server_guard: { downgraded_to: null },
83
+ workers: [{ kind: 'quality', verdict: 'pass', error: null, scores: { code_quality: 8 }, confidence: 0.8, model: 'm', cost_usd: 0.5,
84
+ notes_md: 'long review prose', name: 'Quality', issues: [{ severity: 'minor', file: 'a.js', summary: ' the same finding ' }] }],
85
+ },
86
+ };
87
+ const row = replay.trimRecordedGrade({ task_id: 7, graded_at: 'T', grade });
88
+ assert.equal(row.task_id, '7');
89
+ assert.equal(row.recorded.rubric.functional_fidelity, 9);
90
+ assert.equal(row.recorded.panel_cost_usd, 0.5);
91
+ assert.equal(row.workers[0].notes_md, undefined, 'prose the gate never reads is dropped');
92
+ const [issue] = row.workers[0].issues;
93
+ assert.match(issue.summary, /^h:[0-9a-f]{10}$/);
94
+ assert.equal(issue.summary, replay.trimRecordedGrade({ task_id: 7, grade: { ...grade, signals: { ...grade.signals, workers: [{ ...grade.signals.workers[0], issues: [{ ...grade.signals.workers[0].issues[0], summary: 'the same finding' }] }] } } }).workers[0].issues[0].summary,
95
+ 'hashing the TRIMMED summary keeps the de-duplication key exact');
96
+ assert.equal(replay.trimRecordedGrade({ task_id: 8, grade: { signals: { workers: [] } } }), null, 'not a panel grade');
97
+ const guarded = replay.trimRecordedGrade({ task_id: 9, grade: { ...grade, signals: { ...grade.signals, server_guard: { downgraded_to: 'concerns' } } } });
98
+ assert.equal(guarded.recorded.confidence, undefined, "a confidence the server guards rewrote is theirs, not the panel's — not recorded");
99
+ });
@@ -0,0 +1,132 @@
1
+ // tests/grader_prompt_window_bound.mjs
2
+ //
3
+ // Task 1003648 — a worker prompt too big for its model's window degrades instead
4
+ // of failing outright, and a real overflow is recognised as one.
5
+ //
6
+ // TWO DEFECTS, both in runWorker:
7
+ // 1. The overflow tag never fired. Task 1003318 matched
8
+ // /prompt is too long: the request is .../ — a colon — but the line the
9
+ // `claude` CLI really prints (stored verbatim on task 1003298's grade row) is
10
+ // "Prompt is too long · the request is ~251248 tokens (limit 200000) ...".
11
+ // So a real overflow was filed as SUBAGENT_NONZERO_EXIT and retried as if
12
+ // it were transient. The existing test was green because its fixture used
13
+ // the paraphrase. REAL_REFUSAL below is the real sentence.
14
+ // 2. Nothing smaller was ever tried. The post-change file bundle (up to 120k
15
+ // chars) is the one part of the prompt that can go without losing evidence —
16
+ // the worker can Read those files — so an overflow now retries ONCE without
17
+ // it, and only then stands as PROMPT_TOO_LONG (an explicit lens-unavailable).
18
+ //
19
+ // Run: node tests/grader_prompt_window_bound.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { runWorker, quality, TRANSIENT_WORKER_ERRORS } = require('../modules/grading/grader-workers/index.js');
26
+
27
+ let passed = 0;
28
+ let failed = 0;
29
+ async function test(name, fn) {
30
+ try { await fn(); passed++; console.log(` ok ${name}`); }
31
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
32
+ }
33
+
34
+ // The CLI's own refusal sentence, exactly as stored in
35
+ // signals.workers[quality].stderr_tail on task 1003298's grade (2026-08-27).
36
+ const REAL_REFUSAL = 'Prompt is too long · the request is ~251248 tokens (limit 200000) but this conversation is only ~100861 tokens — the rest is system prompt, tool definitions, and attachment content. A single-exchange conversation cannot be compacted; reduce attached files/tools or start with less context.';
37
+
38
+ // How runSubagent rejects when the CLI exits nonzero: the JSON result envelope is
39
+ // on stdout (--output-format json) and stderr is empty — the 1003298 shape.
40
+ const refusal = () => Object.assign(new Error('subagent exited with code 1'), {
41
+ code: 'SUBAGENT_NONZERO_EXIT', exit_code: 1, run_ms: 1087, stderr: '',
42
+ stdout: JSON.stringify({ type: 'result', subtype: 'success', is_error: true, result: REAL_REFUSAL, duration_ms: 1087 }),
43
+ });
44
+ const GOOD = JSON.stringify({ code_quality: 9, economy: 9, confidence: 0.9, issues: [], notes_md: 'clean' });
45
+
46
+ const BUNDLE_MARKER = 'BUNDLED_CONTENT_OF_a_js_7f3e';
47
+ const DIFF_MARKER = '+const shipped = true; // DIFF_MARKER_91c2';
48
+ const bundle = (files) => ({ files, skipped: [], total_chars: files.reduce((n, f) => n + f.content.length, 0) });
49
+ const ONE_FILE = [{ file: 'a.js', content: `// ${BUNDLE_MARKER}\n${'x'.repeat(5000)}`, truncated: false, chars_total: 5010 }];
50
+
51
+ // The real Quality worker (real prompt scaffold, parser and verdict) with only the
52
+ // subprocess replaced. `outcomes` is consumed one per spawn; every prompt is kept.
53
+ async function run({ contextBundle, outcomes }) {
54
+ const prompts = [];
55
+ const r = await runWorker(quality, {
56
+ task: { id: 1003648, title: 't', description: 'd', kind: 'bug', discipline: 'engineer' },
57
+ valueSummary: 'v', diff: DIFF_MARKER, changedFiles: ['a.js'], diffStats: {},
58
+ model: 'claude-haiku-4-5-20251001', runOpts: {}, contextBundle,
59
+ runSubagentFn: async ({ prompt }) => {
60
+ prompts.push(prompt);
61
+ const next = outcomes.shift();
62
+ if (next === 'refuse') throw refusal();
63
+ return { stdout: next, run_ms: 5, cost_usd: 0.01 };
64
+ },
65
+ });
66
+ return { r, prompts };
67
+ }
68
+
69
+ // ---- 1. the real sentence is recognised (defect 1) ------------------------
70
+
71
+ await test('the CLI\'s real refusal sentence is tagged PROMPT_TOO_LONG, and not retried as transient', async () => {
72
+ const { r, prompts } = await run({ contextBundle: bundle([]), outcomes: ['refuse', GOOD] });
73
+ assert.equal(r.error, 'PROMPT_TOO_LONG', 'the real " · " form must classify, not only a paraphrased colon');
74
+ assert.equal(prompts.length, 1, 'a window overflow is not transient — no task-465 retry of the same prompt');
75
+ assert.equal(r.retried, false);
76
+ assert.equal(TRANSIENT_WORKER_ERRORS.has('PROMPT_TOO_LONG'), false);
77
+ assert.equal('prompt_bounded' in r, false, 'with no bundle there was nothing smaller to try');
78
+ });
79
+
80
+ await test('the colon paraphrase still classifies too', async () => {
81
+ const r = await runWorker(quality, {
82
+ task: {}, diff: 'x', changedFiles: [], model: 'm', runOpts: {},
83
+ runSubagentFn: async () => { throw Object.assign(new Error('exit 1'), { code: 'SUBAGENT_NONZERO_EXIT', exit_code: 1, stderr: 'Prompt is too long: the request is ~9 tokens (limit 8)', stdout: '' }); },
84
+ });
85
+ assert.equal(r.error, 'PROMPT_TOO_LONG');
86
+ });
87
+
88
+ // ---- 2. the overflow degrades: one retry without the bundle (defect 2) -----
89
+
90
+ await test('an overflow with a bundle retries ONCE without it — same diff — and grades normally', async () => {
91
+ const { r, prompts } = await run({ contextBundle: bundle(ONE_FILE), outcomes: ['refuse', GOOD] });
92
+ assert.equal(prompts.length, 2);
93
+ assert.ok(prompts[0].includes(BUNDLE_MARKER), 'the first prompt carried the bundle');
94
+ assert.ok(!prompts[1].includes(BUNDLE_MARKER), 'the retry drops the bundled file contents');
95
+ assert.ok(prompts[1].includes('DIFF_MARKER_91c2'), 'the retry still carries the whole diff');
96
+ assert.ok(prompts[1].length < prompts[0].length);
97
+ assert.equal(r.error, null);
98
+ assert.equal(r.verdict, 'pass');
99
+ assert.deepEqual(r.prompt_bounded, {
100
+ reason: 'PROMPT_TOO_LONG', bundle_dropped: true, bundle_files: 1,
101
+ prompt_chars: prompts[0].length, retry_prompt_chars: prompts[1].length,
102
+ request_tokens: 251248, window_tokens: 200000, retry_error: null,
103
+ });
104
+ });
105
+
106
+ await test('if the smaller prompt overflows too, the lens is explicitly unavailable — two spawns, no score', async () => {
107
+ const { r, prompts } = await run({ contextBundle: bundle(ONE_FILE), outcomes: ['refuse', 'refuse', GOOD] });
108
+ assert.equal(prompts.length, 2, 'one degraded retry, never a loop');
109
+ assert.equal(r.error, 'PROMPT_TOO_LONG');
110
+ assert.deepEqual(r.scores, {}, 'no axis is fabricated for a worker that never ran');
111
+ assert.equal(r.prompt_bounded.retry_error, 'PROMPT_TOO_LONG');
112
+ });
113
+
114
+ await test('an ordinary run keeps the bundle and its exact row shape', async () => {
115
+ const { r, prompts } = await run({ contextBundle: bundle(ONE_FILE), outcomes: [GOOD] });
116
+ assert.equal(prompts.length, 1);
117
+ assert.ok(prompts[0].includes(BUNDLE_MARKER));
118
+ assert.equal(r.verdict, 'pass');
119
+ assert.equal('prompt_bounded' in r, false);
120
+ });
121
+
122
+ await test('a transient error is still retried once with the SAME prompt (the task 465 retry, unchanged)', async () => {
123
+ const { r, prompts } = await run({ contextBundle: bundle(ONE_FILE), outcomes: ['not json at all', GOOD] });
124
+ assert.equal(prompts.length, 2);
125
+ assert.equal(prompts[0], prompts[1], 'a parse_error retry re-sends the same prompt, bundle included');
126
+ assert.equal(r.retried, true);
127
+ assert.equal(r.verdict, 'pass');
128
+ assert.equal('prompt_bounded' in r, false);
129
+ });
130
+
131
+ console.log(`\n${passed} passed, ${failed} failed`);
132
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,194 @@
1
+ // tests/grader_unbacked_score.mjs
2
+ //
3
+ // Task 1002903 — a low score no finding stands behind must reach the builder as
4
+ // what it is, and must still not pass.
5
+ //
6
+ // THE SHAPE. The severity gate (task 1002663, worker-verdict.js
7
+ // applySeverityGatePolicy) downgrades a worker's sub-threshold 'fail' to
8
+ // 'concerns' when no major/blocker finding backs it. The worker keeps its low
9
+ // score, so the grade still read "FAIL 5.5" — and the card said "Fix the issues
10
+ // above" over an empty list. Three defects made that shape:
11
+ // 1. runWorker rebuilt the worker row field by field and dropped the policy's
12
+ // marker, so the stored grade could not say which low score was unbacked;
13
+ // 2. an ADVISORY Narc's unbacked low ff made the panel report PASS while the
14
+ // server's ff gate failed the ship — a PASS verdict inside a FAIL grade;
15
+ // 3. the card had no branch for it, so it printed the ordinary FAIL block.
16
+ // The constraint the task set: close it WITHOUT letting an unbacked low score
17
+ // pass and WITHOUT adding a gate (ADR 0162). Every case below that has a low
18
+ // score also asserts the server still refuses it.
19
+ //
20
+ // Run: node tests/grader_unbacked_score.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const grader = require('../modules/grading/grader.js');
27
+ const { aggregatePanel, validateAndScore, serverSideGuards, THRESHOLD, FALLTHROUGH_CONFIDENCE } = grader;
28
+ const { unbackedLowScores, UNBACKED_SCORE_DOWNGRADE, failResult } = require('../modules/grading/grader-workers/worker-verdict.js');
29
+ const { runWorker, quality, narc, hacker, efficiency } = require('../modules/grading/grader-workers/index.js');
30
+ const { buildGradePayload, formatGradeOutcome } = require('../scripts/gds/ship-grade-steps.js');
31
+ const { CARDS } = require('../scripts/gds/plain-cards.js');
32
+
33
+ let passed = 0;
34
+ let failed = 0;
35
+ async function test(name, fn) {
36
+ try { await fn(); passed++; console.log(` ok ${name}`); }
37
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
38
+ }
39
+
40
+ // A REAL worker row: the real worker module's prompt, parser and verdict, run
41
+ // through the real runWorker, with only the subprocess replaced by its output.
42
+ // That is the point — defect 1 lived in runWorker's row assembly, so a
43
+ // hand-built row would have hidden it.
44
+ async function row(worker, output) {
45
+ return runWorker(worker, {
46
+ task: { id: 1002903, title: 't', description: 'd', kind: 'bug', discipline: 'engineer' },
47
+ valueSummary: 'v', diff: '+ const x = 1;', changedFiles: ['a.js'], diffStats: {},
48
+ model: 'claude-sonnet-5', runOpts: {},
49
+ runSubagentFn: async () => ({ stdout: JSON.stringify(output), run_ms: 5, cost_usd: 0 }),
50
+ });
51
+ }
52
+ const nit = { severity: 'nit', file: 'a.js', summary: 'comment style' };
53
+ const major = { severity: 'major', file: 'a.js', summary: 'breaks the caller' };
54
+ const ac = [{ ac: 'does the thing', status: 'satisfied', evidence: 'a.js:1' }];
55
+
56
+ const OUT = {
57
+ narcPass: { functional_fidelity: 9, confidence: 0.9, issues: [], notes_md: 'ok', ac_checklist: ac },
58
+ narcUnbacked: { functional_fidelity: 4, confidence: 0.9, issues: [nit], notes_md: 'hm', ac_checklist: ac },
59
+ narcLowConf: { functional_fidelity: 7, confidence: 0.2, issues: [], notes_md: 'unsure', ac_checklist: ac },
60
+ qualityPass: { code_quality: 9, economy: 9, confidence: 0.9, issues: [], notes_md: 'fine' },
61
+ qualityUnbacked: { code_quality: 2, economy: 8, confidence: 0.9, issues: [nit], notes_md: 'meh' },
62
+ qualityBacked: { code_quality: 2, economy: 8, confidence: 0.9, issues: [major], notes_md: 'broken' },
63
+ hackerPass: { security_score: 9, confidence: 0.9, issues: [], notes_md: '', surfaces_checked: ['a.js'] },
64
+ hackerUnbacked: { security_score: 4, confidence: 0.9, issues: [nit], notes_md: '', surfaces_checked: ['a.js'] },
65
+ effPass: { efficiency_score: 9, confidence: 0.9, issues: [], notes_md: '', expensive_patterns_checked: ['loop'] },
66
+ };
67
+
68
+ // The server's half: what POST /tasks/:id/grade does to the body ship.js sends —
69
+ // the same two calls the route makes, in the same order.
70
+ function serverGrade(panelResult) {
71
+ const body = buildGradePayload(panelResult, ['a.js']);
72
+ const scored = validateAndScore(body.subagent_output, body.signals);
73
+ return serverSideGuards(scored, { committedFiles: ['a.js'], builderRank: 'archon', diffStats: null });
74
+ }
75
+
76
+ // ---- 1. the helper reads only the policy's own marker ---------------------
77
+
78
+ await test('an unbacked low cq is listed; the axes that were fine are not', async () => {
79
+ const q = await row(quality, OUT.qualityUnbacked);
80
+ assert.equal(q.verdict, 'concerns');
81
+ assert.deepEqual(unbackedLowScores([q], { threshold: THRESHOLD }), [{ kind: 'quality', axis: 'code_quality', score: 2 }]);
82
+ });
83
+
84
+ await test('a low score BACKED by a major finding is not unbacked (the worker stays fail)', async () => {
85
+ const q = await row(quality, OUT.qualityBacked);
86
+ assert.equal(q.verdict, 'fail');
87
+ assert.deepEqual(unbackedLowScores([q], { threshold: THRESHOLD }), []);
88
+ });
89
+
90
+ await test('the exempt Hacker never appears: its unbacked low score stays a hard fail', async () => {
91
+ const h = await row(hacker, OUT.hackerUnbacked);
92
+ assert.equal(h.verdict, 'fail', 'the security lens is fail-closed (task 1002663)');
93
+ assert.deepEqual(unbackedLowScores([h], { threshold: THRESHOLD }), []);
94
+ });
95
+
96
+ await test('an errored worker and junk input yield []', () => {
97
+ assert.deepEqual(unbackedLowScores([failResult({ notesMd: 'x', error: 'parse_error' })], { threshold: THRESHOLD }), []);
98
+ assert.deepEqual(unbackedLowScores(null, { threshold: THRESHOLD }), []);
99
+ assert.deepEqual(unbackedLowScores('workers', { threshold: THRESHOLD }), []);
100
+ assert.deepEqual(unbackedLowScores([null, 'x', { policy_downgrade: UNBACKED_SCORE_DOWNGRADE, scores: 'junk' }], { threshold: THRESHOLD }), []);
101
+ });
102
+
103
+ // ---- 2. runWorker keeps the marker (defect 1) -----------------------------
104
+
105
+ await test('runWorker carries policy_downgrade onto the worker row', async () => {
106
+ const q = await row(quality, OUT.qualityUnbacked);
107
+ assert.equal(q.policy_downgrade, UNBACKED_SCORE_DOWNGRADE);
108
+ });
109
+
110
+ await test('an ordinary worker row keeps its exact shape (no policy_downgrade key)', async () => {
111
+ const q = await row(quality, OUT.qualityPass);
112
+ assert.equal('policy_downgrade' in q, false);
113
+ });
114
+
115
+ // ---- 3. aggregatePanel records it, and never claims a PASS the server refuses
116
+
117
+ await test('Quality unbacked: the panel records unbacked_scores and names it in the notes', async () => {
118
+ const r = aggregatePanel([await row(narc, OUT.narcPass), await row(quality, OUT.qualityUnbacked), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]);
119
+ assert.equal(r.signals.panel_outcome, 'concerns');
120
+ assert.equal(r.passed, false);
121
+ assert.deepEqual(r.signals.unbacked_scores, [{ kind: 'quality', axis: 'code_quality', score: 2 }]);
122
+ assert.match(r.notes_md, /unbacked score: quality code_quality 2\/10/);
123
+ });
124
+
125
+ await test('Narc unbacked low ff: the panel says CONCERNS, not a PASS the server will fail (defect 2)', async () => {
126
+ const r = aggregatePanel([await row(narc, OUT.narcUnbacked), await row(quality, OUT.qualityPass), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]);
127
+ assert.equal(r.signals.panel_outcome, 'concerns', 'every gating worker passed, but ff 4 fails the server gate');
128
+ assert.equal(r.passed, false);
129
+ assert.equal(r.signals.confidence, FALLTHROUGH_CONFIDENCE, 'concerns forwards the fall-through confidence');
130
+ assert.match(r.notes_md, /functional_fidelity 4 is below 6/);
131
+ assert.deepEqual(r.signals.unbacked_scores, [{ kind: 'narc', axis: 'functional_fidelity', score: 4 }]);
132
+ });
133
+
134
+ await test('negative control: a Narc concerns with ff at/above threshold still PASSES (the Narc is advisory, task 481)', async () => {
135
+ const r = aggregatePanel([await row(narc, OUT.narcLowConf), await row(quality, OUT.qualityPass), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]);
136
+ assert.equal(r.signals.panel_outcome, 'pass');
137
+ assert.equal(r.passed, true);
138
+ assert.equal('unbacked_scores' in r.signals, false, 'an ordinary grade keeps its signals shape');
139
+ });
140
+
141
+ // ---- 4. not loosened: the server still refuses every unbacked low score ----
142
+
143
+ await test('the server still refuses both shapes — an unbacked low score never passes', async () => {
144
+ const qualityShape = aggregatePanel([await row(narc, OUT.narcPass), await row(quality, OUT.qualityUnbacked), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]);
145
+ const narcShape = aggregatePanel([await row(narc, OUT.narcUnbacked), await row(quality, OUT.qualityPass), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]);
146
+ for (const [label, r] of [['quality', qualityShape], ['narc', narcShape]]) {
147
+ const g = serverGrade(r);
148
+ assert.equal(g.passed, false, `${label}: the server gate must still fail it`);
149
+ assert.ok(Array.isArray(g.signals.unbacked_scores) && g.signals.unbacked_scores.length === 1, `${label}: the list rides into the stored signals`);
150
+ }
151
+ });
152
+
153
+ // ---- 5. the card tells the truth (defect 3) --------------------------------
154
+
155
+ await test('the card reads UNBACKED LOW SCORE, names the reviewer, and never says "fix the issues"', async () => {
156
+ const g = serverGrade(aggregatePanel([await row(narc, OUT.narcPass), await row(quality, OUT.qualityUnbacked), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]));
157
+ const out = formatGradeOutcome(g, 1002903);
158
+ assert.match(out, /UNBACKED LOW SCORE/);
159
+ assert.match(out, /quality: code_quality 2\/10 — no major or blocker finding/);
160
+ assert.match(out, /nothing listed to fix/);
161
+ assert.match(out, /ship\.js 1002903 --regrade/);
162
+ assert.match(out, /override-request/);
163
+ assert.doesNotMatch(out, /Fix the issues above/);
164
+ assert.ok(out.includes(CARDS.grade_unbacked_score.happened), 'the plain-language card follows');
165
+ });
166
+
167
+ await test('the card falls back to signals.workers for a grade recorded before unbacked_scores', async () => {
168
+ const g = serverGrade(aggregatePanel([await row(narc, OUT.narcPass), await row(quality, OUT.qualityUnbacked), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]));
169
+ delete g.signals.unbacked_scores;
170
+ assert.match(formatGradeOutcome(g, 7), /quality: code_quality 2\/10/);
171
+ });
172
+
173
+ await test('the card steps aside when a finding IS substantive — the ordinary FAIL block lists it', async () => {
174
+ const g = serverGrade(aggregatePanel([await row(narc, OUT.narcPass), await row(quality, OUT.qualityUnbacked), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]));
175
+ g.issues = [...g.issues, { severity: 'blocker', file: 'b.js', summary: 'real problem', worker: 'hacker' }];
176
+ const out = formatGradeOutcome(g, 7);
177
+ assert.doesNotMatch(out, /UNBACKED LOW SCORE/);
178
+ assert.match(out, /Fix the issues above/);
179
+ });
180
+
181
+ await test('the card steps aside when a gating worker hard-failed the panel', async () => {
182
+ const g = serverGrade(aggregatePanel([await row(narc, OUT.narcPass), await row(quality, OUT.qualityUnbacked), await row(hacker, OUT.hackerUnbacked), await row(efficiency, OUT.effPass)]));
183
+ assert.equal(g.signals.panel_outcome, 'fail', 'the exempt Hacker hard-fails');
184
+ assert.doesNotMatch(formatGradeOutcome(g, 7), /UNBACKED LOW SCORE/);
185
+ });
186
+
187
+ await test('a passing grade never gets the branch', async () => {
188
+ const g = serverGrade(aggregatePanel([await row(narc, OUT.narcPass), await row(quality, OUT.qualityPass), await row(hacker, OUT.hackerPass), await row(efficiency, OUT.effPass)]));
189
+ assert.equal(g.passed, true);
190
+ assert.doesNotMatch(formatGradeOutcome(g, 7), /UNBACKED LOW SCORE/);
191
+ });
192
+
193
+ console.log(`\n${passed} passed, ${failed} failed`);
194
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,214 @@
1
+ // tests/hall_preview_scrub.mjs — the hall-preview fixtures carry stand-ins, never real
2
+ // people (task 1003647).
3
+ //
4
+ // scripts/hall-preview/refresh-fixtures.js snapshots live API responses into COMMITTED
5
+ // fixtures. Before this task it wrote them verbatim, so the repo held real builders'
6
+ // GitHub logins, names, avatars and GitHub ids — and, in me.json, one builder's credit
7
+ // balance and month-to-date spend. The fix lives in the writer (scrub-identity.js, run on
8
+ // every snapshot before it is written), because a one-off clean-up of the output re-leaks
9
+ // on the next refresh. These tests pin:
10
+ // 1. the scheme — one stand-in per person, absence preserved, the account's own money
11
+ // figures fixed, everything that is not a person left alone;
12
+ // 2. that it is a fixed point, which is what lets (4) work;
13
+ // 3. that the WRITER applies it — driven with a fake API into a temp dir, no network;
14
+ // 4. that every fixture checked in today is already scrubbed.
15
+ //
16
+ // Every "real" identity below is invented for the test.
17
+ //
18
+ // Run: node --test tests/hall_preview_scrub.mjs
19
+
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { createRequire } from 'node:module';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const FIXTURES = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
31
+ const {
32
+ createScrubber, isOwnAccountFixture, highestStandIn, rewriteScalars, GHOST_AVATAR_URL,
33
+ } = require('../scripts/hall-preview/scrub-identity.js');
34
+ const { refreshFixtures, scrubFixturesOnDisk } = require('../scripts/hall-preview/refresh-fixtures.js');
35
+
36
+ const REAL_AVATAR = 'https://avatars.githubusercontent.com/u/87654321?v=4';
37
+ const liveMe = () => ({
38
+ builder: {
39
+ id: '95', github_id: '87654321', github_login: 'Real-Person', display_name: 'Real Person',
40
+ avatar_url: REAL_AVATAR, total_credits: 4321, karma: 7, rank: 'metic', monthly_budget_usd: null,
41
+ anthropic_email: null,
42
+ },
43
+ cost: { spend_mtd_usd: 123.45, budget_mtd_usd: null },
44
+ active_claims: [{ task_id: '2167', title: 'Check in the hall preview harness', worktree_name: 'frontend-redesign-ux' }],
45
+ });
46
+ const liveDirectory = () => ({
47
+ builders: [
48
+ { id: '95', github_login: 'real-person', display_name: 'Real Person', avatar_url: REAL_AVATAR },
49
+ { id: '7', github_login: 'other-human', display_name: '', avatar_url: null, total_credits: 900 },
50
+ ],
51
+ });
52
+ const liveRequests = () => ({
53
+ requests: [{
54
+ id: '3', requested_by: '7', requested_by_login: 'other-human', requested_by_name: 'Other Human',
55
+ requested_by_avatar_url: 'https://avatars.githubusercontent.com/u/1234567?v=4',
56
+ needs_builder_login: null, needs_builder_name: null, needs_builder_avatar_url: null,
57
+ created_by: '1000025', worktree_name: 'keep-this-name',
58
+ }],
59
+ tasks: [{ id: '9', credits_reward: 30, sandbox_review: { by: 'Real-Person', at: '2026-09-01T00:00:00Z' } }],
60
+ gate: [{ number: 12, author: 'other-human', head_sha: 'abc123' }],
61
+ modules: [{ key: 'ideas', author: 'Cloud Bongos' }],
62
+ });
63
+
64
+ // Every string anywhere in a value, for "does this identity survive anywhere" checks.
65
+ function allStrings(v, out = []) {
66
+ if (typeof v === 'string') out.push(v);
67
+ else if (Array.isArray(v)) v.forEach((x) => allStrings(x, out));
68
+ else if (v && typeof v === 'object') Object.values(v).forEach((x) => allStrings(x, out));
69
+ return out;
70
+ }
71
+
72
+ test('a person becomes ONE stand-in everywhere: login, name, avatar and GitHub id together', () => {
73
+ const s = createScrubber();
74
+ const me = s.scrub(liveMe(), { ownAccount: true });
75
+ const dir = s.scrub(liveDirectory());
76
+ const req = s.scrub(liveRequests());
77
+
78
+ assert.equal(me.builder.github_login, 'builder-1');
79
+ assert.equal(me.builder.display_name, 'Builder 1', 'the name travels with the login beside it');
80
+ assert.equal(me.builder.avatar_url, GHOST_AVATAR_URL);
81
+ assert.equal(me.builder.github_id, '1', 'a GitHub id follows its person, and keeps its type');
82
+ // The same person in another file, spelled with different case, is still person 1.
83
+ assert.equal(dir.builders[0].github_login, 'builder-1');
84
+ assert.equal(dir.builders[0].display_name, 'Builder 1');
85
+ // A second person, first met as a directory row, then as a requester.
86
+ assert.equal(dir.builders[1].github_login, 'builder-2');
87
+ assert.equal(req.requests[0].requested_by_login, 'builder-2');
88
+ assert.equal(req.requests[0].requested_by_name, 'Builder 2', 'a <who>_name pairs with <who>_login');
89
+ assert.equal(req.requests[0].requested_by_avatar_url, GHOST_AVATAR_URL);
90
+ // A login in a `by` field (who approved a staged change) and a PR author.
91
+ assert.equal(req.tasks[0].sandbox_review.by, 'builder-1');
92
+ assert.equal(req.gate[0].author, 'builder-2');
93
+
94
+ const leftovers = [me, dir, req].flatMap((d) => allStrings(d))
95
+ .filter((v) => /real-person|real person|other-human|other human|87654321|1234567/i.test(v));
96
+ assert.deepEqual(leftovers, [], 'no real identity survives anywhere in the output');
97
+ });
98
+
99
+ test('absence is preserved: null stays null and "" stays ""', () => {
100
+ const dir = createScrubber().scrub(liveDirectory());
101
+ assert.equal(dir.builders[1].display_name, '', 'the pages fall back to the login when a name is empty');
102
+ assert.equal(dir.builders[1].avatar_url, null, 'the pages draw initials when there is no avatar');
103
+ const req = createScrubber().scrub(liveRequests());
104
+ assert.equal(req.requests[0].needs_builder_login, null);
105
+ assert.equal(req.requests[0].needs_builder_avatar_url, null);
106
+ });
107
+
108
+ test("the account's own balance and spend become fixed values; other builders keep their numbers", () => {
109
+ const s = createScrubber();
110
+ const me = s.scrub(liveMe(), { ownAccount: true });
111
+ assert.equal(me.builder.total_credits, 1000);
112
+ assert.equal(me.builder.karma, 10);
113
+ assert.equal(me.cost.spend_mtd_usd, 42, 'month-to-date spend is private wherever it appears');
114
+ assert.equal(me.cost.budget_mtd_usd, null, 'an unset budget stays unset');
115
+ const totals = s.scrub({ handle: 'real-person', cross_project: { totals: { credits: 4890, karma: 6, projects: 2 } } }, { ownAccount: true });
116
+ assert.deepEqual(totals.cross_project.totals, { credits: 1000, karma: 10, projects: 2 });
117
+ // Not the account's own file: a stand-in's balance is nobody's, and the leaderboard
118
+ // preview needs real magnitudes to be worth looking at.
119
+ assert.equal(createScrubber().scrub(liveDirectory()).builders[1].total_credits, 900);
120
+ });
121
+
122
+ test('what is not a person is left exactly alone', () => {
123
+ const s = createScrubber();
124
+ const me = s.scrub(liveMe(), { ownAccount: true });
125
+ const req = s.scrub(liveRequests());
126
+ assert.equal(me.builder.id, '95', 'instance-local ids stay — the harness routes on them');
127
+ assert.equal(me.builder.rank, 'metic');
128
+ assert.deepEqual(me.active_claims, liveMe().active_claims, 'task ids, titles and worktree names are project content');
129
+ assert.equal(req.requests[0].worktree_name, 'keep-this-name', 'a <x>_name with no <x>_login is not a person');
130
+ assert.equal(req.requests[0].created_by, '1000025', 'a numeric *_by is a builder id, not a login');
131
+ assert.equal(req.tasks[0].credits_reward, 30, "a task's bounty is not anyone's balance");
132
+ assert.equal(req.modules[0].author, 'Cloud Bongos', "a module's author credit names an organisation");
133
+ });
134
+
135
+ test('scrubbing is a fixed point, and a new run numbers above the stand-ins already in use', () => {
136
+ const s = createScrubber();
137
+ const once = [s.scrub(liveMe(), { ownAccount: true }), s.scrub(liveDirectory()), s.scrub(liveRequests())];
138
+ const again = createScrubber();
139
+ const twice = [again.scrub(once[0], { ownAccount: true }), again.scrub(once[1]), again.scrub(once[2])];
140
+ assert.deepEqual(twice, once);
141
+
142
+ const texts = once.map((d) => JSON.stringify(d));
143
+ assert.equal(highestStandIn(texts), 2);
144
+ const later = createScrubber({ firstNumber: highestStandIn(texts) + 1 }).scrub({ github_login: 'a-newcomer' });
145
+ assert.equal(later.github_login, 'builder-3', 'a newcomer never collides with a stand-in on disk');
146
+ });
147
+
148
+ test('rewriteScalars replaces only the changed values and keeps a hand-written layout', () => {
149
+ const text = '{\n "rows": [ { "github_login": "real-person", "tags": ["a", "b"] },\n { "github_login": null } ],\n "n": 3\n}\n';
150
+ const next = rewriteScalars(text, createScrubber().scrub(JSON.parse(text)));
151
+ assert.equal(next, text.replace('"real-person"', '"builder-1"'));
152
+ });
153
+
154
+ test('isOwnAccountFixture names the snapshotting account\'s own files', () => {
155
+ assert.equal(isOwnAccountFixture('me'), true);
156
+ assert.equal(isOwnAccountFixture('me__credits'), true);
157
+ assert.equal(isOwnAccountFixture('members'), false);
158
+ assert.equal(isOwnAccountFixture('leaderboard'), false);
159
+ });
160
+
161
+ test('the WRITER scrubs: refreshFixtures never puts the live identity on disk', async () => {
162
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hall-fixtures-'));
163
+ try {
164
+ const responses = { '/me': liveMe(), '/builders/directory': liveDirectory(), '/help-requests': liveRequests() };
165
+ const api = { request: async (_m, p) => (p in responses ? { ok: true, status: 200, data: responses[p] } : { ok: false, status: 404 }) };
166
+ const snapshots = [['me', 'me'], ['builders/directory', 'builders__directory'], ['help-requests', 'help-requests'], ['audit-log', 'audit-log']];
167
+ const wrote = await refreshFixtures({ api, dir, snapshots, log: () => {} });
168
+ assert.equal(wrote, 3, 'the 404 is skipped, as before');
169
+
170
+ const onDisk = fs.readdirSync(dir).map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
171
+ for (const real of ['Real-Person', 'real-person', 'Real Person', 'other-human', 'Other Human', '87654321', '1234567', '4321', '123.45']) {
172
+ assert.ok(!onDisk.includes(real), `${real} must never reach the fixture directory`);
173
+ }
174
+ const me = JSON.parse(fs.readFileSync(path.join(dir, 'me.json'), 'utf8'));
175
+ assert.equal(me.builder.github_login, 'builder-1');
176
+ assert.equal(me.builder.total_credits, 1000, 'me.json is the account\'s own file');
177
+ const directory = JSON.parse(fs.readFileSync(path.join(dir, 'builders__directory.json'), 'utf8'));
178
+ assert.equal(directory.builders[0].github_login, 'builder-1', 'one run, one registry: the same person across files');
179
+ } finally {
180
+ fs.rmSync(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test('--scrub-only rewrites a hand-written fixture in place, once', () => {
185
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hall-fixtures-'));
186
+ try {
187
+ const file = path.join(dir, 'connections.json');
188
+ fs.writeFileSync(file, '{ "connections": [ { "handle": "other-human", "display_name": "Other Human", "avatar_url": "https://avatars.githubusercontent.com/u/1234567?v=4" } ] }\n');
189
+ assert.equal(scrubFixturesOnDisk({ dir, log: () => {} }), 1);
190
+ assert.equal(fs.readFileSync(file, 'utf8'),
191
+ `{ "connections": [ { "handle": "builder-1", "display_name": "Builder 1", "avatar_url": "${GHOST_AVATAR_URL}" } ] }\n`);
192
+ assert.equal(scrubFixturesOnDisk({ dir, log: () => {} }), 0, 'a second pass finds nothing to change');
193
+ } finally {
194
+ fs.rmSync(dir, { recursive: true, force: true });
195
+ }
196
+ });
197
+
198
+ test('every committed fixture is already scrubbed — no real person is checked in', () => {
199
+ const files = fs.readdirSync(FIXTURES).filter((f) => f.endsWith('.json')).sort();
200
+ const texts = files.map((f) => fs.readFileSync(path.join(FIXTURES, f), 'utf8'));
201
+ const s = createScrubber({ firstNumber: highestStandIn(texts) + 1 });
202
+ const dirty = files.filter((file, i) => {
203
+ const data = JSON.parse(texts[i]);
204
+ try {
205
+ assert.deepEqual(s.scrub(data, { ownAccount: isOwnAccountFixture(file.slice(0, -5)) }), data);
206
+ return false;
207
+ } catch {
208
+ return true;
209
+ }
210
+ });
211
+ assert.deepEqual(dirty, [],
212
+ 'these fixtures carry a real login, name, avatar, GitHub id or private figure — run '
213
+ + '`node scripts/hall-preview/refresh-fixtures.js --scrub-only` and commit the result');
214
+ });