@bongos/core 1.19.907 → 1.19.910
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.
- package/.bongos-core.json +31 -26
- package/docs/adr/0314-module-protection-is-per-path-not-per-module.md +133 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +17 -17
- package/docs/copy-registry.json +18 -18
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +6 -0
- package/modules/hall-ui/public/collab-lib.js +71 -0
- package/modules/hall-ui/public/collab.css +27 -0
- package/modules/hall-ui/public/collab.html +3 -0
- package/modules/hall-ui/public/collab.js +32 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +1 -1
- package/scripts/gds/gen-file-map.js +96 -2
- package/scripts/gds/ship-check.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +92 -0
- package/tests/file_map.mjs +59 -0
- package/tests/hall_collab_world.mjs +11 -3
- package/tests/module-scope-map.mjs +71 -0
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.910'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/collab_page.mjs
CHANGED
|
@@ -136,6 +136,98 @@ test('the two calm bands are not chips, and the date is not said twice', () => {
|
|
|
136
136
|
assert.match(js, /title="Asked \$\{escapeHtml\(String\(req\.created_at/, 'the exact date is still reachable');
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
// ---- THE ROOM'S OWN CONDITION (task 1004067, from idea 1001065) -----------
|
|
140
|
+
//
|
|
141
|
+
// The report: fourteen of fourteen rows wore the "waiting over a week" pill, so
|
|
142
|
+
// the loudest thing on the page carried no information. Task 1004041 made the
|
|
143
|
+
// WORDS differ, which is why these tests are about the TREATMENT — fourteen
|
|
144
|
+
// differently-worded rows in identical danger ink and identical rings still read
|
|
145
|
+
// as a wall. Each case below is run through the lib, not grepped, so a rule that
|
|
146
|
+
// is wrong fails here rather than looking plausible in a diff.
|
|
147
|
+
const room = (...ages) => ages.map((d) => ({ created_at: daysAgo(d) }));
|
|
148
|
+
|
|
149
|
+
test('a uniformly old room states its condition once instead of on every row', () => {
|
|
150
|
+
const L = lib();
|
|
151
|
+
// The reported board, reproduced: fourteen rows, every one past a week.
|
|
152
|
+
const rows = room(8, 9, 11, 13, 15, 16, 18, 21, 24, 27, 30, 34, 38, 41);
|
|
153
|
+
const baseline = L.roomBaseline(rows, NOW);
|
|
154
|
+
assert.ok(baseline, 'fourteen of fourteen is a room condition, not fourteen warnings');
|
|
155
|
+
assert.equal(baseline.band.id, 'stale');
|
|
156
|
+
assert.equal(L.roomAgeLine(baseline), 'all 14 waiting over a week');
|
|
157
|
+
const loud = rows.filter((r) => !L.isRoomQuiet(L.ageBand(r.created_at, NOW), baseline));
|
|
158
|
+
assert.equal(loud.length, 0, 'no row is worse than a room where every row is the same');
|
|
159
|
+
// And nothing is silently dead: each row still says its own real age.
|
|
160
|
+
const words = new Set(rows.map((r) => L.ageLabel(r.created_at, NOW)));
|
|
161
|
+
assert.ok(words.size > 1, 'the per-row words still carry the differing ages');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('the pill is kept for the row that is worse than its room', () => {
|
|
165
|
+
const L = lib();
|
|
166
|
+
// Six of eight sitting a few days, two genuinely old. The two are the whole
|
|
167
|
+
// point of the column, and before this they were indistinguishable from the six.
|
|
168
|
+
const rows = room(4, 4, 5, 5, 5, 6, 21, 30);
|
|
169
|
+
const baseline = L.roomBaseline(rows, NOW);
|
|
170
|
+
assert.equal(baseline.band.id, 'aging');
|
|
171
|
+
assert.equal(L.roomAgeLine(baseline), '6 of 8 waiting a few days');
|
|
172
|
+
const loud = rows
|
|
173
|
+
.filter((r) => !L.isRoomQuiet(L.ageBand(r.created_at, NOW), baseline))
|
|
174
|
+
.map((r) => L.ageLabel(r.created_at, NOW));
|
|
175
|
+
assert.deepEqual(loud, ['waiting 3 weeks', 'waiting 4 weeks'], 'only the exceptions keep a chip');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('a mixed room is left exactly as it was', () => {
|
|
179
|
+
const L = lib();
|
|
180
|
+
// No band is dominant here, so nothing is quieted and nothing is announced —
|
|
181
|
+
// which is what keeps this inert on the board as it usually looks.
|
|
182
|
+
const rows = room(0.2, 0.5, 1, 2, 4, 5, 9, 21);
|
|
183
|
+
assert.equal(L.roomBaseline(rows, NOW), null);
|
|
184
|
+
assert.equal(L.roomAgeLine(null), '');
|
|
185
|
+
for (const r of rows) {
|
|
186
|
+
assert.equal(L.isRoomQuiet(L.ageBand(r.created_at, NOW), null), false, 'no baseline quiets nothing');
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('a short list keeps every pill, and a calm room announces nothing', () => {
|
|
191
|
+
const L = lib();
|
|
192
|
+
// Three chips can be read one at a time; a wall needs a crowd.
|
|
193
|
+
assert.equal(L.roomBaseline(room(30, 30, 30), NOW), null, 'under the minimum, every row keeps its own');
|
|
194
|
+
assert.equal(L.BASELINE_MIN_ROWS, 4);
|
|
195
|
+
// A room that is uniformly calm has no alarm to move to the head. Announcing
|
|
196
|
+
// one there would invent the noise this removes.
|
|
197
|
+
assert.equal(L.roomBaseline(room(0.1, 0.2, 0.3, 0.4, 0.5), NOW), null, 'all fresh says nothing');
|
|
198
|
+
assert.equal(L.roomBaseline(room(1, 1, 2, 2, 2), NOW), null, 'all waiting-a-day says nothing either');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('the room line and the rows cannot describe the threshold two ways', () => {
|
|
202
|
+
const L = lib();
|
|
203
|
+
// The head's words come from the band's own label, so there is one vocabulary.
|
|
204
|
+
for (const b of L.BANDS) {
|
|
205
|
+
const line = L.roomAgeLine({ band: b, count: 9, total: 12 });
|
|
206
|
+
assert.equal(line, `9 of 12 ${b.label}`, `${b.id} reads in its own words`);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('the quiet treatment is wired through the render, not just defined', () => {
|
|
211
|
+
const js = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
212
|
+
assert.match(js, /function ageChip\(req, nowMs, baseline\)/, 'the chip is told about its room');
|
|
213
|
+
assert.match(js, /L\.isRoomQuiet\(band, baseline\)/, 'through the lib, so the rule is the tested one');
|
|
214
|
+
assert.match(js, /cb-age--quiet/, 'and the class reaches the markup');
|
|
215
|
+
// Over the WHOLE list: a baseline computed on the shown three would flip when
|
|
216
|
+
// a reader pressed "Show all" and re-alarm the rows it had just quieted.
|
|
217
|
+
assert.match(js, /const baseline = L\.roomBaseline\(list, nowMs\)/, 'the room is the whole block');
|
|
218
|
+
const html = read('modules', 'hall-ui', 'public', 'collab.html');
|
|
219
|
+
for (const key of ['for-me', 'mine', 'all']) {
|
|
220
|
+
assert.ok(html.includes(`id="cb-${key}-age"`), `the ${key} head has somewhere to say it`);
|
|
221
|
+
}
|
|
222
|
+
const css = read('modules', 'hall-ui', 'public', 'collab.css');
|
|
223
|
+
assert.match(
|
|
224
|
+
css,
|
|
225
|
+
/\.cb-age\.cb-age--quiet\s*\{[^}]*border-color: transparent/,
|
|
226
|
+
'the quieted row drops its ring',
|
|
227
|
+
);
|
|
228
|
+
assert.match(css, /\.cb-age\.cb-age--quiet\s*\{[^}]*color: var\(--ink-soft\)/, 'and the danger ink with it');
|
|
229
|
+
});
|
|
230
|
+
|
|
139
231
|
test('the alarm does not sit beside the accent control', () => {
|
|
140
232
|
// DESIGN.md spends the accent only on what is pressable or live. On "Your
|
|
141
233
|
// asks" the age pill (danger ink) used to share the right column with the
|
package/tests/file_map.mjs
CHANGED
|
@@ -93,6 +93,65 @@ test('generation is deterministic + idempotent (a second run is a no-op)', () =>
|
|
|
93
93
|
assert.match(r2.stdout, /already current/);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
// ── module coverage (task 1004066) ────────────────────────────────────────────
|
|
97
|
+
// The check is a set comparison over a directory listing, and a set comparison breaks by
|
|
98
|
+
// comparing two empty sets and passing. Every case below is written so that a matcher which
|
|
99
|
+
// vouches for everything, or a scan which enumerates nothing, FAILS here.
|
|
100
|
+
|
|
101
|
+
test('every dir under modules/ is documented in file-map.md — and the set being compared is not empty', () => {
|
|
102
|
+
const cov = gen.moduleCoverage();
|
|
103
|
+
assert.ok(cov.onDisk.length > 0, 'modules/ enumerated to nothing — the coverage check would be vacuous');
|
|
104
|
+
assert.deepEqual(cov.defects, [], `module-coverage scan defects: ${cov.defects.join('; ')}`);
|
|
105
|
+
assert.deepEqual(cov.undocumented, [], `modules with no file-map entry: ${cov.undocumented.join(', ')}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('--check FAILS (exit 2) on a module with no file-map entry — the trap case', () => {
|
|
109
|
+
const probe = path.join(ROOT, 'modules', '__filemap_probe__');
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(probe, { recursive: true });
|
|
112
|
+
writeFileSync(path.join(probe, 'module.json'), '{"key":"__filemap_probe__"}\n');
|
|
113
|
+
const cov = gen.moduleCoverage();
|
|
114
|
+
assert.ok(cov.onDisk.includes('__filemap_probe__'), 'the probe module must be enumerated');
|
|
115
|
+
assert.deepEqual(cov.undocumented, ['__filemap_probe__'], 'an undocumented module must be the ONLY finding');
|
|
116
|
+
const r = run(['--check']);
|
|
117
|
+
assert.equal(r.code, 2, 'an undocumented module must fail the gate');
|
|
118
|
+
assert.match(r.stderr, /modules with NO docs\/file-map\.md entry/);
|
|
119
|
+
assert.match(r.stderr, /__filemap_probe__/);
|
|
120
|
+
} finally {
|
|
121
|
+
rmSync(probe, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
// Asserted on the module finding rather than the exit code: --check reports every problem class
|
|
124
|
+
// at once, and a checkout carrying unrelated local noise (materialised module skills with no
|
|
125
|
+
// note) would otherwise make this read as "the probe is still there".
|
|
126
|
+
const after = run(['--check']);
|
|
127
|
+
assert.equal(gen.moduleCoverage().undocumented.length, 0, 'coverage is clean once the probe module is removed');
|
|
128
|
+
assert.doesNotMatch(after.stderr || '', /modules with NO docs\/file-map\.md entry/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the matcher discriminates: a real module passes, an absent one does not, and a prefix cannot vouch', () => {
|
|
132
|
+
const md = readFileSync(path.join(ROOT, 'docs', 'file-map.md'), 'utf8');
|
|
133
|
+
const block = gen.modulesBlock(md);
|
|
134
|
+
assert.ok(block.length > 0, 'the modules/ tree block must be findable — an empty block would make entries unreadable');
|
|
135
|
+
assert.ok(gen.isModuleDocumented(md, block, 'government'), 'a documented module reads as documented');
|
|
136
|
+
assert.equal(gen.isModuleDocumented(md, block, '__no_such_module__'), false, 'an absent module must NOT read as documented');
|
|
137
|
+
// A bare substring test would let `modules/agents-extra` vouch for `agents`.
|
|
138
|
+
assert.equal(
|
|
139
|
+
gen.isModuleDocumented('see modules/agents-extra/thing.js', '', 'agents'),
|
|
140
|
+
false,
|
|
141
|
+
'a longer path sharing the prefix must not count as an entry',
|
|
142
|
+
);
|
|
143
|
+
// A skill dir listed in the same `├── name/` shape elsewhere in the map must not vouch either:
|
|
144
|
+
// the entry search is scoped to the modules/ block.
|
|
145
|
+
assert.equal(gen.modulesBlock('├── .claude/skills/\n├── recall/ ← a skill\n'), '', 'no modules/ block → no block to read entries from');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('module coverage reports a SCAN DEFECT rather than passing when the map cannot be read', () => {
|
|
149
|
+
const cov = gen.moduleCoverage('');
|
|
150
|
+
assert.ok(cov.onDisk.length > 0);
|
|
151
|
+
assert.equal(cov.defects.length, 1, 'an unreadable map is a defect, not a pass');
|
|
152
|
+
assert.match(cov.defects[0], /cannot run/);
|
|
153
|
+
});
|
|
154
|
+
|
|
96
155
|
test('every note maps to a real skill/routine (sidecar has no dangling keys)', () => {
|
|
97
156
|
const notes = JSON.parse(readFileSync(path.join(ROOT, 'docs', 'file-map.notes.json'), 'utf8'));
|
|
98
157
|
for (const section of gen.SECTIONS) {
|
|
@@ -230,7 +230,11 @@ test('the door and the copy control are on the row, and the copy control is reac
|
|
|
230
230
|
// One request can appear in three blocks at once, so the id is block-scoped.
|
|
231
231
|
assert.match(js, /function askId\(key, id\)/);
|
|
232
232
|
assert.match(js, /cb-ask-\$\{key\}-/);
|
|
233
|
-
|
|
233
|
+
// A bracket class, not a closing paren: paintBlock also hands the row its
|
|
234
|
+
// block's age baseline (task 1004067), and pinning the exact arity would red
|
|
235
|
+
// this on an argument the invariant — that the KEY reaches the row — does not
|
|
236
|
+
// care about.
|
|
237
|
+
assert.match(js, /rowFn\(r, nowMs, false, key[,)]/, 'and paintBlock hands the key down');
|
|
234
238
|
|
|
235
239
|
const css = read('collab.css');
|
|
236
240
|
for (const cls of ['.cb-ask ', '.cb-ask__door', '.cb-ask__copy', '.cb-ask__full']) {
|
|
@@ -262,8 +266,12 @@ test('the age is a state pill in the row\'s right column, and every band has its
|
|
|
262
266
|
assert.match(css, /\.cb-age--stale\s*\{[^}]*var\(--danger\)/, 'stale is the danger ink');
|
|
263
267
|
assert.match(css, /\.cb-age--aging\s*\{[^}]*var\(--warn\)/, 'aging is the warn ink');
|
|
264
268
|
const js = read('collab.js');
|
|
265
|
-
|
|
266
|
-
|
|
269
|
+
// Both pins stop short of the closing character, because the band's class is
|
|
270
|
+
// now followed by the room's quiet modifier and ageChip by the baseline that
|
|
271
|
+
// decides it (task 1004067). What they assert is unchanged: the shell's pill,
|
|
272
|
+
// in the right column.
|
|
273
|
+
assert.match(js, /class="fact-pill cb-age cb-age--\$\{band\.id\}/, 'the pill is the shell\'s fact-pill');
|
|
274
|
+
assert.match(js, /<div class="ov-row__right">\$\{ageChip\(req, nowMs[,)]/, 'in the right column, so a glance down the list reads the ages');
|
|
267
275
|
});
|
|
268
276
|
|
|
269
277
|
// ---- the copy desk: quieter, and no fact lost ----------------------------------
|
|
@@ -54,6 +54,77 @@ test('`protected` is DERIVED from the imported PROTECTED_GLOBS, never hand-set',
|
|
|
54
54
|
assert.ok(prot.length < scope.moduleKeys().length, 'expected at least one non-protected module');
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
// THE PROTECTED SET IS PINNED BY NAME (task 1004070, ADR 0314).
|
|
58
|
+
//
|
|
59
|
+
// The assertion above says the roster contains both kinds; it passes on any
|
|
60
|
+
// twelve. That is the shape a silent unprotection hides in — a glob edit that
|
|
61
|
+
// dropped `migrations/` off `lifecycle` would keep the count and leave the ship
|
|
62
|
+
// pipeline scopeable by anyone. It has already drifted once in the other
|
|
63
|
+
// direction: task 1004070 was written against a re-measurement of 11, and
|
|
64
|
+
// `agents` (protected by `modules/agents/`, itself a PROTECTED_GLOBS entry) was
|
|
65
|
+
// missing from it.
|
|
66
|
+
//
|
|
67
|
+
// So the set is spelled out. A module ARRIVING here is a deliberate line in a
|
|
68
|
+
// diff; a module LEAVING is the one that must never happen quietly.
|
|
69
|
+
const PROTECTED_MODULES = [
|
|
70
|
+
'agents',
|
|
71
|
+
'autonomy',
|
|
72
|
+
'builder-settings',
|
|
73
|
+
'copy-desk',
|
|
74
|
+
'dev-box',
|
|
75
|
+
'devsecops',
|
|
76
|
+
'government',
|
|
77
|
+
'grading',
|
|
78
|
+
'kernel',
|
|
79
|
+
'lifecycle',
|
|
80
|
+
'methodology',
|
|
81
|
+
'onboarding',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
test('the protected module set is exactly this, by name', () => {
|
|
85
|
+
assert.deepEqual(
|
|
86
|
+
scope.protectedModules().slice().sort(),
|
|
87
|
+
[...PROTECTED_MODULES].sort(),
|
|
88
|
+
'the derived protected set changed — if that was intended, edit PROTECTED_MODULES in the same diff and say why',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the seven trust-core modules can never leave the protected set', () => {
|
|
93
|
+
// Stated separately from the pin above because these are not interchangeable
|
|
94
|
+
// with the rest: their territory IS the trust / ship / authz core, so an edit
|
|
95
|
+
// that unprotects one is a security regression rather than a roster change.
|
|
96
|
+
// ADR 0314 narrows which MODULES derive protected; it must never reach these.
|
|
97
|
+
for (const k of ['kernel', 'lifecycle', 'government', 'grading', 'dev-box', 'devsecops', 'methodology']) {
|
|
98
|
+
assert.equal(scope.scopeForModule(k).protected, true, `${k} must stay protected`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('narrowing the module roster must not narrow the PATH wall', () => {
|
|
103
|
+
// The other half of ADR 0314's verification, and the one that would make this
|
|
104
|
+
// a false success: whatever happens to the module flag, a sub-Metic builder
|
|
105
|
+
// touching a protected CLI is still refused, by the per-file check.
|
|
106
|
+
// Each file is scoped to the module that OWNS it, deliberately: check() runs
|
|
107
|
+
// scope-subset before the path wall, so scoping them all to one module would
|
|
108
|
+
// get `out_of_scope` and prove nothing about protection. The owning module is
|
|
109
|
+
// what makes the scope wall pass, leaving the path wall as the only thing that
|
|
110
|
+
// can refuse — which is exactly the claim under test.
|
|
111
|
+
const owned = [
|
|
112
|
+
['onboarding', 'scripts/gds/setup.js'],
|
|
113
|
+
['autonomy', 'scripts/gds/conductor-dispatch.js'],
|
|
114
|
+
['copy-desk', 'scripts/gds/copy-inventory.js'],
|
|
115
|
+
];
|
|
116
|
+
for (const [mod, f] of owned) {
|
|
117
|
+
assert.ok(PROTECTED_GLOBS.includes(f), `fixture assumption: ${f} is a protected surface`);
|
|
118
|
+
assert.equal(gsc.scopeSubset([mod], [f]).ok, true, `fixture assumption: ${mod} owns ${f}`);
|
|
119
|
+
const r = gsc.check([mod], [f], 'thetes');
|
|
120
|
+
assert.equal(r.ok, false, `a sub-Metic builder must still be refused on ${f}`);
|
|
121
|
+
assert.equal(r.code, 'protected_path', `${f} must be refused BY THE PATH WALL, not by scope`);
|
|
122
|
+
// ...and Metic+ passes, so the refusal is the rank floor and not the path
|
|
123
|
+
// being unreachable to everyone.
|
|
124
|
+
assert.equal(gsc.check([mod], [f], 'metic').ok, true, `a Metic must still be able to author on ${f}`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
57
128
|
test('isProtected actually consults PROTECTED_GLOBS (overlap, prefix-aware both ways)', () => {
|
|
58
129
|
assert.ok(PROTECTED_GLOBS.includes('migrations/'), 'fixture assumption: migrations/ is protected');
|
|
59
130
|
assert.equal(scope.isProtected(['migrations/']), true, 'a protected glob must compute protected');
|