@bongos/core 1.19.968 → 1.19.970

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.
@@ -0,0 +1,150 @@
1
+ // tests/merge_driver_register.mjs
2
+ //
3
+ // Task 1003846 — registerMergeDriver wrote two git-config keys in sequence inside one
4
+ // try, `.name` first. If the first succeeded and the second threw, the half-written
5
+ // section SURVIVED, and git does not treat a named driver with no command as "fall back
6
+ // to a text merge" — it treats it as fatal:
7
+ //
8
+ // fatal: custom merge driver otb-regen lacks command line.
9
+ //
10
+ // exit 128, no unmerged paths. Every merge touching any `merge=otb-regen` path then dies
11
+ // — the root CLAUDE.md, the file/session-log maps, two nested CLAUDE.md files, the
12
+ // copy-registry pair and the nine generated API artifacts. So the partial-write state was
13
+ // strictly WORSE than not registering at all, which merely yields an ordinary conflict.
14
+ //
15
+ // This file pins BOTH halves of the fix, and pins them against real git rather than a
16
+ // mock, because the whole finding rests on how git itself reads a partial section — a
17
+ // mock would only assert what we already believe. The first two tests would have passed
18
+ // on the buggy code too if they were written against a stub; they are written against a
19
+ // throwaway repo on disk precisely so they cannot.
20
+ //
21
+ // Run: node --test tests/merge_driver_register.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { test } from 'node:test';
25
+ import { execFileSync } from 'node:child_process';
26
+ import { createRequire } from 'node:module';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ // fileURLToPath, never URL.pathname — on Windows the latter yields '/C:/…', which is not
34
+ // a usable path. Enforced by fitness.js (task 1002889).
35
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
+ const { classifyMergeDriverState, registerMergeDriver, MERGE_DRIVER_NAME } =
37
+ require(path.join(REPO_ROOT, 'scripts/gds/install-git-hooks.js'));
38
+
39
+ // A throwaway repo with one file attributed to the driver and two divergent branches,
40
+ // so `git merge` actually has to consult the driver config.
41
+ function scratchRepo() {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mergedriver-'));
43
+ const git = (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
44
+ git(['init', '-q', '.']);
45
+ git(['config', 'user.email', 'test@example.com']);
46
+ git(['config', 'user.name', 'test']);
47
+ fs.writeFileSync(path.join(dir, 'f.md'), 'base\n');
48
+ fs.writeFileSync(path.join(dir, '.gitattributes'), `f.md merge=${MERGE_DRIVER_NAME}\n`);
49
+ git(['add', '-A']);
50
+ git(['commit', '-qm', 'base']);
51
+ const base = git(['branch', '--show-current']).trim();
52
+ git(['checkout', '-qb', 'other']);
53
+ fs.writeFileSync(path.join(dir, 'f.md'), 'theirs\n');
54
+ git(['commit', '-qam', 'other']);
55
+ git(['checkout', '-q', base]);
56
+ fs.writeFileSync(path.join(dir, 'f.md'), 'ours\n');
57
+ git(['commit', '-qam', 'mine']);
58
+ return { dir, git };
59
+ }
60
+
61
+ // Returns null on a clean merge, or git's stderr on failure.
62
+ function tryMerge(dir) {
63
+ try {
64
+ execFileSync('git', ['merge', 'other'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
65
+ return null;
66
+ } catch (e) {
67
+ return String(e.stderr || e.message);
68
+ }
69
+ }
70
+
71
+ test('THE BUG: a section with .name and no .driver makes every merge fatal', () => {
72
+ const { dir, git } = scratchRepo();
73
+ git(['config', `merge.${MERGE_DRIVER_NAME}.name`, 'half-written']);
74
+ const err = tryMerge(dir);
75
+ assert.ok(err, 'merge must FAIL in the half-written state — if this passes, the premise is gone');
76
+ assert.match(err, /lacks command line/,
77
+ 'git must reject a named driver with no command (this is why .name cannot be written first)');
78
+ fs.rmSync(dir, { recursive: true, force: true });
79
+ });
80
+
81
+ test('THE FIX RESTS ON THIS: a section with only .driver is valid and runs', () => {
82
+ const { dir, git } = scratchRepo();
83
+ // `cp %B %A` stands in for git-merge-regen.js: it resolves by taking theirs, which is
84
+ // observable in the file afterwards, so this asserts the driver RAN — not merely that
85
+ // the merge exited 0, which a fallback text merge would also do.
86
+ git(['config', `merge.${MERGE_DRIVER_NAME}.driver`, 'cp %B %A']);
87
+ assert.equal(tryMerge(dir), null, 'a driver-only section must merge cleanly');
88
+ assert.equal(fs.readFileSync(path.join(dir, 'f.md'), 'utf8'), 'theirs\n',
89
+ 'the custom driver must have run — otherwise this proves nothing about ordering');
90
+ fs.rmSync(dir, { recursive: true, force: true });
91
+ });
92
+
93
+ test('registerMergeDriver writes .driver BEFORE .name', () => {
94
+ const order = [];
95
+ const run = (_bin, args) => { order.push(args[1]); };
96
+ const res = registerMergeDriver('/nowhere', { run });
97
+ assert.equal(res.ok, true);
98
+ assert.deepEqual(order, [`merge.${MERGE_DRIVER_NAME}.driver`, `merge.${MERGE_DRIVER_NAME}.name`],
99
+ 'order is the fix: the intermediate state after write #1 must be the SAFE one');
100
+ });
101
+
102
+ test('a failure mid-registration removes the whole section, leaving no trace', () => {
103
+ const calls = [];
104
+ const run = (_bin, args) => {
105
+ calls.push(args);
106
+ // Fail on the SECOND write (.name) — the exact partial-write this task is about.
107
+ if (args[1] === `merge.${MERGE_DRIVER_NAME}.name`) throw new Error('simulated config failure');
108
+ };
109
+ const res = registerMergeDriver('/nowhere', { run });
110
+ assert.equal(res.ok, false, 'a failed registration must report failure');
111
+ assert.match(res.error, /simulated config failure/, 'and must report the ORIGINAL error, not the cleanup');
112
+ assert.deepEqual(calls[calls.length - 1], ['config', '--remove-section', `merge.${MERGE_DRIVER_NAME}`],
113
+ 'the last action must be removing the section, so nothing partial survives');
114
+ });
115
+
116
+ test('cleanup failing does not mask the original error', () => {
117
+ const run = (_bin, args) => {
118
+ if (args[1] === `merge.${MERGE_DRIVER_NAME}.name`) throw new Error('original failure');
119
+ if (args[1] === '--remove-section') throw new Error('cleanup also failed');
120
+ };
121
+ const res = registerMergeDriver('/nowhere', { run });
122
+ assert.equal(res.ok, false);
123
+ assert.match(res.error, /original failure/, 'the reported error must be the one that matters');
124
+ });
125
+
126
+ test('classifyMergeDriverState: name without driver is the FAIL doctor must surface', () => {
127
+ const v = classifyMergeDriverState({ name: 'something', driver: '' });
128
+ assert.equal(v.status, 'fail');
129
+ assert.equal(v.code, 'name-without-driver');
130
+ assert.match(v.message, /lacks command line/, 'the message must quote the fatal a builder will actually see');
131
+ assert.match(v.message, /--remove-section/, 'and must give a way out');
132
+ });
133
+
134
+ test('classifyMergeDriverState: driver present is ok, with or without a name', () => {
135
+ assert.equal(classifyMergeDriverState({ name: 'n', driver: 'd' }).status, 'ok');
136
+ assert.equal(classifyMergeDriverState({ name: '', driver: 'd' }).status, 'ok',
137
+ 'driver-only is a WORKING section (proved against real git above), so it must not be reported as a fault');
138
+ });
139
+
140
+ test('classifyMergeDriverState: neither set is a warn, not a fail', () => {
141
+ const v = classifyMergeDriverState({ name: '', driver: '' });
142
+ assert.equal(v.status, 'warn');
143
+ assert.equal(v.code, 'unregistered');
144
+ assert.match(v.message, /ship\.js still self-heals/, 'absent is not a hole in the walls — say so');
145
+ });
146
+
147
+ test('whitespace-only config values count as absent', () => {
148
+ assert.equal(classifyMergeDriverState({ name: ' ', driver: ' ' }).code, 'unregistered');
149
+ assert.equal(classifyMergeDriverState({ name: 'n', driver: ' ' }).code, 'name-without-driver');
150
+ });
@@ -181,7 +181,11 @@ test('the sign-in printers, the release refusals and the claim guard each print
181
181
 
182
182
  test('every way start.js can refuse or fail prints a card', () => {
183
183
  const src = read('scripts/gds/start.js');
184
- const codes = [...src.matchAll(/\.error === '([A-Za-z_]+)'/g)].map((m) => m[1]);
184
+ // BOTH spellings, or this assertion pins the bug it was written above: ADR 0116
185
+ // nests the slug as `{ error: { code } }`, so the flat `.error === 'slug'` compares
186
+ // an object to a string and is never true. Task 1003658 moved the live site to
187
+ // errCode(); matching only the flat form made the FIX read as "the branch moved".
188
+ const codes = [...src.matchAll(/(?:\.error|errCode\([^)]*\)) === '([A-Za-z_]+)'/g)].map((m) => m[1]);
185
189
  assert.ok(codes.includes('stage_required'), 'start.js no longer names its refusal — has the branch moved?');
186
190
  assert.deepEqual(missing(codes), []);
187
191
  for (const code of [...new Set(codes)]) assert.ok(src.includes(`plainCardLines('${code}')`), `start.js never prints the ${code} card`);
@@ -2858,6 +2858,111 @@ await ta('ensurePrivateRepoAccess(dry-run): fully OFFLINE — no token read, no
2858
2858
  assert.equal(tokenReads, 0, 'not even the credential read');
2859
2859
  });
2860
2860
 
2861
+ console.log('\nflip-to-private: wiring BEFORE the flip (task 1004167):');
2862
+
2863
+ // The flip must wire first and flip second — a deploy key authenticates fine against a
2864
+ // still-public repo, so wiring first leaves no broken window, whereas flipping first
2865
+ // breaks the very next deploy pull. But at wire time GitHub still answers `private:
2866
+ // false`, and the function branches on that OBSERVED value. `intendedPrivate` is the
2867
+ // caller's declaration of where the repo is going.
2868
+
2869
+ await ta('intendedPrivate: wires a repo GitHub still reports PUBLIC — the pre-flip window', async () => {
2870
+ const { deps, cmds, apiCalls } = repoAuthDeps({ priv: false, existing: false });
2871
+ deps.intendedPrivate = true;
2872
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2873
+ assert.equal(r.private, true, 'the declared INTENT decides, not the current visibility');
2874
+ const add = apiCalls.find((c) => c[0] === 'add');
2875
+ assert.ok(add, 'the key registers while the repo is still public — it authenticates either way');
2876
+ assert.equal(add[3], true, 'still read-only: the override widens WHEN we wire, never what the key may do');
2877
+ const joined = cmds.join('\n');
2878
+ assert.match(joined, /ssh-keygen -q -t ed25519/);
2879
+ assert.match(joined, /remote set-url origin git@github\.com:example-owner\/demo\.git/);
2880
+ assert.match(joined, /config core\.sshCommand/);
2881
+ });
2882
+
2883
+ await ta('intendedPrivate: does NOT un-wire an existing key — the un-wire branch is what made pre-flip wiring impossible', async () => {
2884
+ const { deps, cmds, apiCalls } = repoAuthDeps({
2885
+ priv: false, existing: true,
2886
+ keys: [{ id: 11, title: 'cloudbongos-deploy-demo', key: 'ssh-ed25519 AAAAlocal', read_only: true }],
2887
+ });
2888
+ deps.intendedPrivate = true;
2889
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2890
+ assert.ok(!r.unwired, 'the caller asked for wiring; tearing it down is the exact opposite');
2891
+ assert.ok(!apiCalls.some((c) => c[0] === 'del'), 'the registered key survives');
2892
+ const joined = cmds.join('\n');
2893
+ assert.ok(!/config --unset core\.sshCommand/.test(joined), 'sshCommand stays pinned');
2894
+ assert.ok(!/remote set-url origin git@github-deploy:/.test(joined), 'origin is not returned to the shared alias');
2895
+ });
2896
+
2897
+ await ta('intendedPrivate does NOT bypass the admin precondition', async () => {
2898
+ const { deps, cmds, apiCalls } = repoAuthDeps({ priv: false, existing: false });
2899
+ deps.intendedPrivate = true;
2900
+ deps.githubRepos.getUserRepo = async () => ({ full_name: 'example-owner/demo', private: false, admin: false });
2901
+ await assert.rejects(() => P.ensurePrivateRepoAccess(privInst, deps), /repository access is not admin: .*ADMIN/);
2902
+ assert.ok(!cmds.some((c) => /ssh-keygen/.test(c)), 'no key minted for a credential that cannot register it');
2903
+ assert.ok(!apiCalls.some((c) => c[0] === 'add'));
2904
+ });
2905
+
2906
+ await ta('no override → every existing path is byte-identical (the override is opt-in only)', async () => {
2907
+ const { deps, cmds, apiCalls } = repoAuthDeps({ priv: false, existing: false });
2908
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2909
+ assert.equal(r.private, false);
2910
+ assert.equal(cmds.length, 0);
2911
+ assert.deepEqual(apiCalls.map((c) => c[0]), ['get'], 'still one visibility read and nothing else');
2912
+ });
2913
+
2914
+ // A declared intent changes what a soft skip MEANS. Without it, "no credential" is a
2915
+ // warning about a repo that merely might be private. With it, the caller is about to
2916
+ // flip — so skipping quietly hands back an unwired checkout and the flip then breaks
2917
+ // the recurring pull for good (walk finding N4, the thing ADR 0176 exists to prevent).
2918
+
2919
+ await ta('intendedPrivate + no token: REFUSES rather than skipping — a silent skip would break the next pull', async () => {
2920
+ const { deps, cmds, apiCalls } = repoAuthDeps({ token: null });
2921
+ deps.intendedPrivate = true;
2922
+ await assert.rejects(() => P.ensurePrivateRepoAccess(privInst, deps), /cannot be made private[\s\S]*credential/i);
2923
+ assert.equal(cmds.length, 0);
2924
+ assert.equal(apiCalls.length, 0);
2925
+ });
2926
+
2927
+ await ta('intendedPrivate + unreachable GitHub: REFUSES rather than skipping', async () => {
2928
+ const { deps, cmds } = repoAuthDeps({ existing: false });
2929
+ deps.intendedPrivate = true;
2930
+ deps.githubRepos.getUserRepo = async () => { throw new Error('socket hang up'); };
2931
+ await assert.rejects(() => P.ensurePrivateRepoAccess(privInst, deps), /cannot be made private[\s\S]*try again/i);
2932
+ assert.ok(!cmds.some((c) => /ssh-keygen/.test(c)), 'nothing wired on a read we could not complete');
2933
+ });
2934
+
2935
+ await ta('without the intent, both soft skips stay soft (unchanged for every existing caller)', async () => {
2936
+ const noTok = repoAuthDeps({ token: null });
2937
+ assert.equal((await P.ensurePrivateRepoAccess(privInst, noTok.deps)).skipped, 'no_token');
2938
+ const unreach = repoAuthDeps({ existing: false });
2939
+ unreach.deps.githubRepos.getUserRepo = async () => { throw new Error('socket hang up'); };
2940
+ assert.equal((await P.ensurePrivateRepoAccess(privInst, unreach.deps)).skipped, 'github_unreachable');
2941
+ });
2942
+
2943
+ await ta('intendedPrivate: the apply-path log does not claim the repo IS private when GitHub says otherwise', async () => {
2944
+ const lines = [];
2945
+ const { deps } = repoAuthDeps({ priv: false, existing: false });
2946
+ deps.intendedPrivate = true;
2947
+ deps.log = (m) => lines.push(String(m));
2948
+ await P.ensurePrivateRepoAccess(privInst, deps);
2949
+ const joined = lines.join('\n');
2950
+ assert.doesNotMatch(joined, /is PRIVATE/, 'GitHub reported it public — the ops log must not misstate that');
2951
+ assert.match(joined, /ABOUT TO BE MADE PRIVATE/);
2952
+ });
2953
+
2954
+ await ta('intendedPrivate(dry-run): names the pre-flip intent in the plan line, and stays OFFLINE', async () => {
2955
+ const lines = [];
2956
+ const { deps, cmds, apiCalls } = repoAuthDeps({ apply: false, priv: false, existing: false });
2957
+ deps.intendedPrivate = true;
2958
+ deps.log = (m) => lines.push(String(m));
2959
+ const r = await P.ensurePrivateRepoAccess(privInst, deps);
2960
+ assert.equal(r.planned, true);
2961
+ assert.equal(cmds.length, 0, 'no mutation in dry-run');
2962
+ assert.deepEqual(apiCalls, [], 'a plan run stays a safe inspection tool');
2963
+ assert.match(lines.join('\n'), /ABOUT TO BE MADE PRIVATE/, 'the plan says which intent it planned for');
2964
+ });
2965
+
2861
2966
  await ta('provisionInstance(standalone): the private-repo wiring lands BETWEEN the scaffold and the step-2 pull', async () => {
2862
2967
  const { deps, cmds } = repoAuthDeps({ existing: false });
2863
2968
  const r = await P.provisionInstance({ ...coTenant, hosting_shape: 'standalone', target_ref: 'example-owner/mercury' }, deps);
@@ -54,10 +54,46 @@ test('CLAUDE.md is xenos (write-protected but READ-PUBLIC — the carve-out)', (
54
54
  assert.equal(minRankForDoc('CLAUDE.md'), 'xenos');
55
55
  });
56
56
 
57
- test('SENSITIVE_DOC_GLOBS is the curated source of truth (subtrees end with /)', () => {
58
- assert.ok(SENSITIVE_DOC_GLOBS.includes('docs/security/'));
59
- assert.ok(SENSITIVE_DOC_GLOBS.includes('docs/audits/'));
60
- assert.ok(SENSITIVE_DOC_GLOBS.includes('docs/canonical-permissions.md'));
57
+ // EXHAUSTIVE, not a spot-check (task 1002882). The old version asserted three of the
58
+ // six entries with includes(), which meant the list could GAIN or LOSE an entry — a
59
+ // whole read-gated subtree appearing or vanishing — with nothing red. docs/governance/
60
+ // was added under task 1002598 and this test never noticed.
61
+ //
62
+ // A deepEqual pin makes every change to the read floor a deliberate, reviewed edit:
63
+ // you cannot widen or narrow what a sub-Metic search can reach without also editing
64
+ // this line and saying why. The functional assertions above already cover BEHAVIOUR
65
+ // (minRankForDoc returns 'metic'); this one states the list's INTENT, which is the
66
+ // half that was under-described.
67
+ //
68
+ // Keep it in sync with scripts/gds/publish-manifest.js:185, where SENSITIVE_DOC_EXCLUDE
69
+ // is derived from this same array by filtering to docs/security + docs/audits — a
70
+ // silent addition here changes the PUBLISH boundary too.
71
+ test('SENSITIVE_DOC_GLOBS is the curated source of truth (exhaustive pin)', () => {
72
+ assert.deepEqual(SENSITIVE_DOC_GLOBS, [
73
+ 'docs/canonical-permissions.md',
74
+ 'docs/security/',
75
+ 'docs/audits/',
76
+ 'docs/governance/',
77
+ 'docs/adr/0016-trust-boundary-server-enforced-permissions.md',
78
+ 'docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md',
79
+ ], 'adding or removing a read-gated doc must be a deliberate edit here, not a silent one');
80
+
81
+ // A trailing '/' means "subtree" to the glob dialect this shares with
82
+ // permission-path-check; a missing one would gate only an exact file of that name.
83
+ for (const g of ['docs/security/', 'docs/audits/', 'docs/governance/']) {
84
+ assert.ok(SENSITIVE_DOC_GLOBS.includes(g), `${g} must keep its trailing slash to gate the subtree`);
85
+ }
86
+ });
87
+
88
+ // The publish boundary reads this same array, so a change here silently moves what the
89
+ // public AGPL mirror ships. Pin the derivation, not just the source (task 1002882).
90
+ test('SENSITIVE_DOC_EXCLUDE derives docs/security + docs/audits from the same array', () => {
91
+ const derived = SENSITIVE_DOC_GLOBS.filter(
92
+ (g) => g.startsWith('docs/security') || g.startsWith('docs/audits')
93
+ );
94
+ assert.deepEqual(derived, ['docs/security/', 'docs/audits/'],
95
+ 'publish-manifest.js:185 applies exactly this filter — if the array gains another '
96
+ + 'docs/security* or docs/audits* entry, the publish denylist changes with it');
61
97
  });
62
98
 
63
99
  test('collectMarkdown finds the repo corpus but excludes node_modules/.git/worktrees/public', () => {