@bongos/core 1.19.724 → 1.19.726

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.
@@ -518,3 +518,140 @@ test('the floor decision reads the hit\'s registry floor, so a re-map takes effe
518
518
  const atFloor = ppc.checkPermissionPaths({ changedFiles: ['migrations/001.sql'], builderRank: 'metic' });
519
519
  assert.equal(atFloor.hardFail, false);
520
520
  });
521
+
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // task 1002874 — NFC + case-folding, the last piece of R101's ADR 0151 §4 scope.
525
+ //
526
+ // R101 shipped the registry with matching left exact-BYTES, deliberately and
527
+ // declared: its hard constraint was byte-identical behaviour, and normalisation
528
+ // changes what matches. This IS that change, so these come in two halves — the
529
+ // wall must now hold against a re-spelling, and nothing protected before may have
530
+ // stopped being protected.
531
+ // ---------------------------------------------------------------------------
532
+
533
+ // A real literal surface from the registry, not a fixture: this cannot pass
534
+ // against a glob that no longer exists.
535
+ const LITERAL = ppc.PROTECTED_SURFACES.find((s) => !s.glob.includes('*') && !s.glob.endsWith('/'));
536
+
537
+ test('a CASE variant of a protected path is still protected', () => {
538
+ assert.ok(LITERAL, 'no literal surface in the registry — this test is reading the wrong shape');
539
+ assert.ok(ppc.surfaceFor(LITERAL.glob), 'the exact spelling must match (sanity)');
540
+ // macOS by default and Windows are case-INsensitive: this names the SAME file.
541
+ assert.ok(ppc.surfaceFor(LITERAL.glob.toUpperCase()),
542
+ 'an uppercased spelling of a protected file must not step over the wall');
543
+ const mixed = LITERAL.glob.replace(/(^|\/)([a-z])/g, (m, s, c) => s + c.toUpperCase());
544
+ assert.ok(ppc.surfaceFor(mixed), `a mixed-case spelling must not step over the wall: ${mixed}`);
545
+ });
546
+
547
+ test('the REPORTED path keeps its original case — only matching is folded', () => {
548
+ const shouty = LITERAL.glob.toUpperCase();
549
+ const [hit] = ppc.matchProtected([shouty]);
550
+ assert.ok(hit, 'the case variant must be caught');
551
+ assert.equal(hit.file, shouty,
552
+ 'findings, refusals and grader issues all carry this path; a lowercased one names '
553
+ + 'nothing on a case-SENSITIVE filesystem (Linux, i.e. CI) and would ENOENT for a file that exists');
554
+ assert.equal(hit.glob, LITERAL.glob, 'and the registry glob is reported verbatim, not folded');
555
+ });
556
+
557
+ test('an NFD-composed path matches its NFC glob', () => {
558
+ // The registry is pure ASCII today, so this exercises the mechanism directly:
559
+ // one filename, two different byte strings, either of which a checkout can hand you.
560
+ const nfc = 'modules/caf\u00e9/secret.js';
561
+ const nfd = 'modules/cafe\u0301/secret.js';
562
+ assert.notEqual(nfc, nfd, 'the two spellings must really differ in bytes, or this proves nothing');
563
+ const fold = (s) => s.normalize('NFC').toLowerCase();
564
+ const re = ppc.globToRegExp(fold(nfc));
565
+ assert.ok(re.test(fold(nfd)), 'NFD and NFC spellings of one filename must match the same glob');
566
+ assert.ok(re.test(fold(nfc.toUpperCase())), 'and case must not separate them either');
567
+ });
568
+
569
+ test('STRICTLY WIDENING: every glob still matches a path it matched before', () => {
570
+ // The regression that would matter is a path silently becoming UNprotected, so
571
+ // walk the whole registry and synthesise a concrete path from each glob.
572
+ let checked = 0;
573
+ for (const s of ppc.PROTECTED_SURFACES) {
574
+ const sample = s.glob.endsWith('/')
575
+ ? `${s.glob}probe.js`
576
+ : s.glob.replace(/\*\*/g, 'x/y').replace(/\*/g, 'z');
577
+ assert.ok(ppc.surfaceFor(sample), `${s.glob} no longer protects ${sample} — this change must never narrow`);
578
+ checked += 1;
579
+ }
580
+ assert.ok(checked >= 40, `only ${checked} surfaces walked — the registry read is not working`);
581
+ });
582
+
583
+ test('an UNprotected path is still unprotected — folding must not over-reach', () => {
584
+ for (const p of ['README.md', 'docs/notes.md', 'tests/example.mjs']) {
585
+ assert.equal(ppc.surfaceFor(p), null, `${p} must not become protected by normalisation`);
586
+ }
587
+ });
588
+
589
+ test('the matcher never uses a LOCALE-sensitive case fold (the Turkish-I hazard)', () => {
590
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'permission-path-check.js'), 'utf8')
591
+ .replace(/\/\*[\s\S]*?\*\//g, '')
592
+ .replace(/\/\/[^\n]*/g, '');
593
+ assert.ok(src.length > 1000, `comment-stripping left only ${src.length} chars — the scan is reading nothing`);
594
+ assert.ok(!/toLocaleLowerCase|toLocaleUpperCase/.test(src),
595
+ 'toLocaleLowerCase("tr") maps I to dotless i — measured — so a Turkish locale would fold '
596
+ + 'two different paths together. Plain toLowerCase is locale-independent by specification '
597
+ + 'and is the correct primitive here; the ADR note warning against it does not apply to JS.');
598
+ assert.match(src, /toLowerCase\(\)/, 'and the fold must actually be applied');
599
+ assert.match(src, /normalize\('NFC'\)/, 'as must NFC composition');
600
+ });
601
+
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // task 1002874, round 2 — the fold has to reach every PROTECTED matcher, not just
605
+ // the one in permission-path-check.
606
+ //
607
+ // Round 1 fixed matchProtected/surfaceFor and trusted the task description's claim
608
+ // that all eleven substrate consumers route through them. They do not.
609
+ // gate-review.js — the CI ESCALATE / HARD_FLOOR classifier — imported the RAW
610
+ // PROTECTED_GLOBS plus globToRegExp and compiled its own byte-exact regexes, so an
611
+ // uppercased spelling of a gate surface still walked past the auto-merge gate after
612
+ // the wall itself was fixed. Its isCanonicalPath() rejects non-NFC and non-ASCII
613
+ // but does not case-fold, so it never covered this.
614
+ // ---------------------------------------------------------------------------
615
+
616
+ test('compileGlobMatcher folds BOTH sides', () => {
617
+ const m = ppc.compileGlobMatcher(['src/bongos/auth.js', 'migrations/']);
618
+ assert.ok(m('src/bongos/auth.js'), 'exact still matches');
619
+ assert.ok(m('SRC/BONGOS/AUTH.JS'), 'case variant matches');
620
+ assert.ok(m('Migrations/001.sql'), 'a folded subtree prefix still matches');
621
+ assert.ok(!m('docs/readme.md'), 'and an unrelated path does not');
622
+ assert.ok(!m(''), 'empty is never a match');
623
+ assert.ok(!m(null), 'nor is a missing path');
624
+ });
625
+
626
+ test('gate-review classifies a CASE-VARIANT gate surface as hard-floor', () => {
627
+ const gr = require(path.join(ROOT, 'scripts', 'gds', 'gate-review.js'));
628
+ const literal = gr.HARD_FLOOR_GLOBS.find((g) => !g.includes('*') && !g.endsWith('/'));
629
+ assert.ok(literal, 'no literal hard-floor glob — this test is reading the wrong shape');
630
+ assert.ok(gr.matchesHardFloor(literal), 'the exact spelling must escalate (sanity)');
631
+ assert.ok(gr.matchesHardFloor(literal.toUpperCase()),
632
+ 'an uppercased gate surface must still escalate — otherwise CI auto-merge is bypassable '
633
+ + 'by re-spelling, which is the hole this task exists to close');
634
+ });
635
+
636
+ test('no consumer compiles a PROTECTED glob list byte-exact', () => {
637
+ // The drift guard. Both globToRegExp and the raw glob lists are exported, so a
638
+ // consumer can rebuild the byte-exact matcher by accident — which is exactly what
639
+ // gate-review had done. globToRegExp stays exported because ALLOW-lists
640
+ // legitimately need it: folding publish-manifest's PUBLISH_ALLOWLIST would widen
641
+ // what reaches the public OSS mirror, i.e. publish MORE. So the ban is narrow —
642
+ // pairing a PROTECTED list with the raw compiler in one file.
643
+ const dir = path.join(ROOT, 'scripts', 'gds');
644
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'));
645
+ assert.ok(files.length > 20, `only ${files.length} scripts scanned — the sweep is not reading the CLI directory`);
646
+ const offenders = [];
647
+ for (const f of files) {
648
+ const src = fs.readFileSync(path.join(dir, f), 'utf8')
649
+ .replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
650
+ const usesRawCompiler = /globToRegExp\s*\(/.test(src);
651
+ const usesProtectedList = /(PROTECTED_GLOBS|HARD_FLOOR_GLOBS)\s*\.\s*map/.test(src);
652
+ if (usesRawCompiler && usesProtectedList) offenders.push(f);
653
+ }
654
+ assert.deepEqual(offenders, [],
655
+ `these compile a protected-surface list with the raw byte-exact compiler; use `
656
+ + `compileGlobMatcher so the fold reaches them too: ${offenders.join(', ')}`);
657
+ });
package/tests/init.mjs CHANGED
@@ -535,10 +535,60 @@ test('writeConfigs (greenfield): writes a root README START-HERE, never clobberi
535
535
  const md = readFileSync(res.readmePath, 'utf8');
536
536
  assert.ok(/## Start here/.test(md) && md.includes('npm install'), 'is the START-HERE guide');
537
537
 
538
- // A --force re-init must NOT clobber a README the owner has since edited.
538
+ // A --force re-init must NOT clobber a README the owner has since edited. It APPENDS
539
+ // the start-here section instead of skipping (the old behaviour silently owed the
540
+ // `npm install` instruction to every repo that already had a README).
539
541
  writeFileSync(res.readmePath, '# my own readme\n');
540
542
  init.writeConfigs(fullSpec(), { dir, force: true, dryRun: false, log: () => {} });
541
- assert.equal(readFileSync(res.readmePath, 'utf8'), '# my own readme\n', 'existing README left untouched on --force');
543
+ const after = readFileSync(res.readmePath, 'utf8');
544
+ assert.ok(after.startsWith('# my own readme\n'), 'the owner\'s own README content is never clobbered');
545
+ assert.ok(after.includes('npm install'), 'and the first-run instruction is still delivered');
546
+ } finally {
547
+ rmSync(dir, { recursive: true, force: true });
548
+ }
549
+ });
550
+
551
+ test('appendReadmeSection: appends once, then is idempotent', () => {
552
+ const section = init.buildReadmeSection({ identity: { productName: 'Mercury' } });
553
+ const own = '# hermes_inventory_management\nInventory Management\n';
554
+ const first = init.appendReadmeSection(own, section);
555
+ assert.equal(first.action, 'appended');
556
+ assert.ok(first.text.startsWith(own), 'the owner\'s content stays at the top, byte-identical');
557
+ assert.ok(first.text.includes('npm install'), 'delivers the first-run instruction');
558
+
559
+ const second = init.appendReadmeSection(first.text, section);
560
+ assert.equal(second.action, 'present', 're-running init must not stack duplicate sections');
561
+ assert.equal(second.text, first.text, 'and must not rewrite the file at all');
562
+ });
563
+
564
+ test('buildReadmeSection: nests under the owner\'s README — no second H1', () => {
565
+ const section = init.buildReadmeSection({ identity: { productName: 'Mercury' } });
566
+ assert.ok(!/^# /m.test(section), 'no H1 that would compete with the owner\'s title');
567
+ assert.ok(section.includes(init.README_MARKER), 'carries the marker that makes re-runs idempotent');
568
+ });
569
+
570
+ test('layerReadme (adopt): an existing README GETS the start-here section, not a skip', () => {
571
+ const dir = mkdtempSync(join(tmpdir(), 'adopt-readme-'));
572
+ try {
573
+ // The exact shape that broke: a brownfield repo whose own README predates adoption.
574
+ writeFileSync(join(dir, 'README.md'), '# hermes_inventory_management\nInventory Management\n');
575
+ const action = init.layerReadme({ identity: { productName: 'Mercury' } }, { dir, dryRun: false, log: () => {} });
576
+ assert.equal(action, 'appended');
577
+ const md = readFileSync(join(dir, 'README.md'), 'utf8');
578
+ assert.ok(md.startsWith('# hermes_inventory_management'), 'owner content preserved');
579
+ assert.ok(md.includes('npm install'), 'THE FIX: adopt no longer silently owes the first-run guide');
580
+ } finally {
581
+ rmSync(dir, { recursive: true, force: true });
582
+ }
583
+ });
584
+
585
+ test('layerReadme (dry-run): reports the append but writes nothing', () => {
586
+ const dir = mkdtempSync(join(tmpdir(), 'adopt-readme-dry-'));
587
+ try {
588
+ writeFileSync(join(dir, 'README.md'), '# mine\n');
589
+ const action = init.layerReadme({ identity: { productName: 'Mercury' } }, { dir, dryRun: true, log: () => {} });
590
+ assert.equal(action, 'appended');
591
+ assert.equal(readFileSync(join(dir, 'README.md'), 'utf8'), '# mine\n', 'dry-run writes nothing');
542
592
  } finally {
543
593
  rmSync(dir, { recursive: true, force: true });
544
594
  }
@@ -48,6 +48,7 @@ const PUBLISHED_SURFACE = [
48
48
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
49
49
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
50
50
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
51
+ 'isModuleEnabled', // task 1003400: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
51
52
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
52
53
  'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
53
54
  'contribute', 'contributions', 'collectContributions', 'listContributionPoints',
@@ -208,6 +208,88 @@ test('a good bind still produces the blob/tree bases callers rely on', () => {
208
208
  }
209
209
  });
210
210
 
211
+ // ---- loadFromBranding: the source a standalone instance actually has -------
212
+ //
213
+ // An installed core sits in node_modules/@bongos/core, so loadFromGit correctly refuses
214
+ // and nothing sets the env pin — which meant EVERY standalone instance served a 503 from
215
+ // /public/repo-info while `config/branding.json` held the answer. These cases pin the new
216
+ // source AND, just as importantly, pin that it did not disturb the two above it.
217
+
218
+ const PACK = { repo: { owner: FIXTURE_OWNER, name: FIXTURE_REPO } };
219
+
220
+ test('a branding pack with owner + name binds (repo.name maps to repo)', () => {
221
+ assert.deepEqual(ri.loadFromBranding({ branding: () => PACK }), {
222
+ owner: FIXTURE_OWNER,
223
+ repo: FIXTURE_REPO,
224
+ });
225
+ });
226
+
227
+ test('the neutral pack (empty strings) is NOT an answer', () => {
228
+ assert.equal(ri.loadFromBranding({ branding: () => ({ repo: { owner: '', name: '' } }) }), null);
229
+ assert.equal(ri.loadFromBranding({ branding: () => ({ repo: { owner: ' ', name: FIXTURE_REPO } }) }), null);
230
+ });
231
+
232
+ test('a pack with no repo key at all → null', () => {
233
+ assert.equal(ri.loadFromBranding({ branding: () => ({ identity: { productName: 'x' } }) }), null);
234
+ assert.equal(ri.loadFromBranding({ branding: () => null }), null);
235
+ });
236
+
237
+ test('a THROWING branding loader degrades to null, never a crash', () => {
238
+ assert.equal(ri.loadFromBranding({ branding: () => { throw new Error('malformed pack'); } }), null);
239
+ });
240
+
241
+ test('THE FIX: packaged instance, no env, no git → branding answers instead of 503', () => {
242
+ ri._resetCacheForTests();
243
+ try {
244
+ const root = path.resolve('/srv/instance/node_modules/@bongos/core');
245
+ const info = ri.loadRepoInfo({
246
+ execSync: mkExec({ toplevel: path.resolve('/srv/instance') }), // git walks up → refused
247
+ packageRoot: root,
248
+ branding: () => PACK,
249
+ });
250
+ assert.equal(info.error, undefined, 'the instance declares its own repo — that is not an error case');
251
+ assert.equal(info.owner, FIXTURE_OWNER);
252
+ assert.equal(info.repo, FIXTURE_REPO);
253
+ assert.equal(info.blob_base, `https://github.com/${FIXTURE_OWNER}/${FIXTURE_REPO}/blob/main`);
254
+ } finally {
255
+ ri._resetCacheForTests();
256
+ }
257
+ });
258
+
259
+ test('git still WINS over branding (the core dev checkout is unchanged)', () => {
260
+ ri._resetCacheForTests();
261
+ try {
262
+ const root = path.resolve('/srv/cloud-bongos');
263
+ const info = ri.loadRepoInfo({
264
+ execSync: mkExec({ toplevel: root }), // the remote resolves → git answers
265
+ packageRoot: root,
266
+ branding: () => ({ repo: { owner: 'packowner', name: 'packrepo' } }),
267
+ });
268
+ assert.equal(info.owner, FIXTURE_OWNER, 'branding must never override a resolved git remote');
269
+ } finally {
270
+ ri._resetCacheForTests();
271
+ }
272
+ });
273
+
274
+ test('the env pin still WINS over branding (the operator override stays on top)', () => {
275
+ ri._resetCacheForTests();
276
+ process.env.OTB_GITHUB_OWNER = 'envowner';
277
+ process.env.OTB_GITHUB_REPO = 'envrepo';
278
+ try {
279
+ const root = path.resolve('/srv/instance/node_modules/@bongos/core');
280
+ const info = ri.loadRepoInfo({
281
+ execSync: mkExec({ toplevel: path.resolve('/srv/instance') }),
282
+ packageRoot: root,
283
+ branding: () => PACK,
284
+ });
285
+ assert.equal(info.owner, 'envowner');
286
+ } finally {
287
+ delete process.env.OTB_GITHUB_OWNER;
288
+ delete process.env.OTB_GITHUB_REPO;
289
+ ri._resetCacheForTests();
290
+ }
291
+ });
292
+
211
293
  // ---- end-to-end canary: the regression this must never cause ---------------
212
294
  //
213
295
  // The only case that touches real git. It earns its place — an anchor rule that is too
@@ -91,6 +91,22 @@ t('names the Settings page, CLI Access, and the owner checkbox repair', () => {
91
91
  assert.match(text, /Web sign-in is unaffected/i, 'says what still works');
92
92
  });
93
93
 
94
+ t('the closing step adapts to the caller — setup pastes, login runs a one-liner', () => {
95
+ const forSetup = setup.deviceFlowFallbackLines('https://spike.cloudbongos.com').join('\n');
96
+ const forLogin = setup.deviceFlowFallbackLines('https://spike.cloudbongos.com', { finish: 'login' }).join('\n');
97
+
98
+ // The default must be byte-stable — setup's own prompt still says "paste it below".
99
+ assert.match(forSetup, /paste it below/, 'setup keeps its inline-paste wording');
100
+ assert.doesNotMatch(forLogin, /paste it below/, '`bongos login` has no paste prompt to point at');
101
+ assert.match(forLogin, /run it in this shell/, 'login tells you what actually works there');
102
+
103
+ // Everything that is genuinely shared stays shared — one source of truth.
104
+ for (const shared of [/Enable Device Flow/, /CLI Access/, /builders\/settings/]) {
105
+ assert.match(forSetup, shared);
106
+ assert.match(forLogin, shared);
107
+ }
108
+ });
109
+
94
110
  // ── redeemCliToken in a hermetic child against a stubbed instance API ────────
95
111
  // A child process because API_BASE + the session path resolve at module load:
96
112
  // the child gets GDS_API_BASE → the stub and HOME/USERPROFILE → a throwaway