@bongos/core 1.19.478 → 1.19.479

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 (34) hide show
  1. package/.bongos-core.json +69 -34
  2. package/.claude/skills/design/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +4 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0230-a-look-skill-is-a-composition-grammar-the-palette-is-the-pack.md +36 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +80 -1
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +6 -6
  13. package/docs/copy-registry.json +6 -6
  14. package/docs/file-map.md +1 -1
  15. package/docs/module-api-changelog.md +2 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  17. package/modules/economy/credits.js +78 -0
  18. package/modules/economy/module.json +1 -1
  19. package/modules/economy/reward.js +4 -0
  20. package/modules/economy/routes/credits.js +151 -0
  21. package/modules/hall-ui/public/md-reader.js +24 -1
  22. package/modules/ui-design/module.json +1 -1
  23. package/modules/ui-design/skills/README.md +6 -2
  24. package/modules/ui-design/skills/gpt-taste/SKILL.md +110 -0
  25. package/modules/ui-design/skills/industrial-brutalist-ui/SKILL.md +110 -0
  26. package/modules/ui-design/skills/minimalist-ui/SKILL.md +103 -0
  27. package/modules/ui-design/skills/stitch-design-taste/SKILL.md +91 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/src/module-api.js +1 -1
  31. package/tests/credit_ledger_read.mjs +347 -0
  32. package/tests/hall_reading_world.mjs +80 -0
  33. package/tests/own_scope_guard.mjs +24 -2
  34. package/tests/ui_design_skills.mjs +56 -1
@@ -0,0 +1,347 @@
1
+ // tests/credit_ledger_read.mjs
2
+ //
3
+ // task 1003484 (goal 1000067) — the credit ledger gets a read surface, resolving
4
+ // blocker 1000127. Until this, `credit_log` had NO reader anywhere in the API:
5
+ // every in-tree query was an internal aggregate or an existence probe, so the
6
+ // three ideator streams (ADR 0172) could not be audited from outside at all.
7
+ //
8
+ // What this pins, in the order it matters:
9
+ //
10
+ // 1. THE ANTI-DRIFT GUARD. The stream reason keys EMBED the idea id
11
+ // (`idea.credit.full_pass:<ideaId>`), because that string is the
12
+ // idempotency predicate. So per-stream audit is a PREFIX question, and the
13
+ // prefixes this route publishes are asserted against the actual writers in
14
+ // credits.js — not against a transcription of them. If someone renames a
15
+ // reason key, this test fails rather than the audit filter silently
16
+ // returning nothing forever.
17
+ // 2. The filter SQL: builder scoping, exact vs prefix reason, LIKE-wildcard
18
+ // escaping (a `%` in a query string must not widen the scan), and the
19
+ // unpaginated total.
20
+ // 3. The leak rule: reward_basis is session top-up bookkeeping, not money a
21
+ // reader should see.
22
+ // 4. The two gates: /me/credits takes its builder from the SESSION (there is
23
+ // no parameter to tamper with), and /builders/:id/credits refuses a caller
24
+ // without the roster-read atom.
25
+ //
26
+ // Pure — a recording fake pool over the real router, no Postgres, no network
27
+ // beyond a loopback listener. Runs in the DB-free unit gate.
28
+ //
29
+ // Run: node tests/credit_ledger_read.mjs
30
+
31
+ import { strict as assert } from 'node:assert';
32
+ import { test, after } from 'node:test';
33
+ import { createRequire } from 'node:module';
34
+ import http from 'node:http';
35
+ import { readFileSync } from 'node:fs';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { dirname, join } from 'node:path';
38
+
39
+ const require = createRequire(import.meta.url);
40
+ const express = require('express');
41
+
42
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
43
+ const CREDITS_SRC = readFileSync(join(ROOT, 'modules/economy/credits.js'), 'utf8');
44
+
45
+ // ---- the recording fake pool ----------------------------------------------
46
+ //
47
+ // One shared pool object, so the domain's `pool` (destructured once at require
48
+ // time, through the pinned module-api below) and each test's queue are the same
49
+ // thing.
50
+ let queued = [];
51
+ let calls = [];
52
+ const pool = {
53
+ query: async (text, params) => {
54
+ calls.push({ text: String(text), params: params || [] });
55
+ return queued.shift() || { rows: [], rowCount: 0 };
56
+ },
57
+ };
58
+ function expect(...responses) { queued = responses; calls = []; }
59
+ const lastCall = () => calls[calls.length - 1];
60
+ // Collapse whitespace so an assertion reads like the SQL, not like its indentation.
61
+ const sqlOf = (c) => c.text.replace(/\s+/g, ' ').trim();
62
+
63
+ // ---- the route over the real domain, with core pinned ----------------------
64
+
65
+ const SESSION = { id: 77, github_login: 'octo', rank: 'xenos' };
66
+ let permissionGranted = true; // flipped by the refusal test
67
+ let knownBuilder = true; // flipped by the 404 test
68
+
69
+ const helpers = require('../src/bongos/routes/_helpers.js');
70
+ const moduleApiPath = require.resolve('../src/module-api.js');
71
+ require.cache[moduleApiPath] = {
72
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
73
+ exports: {
74
+ pool,
75
+ parsePagination: helpers.parsePagination,
76
+ pageMeta: helpers.pageMeta,
77
+ parseId: helpers.parseId,
78
+ validateOrRespond: helpers.validateOrRespond,
79
+ LIMITS: helpers.LIMITS,
80
+ // The own-scope stub: a live session, exactly as requireBuilder leaves it.
81
+ requireBuilder: (req, _res, next) => { req.builder = { ...SESSION }; next(); },
82
+ // The atom gate, controllable — so the refusal is proven, not assumed.
83
+ requirePermission: () => (req, res, next) => (
84
+ permissionGranted ? next() : res.fail('permission_forbidden', 403)
85
+ ),
86
+ getBuilderById: async (id) => (knownBuilder ? { id, github_login: 'target' } : null),
87
+ // Loaded-but-unused by the credit domain at require time.
88
+ branding: () => ({}),
89
+ llmPricing: { priceModelUsage: () => ({ totalUsd: 0 }), PRICES: {} },
90
+ },
91
+ };
92
+
93
+ const credits = require('../modules/economy/credits.js');
94
+ const buildCreditsRouter = require('../modules/economy/routes/credits.js');
95
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
96
+
97
+ const app = express();
98
+ app.use(express.json());
99
+ app.use(attachFail);
100
+ app.use('/api/gds', buildCreditsRouter());
101
+ const server = app.listen(0, '127.0.0.1');
102
+ await new Promise((r) => server.once('listening', r));
103
+ const port = server.address().port;
104
+ after(() => server.close());
105
+
106
+ function call(method, path) {
107
+ return new Promise((resolve, reject) => {
108
+ const req = http.request({ host: '127.0.0.1', port, method, path }, (res) => {
109
+ const chunks = [];
110
+ res.on('data', (c) => chunks.push(c));
111
+ res.on('end', () => {
112
+ const text = Buffer.concat(chunks).toString('utf8');
113
+ let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
114
+ resolve({ status: res.statusCode, json, text });
115
+ });
116
+ });
117
+ req.on('error', reject);
118
+ req.end();
119
+ });
120
+ }
121
+
122
+ // A ledger row as the SELECT hands it back (window count rides along).
123
+ const row = (over = {}) => ({
124
+ id: '5', delta: 5, reason: 'idea.credit.full_pass:20', description: null,
125
+ task_id: null, session_id: null, recorded_at: '2026-09-01T00:00:00.000Z',
126
+ total_count: 1, ...over,
127
+ });
128
+
129
+ // ---- 1. the anti-drift guard ----------------------------------------------
130
+
131
+ test('the published stream prefixes are the REAL writers in credits.js, not a transcription', () => {
132
+ const p = credits.IDEA_STREAM_REASON_PREFIXES;
133
+ // Each writer builds its reason as `<prefix>${...}` — assert that exact
134
+ // template literal is present in the source. A renamed key breaks this test
135
+ // instead of silently making the audit filter match nothing, forever.
136
+ for (const [stream, prefix] of Object.entries(p)) {
137
+ assert.ok(
138
+ CREDITS_SRC.includes('`' + prefix + '${'),
139
+ `stream '${stream}' publishes prefix '${prefix}', but no writer in credits.js builds a reason with it`
140
+ );
141
+ }
142
+ });
143
+
144
+ test('all three ideator streams are covered — one missing prefix is one unauditable lane', () => {
145
+ assert.deepEqual(
146
+ Object.keys(credits.IDEA_STREAM_REASON_PREFIXES).sort(),
147
+ ['full_pass', 'governance_pass', 'task_ship'],
148
+ 'ADR 0172 defines exactly three streams; each needs a prefix or it cannot be filtered for'
149
+ );
150
+ });
151
+
152
+ test('the prefixes are prefixes, not whole keys — the reason embeds the idea id', () => {
153
+ for (const prefix of Object.values(credits.IDEA_STREAM_REASON_PREFIXES)) {
154
+ assert.ok(prefix.endsWith(':'), `'${prefix}' must end at the separator, or it matches nothing`);
155
+ }
156
+ });
157
+
158
+ // ---- 2. the filter SQL -----------------------------------------------------
159
+
160
+ test('an unfiltered read is scoped to the builder, newest-first, and paged', async () => {
161
+ expect({ rows: [row()] });
162
+ const out = await credits.creditLedger(77, { limit: 10, offset: 0 });
163
+ const sql = sqlOf(lastCall());
164
+ assert.match(sql, /WHERE builder_id = \$1/, 'the ledger is never read unscoped');
165
+ assert.match(sql, /ORDER BY recorded_at DESC, id DESC/, 'newest first, with id as the stable tiebreak');
166
+ assert.match(sql, /LIMIT \$2 OFFSET \$3/);
167
+ assert.deepEqual(lastCall().params, [77, 10, 0]);
168
+ assert.equal(out.total, 1);
169
+ });
170
+
171
+ test('total is the UNPAGINATED count, so a caller can tell more rows exist', async () => {
172
+ expect({ rows: [row({ total_count: 412 }), row({ id: '6', total_count: 412 })] });
173
+ const out = await credits.creditLedger(77, { limit: 2, offset: 0 });
174
+ assert.match(sqlOf(lastCall()), /COUNT\(\*\) OVER \(\)::int AS total_count/);
175
+ assert.equal(out.total, 412, 'the window count, not the page length');
176
+ assert.equal(out.rows.length, 2);
177
+ });
178
+
179
+ test('an empty page reports total 0 rather than throwing on rows[0]', async () => {
180
+ expect({ rows: [] });
181
+ const out = await credits.creditLedger(77, { limit: 10, offset: 900 });
182
+ assert.deepEqual(out, { rows: [], total: 0 });
183
+ });
184
+
185
+ test('reason is an EXACT match — right for the unparameterized keys', async () => {
186
+ expect({ rows: [] });
187
+ await credits.creditLedger(77, { reason: 'task.shipped' });
188
+ assert.match(sqlOf(lastCall()), /reason = \$2/);
189
+ assert.equal(lastCall().params[1], 'task.shipped');
190
+ });
191
+
192
+ test('reasonPrefix is a LIKE with an explicit ESCAPE — the per-stream audit filter', async () => {
193
+ expect({ rows: [] });
194
+ await credits.creditLedger(77, { reasonPrefix: credits.IDEA_STREAM_REASON_PREFIXES.full_pass });
195
+ // ESCAPE '\' — one literal backslash, which is what the SQL carries.
196
+ assert.match(sqlOf(lastCall()), /reason LIKE \$2 ESCAPE '\\'/);
197
+ // The stream keys contain '_', which is itself a LIKE wildcard — so our OWN
198
+ // prefix has to be escaped too, or 'full_pass' would also match 'fullXpass'.
199
+ assert.equal(lastCall().params[1], 'idea.credit.full\\_pass:%', 'anchored at the start, open at the end');
200
+ });
201
+
202
+ test("a caller's LIKE wildcards are escaped — a '%' cannot widen the scan", async () => {
203
+ expect({ rows: [] });
204
+ await credits.creditLedger(77, { reasonPrefix: '100%_off\\x' });
205
+ assert.equal(lastCall().params[1], '100\\%\\_off\\\\x%',
206
+ 'every %, _ and backslash is escaped; only the trailing % is ours');
207
+ });
208
+
209
+ test('filters AND together and number their placeholders in order', async () => {
210
+ expect({ rows: [] });
211
+ await credits.creditLedger(77, {
212
+ reason: 'task.shipped', taskId: 1234, since: '2026-01-01T00:00:00Z', limit: 5, offset: 10,
213
+ });
214
+ const sql = sqlOf(lastCall());
215
+ assert.match(sql, /WHERE builder_id = \$1 AND reason = \$2 AND task_id = \$3 AND recorded_at >= \$4::timestamptz/);
216
+ assert.match(sql, /LIMIT \$5 OFFSET \$6/);
217
+ assert.deepEqual(lastCall().params, [77, 'task.shipped', 1234, '2026-01-01T00:00:00Z', 5, 10]);
218
+ });
219
+
220
+ test('a non-positive builder id reads nothing at all — it never reaches SQL', async () => {
221
+ expect({ rows: [row()] });
222
+ const out = await credits.creditLedger(0);
223
+ assert.deepEqual(out, { rows: [], total: 0 });
224
+ assert.equal(calls.length, 0, 'a bad id must not turn into an unscoped query');
225
+ });
226
+
227
+ // ---- 3. the leak rule ------------------------------------------------------
228
+
229
+ test('reward_basis is never selected — it is bookkeeping, not money a reader should see', async () => {
230
+ expect({ rows: [] });
231
+ await credits.creditLedger(77, {});
232
+ assert.doesNotMatch(sqlOf(lastCall()), /reward_basis/);
233
+ });
234
+
235
+ test('total_count is stripped from the rows it rode in on', async () => {
236
+ expect({ rows: [row({ total_count: 9 })] });
237
+ const out = await credits.creditLedger(77, {});
238
+ assert.equal(out.total, 9);
239
+ assert.ok(!('total_count' in out.rows[0]), 'an internal window count is not a ledger field');
240
+ assert.deepEqual(Object.keys(out.rows[0]).sort(),
241
+ ['delta', 'description', 'id', 'reason', 'recorded_at', 'session_id', 'task_id']);
242
+ });
243
+
244
+ // ---- 4. the two gates ------------------------------------------------------
245
+
246
+ test('GET /me/credits takes its builder from the SESSION — there is no parameter to tamper with', async () => {
247
+ expect({ rows: [row()] });
248
+ const res = await call('GET', '/api/gds/me/credits?builder_id=999');
249
+ assert.equal(res.status, 200);
250
+ assert.equal(res.json.builder_id, 77, 'the session id, never the query string');
251
+ assert.equal(lastCall().params[0], 77);
252
+ });
253
+
254
+ test('GET /me/credits serves the stream prefixes beside the rows, so the filter is discoverable', async () => {
255
+ expect({ rows: [row()] });
256
+ const res = await call('GET', '/api/gds/me/credits');
257
+ assert.deepEqual(res.json.streams, credits.IDEA_STREAM_REASON_PREFIXES);
258
+ assert.deepEqual(res.json.page, { limit: 50, offset: 0, total: 1 });
259
+ });
260
+
261
+ test('the page limit is clamped — the ledger grows forever, so an uncapped read cannot be asked for', async () => {
262
+ expect({ rows: [] });
263
+ const res = await call('GET', '/api/gds/me/credits?limit=100000');
264
+ assert.equal(res.json.page.limit, 200);
265
+ });
266
+
267
+ test('a ledger read is never cached — a stale balance is a wrong one', async () => {
268
+ expect({ rows: [] });
269
+ const res = await new Promise((resolve, reject) => {
270
+ http.request({ host: '127.0.0.1', port, method: 'GET', path: '/api/gds/me/credits' }, (r) => {
271
+ r.resume(); r.on('end', () => resolve(r));
272
+ }).on('error', reject).end();
273
+ });
274
+ assert.equal(res.headers['cache-control'], 'no-store');
275
+ });
276
+
277
+ test('a caller WITHOUT the roster-read atom cannot read another builder ledger', async () => {
278
+ permissionGranted = false;
279
+ expect({ rows: [row()] });
280
+ const res = await call('GET', '/api/gds/builders/999/credits');
281
+ permissionGranted = true;
282
+ assert.equal(res.status, 403);
283
+ assert.equal(calls.length, 0, 'refused before any query — the gate is not decorative');
284
+ });
285
+
286
+ test('a permitted caller reads the TARGET builder ledger, not their own', async () => {
287
+ expect({ rows: [row()] });
288
+ const res = await call('GET', '/api/gds/builders/999/credits');
289
+ assert.equal(res.status, 200);
290
+ assert.equal(res.json.builder_id, 999);
291
+ assert.equal(lastCall().params[0], 999);
292
+ });
293
+
294
+ test('an unknown builder is a 404, not an empty ledger that reads like "paid nothing"', async () => {
295
+ knownBuilder = false;
296
+ expect({ rows: [] });
297
+ const res = await call('GET', '/api/gds/builders/999/credits');
298
+ knownBuilder = true;
299
+ assert.equal(res.status, 404);
300
+ assert.equal(res.json.error.code, 'builder_not_found');
301
+ });
302
+
303
+ test('a non-numeric builder id is refused — /builders/me/credits is not a route', async () => {
304
+ const res = await call('GET', '/api/gds/builders/me/credits');
305
+ assert.equal(res.status, 400);
306
+ assert.equal(res.json.error.code, 'bad_builder_id');
307
+ });
308
+
309
+ // ---- 5. bad filters are 400s, not 500s -------------------------------------
310
+
311
+ test('an unparseable since is a bad request, not a Postgres error surfacing as a 500', async () => {
312
+ expect({ rows: [] });
313
+ const res = await call('GET', '/api/gds/me/credits?since=not-a-date');
314
+ assert.equal(res.status, 400);
315
+ assert.equal(res.json.error.code, 'bad_since');
316
+ assert.equal(calls.length, 0);
317
+ });
318
+
319
+ test('a non-positive task_id filter is refused', async () => {
320
+ const res = await call('GET', '/api/gds/me/credits?task_id=0');
321
+ assert.equal(res.status, 400);
322
+ assert.equal(res.json.error.code, 'bad_task_id');
323
+ });
324
+
325
+ test('an over-long reason filter is refused rather than passed to the scan', async () => {
326
+ const res = await call('GET', `/api/gds/me/credits?reason=${'x'.repeat(201)}`);
327
+ assert.equal(res.status, 400);
328
+ assert.equal(res.json.error.code, 'bad_reason_filter');
329
+ });
330
+
331
+ test('the reason_prefix query param reaches the domain as a prefix filter', async () => {
332
+ expect({ rows: [] });
333
+ const res = await call('GET', '/api/gds/me/credits?reason_prefix=idea.credit.governance_pass%3A');
334
+ assert.equal(res.status, 200);
335
+ assert.equal(lastCall().params[1], 'idea.credit.governance\\_pass:%',
336
+ "the writer's own '_' is escaped, so it matches literally");
337
+ });
338
+
339
+ // ---- 6. the port ------------------------------------------------------------
340
+
341
+ test('the read rides the reward PORT, so a core hall view can resolve it without importing economy', async () => {
342
+ const reward = require('../modules/economy/reward.js');
343
+ assert.equal(typeof reward.creditLedger, 'function');
344
+ expect({ rows: [row()] });
345
+ const out = await reward.creditLedger(77, { limit: 1 });
346
+ assert.equal(out.total, 1, 'delegated, not spread — the port reaches the live function');
347
+ });
@@ -428,3 +428,83 @@ test('with no repo bases the resolver degrades instead of crashing the page', ()
428
428
  assert.equal(resolve('../../CLAUDE.md'), '../../CLAUDE.md',
429
429
  'the bases are fetched at boot; before they land a relative href passes through and 404s harmlessly');
430
430
  });
431
+
432
+ // A link target reaches an href only if the reader vouches for its scheme
433
+ // (task 1003482). escapeHtml stops markup, not URI schemes: a `javascript:`
434
+ // target would run on click. Every document the hall renders today is
435
+ // repo-controlled, but the reference room widened what flows through the
436
+ // shared reader, so the door closes before it meets a document someone else
437
+ // wrote. The hostile list carries the shapes a browser's URL parser would
438
+ // still read as a scheme: tab/newline inside, whitespace before, backslashes
439
+ // for slashes.
440
+ const HOSTILE_TARGETS = [
441
+ 'javascript:alert(1)',
442
+ 'JavaScript:alert(1)',
443
+ 'java\tscript:alert(1)',
444
+ ' javascript:alert(1)',
445
+ 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
446
+ 'vbscript:MsgBox(1)',
447
+ 'file:///etc/passwd',
448
+ 'ftp://example.org/x',
449
+ '//evil.example/x',
450
+ '\\\\evil.example/x',
451
+ '/\\evil.example/x',
452
+ ];
453
+ const REPO_BASES = {
454
+ baseDir: 'docs/onboarding/',
455
+ getBlobBase: () => 'https://github.com/o/r/blob/main',
456
+ getTreeBase: () => 'https://github.com/o/r/tree/main',
457
+ };
458
+ const VOUCHED_TARGETS = [
459
+ ['https://example.org/x', 'https://example.org/x'],
460
+ ['http://example.org/x', 'http://example.org/x'],
461
+ ['mailto:hello@example.org', 'mailto:hello@example.org'],
462
+ ['tel:+15555550100', 'tel:+15555550100'],
463
+ ['#a-section', '#a-section'],
464
+ ['../../CLAUDE.md', 'https://github.com/o/r/blob/main/CLAUDE.md'],
465
+ ];
466
+
467
+ test('the allowlist is the reader\'s own, and it is short', () => {
468
+ const MD = loadReader();
469
+ for (const [target] of VOUCHED_TARGETS) assert.ok(MD.isAllowedLinkHref(target), `${target} is vouched for`);
470
+ assert.ok(MD.isAllowedLinkHref('docs/recipes/'), 'a bare relative path is a repo path');
471
+ assert.ok(MD.isAllowedLinkHref('/docs/recipes/'), 'one leading slash is a repo path, not a host');
472
+ for (const target of HOSTILE_TARGETS) assert.ok(!MD.isAllowedLinkHref(target), `${JSON.stringify(target)} is refused`);
473
+ });
474
+
475
+ test('a hostile link target renders as its text, with no <a> at all', () => {
476
+ const MD = loadReader();
477
+ const resolvers = {
478
+ 'no bases': MD.makeResolveLinkHref({ baseDir: 'docs/onboarding/' }),
479
+ 'bases up': MD.makeResolveLinkHref(REPO_BASES),
480
+ identity: (h) => h,
481
+ };
482
+ for (const target of HOSTILE_TARGETS) {
483
+ for (const [name, resolve] of Object.entries(resolvers)) {
484
+ const { html } = MD.parseMarkdown(`see [the door](${target}) here\n`, resolve);
485
+ assert.doesNotMatch(html, /<a[\s>]|href=/, `${JSON.stringify(target)} under the ${name} resolver writes no anchor`);
486
+ // (a `)` inside a target ends the link — the parser's own limit, so
487
+ // `alert(1)` leaves one behind as text; what matters is no anchor)
488
+ assert.match(html, /<p>see the door\)? here<\/p>/, `… and the label survives as text`);
489
+ }
490
+ }
491
+ });
492
+
493
+ test('the resolver refuses a hostile target before the repo bases can dress it as a GitHub path', () => {
494
+ const MD = loadReader();
495
+ const resolve = MD.makeResolveLinkHref(REPO_BASES);
496
+ for (const target of HOSTILE_TARGETS) {
497
+ assert.equal(resolve(target), null, `${JSON.stringify(target)} is refused, not rewritten to ${resolve(target)}`);
498
+ }
499
+ });
500
+
501
+ test('the vouched shapes still become links, exactly as before', () => {
502
+ const MD = loadReader();
503
+ const resolve = MD.makeResolveLinkHref(REPO_BASES);
504
+ for (const [target, href] of VOUCHED_TARGETS) {
505
+ const { html } = MD.parseMarkdown(`[t](${target})\n`, resolve);
506
+ assert.ok(html.includes(`<a href="${href}"`), `${target} → ${href}, got ${html}`);
507
+ }
508
+ const degraded = MD.parseMarkdown('[t](../../CLAUDE.md)\n', MD.makeResolveLinkHref({ baseDir: 'docs/onboarding/' })).html;
509
+ assert.ok(degraded.includes('<a href="../../CLAUDE.md"'), 'and with no bases a repo path still degrades to a link, as §10 pins above');
510
+ });
@@ -89,6 +89,20 @@ const OWN_SCOPED_SURFACES = [
89
89
  why: 'the per-builder preference domain (wandering, skill, sound, render, model-allocation, display name) carved out of /me — read and write, all keyed on req.builder.id.',
90
90
  crossOwner: [],
91
91
  },
92
+ {
93
+ file: 'modules/economy/routes/credits.js',
94
+ why: 'the credit-ledger read (task 1003484, blocker 1000127). GET /me/credits takes its builder from req.builder.id and never reads an id off the request, so there is no parameter to tamper with. The one cross-owner route names its target in the path and is gate-verified below.',
95
+ // Two routes, one of them cross-owner — so one own-scoped route is the whole
96
+ // truth here, not a parser that stopped matching.
97
+ minOwnScoped: 1,
98
+ crossOwner: [
99
+ {
100
+ path: '/builders/:builderId/credits',
101
+ gate: /requirePermission\(\s*['"]builder\.roster\.read['"]\s*\)/,
102
+ why: "itemised read of ANOTHER builder's credit ledger — permission-gated on the builder.roster.read atom, the same gate /builders/:id/active-claims carries for the same act (reading another builder's record off the roster).",
103
+ },
104
+ ],
105
+ },
92
106
  {
93
107
  file: 'modules/platform-identity/routes/profile.js',
94
108
  why: "the builder's OWN cross-project profile controls (hide flag, handle, bio/links) — all keyed on req.builder.github_id.",
@@ -139,6 +153,12 @@ const OWN_SCOPED_SURFACES = [
139
153
 
140
154
  // Floors that keep this guard from going quietly vacuous (a regex that stops
141
155
  // matching would otherwise "pass" by checking nothing).
156
+ //
157
+ // The per-file floor is a DEFAULT, not a law: a surface may declare its own
158
+ // `minOwnScoped` when it genuinely mounts fewer (modules/economy/routes/credits.js
159
+ // is one own-scoped read plus one gated cross-owner read). The floor's job is to
160
+ // notice a file whose routes STOPPED parsing — so what matters is that every
161
+ // surface has a non-zero floor it must clear, not that they all share one number.
142
162
  const MIN_OWN_SCOPED_ROUTES_TOTAL = 28;
143
163
  const MIN_OWN_SCOPED_ROUTES_PER_FILE = 3;
144
164
 
@@ -416,9 +436,11 @@ for (const surface of OWN_SCOPED_SURFACES) {
416
436
  }
417
437
 
418
438
  t(`${surface.file} — contributes a real number of own-scoped routes (not vacuous)`, () => {
439
+ const floor = surface.minOwnScoped ?? MIN_OWN_SCOPED_ROUTES_PER_FILE;
440
+ assert.ok(floor > 0, 'a declared floor of 0 would exempt the file from this check entirely');
419
441
  assert.ok(
420
- ownScopedHere >= MIN_OWN_SCOPED_ROUTES_PER_FILE,
421
- `only ${ownScopedHere} own-scoped routes found (floor ${MIN_OWN_SCOPED_ROUTES_PER_FILE}) — either routes moved out or the parser stopped seeing them`
442
+ ownScopedHere >= floor,
443
+ `only ${ownScopedHere} own-scoped routes found (floor ${floor}) — either routes moved out or the parser stopped seeing them`
422
444
  );
423
445
  });
424
446
 
@@ -8,7 +8,11 @@
8
8
  // 0220) the same file pins the IN-HOUSE rebuilds under skills/<name>/: the same preamble
9
9
  // first and once, a `## Rebuilt from` section instead of a sidecar (the spec task, ADR
10
10
  // 0198, "no upstream bytes"), the style library named as the palette source, no image
11
- // mandate, and the preamble copies proven not to be doc-entropy debt. DB-free.
11
+ // mandate, and the preamble copies proven not to be doc-entropy debt. Since task 1003327
12
+ // (ADR 0230) it also pins the four LOOKS: each names the style-library entry it was tuned
13
+ // against, declares no colour of its own outside the legal black/white carve-out, adds no
14
+ // face, and keeps the translations that ADR fixed (the archetype that still ships both modes,
15
+ // the deterministic selection, the transcribed colour contract). DB-free.
12
16
  import assert from 'node:assert/strict';
13
17
  import { test } from 'node:test';
14
18
  import { createRequire } from 'node:module';
@@ -200,6 +204,57 @@ test('the worked example of the image family is the grove specimen: the pair wit
200
204
  assert.match(design, /grove-pod\.jpg/, 'the look\'s DESIGN.md names its specimen\'s plate instead of claiming it brings none');
201
205
  });
202
206
 
207
+ // ── the four looks (task 1003327, ADR 0230): a composition grammar, never a palette ──
208
+ const LOOKS = ['minimalist-ui', 'industrial-brutalist-ui', 'gpt-taste', 'stitch-design-taste'];
209
+ const LIBRARY = ['chrome-world', 'expedition', 'grove'];
210
+ const ownRules = (name) => {
211
+ const pre = read(path.join(SKILLS, 'PREAMBLE.md')).trimEnd();
212
+ const text = read(path.join(SKILLS, name, 'SKILL.md'));
213
+ return text.slice(text.indexOf(pre) + pre.length);
214
+ };
215
+
216
+ test('the four looks of task 1003327 exist as in-house skills, are declared, and each names the library entry it was tuned against', () => {
217
+ for (const name of LOOKS) {
218
+ assert.ok(ownDirs.includes(name), `${name}: skills/${name}/SKILL.md exists`);
219
+ assert.ok(manifest.contributes.skills.includes(name), `${name}: declared in contributes.skills`);
220
+ const own = ownRules(name);
221
+ assert.ok(LIBRARY.some((look) => own.includes(`\`${look}\``)), `${name}: names a style-library entry by directory, so a builder can render the grammar under a real look`);
222
+ assert.match(own, /1003327/, `${name}: names the port task`);
223
+ assert.match(own, /ADR 0230/, `${name}: names the look-skill ADR`);
224
+ }
225
+ });
226
+
227
+ test('a look skill ships a grammar and no world of its own: no palette of its own, no face, the pack is the source', () => {
228
+ for (const name of LOOKS) {
229
+ const own = ownRules(name);
230
+ // the preamble's only legal literal is pure black or white; every other colour is a var() or a color-mix()
231
+ const illegal = (own.match(/#[0-9a-fA-F]{3,8}\b/g) || []).filter((h) => !/^#(0{3,8}|f{3,8})$/i.test(h));
232
+ assert.deepEqual(illegal, [], `${name}: a look skill declares no colour of its own (found ${illegal.join(', ')})`);
233
+ assert.doesNotMatch(own, /\b(add|load|install|import|declare|register)\s+(an?\s+|the\s+|a new\s+)?(@?font-face|webfont|typeface|font family|google font)/i, `${name}: a look skill adds no face; the pack's two stacks are the type system`);
234
+ assert.doesNotMatch(own, /https?:\/\/fonts\./i, `${name}: no font host of its own`);
235
+ assert.match(own, /the pack|instance's pack/i, `${name}: says the palette is the instance's pack`);
236
+ }
237
+ });
238
+
239
+ test('each look keeps the translation ADR 0230 fixed: the substrate is an archetype, the selection is deterministic, the document transcribes, the placeholder photography is gone', () => {
240
+ const brutalist = ownRules('industrial-brutalist-ui');
241
+ assert.match(brutalist, /the other mode/i, 'brutalist: the substrate became an archetype and the other mode still ships');
242
+ assert.match(brutalist, /never mixed/i, 'brutalist: the two archetypes are never mixed in one interface');
243
+ const gpt = ownRules('gpt-taste');
244
+ assert.match(gpt, /deterministic, not random/i, 'gpt-taste: the selection is deterministic, not a roll');
245
+ assert.match(gpt, /sibling/i, 'gpt-taste: the selection is checked against sibling surfaces');
246
+ const stitch = ownRules('stitch-design-taste');
247
+ assert.match(stitch, /transcrib/i, 'stitch: the colour contract is transcribed from the pack');
248
+ assert.match(stitch, /diff/i, 'stitch: the thirteen are diffed against the pack, not retyped');
249
+ for (const name of ['minimalist-ui', 'gpt-taste']) {
250
+ assert.match(ownRules(name), /seeded placeholder photograph/i, `${name}: names the upstream's seeded placeholder photography, which this port drops`);
251
+ }
252
+ const index = read(path.join(ROOT, 'docs', 'adr', 'README.md'));
253
+ const adr = fs.readdirSync(path.join(ROOT, 'docs', 'adr')).find((f) => f.startsWith('0230-'));
254
+ assert.ok(adr, 'docs/adr/0230-*.md exists');
255
+ assert.ok(index.includes(`](${adr})`), 'docs/adr/README.md links ADR 0230');
256
+ });
257
+
203
258
  test('every in-house SKILL.md opens with the PLATFORM PREAMBLE, byte-identical to PREAMBLE.md, exactly once, right after its frontmatter', () => {
204
259
  const pre = read(path.join(SKILLS, 'PREAMBLE.md')).trimEnd();
205
260
  for (const name of ownDirs) {