@bongos/core 1.19.1032 → 1.19.1033

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 (68) hide show
  1. package/.bongos-core.json +159 -54
  2. package/.claude/skills/grade-audit/SKILL.md +1 -1
  3. package/.claude/skills/grader-health/SKILL.md +1 -1
  4. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  5. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  6. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  7. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  8. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  9. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  10. package/docs/adr/README.md +3 -3
  11. package/docs/architecture.md +1 -1
  12. package/docs/file-map.md +4 -3
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/recipes/autobongos-windows-host.md +3 -1
  15. package/docs/recipes/packaging-the-core.md +2 -0
  16. package/docs/recipes/redteam-patrol.md +2 -2
  17. package/docs/recipes/upgrading-the-core.md +1 -1
  18. package/modules/autonomy/gauge.js +57 -9
  19. package/modules/hall-ui/records/builders-roster.md +10 -0
  20. package/modules/hall-ui/records/catalog.md +10 -0
  21. package/modules/hall-ui/records/collaboration.md +14 -0
  22. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  23. package/modules/hall-ui/records/oversight-family.md +19 -0
  24. package/modules/hall-ui/records/panel-family.md +19 -0
  25. package/modules/hall-ui/records/reading-rooms.md +12 -0
  26. package/modules/hall-ui/records/record-family.md +13 -0
  27. package/modules/hall-ui/records/reference-room.md +9 -0
  28. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  29. package/modules/hall-ui/records/sky.md +23 -0
  30. package/modules/lifecycle/dependency-advisory.js +2 -1
  31. package/modules/lifecycle/ship-preflight.js +91 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +7411 -0
  35. package/scripts/gds/address-park-sweep.sh +3 -1
  36. package/scripts/gds/autobongos-run.js +81 -1
  37. package/scripts/gds/baseline-staleness.js +1 -1
  38. package/scripts/gds/db-backup-nightly.sh +9 -4
  39. package/scripts/gds/db-backup-offsite.sh +4 -2
  40. package/scripts/gds/package-core.js +72 -1
  41. package/scripts/gds/provision-core-upgrade.js +128 -77
  42. package/scripts/gds/redteam-patrol.js +4 -4
  43. package/scripts/gds/release-notes.js +271 -0
  44. package/scripts/gds/seed-board-room-tasks.js +1 -1
  45. package/scripts/gds/ship-flow.js +5 -3
  46. package/scripts/gds/ship-land.js +86 -7
  47. package/scripts/gds/ship-preflight-steps.js +56 -3
  48. package/scripts/gds/stray-unit-sweep.sh +3 -1
  49. package/scripts/gds/upgrade-migrate.js +67 -0
  50. package/scripts/gds/upgrade.js +6 -4
  51. package/src/module-api.js +1 -1
  52. package/tests/adr_0162_citation.mjs +184 -0
  53. package/tests/autobongos_loop.mjs +114 -0
  54. package/tests/autonomy_gauge.mjs +56 -0
  55. package/tests/core_upgrade_runner.mjs +89 -1
  56. package/tests/db_backup_nightly.mjs +21 -3
  57. package/tests/db_backup_offsite.mjs +81 -0
  58. package/tests/hall_records.mjs +85 -0
  59. package/tests/helpers.mjs +25 -0
  60. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  61. package/tests/plain_cards.mjs +1 -1
  62. package/tests/redteam_patrol.mjs +1 -1
  63. package/tests/release_notes.mjs +327 -0
  64. package/tests/shell_stat_portability.mjs +49 -0
  65. package/tests/ship_land_conflict.mjs +181 -0
  66. package/tests/ship_preflight.mjs +160 -3
  67. package/tests/sweep_app_account.mjs +15 -2
  68. package/tests/upgrade_migrate.mjs +195 -0
@@ -0,0 +1,81 @@
1
+ // tests/db_backup_offsite.mjs — the weekly offsite copy sizes what it ships the same way
2
+ // on every platform (task 1004170).
3
+ //
4
+ // THE BUG. db-backup-offsite.sh read both of its sizes — the local dump it picks and the
5
+ // ciphertext it uploads — with GNU-only `stat -c '%s'`, which BSD/macOS stat refuses
6
+ // outright. Every run on a Mac died before it encrypted anything, and CI's Linux never
7
+ // saw it, because nothing ran the script at all. The empty-ciphertext guard rides on the
8
+ // second of those sizes, so it is pinned here too.
9
+ //
10
+ // The script runs for real under macOS-shaped `stat` and `wc` (tests/helpers.mjs), with
11
+ // stub `age` and `python3` first on PATH: nothing is encrypted, no bucket is touched and
12
+ // no network is used.
13
+ //
14
+ // Run: node --test tests/db_backup_offsite.mjs
15
+
16
+ import assert from 'node:assert/strict';
17
+ import { test } from 'node:test';
18
+ import { spawnSync } from 'node:child_process';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { macShapedTools } from './helpers.mjs';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const SCRIPT = path.join(ROOT, 'scripts', 'gds', 'db-backup-offsite.sh');
27
+ const skip = process.platform === 'win32' ? 'needs a POSIX bash + coreutils (runs in CI)' : false;
28
+ // The script's env-var prefix, built rather than written — the same reason as in
29
+ // tests/db_backup_nightly.mjs: the legacy-literal ratchet counts the spelled-out form.
30
+ const ENV = 'gds_'.toUpperCase();
31
+
32
+ const DUMP_NAME = 'smallproj-2026-09-20T000000Z.sql.gz';
33
+ const DUMP = '--\n-- PostgreSQL database dump complete\n--\n';
34
+ const CIPHER = 'age-encryption.org/v1\n-> X25519 stub\n';
35
+
36
+ // `cipher` is what the stub age writes to its -o file. The stub python3 answers the
37
+ // boto3 probe and, for the upload program, records the object key it was handed.
38
+ function runOffsite({ cipher = CIPHER } = {}) {
39
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'db-offsite-'));
40
+ const bin = path.join(tmp, 'bin');
41
+ const dir = path.join(tmp, 'backups');
42
+ const uploads = path.join(tmp, 'uploads.log');
43
+ fs.mkdirSync(bin);
44
+ fs.mkdirSync(dir);
45
+ macShapedTools(bin);
46
+ fs.writeFileSync(path.join(dir, DUMP_NAME), DUMP);
47
+ fs.writeFileSync(path.join(tmp, 'cipher'), cipher);
48
+ fs.writeFileSync(path.join(bin, 'age'),
49
+ `#!/bin/bash\nwhile [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done\ncp "${path.join(tmp, 'cipher')}" "$out"\n`, { mode: 0o755 });
50
+ fs.writeFileSync(path.join(bin, 'python3'),
51
+ `#!/bin/bash\n[ "$1" = -c ] && exit 0\ncat > /dev/null\necho "$${ENV}OFFSITE_KEY" >> "${uploads}"\n`, { mode: 0o755 });
52
+ const r = spawnSync('bash', [SCRIPT], {
53
+ encoding: 'utf8',
54
+ env: {
55
+ ...process.env,
56
+ PATH: `${bin}:${process.env.PATH}`,
57
+ [`${ENV}BACKUP_DB`]: 'smallproj',
58
+ [`${ENV}BACKUP_DIR`]: dir,
59
+ [`${ENV}SPACES_ENV`]: path.join(tmp, 'no-such-spaces.env'),
60
+ [`${ENV}OFFSITE_AGE_RECIPIENT`]: 'age1stubrecipient',
61
+ SPACES_KEY: 'k', SPACES_SECRET: 's', SPACES_BUCKET: 'b', SPACES_REGION: 'r', SPACES_ENDPOINT: 'https://example.invalid',
62
+ },
63
+ });
64
+ const sent = fs.existsSync(uploads) ? fs.readFileSync(uploads, 'utf8').split('\n').filter(Boolean) : [];
65
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr, sent };
66
+ }
67
+
68
+ test('it sizes the dump and its ciphertext on macOS as on Linux, then ships it (task 1004170)', { skip }, () => {
69
+ const r = runOffsite();
70
+ assert.equal(r.status, 0, r.stderr);
71
+ assert.match(r.stdout, new RegExp(`\\(${Buffer.byteLength(DUMP)} bytes → ${Buffer.byteLength(CIPHER)} encrypted\\)`),
72
+ 'both sizes are the real byte counts, unpadded');
73
+ assert.deepEqual(r.sent, [`gds-offsite/${DUMP_NAME}.age`]);
74
+ });
75
+
76
+ test('an empty ciphertext is refused, never uploaded as a backup', { skip }, () => {
77
+ const r = runOffsite({ cipher: '' });
78
+ assert.equal(r.status, 2, r.stderr);
79
+ assert.match(r.stderr, /encryption produced an empty file/);
80
+ assert.deepEqual(r.sent, [], 'nothing reached the bucket');
81
+ });
@@ -0,0 +1,85 @@
1
+ // tests/hall_records.mjs — the hall's page records live one file per family (task 1003499).
2
+ //
3
+ // WHY. modules/hall-ui/CLAUDE.md used to carry every page family's "**The X landed
4
+ // next (task N)**" record, each appended immediately above the same heading. So every
5
+ // concurrent hall task inserted at the SAME line and collided with every other one —
6
+ // the file needed five hand-resolved merges in September 2026 alone, and each new
7
+ // record made the next collision likelier. Git cannot conflict on two branches that
8
+ // each ADD a file, so the records moved to modules/hall-ui/records/, one per family,
9
+ // and CLAUDE.md keeps a pointer. This pins the convention, so the next record lands
10
+ // as a file rather than as a fifteenth section of the one file every hall task edits.
11
+ //
12
+ // Run: node tests/hall_records.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const DOC = path.join(ROOT, 'modules/hall-ui/CLAUDE.md');
21
+ const DIR = path.join(ROOT, 'modules/hall-ui/records');
22
+
23
+ let passed = 0;
24
+ let failed = 0;
25
+ function test(name, fn) {
26
+ try { fn(); passed++; console.log(` ok ${name}`); }
27
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
28
+ }
29
+
30
+ const doc = fs.readFileSync(DOC, 'utf8').replace(/\r\n/g, '\n');
31
+ const records = fs.existsSync(DIR) ? fs.readdirSync(DIR).filter((f) => f.endsWith('.md')).sort() : [];
32
+
33
+ // The shape every record took: a bold lead-in naming a page family and saying it
34
+ // "landed" (first / next / last / at / on). Anchored to a line start so prose that
35
+ // merely uses the word stays legal.
36
+ const RECORD_LEAD = /^\*\*(?:The|THE)\b[^*\n]*\blanded\b/gm;
37
+
38
+ test('the hall doc carries no page record of its own', () => {
39
+ const leads = doc.match(RECORD_LEAD) || [];
40
+ assert.deepEqual(leads, [],
41
+ 'a page record was added to modules/hall-ui/CLAUDE.md — give it its own file in modules/hall-ui/records/ instead (task 1003499)');
42
+ });
43
+
44
+ test('the hall doc points at the records, and says a new one is a new file', () => {
45
+ const at = doc.indexOf('### Page records');
46
+ assert.ok(at > 0, 'the "### Page records" pointer is gone, so nothing tells an agent the records exist');
47
+ const pointer = doc.slice(at, doc.indexOf('\n### ', at + 1));
48
+ assert.match(pointer, /\]\(records\/\)/, 'the pointer links the records directory');
49
+ assert.match(pointer, /head -n 1 modules\/hall-ui\/records\/\*\.md/, 'and says how to find the record for a page');
50
+ assert.match(pointer, /adds a NEW file/, 'and says a new record is a new file, not a new section here');
51
+ });
52
+
53
+ test('the records directory holds the records', () => {
54
+ assert.ok(records.length >= 11, `expected the moved records in modules/hall-ui/records/, found ${records.length}`);
55
+ });
56
+
57
+ test('every record opens with a one-line title naming its pages and its task', () => {
58
+ // `head -n 1 records/*.md` is the index — there is deliberately no hand-kept list,
59
+ // because a list every record appends to would be the same collision in a new place.
60
+ for (const f of records) {
61
+ const text = fs.readFileSync(path.join(DIR, f), 'utf8').replace(/\r\n/g, '\n');
62
+ const [first, blank, ...rest] = text.split('\n');
63
+ assert.match(first, /^# \S/, `${f}: line 1 must be the "# " title that head -n 1 lists`);
64
+ assert.match(first, /\btasks? \d{7}\b/, `${f}: the title names the task(s) the record belongs to`);
65
+ assert.equal(blank, '', `${f}: a blank line follows the title`);
66
+ assert.ok(rest.join('\n').trim().length > 200, `${f}: the record has a body`);
67
+ assert.equal((text.match(/^# /gm) || []).length, 1, `${f}: one title per file — a second one is a second record`);
68
+ }
69
+ });
70
+
71
+ test('every relative link in a record resolves from records/', () => {
72
+ // records/ sits one folder below CLAUDE.md, so a section copied out of it with its
73
+ // `../../docs/...` links intact points one level short — the trap this move met.
74
+ for (const f of records) {
75
+ const text = fs.readFileSync(path.join(DIR, f), 'utf8');
76
+ for (const [, href] of text.matchAll(/\]\(([^)\s]+)\)/g)) {
77
+ if (/^(?:[a-z]+:|#|\/)/i.test(href)) continue;
78
+ const target = path.resolve(DIR, href.split('#')[0]);
79
+ assert.ok(fs.existsSync(target), `${f}: link ${href} resolves to nothing (${path.relative(ROOT, target)})`);
80
+ }
81
+ }
82
+ });
83
+
84
+ console.log(`\nhall page records (task 1003499) tests: ${passed} passed, ${failed} failed`);
85
+ process.exitCode = failed > 0 ? 1 : 0;
package/tests/helpers.mjs CHANGED
@@ -987,3 +987,28 @@ export function namesFieldKey(src, key) {
987
987
  || new RegExp(`\\.${k}\\b`).test(src)
988
988
  || new RegExp(`=\\s*${k}\\b`).test(src);
989
989
  }
990
+
991
+ // macShapedTools — write BSD/macOS-shaped `stat` and `wc` into binDir, for a shell
992
+ // script under test to meet first on PATH (task 1004170).
993
+ //
994
+ // The backup scripts died on every Mac builder's machine and passed on CI's Linux, so
995
+ // the red was invisible to anyone reading Actions. These bring the two differences to
996
+ // any platform: BSD stat refuses GNU's `-c` outright, and BSD wc pads the number it
997
+ // prints (" 11"). Only the stdin forms the scripts use are modelled (`wc -c < f`,
998
+ // `... | wc -l`); any stat call other than `-c` fails loudly rather than pretend to be
999
+ // BSD. Shared because both backup suites need the same pair.
1000
+ export function macShapedTools(binDir) {
1001
+ const realWc = execFileSync('bash', ['-c', 'command -v wc'], { encoding: 'utf8' }).trim();
1002
+ fs.writeFileSync(path.join(binDir, 'stat'), [
1003
+ '#!/bin/bash',
1004
+ 'if [ "$1" = -c ]; then echo "stat: illegal option -- c" >&2; exit 1; fi',
1005
+ 'echo "macShapedTools: only the -c refusal is modelled, not BSD stat itself" >&2; exit 1',
1006
+ '',
1007
+ ].join('\n'), { mode: 0o755 });
1008
+ fs.writeFileSync(path.join(binDir, 'wc'), [
1009
+ '#!/bin/bash',
1010
+ `n=$("${realWc}" "$@") || exit $?`,
1011
+ 'printf "%8s\\n" "${n//[[:space:]]/}"',
1012
+ '',
1013
+ ].join('\n'), { mode: 0o755 });
1014
+ }
@@ -24,6 +24,8 @@ import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { upgradeInvocation } from '../scripts/gds/provision-core-upgrade.js';
26
26
  import { migrateCmd, standaloneMigrateCmd, dbName } from '../scripts/gds/provision-repo.js';
27
+ import { CORE_MIGRATE_SCRIPT } from '../scripts/gds/upgrade-migrate.js';
28
+ import { INSTANCE_MIGRATE_SCRIPT } from '../scripts/gds/init.js';
27
29
 
28
30
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
31
  const tenant = { slug: 'charter', db_name: 'charter_db', hosting_shape: 'co-tenant', port: 3107, status: 'active' };
@@ -35,7 +37,12 @@ test('bongos upgrade migrates before it restarts, and only --skip-migrate opts o
35
37
  assert.ok(migrate > 0, 'the upgrade flow runs migrate');
36
38
  assert.ok(restart > migrate, 'the restart that makes new code live comes AFTER migrate');
37
39
  assert.match(src.slice(migrate - 200, migrate), /if \(!opts\.skipMigrate\)/, 'the only way past migrate is the explicit flag');
38
- assert.match(src, /run\('npm', \['run', 'migrate'\]/, 'migrate is the instance scaffold script, i.e. the core migrate.sh');
40
+ // WHICH command is upgrade-migrate.js's call (task 1004274): the instance's declared script,
41
+ // else the one a scaffold declares — the core migrate.sh either way, so an adopted project with
42
+ // no scripts is migrated rather than rolled back. tests/upgrade_migrate.mjs exercises both.
43
+ assert.match(src, /function runMigrate\([^)]*\) \{\n\s*const \{ cmd, args, env \} = require\('\.\/upgrade-migrate'\)\.migratePlan\(instanceDir\)/,
44
+ 'the migrate step spawns what migratePlan decides');
45
+ assert.equal(`bash ${CORE_MIGRATE_SCRIPT}`, INSTANCE_MIGRATE_SCRIPT, 'migrate is the instance scaffold script, i.e. the core migrate.sh');
39
46
  });
40
47
 
41
48
  test('the control-plane runner targets the instance database, not its own', () => {
@@ -167,7 +167,7 @@ test('the sign-in printers, the release refusals and the claim guard each print
167
167
  'scripts/gds/cli-lib.js': ['session_invalid', 'no_session'],
168
168
  'scripts/gds/release.js': ['no_active_claim', 'CLAIM_SESSION_MISMATCH'],
169
169
  'scripts/gds/claim.js': ['main_checkout_claim'],
170
- 'scripts/gds/ship-land.js': ['land_pending', 'reconcile_blocked'],
170
+ 'scripts/gds/ship-land.js': ['land_pending', 'reconcile_blocked', 'merge_conflict'], // merge_conflict: task 1003499
171
171
  'scripts/gds/push-path-brief.js': ['no_push_credential'],
172
172
  };
173
173
  for (const [file, codes] of Object.entries(wired)) {
@@ -83,7 +83,7 @@ test('disposition: red is reserved for findings that could not be recorded', ()
83
83
  // A dry run prints; it never claims to have filed, so it never fails.
84
84
  assert.deepEqual(p.disposition({ findingClasses: 3, doFile: false, blocker: null }),
85
85
  { action: 'dry-run', exitCode: 0 });
86
- // Findings filed for adjudication = the patrol working as designed (ADR 0162).
86
+ // Findings filed for adjudication = the patrol working as designed (ADR 0158 §2).
87
87
  // If this ever became a failure, a weekly red would train everyone to ignore it.
88
88
  assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: null }),
89
89
  { action: 'file', exitCode: 0 });
@@ -0,0 +1,327 @@
1
+ // tests/release_notes.mjs — the release summaries shipped INSIDE the core package
2
+ // (task 1004218): generated from history at pack time, redacted like a doc, pinned like
3
+ // every other file, and read back by the upgrade runner for a from→to range.
4
+ //
5
+ // WHAT IS ACTUALLY AT RISK, in the order it would hurt:
6
+ //
7
+ // 1. THE WRONG RELEASE GETS THE CREDIT. The obvious generator — split the log wherever a
8
+ // `release: core X.Y.Z` subject appears — credits a merge made on a branch before one
9
+ // release, but landed after it, to the EARLIER release. The history below contains
10
+ // exactly that shape, backdated, and every release is checked against the checkout
11
+ // reader's own `git log --merges <prev>..<release>` answer.
12
+ // 2. A LEAK THAT WEDGES THE LANE. These are builder-written sentences, and the real
13
+ // history holds ones that name a prior instance. Shipped verbatim, the fail-closed leak
14
+ // scan would refuse every pack from then on — the history cannot be edited.
15
+ // 3. A RANGE READ AS "NOTHING CHANGED". A range the notes do not reach must come back as
16
+ // a reason, never as an empty list, and the boundaries are `from` exclusive, `to`
17
+ // inclusive.
18
+ //
19
+ // Run: node tests/release_notes.mjs
20
+
21
+ import { test } from 'node:test';
22
+ import { strict as assert } from 'node:assert';
23
+ import { execFileSync, spawnSync } from 'node:child_process';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import {
28
+ RELEASE_NOTES_FILE, parseChangeRecords, parseLogRecords, buildReleaseNotes, generateReleaseNotes,
29
+ serializeReleaseNotes, boundReleaseNotes, notesBetween,
30
+ } from '../scripts/gds/release-notes.js';
31
+ import {
32
+ releaseNotesArtifact, scanArtifactForLeaks, PRIOR_INSTANCE_FLOOR, buildTar, TAR_ROOT,
33
+ sha256hex, canonicalTreeHash, buildCoreManifest, withTarballInfo, reproducePinFromTree,
34
+ } from '../scripts/gds/package-core.js';
35
+ import {
36
+ readPackagedNotes, readInstalledNotes, parseChangeRecords as runnerParser,
37
+ } from '../scripts/gds/provision-core-upgrade.js';
38
+ import { buildRedactionRules, collectSensitiveLiterals } from '../scripts/gds/mirror-redact.js';
39
+
40
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-release-notes-test-'));
41
+ process.on('exit', () => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* best-effort */ } });
42
+
43
+ const CARRIER = (v) => `release: core ${v} — CI auto-patch carrier (publish-on-merge, ADR 0161)`;
44
+
45
+ // ---- a real core-shaped history ---------------------------------------------------
46
+ //
47
+ // main: C0 ─ M1 ─ R(1.0.1) ─────────────── M2 ─ R(1.0.2) ─ M3
48
+ // │ │ │ │
49
+ // │ task-1 feat ──┘ hand-3 ┘
50
+ // └─ F1 ─ N(nested merge, backdated to 2020) ─ S(merges main at 1.0.1)
51
+ //
52
+ // N was made on the branch long before 1.0.1 and landed with M2, after it. Its date puts
53
+ // it at the very end of a date-ordered log, so a subject-split generator hands it to 1.0.1.
54
+ function makeHistory() {
55
+ const dir = fs.mkdtempSync(path.join(TMP, 'repo-'));
56
+ const git = (args, env = {}) => execFileSync('git', args, {
57
+ cwd: dir, encoding: 'utf8', env: { ...process.env, ...env },
58
+ });
59
+ let n = 0;
60
+ const commit = (msg, env) => {
61
+ fs.writeFileSync(path.join(dir, `c${n++}.txt`), `${msg}\n`); // a file each, so no branch conflicts
62
+ git(['add', '-A'], env);
63
+ git(['-c', 'commit.gpgsign=false', 'commit', '-q', '--no-verify', '-m', msg], env);
64
+ };
65
+ const merge = (branch, subject, body, env) => git(['-c', 'commit.gpgsign=false', 'merge', '-q', '--no-ff', '--no-verify',
66
+ '-m', subject, ...(body ? ['-m', body] : []), branch], env);
67
+ const old = (d) => ({ GIT_AUTHOR_DATE: d, GIT_COMMITTER_DATE: d });
68
+
69
+ git(['init', '-q', '-b', 'main']);
70
+ git(['config', 'user.email', 'test@example.com']);
71
+ git(['config', 'user.name', 'Test']);
72
+ commit('initial', old('2020-01-01T00:00:00Z'));
73
+ git(['checkout', '-q', '-b', 'feat']);
74
+ commit('feature work begins', old('2020-01-02T00:00:00Z'));
75
+ git(['checkout', '-q', '-b', 'sub-7']);
76
+ commit('a sub-branch', old('2020-01-03T00:00:00Z'));
77
+ git(['checkout', '-q', 'feat']);
78
+ merge('sub-7', 'Merge sub-7: The nested summary, merged on its branch long ago.', null, old('2020-01-04T00:00:00Z'));
79
+ git(['checkout', '-q', 'main']);
80
+ git(['checkout', '-q', '-b', 'task-1']);
81
+ commit('work for task 1');
82
+ git(['checkout', '-q', 'main']);
83
+ merge('task-1', 'Merge pull request #1 from an-owner/task-1', 'task 1001: The first summary.');
84
+ commit(CARRIER('1.0.1'));
85
+ git(['checkout', '-q', 'feat']);
86
+ merge('main', 'Merge origin/main into feat (pre-stage freshness, task 1068)');
87
+ git(['checkout', '-q', 'main']);
88
+ merge('feat', 'Merge pull request #2 from an-owner/task-2', 'task 1002: The second summary.');
89
+ commit(CARRIER('1.0.2'));
90
+ git(['checkout', '-q', '-b', 'hand-3']);
91
+ commit('work for the next release');
92
+ git(['checkout', '-q', 'main']);
93
+ merge('hand-3', 'Merge hand-3: An unreleased summary.');
94
+ const shaOf = (v) => git(['log', '--format=%H', '-1', '-F', `--grep=release: core ${v} `]).trim();
95
+ return { dir, git: (args) => git(args), shaOf };
96
+ }
97
+
98
+ const H = makeHistory();
99
+ const repoGit = H.git;
100
+ const texts = (list) => list.map((e) => e.text);
101
+
102
+ test('each release is credited with EXACTLY what landed since the previous one — even a backdated branch merge', () => {
103
+ const r = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.3' });
104
+ assert.equal(r.skipped, undefined);
105
+ assert.deepEqual(texts(r.notes['1.0.1']), ['The first summary.']);
106
+ assert.equal(r.notes['1.0.1'][0].id, '1001', 'the CI shape keeps its task id');
107
+ assert.deepEqual(texts(r.notes['1.0.2']), ['The second summary.', 'The nested summary, merged on its branch long ago.'],
108
+ 'the nested merge LANDED with 1.0.2 — its 2020 date must not hand it to 1.0.1');
109
+ assert.ok(!texts(r.notes['1.0.2']).some((t) => /pre-stage freshness/.test(t)), 'the mechanics of landing stay dropped');
110
+ });
111
+
112
+ test('the answer is the checkout reader’s own range — git log --merges <prev>..<release>', () => {
113
+ // The fallback path asks git for this one range at a time; the generator must agree
114
+ // with it for every release, or the page would say different things on different boxes.
115
+ const { notes } = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.3' });
116
+ const log = repoGit(['log', '--merges', '--format=%s%x1f%b%x1e', `${H.shaOf('1.0.1')}..${H.shaOf('1.0.2')}`]);
117
+ assert.deepEqual(texts(notes['1.0.2']).sort(), texts(parseChangeRecords(log)).sort());
118
+ assert.equal(runnerParser, parseChangeRecords, 'one parser, not two: the runner re-exports this one');
119
+ });
120
+
121
+ test('the unreleased tail belongs to the packed version only when that version has no release yet', () => {
122
+ const declared = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.3' });
123
+ assert.deepEqual(texts(declared.notes['1.0.3']), ['An unreleased summary.'], 'a hand-declared version carries its own tail');
124
+ const released = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.2' });
125
+ assert.deepEqual(Object.keys(released.notes), ['1.0.1', '1.0.2'], 'on a work merge the tail is the NEXT release’s, and is not guessed at');
126
+ const behind = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '0.9.0' });
127
+ assert.deepEqual(Object.keys(behind.notes), ['1.0.1', '1.0.2'], 'a version older than the newest release never takes the tail');
128
+ });
129
+
130
+ test('a shallow clone produces NO notes, and says why — never a partial file that looks whole', () => {
131
+ const shallow = path.join(TMP, 'shallow');
132
+ execFileSync('git', ['clone', '-q', '--depth', '1', `file://${H.dir}`, shallow]);
133
+ const r = generateReleaseNotes({
134
+ git: (args) => execFileSync('git', ['-C', shallow, ...args], { encoding: 'utf8' }), ref: 'HEAD', version: '1.0.3',
135
+ });
136
+ assert.equal(r.notes, undefined);
137
+ assert.match(r.skipped, /shallow clone/);
138
+ });
139
+
140
+ test('a version released twice keeps both halves, and a repeated sentence once', () => {
141
+ const rec = (sha, parents, subject, body = '') => `${sha}\x1f${parents}\x1f${subject}\x1f${body}\x1e\n`;
142
+ const log = [
143
+ rec('e', 'd', CARRIER('2.0.0')),
144
+ rec('d', 'c x', 'Merge b-2: Second half.'),
145
+ rec('c', 'b', CARRIER('2.0.0')),
146
+ rec('b', 'a y', 'Merge b-1: First half.'),
147
+ rec('a', '', 'initial'),
148
+ ].join('');
149
+ const { notes } = buildReleaseNotes(parseLogRecords(log));
150
+ assert.deepEqual(texts(notes['2.0.0']), ['First half.', 'Second half.']);
151
+ });
152
+
153
+ // ---- the range ---------------------------------------------------------------------
154
+
155
+ const NOTES = {
156
+ '1.19.900': [{ text: 'already live' }],
157
+ '1.19.901': [{ id: '1', text: 'first in range' }],
158
+ '1.19.920': [],
159
+ '1.19.949': [{ id: '3', text: 'the target itself' }, { text: 'first in range' }],
160
+ '1.19.950': [{ text: 'after the target' }],
161
+ };
162
+
163
+ test('(from, to]: from EXCLUSIVE, to INCLUSIVE, newest release first, a sentence said once', () => {
164
+ assert.deepEqual(texts(notesBetween(NOTES, '1.19.900', '1.19.949').entries), ['the target itself', 'first in range']);
165
+ assert.deepEqual(texts(notesBetween(NOTES, '1.19.901', '1.19.949').entries), ['the target itself', 'first in range'],
166
+ 'starting AT 1.19.901 excludes its own entry — the one left is the target’s repeat of it');
167
+ assert.deepEqual(texts(notesBetween(NOTES, '1.19.949', '1.19.950').entries), ['after the target']);
168
+ });
169
+
170
+ test('a release of pure housekeeping is COVERED and empty — distinct from not covered', () => {
171
+ assert.deepEqual(notesBetween(NOTES, '1.19.901', '1.19.920'), { entries: [] });
172
+ });
173
+
174
+ test('a range the notes do not reach is refused with a reason, never an empty list', () => {
175
+ assert.match(notesBetween(NOTES, '1.19.900', '1.19.999').uncovered, /do not list core 1\.19\.999/);
176
+ assert.match(notesBetween(NOTES, '1.19.850', '1.19.949').uncovered, /begin at core 1\.19\.900, after 1\.19\.850/);
177
+ assert.match(notesBetween(null, '1.19.900', '1.19.949').uncovered, /not a list of releases/);
178
+ assert.match(notesBetween([], '1.19.900', '1.19.949').uncovered, /not a list of releases/);
179
+ });
180
+
181
+ test('a file off the network is re-checked, not trusted', () => {
182
+ const messy = {
183
+ '1.0.1': [],
184
+ '1.0.2': [null, { text: '' }, { text: 42 }, { id: 'x; rm -rf', text: 'kept, bad id dropped' }, { id: 12, text: 'y'.repeat(900) }],
185
+ 'not-a-version': [{ text: 'ignored' }],
186
+ '1.0.3': 'not an array',
187
+ };
188
+ const { entries } = notesBetween(messy, '1.0.1', '1.0.2');
189
+ assert.deepEqual(entries[0], { text: 'kept, bad id dropped' });
190
+ assert.equal(entries[1].id, '12');
191
+ assert.ok(entries[1].text.length <= 400, 'bounded — it reaches a DOM');
192
+ assert.equal(entries.length, 2);
193
+ });
194
+
195
+ // ---- the bound -----------------------------------------------------------------------
196
+
197
+ test('past the bound the OLDEST releases go, the fewest that fit, and the newest always stays', () => {
198
+ const big = {};
199
+ for (let i = 1; i <= 60; i++) big[`1.0.${i}`] = [{ text: `release ${i} ${'x'.repeat(200)}` }];
200
+ const full = serializeReleaseNotes(big).length;
201
+ const cap = Math.floor(full / 2);
202
+ const b = boundReleaseNotes(big, cap);
203
+ assert.ok(serializeReleaseNotes(b.notes).length <= cap);
204
+ assert.equal(b.dropped[0], '1.0.1', 'oldest first');
205
+ assert.ok('1.0.60' in b.notes);
206
+ const oneMore = [b.dropped[b.dropped.length - 1], ...Object.keys(b.notes)];
207
+ assert.ok(serializeReleaseNotes(Object.fromEntries(oneMore.map((v) => [v, big[v]]))).length > cap, 'no more dropped than needed');
208
+ assert.deepEqual(boundReleaseNotes(big, full).dropped, [], 'under the bound nothing moves');
209
+ // …and the reader then refuses what the trimmed file no longer holds.
210
+ assert.match(notesBetween(b.notes, '1.0.1', '1.0.60').uncovered, /begin at core/);
211
+ assert.ok(notesBetween(b.notes, Object.keys(b.notes)[0], '1.0.60').entries);
212
+ });
213
+
214
+ // ---- the neutral floor ---------------------------------------------------------------
215
+
216
+ const floorExtra = { owners: [...PRIOR_INSTANCE_FLOOR.owners], tokens: [...PRIOR_INSTANCE_FLOOR.tokens], literals: [] };
217
+ const rules = buildRedactionRules({}, floorExtra);
218
+ const sensitiveLiterals = collectSensitiveLiterals({}, floorExtra);
219
+ const OWNER = PRIOR_INSTANCE_FLOOR.owners[0]; // never typed here: this file ships too
220
+
221
+ test('a summary naming a prior instance’s owner is REDACTED like a doc, and the scan then passes', () => {
222
+ const notes = { '1.0.1': [], '1.0.2': [{ id: '7', text: `Asking ${OWNER} for help now reaches them.` }] };
223
+ const art = releaseNotesArtifact(notes, { rules, sensitiveLiterals, envSecrets: [], knownDocNames: new Set() });
224
+ assert.deepEqual(art.leaks, []);
225
+ assert.equal(art.redacted, 1);
226
+ assert.ok(!art.buf.toString('utf8').toLowerCase().includes(OWNER.toLowerCase()));
227
+ assert.match(art.notes['1.0.2'][0].text, /^Asking example-owner for help/);
228
+ assert.deepEqual(scanArtifactForLeaks([{ path: RELEASE_NOTES_FILE, content: art.buf }]).literalHits, []);
229
+ // The post-build scan reads the generated file like any other: unredacted, it is flagged.
230
+ assert.ok(scanArtifactForLeaks([{ path: RELEASE_NOTES_FILE, content: serializeReleaseNotes(notes) }]).literalHits.length > 0);
231
+ });
232
+
233
+ test('residue AFTER redaction is reported as a leak — which main() treats as fatal', () => {
234
+ const notes = { '1.0.2': [{ text: `Asking ${OWNER} for help.` }] };
235
+ const blind = buildRedactionRules({}, {}); // a redactor that does not know the literal
236
+ const art = releaseNotesArtifact(notes, { rules: blind, sensitiveLiterals, envSecrets: [], knownDocNames: new Set() });
237
+ assert.ok(art.leaks.some((l) => /^doc:literal:/.test(l.label)), JSON.stringify(art.leaks));
238
+ });
239
+
240
+ // ---- packed, verified, downloaded, read back ------------------------------------------
241
+
242
+ // The artifact the way main() assembles it: the notes staged beside package.json into ONE
243
+ // file set, from which the pin is computed — then the pin reproduced from the unpacked tgz,
244
+ // which is what --self-verify does at publish time.
245
+ function pack({ withNotes = true } = {}) {
246
+ const gen = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.3' });
247
+ const art = releaseNotesArtifact(gen.notes, { rules, sensitiveLiterals, envSecrets: [], knownDocNames: new Set() });
248
+ const files = [{ path: 'package.json', buf: Buffer.from('{"name":"@bongos/core","version":"1.0.3"}\n') }];
249
+ if (withNotes) files.push({ path: RELEASE_NOTES_FILE, buf: art.buf });
250
+ const filesMeta = files.map((f) => ({ path: f.path, mode: '100644', sha256: sha256hex(f.buf) }));
251
+ const tgz = buildTar(files.map((f) => ({ name: TAR_ROOT + f.path, mode: 0o644, buf: f.buf })));
252
+ const out = fs.mkdtempSync(path.join(TMP, 'dist-'));
253
+ const tgzPath = path.join(out, 'bongos-core-1.0.3.tgz');
254
+ fs.writeFileSync(tgzPath, tgz);
255
+ const sidecar = withTarballInfo(buildCoreManifest({
256
+ version: '1.0.3', coreContract: '1.0.3', sourceCommit: null, sourceRef: 'HEAD', builtAtIso: new Date(0).toISOString(),
257
+ files: filesMeta, treeHash: canonicalTreeHash(filesMeta),
258
+ }), { name: 'bongos-core-1.0.3.tgz', sha256: sha256hex(tgz), bytes: tgz.length });
259
+ return { art, tgzPath, sidecar };
260
+ }
261
+
262
+ // `npm pack` stood in for (no registry in a unit test); `tar` is the real one.
263
+ function registry(tgzPath, asked = []) {
264
+ return (cmd, args, opts) => {
265
+ if (cmd !== 'npm') return spawnSync(cmd, args, opts);
266
+ asked.push(args[1]);
267
+ fs.copyFileSync(tgzPath, path.join(args[args.indexOf('--pack-destination') + 1], path.basename(tgzPath)));
268
+ return { status: 0, stdout: `${path.basename(tgzPath)}\n`, stderr: '' };
269
+ };
270
+ }
271
+
272
+ test('PACKED, SELF-VERIFIED, DOWNLOADED, READ BACK — and the range comes out right', () => {
273
+ const { art, tgzPath, sidecar } = pack();
274
+ // --self-verify: the pin reproduces from the unpacked tarball, notes file included.
275
+ const unpacked = fs.mkdtempSync(path.join(TMP, 'unpacked-'));
276
+ assert.equal(spawnSync('tar', ['-xzf', tgzPath, '-C', unpacked]).status, 0);
277
+ const rep = reproducePinFromTree({ treeRoot: path.join(unpacked, 'package'), sidecar, tgzPath });
278
+ assert.ok(rep.ok, rep.problems.join('; '));
279
+ assert.ok(sidecar.files.some((f) => f.path === RELEASE_NOTES_FILE), 'the notes are IN the pin, not beside it');
280
+
281
+ const asked = [];
282
+ const got = readPackagedNotes('1.0.3', { spawn: registry(tgzPath, asked), tmpDir: TMP });
283
+ assert.deepEqual(asked, ['@bongos/core@1.0.3'], 'the target’s own package is the one fetched');
284
+ assert.deepEqual(got.notes, art.notes, 'what comes out is what went in');
285
+ const r = notesBetween(got.notes, '1.0.1', '1.0.3');
286
+ assert.deepEqual(texts(r.entries), ['An unreleased summary.', 'The second summary.', 'The nested summary, merged on its branch long ago.'],
287
+ '1.0.1’s own summary is excluded; the target’s is included');
288
+ assert.deepEqual(fs.readdirSync(TMP).filter((n) => n.startsWith('bongos-release-notes-')), [], 'the download does not outlive the call');
289
+ });
290
+
291
+ test('a package published before the notes existed says so — the real tar, a real tarball without the file', () => {
292
+ const { tgzPath } = pack({ withNotes: false });
293
+ const got = readPackagedNotes('1.0.3', { spawn: registry(tgzPath), tmpDir: TMP });
294
+ assert.equal(got.notes, undefined);
295
+ assert.match(got.missing, /the core 1\.0\.3 package carries no release notes/);
296
+ });
297
+
298
+ test('every way the download can fail is named, and cleaned up', () => {
299
+ const fake = (npm, tar) => (cmd, args) => {
300
+ if (cmd === 'npm') {
301
+ if (npm.writes) fs.writeFileSync(path.join(args[args.indexOf('--pack-destination') + 1], 'bongos-core-9.9.9.tgz'), 'x');
302
+ return npm;
303
+ }
304
+ return tar;
305
+ };
306
+ const ok = { status: 0, stdout: '', stderr: '', writes: true };
307
+ const cases = [
308
+ [fake({ status: 1, stdout: '', stderr: 'npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/x' }, null),
309
+ /could not download the core 9\.9\.9 package to read its release notes \(npm error 404 Not Found/],
310
+ [fake({ status: 0, stdout: '', stderr: '' }, null), /left no tarball/],
311
+ [fake(ok, { status: 2, stdout: '', stderr: 'tar: Error opening archive: Unrecognized archive format' }), /could not read the release notes out of the core 9\.9\.9 package/],
312
+ [fake(ok, { status: 0, stdout: '{not json', stderr: '' }), /not valid JSON/],
313
+ [fake({ error: new Error('spawnSync npm ETIMEDOUT'), status: null }, null), /ETIMEDOUT/],
314
+ ];
315
+ for (const [spawn, want] of cases) assert.match(readPackagedNotes('9.9.9', { spawn, tmpDir: TMP }).error, want);
316
+ let spawned = false;
317
+ assert.match(readPackagedNotes("9.9.9'; rm -rf /", { spawn: () => { spawned = true; } }).error, /could not work out/);
318
+ assert.equal(spawned, false, 'a version that is not three numbers reaches no command');
319
+ assert.deepEqual(fs.readdirSync(TMP).filter((n) => n.startsWith('bongos-release-notes-')), []);
320
+ });
321
+
322
+ test('the installed core’s own file: read when present, and a missing or broken one is not an answer', () => {
323
+ const at = (body) => ({ fs: { readFileSync: (p) => { assert.ok(p.endsWith(RELEASE_NOTES_FILE)); if (body === null) throw new Error('ENOENT'); return body; } } });
324
+ assert.deepEqual(readInstalledNotes(at('{"1.0.1":[]}')).notes, { '1.0.1': [] });
325
+ assert.match(readInstalledNotes(at(null)).missing, /carries no release notes/);
326
+ assert.match(readInstalledNotes(at('{bad')).error, /not valid JSON/);
327
+ });
@@ -0,0 +1,49 @@
1
+ // tests/shell_stat_portability.mjs — no shell script leans on one platform's `stat`
2
+ // (task 1004170).
3
+ //
4
+ // THE BUG. GNU stat takes `-c FORMAT`; BSD/macOS stat refuses `-c` outright, and its own
5
+ // `-f FORMAT` means "file system status" to GNU. Five scripts called `stat -c` alone, so
6
+ // each died, or lost its answer, on every Mac, and passed on CI's Linux, so nobody
7
+ // reading Actions saw it. Only one of the five had a test that ran it; the suites beside
8
+ // each script now run them under macOS-shaped tools. This is the ratchet that stops a
9
+ // sixth arriving without one.
10
+ //
11
+ // THE RULE, per line of every tracked shell script: a `stat -c` carries its BSD twin after
12
+ // an `||` on the same line, and a `stat -f` never appears without one before it. A byte
13
+ // count needs neither: POSIX `wc -c` answers it everywhere.
14
+
15
+ import assert from 'node:assert/strict';
16
+ import { test } from 'node:test';
17
+ import { execFileSync } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const GNU = /(^|[^\w-])stat\s+(-c|--format|--printf)[\s=]/;
24
+ const BSD = /(^|[^\w-])stat\s+-f\s/;
25
+
26
+ // offending — the lines of `src` that ask stat in only one platform's dialect.
27
+ function offending(src) {
28
+ return src.split('\n').map((l, i) => ({ n: i + 1, l })).filter(({ l }) => {
29
+ if (/^\s*#/.test(l)) return false;
30
+ const g = l.search(GNU);
31
+ const b = l.search(BSD);
32
+ if (g < 0 && b < 0) return false;
33
+ return !(g >= 0 && b > g && l.slice(g, b).includes('||'));
34
+ });
35
+ }
36
+
37
+ test('every tracked shell script asks stat in both dialects, or not at all', () => {
38
+ const files = execFileSync('git', ['ls-files', '*.sh'], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean);
39
+ assert.ok(files.length > 20, `the scan must see the scripts, got ${files.length}`);
40
+ const hits = files.flatMap((f) => offending(fs.readFileSync(path.join(ROOT, f), 'utf8')).map(({ n, l }) => `${f}:${n}: ${l.trim()}`));
41
+ assert.deepEqual(hits, [], "size: wc -c < f | tr -d '[:space:]'; anything else: stat -c FMT f || stat -f FMT f");
42
+ });
43
+
44
+ test('the rule catches a lone GNU or BSD stat, and passes the paired form and prose', () => {
45
+ assert.equal(offending("BYTES=$(stat -c '%s' \"$f\")").length, 1);
46
+ assert.equal(offending('U=$(stat -f %Su "$d")').length, 1);
47
+ assert.equal(offending('U=$(stat -c %U "$d" 2>/dev/null || stat -f %Su "$d" 2>/dev/null)').length, 0);
48
+ assert.equal(offending(" # was stat -c '%s', which BSD refuses").length, 0, 'a comment is not a call');
49
+ });