@bongos/core 1.19.821 → 1.19.823
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 -16
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/dead-deps.js +141 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-deps.js +25 -3
- package/scripts/gds/deploy-divergence.js +5 -1
- package/scripts/gds/fetch-sound-prefs.js +40 -5
- package/scripts/gds/probe-cache.js +0 -0
- package/scripts/gds/strand-watch.js +8 -1
- package/src/module-api.js +1 -1
- package/tests/probe_cache.mjs +200 -0
- package/tests/prose_gated_deps.mjs +139 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.823",
|
|
6
|
+
"core_contract": "1.19.823",
|
|
7
|
+
"source_commit": "2dbaf2b0a9d49393d3f3daf6f501a09087b8a156",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-17T11:55:48.898Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 490,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2230,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2746,
|
|
20
|
+
"tree_sha256": "6b21d5cba0536e95777730af1de9c226f7d38a89997a4cb586847a39748e7f37",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2547,7 +2547,7 @@
|
|
|
2547
2547
|
{
|
|
2548
2548
|
"path": "docs/module-api-changelog.md",
|
|
2549
2549
|
"mode": "0000644",
|
|
2550
|
-
"sha256": "
|
|
2550
|
+
"sha256": "5236a1550b3f87d767e2f59479b3f2ef8859621f0c57bbd7a054b8d056c1a2ef"
|
|
2551
2551
|
},
|
|
2552
2552
|
{
|
|
2553
2553
|
"path": "docs/modules-contract.md",
|
|
@@ -5612,7 +5612,7 @@
|
|
|
5612
5612
|
{
|
|
5613
5613
|
"path": "modules/lifecycle/dead-deps.js",
|
|
5614
5614
|
"mode": "0000644",
|
|
5615
|
-
"sha256": "
|
|
5615
|
+
"sha256": "4738131a48e85dd7bf0644ddd3686c85d0d1d0da55ee623b1834d048c614b91c"
|
|
5616
5616
|
},
|
|
5617
5617
|
{
|
|
5618
5618
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -7812,12 +7812,12 @@
|
|
|
7812
7812
|
{
|
|
7813
7813
|
"path": "package-lock.json",
|
|
7814
7814
|
"mode": "0000644",
|
|
7815
|
-
"sha256": "
|
|
7815
|
+
"sha256": "ade8e48efd7ae7eaeb9f51472f731940ca866a1f0a8917de7dadab245428908b"
|
|
7816
7816
|
},
|
|
7817
7817
|
{
|
|
7818
7818
|
"path": "package.json",
|
|
7819
7819
|
"mode": "0000644",
|
|
7820
|
-
"sha256": "
|
|
7820
|
+
"sha256": "bd706beddc637ac4587a99436be805e327d7392306525124874ea39092fa6149"
|
|
7821
7821
|
},
|
|
7822
7822
|
{
|
|
7823
7823
|
"path": "public-docs/index.html",
|
|
@@ -7947,7 +7947,7 @@
|
|
|
7947
7947
|
{
|
|
7948
7948
|
"path": "scripts/gds/audit-deps.js",
|
|
7949
7949
|
"mode": "0000644",
|
|
7950
|
-
"sha256": "
|
|
7950
|
+
"sha256": "ff7ccdf15ae2c215d3330e957d6a36adc7d48f5d9bd5042b5d61bb228b878073"
|
|
7951
7951
|
},
|
|
7952
7952
|
{
|
|
7953
7953
|
"path": "scripts/gds/audit-exports.js",
|
|
@@ -8212,7 +8212,7 @@
|
|
|
8212
8212
|
{
|
|
8213
8213
|
"path": "scripts/gds/deploy-divergence.js",
|
|
8214
8214
|
"mode": "0000644",
|
|
8215
|
-
"sha256": "
|
|
8215
|
+
"sha256": "39879d5d26a325529620ab4fd180f133643d0e4c2a5a9ef92f8ee347ad0ba0d7"
|
|
8216
8216
|
},
|
|
8217
8217
|
{
|
|
8218
8218
|
"path": "scripts/gds/design-sync.js",
|
|
@@ -8302,7 +8302,7 @@
|
|
|
8302
8302
|
{
|
|
8303
8303
|
"path": "scripts/gds/fetch-sound-prefs.js",
|
|
8304
8304
|
"mode": "0000644",
|
|
8305
|
-
"sha256": "
|
|
8305
|
+
"sha256": "f2239bdd653ceda6f2595d454e618bff5273f46eb63d3cb6d8040190ee0125e9"
|
|
8306
8306
|
},
|
|
8307
8307
|
{
|
|
8308
8308
|
"path": "scripts/gds/figma-design-sync.js",
|
|
@@ -8689,6 +8689,11 @@
|
|
|
8689
8689
|
"mode": "0000644",
|
|
8690
8690
|
"sha256": "9898b6f8e60b9f5ee4aeb9a023d1b68bb5bfbdf589eed10923a8e492716340ed"
|
|
8691
8691
|
},
|
|
8692
|
+
{
|
|
8693
|
+
"path": "scripts/gds/probe-cache.js",
|
|
8694
|
+
"mode": "0000644",
|
|
8695
|
+
"sha256": "34d891e8ff58f0da957d300021210dbc34448fe676873b6b77e13ad3b7179d2e"
|
|
8696
|
+
},
|
|
8692
8697
|
{
|
|
8693
8698
|
"path": "scripts/gds/prove-strict-versioning.js",
|
|
8694
8699
|
"mode": "0000644",
|
|
@@ -9232,7 +9237,7 @@
|
|
|
9232
9237
|
{
|
|
9233
9238
|
"path": "scripts/gds/strand-watch.js",
|
|
9234
9239
|
"mode": "0000644",
|
|
9235
|
-
"sha256": "
|
|
9240
|
+
"sha256": "2a9959bd2e45046dadcda9982998a5b7163cef00b5d719ff299314daa5409770"
|
|
9236
9241
|
},
|
|
9237
9242
|
{
|
|
9238
9243
|
"path": "scripts/gds/stray-unit-sweep.sh",
|
|
@@ -9722,7 +9727,7 @@
|
|
|
9722
9727
|
{
|
|
9723
9728
|
"path": "src/module-api.js",
|
|
9724
9729
|
"mode": "0000644",
|
|
9725
|
-
"sha256": "
|
|
9730
|
+
"sha256": "2ac366c05863ab86d0bdb5b3df3bf6f605e4edb0182a977c83c781cf503bf5b7"
|
|
9726
9731
|
},
|
|
9727
9732
|
{
|
|
9728
9733
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12344,6 +12349,11 @@
|
|
|
12344
12349
|
"mode": "0000644",
|
|
12345
12350
|
"sha256": "3c85d28ecc55e375988f3e55aee993c8c84220889678262d8207146c2772f3f5"
|
|
12346
12351
|
},
|
|
12352
|
+
{
|
|
12353
|
+
"path": "tests/probe_cache.mjs",
|
|
12354
|
+
"mode": "0000644",
|
|
12355
|
+
"sha256": "26c734a1ab1996e1aff6197a9e0b19a448b28c772e0e75b00fee2433535c1193"
|
|
12356
|
+
},
|
|
12347
12357
|
{
|
|
12348
12358
|
"path": "tests/profile_activity.mjs",
|
|
12349
12359
|
"mode": "0000644",
|
|
@@ -12494,6 +12504,11 @@
|
|
|
12494
12504
|
"mode": "0000644",
|
|
12495
12505
|
"sha256": "a36acb19e59db1f9eb885ab6497a43d1c8ce0ba6198b0f5cc98f33bf08837fdf"
|
|
12496
12506
|
},
|
|
12507
|
+
{
|
|
12508
|
+
"path": "tests/prose_gated_deps.mjs",
|
|
12509
|
+
"mode": "0000644",
|
|
12510
|
+
"sha256": "5d85d13a0cf1feb3c0aa809841126f4f9e9effe66fc57b5064b612627ce4916f"
|
|
12511
|
+
},
|
|
12497
12512
|
{
|
|
12498
12513
|
"path": "tests/provision.mjs",
|
|
12499
12514
|
"mode": "0000644",
|
|
@@ -2116,5 +2116,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2116
2116
|
landed since 1.19.819 with no explicit bump. run 35209838815. (task 1002620)
|
|
2117
2117
|
1.19.821 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2118
2118
|
landed since 1.19.820 with no explicit bump. run 35212866365. (task 1002620)
|
|
2119
|
+
1.19.822 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2120
|
+
landed since 1.19.821 with no explicit bump. run 35213847140. (task 1002620)
|
|
2121
|
+
1.19.823 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2122
|
+
landed since 1.19.822 with no explicit bump. run 35218228051. (task 1002620)
|
|
2119
2123
|
---------------------------------------------------------------------------
|
|
2120
2124
|
```
|
|
@@ -198,7 +198,148 @@ function findStrandedTasks(tasks) {
|
|
|
198
198
|
return out;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// ---- PROSE-DECLARED GATES (task 1003857) -----------------------------------
|
|
202
|
+
//
|
|
203
|
+
// Everything above reads the STRUCTURED graph — blocked_by edges. A gate written
|
|
204
|
+
// in a task's DESCRIPTION is entirely unpoliced, and they go stale the same way:
|
|
205
|
+
// task 1002839 (ready) names "existing open task 1001380" as a precondition, and
|
|
206
|
+
// 1001380 was abandoned on 2026-08-18, superseded by ADR 0089. A builder opening
|
|
207
|
+
// 1002839 is told to coordinate with a task that does not exist, and told it is open.
|
|
208
|
+
//
|
|
209
|
+
// ADVISORY, NEVER A GATE. A prose match is weaker evidence than an edge: the text
|
|
210
|
+
// may be history ("this replaces task N"), a citation, or simply wrong. It reports
|
|
211
|
+
// candidates for a human to read and must never refuse a claim — that is the
|
|
212
|
+
// difference between this and DEAD_DEP_STATUSES above, and it is deliberate.
|
|
213
|
+
//
|
|
214
|
+
// PRECISION COMES FROM THE VERB, NOT THE ID. Matching "any abandoned id in the
|
|
215
|
+
// body" is the mistake that made the sibling supersession scan unusable (idea
|
|
216
|
+
// 1000807: ~44 hits, nearly all citations). So an id counts only when a GATE PHRASE
|
|
217
|
+
// governs it within a short window — needs / depends on / blocked by / gated on /
|
|
218
|
+
// requires / after / prereq — and the window is small enough that a paragraph
|
|
219
|
+
// mentioning a gate and, separately, an id does not join them up.
|
|
220
|
+
// TWO PHRASINGS, because the live evidence has both — and the second is the one the
|
|
221
|
+
// filing task's own example actually uses.
|
|
222
|
+
//
|
|
223
|
+
// A GATE: 'needs task N', 'blocked by N', 'depends on N'.
|
|
224
|
+
// A LIVENESS ASSERTION: 'existing OPEN task N', 'task N is ready to claim'.
|
|
225
|
+
//
|
|
226
|
+
// Task 1002839 — the live instance the task names — reads "and existing open task
|
|
227
|
+
// 1001380 covers overlapping ground". That is not a dependency; it is a claim that
|
|
228
|
+
// 1001380 is OPEN, and 1001380 was abandoned on 2026-08-18. A gate-only matcher
|
|
229
|
+
// missed it, which I found by measuring rather than by reading the spec: the first
|
|
230
|
+
// version returned it only because a 'needs' in the PREVIOUS clause leaked across a
|
|
231
|
+
// semicolon, and tightening that correctly dropped it.
|
|
232
|
+
//
|
|
233
|
+
// Both shapes send a builder to a task that is not there, which is the harm. Only
|
|
234
|
+
// the liveness one also states something false on its face.
|
|
235
|
+
const GATE_PHRASE_RE = /\b(?:needs?|depends?\s+on|dependent\s+on|blocked\s+by|blocks?|gated\s+on|gates?|requires?|required\s+by|prereq(?:uisite)?s?|precondition|waits?\s+on|waiting\s+on|after)\b/i;
|
|
236
|
+
|
|
237
|
+
// 'open task N', 'existing open task N', 'still-open task N', 'the open task N'.
|
|
238
|
+
// Anchored to the id's immediate left so a paragraph that says 'open' somewhere does
|
|
239
|
+
// not qualify every id in it.
|
|
240
|
+
const LIVENESS_BEFORE_RE = /\b(?:still[-\s]+)?open\s+(?:task\s+)?#?$/i;
|
|
241
|
+
// 'task N is ready to claim', 'task N is still open', 'task N remains open'.
|
|
242
|
+
const LIVENESS_AFTER_RE = /^\s*(?:\([^)]{0,80}\)\s*)?(?:is|remains|stays)\s+(?:still\s+)?(?:open|ready\s+to\s+claim|unclaimed|in\s+flight)\b/i;
|
|
243
|
+
|
|
244
|
+
// How far after a gate phrase an id still counts as governed by it. Sized to span
|
|
245
|
+
// 'needs the existing open task 1001380 to land first', not a whole paragraph.
|
|
246
|
+
const GATE_WINDOW = 80;
|
|
247
|
+
|
|
248
|
+
// A task id in prose. Two forms, and the second is the trap.
|
|
249
|
+
//
|
|
250
|
+
// LIVE form — 6-7 digits: 'existing open task 1001380'.
|
|
251
|
+
// SHORT form — the PRE-OFFSET ref this project used before the import, written as
|
|
252
|
+
// `#528` or 'task 528'. Task 1000528's own gate is written that way,
|
|
253
|
+
// and it is the case that motivated the original row — so a live-id
|
|
254
|
+
// match misses precisely the instance this exists to find.
|
|
255
|
+
//
|
|
256
|
+
// The short form is only recognised with an explicit `task ` / `#` prefix. A bare
|
|
257
|
+
// 3-digit number in prose is a count, a line number or a year far more often than it
|
|
258
|
+
// is a task, and this runs over every description on the board.
|
|
259
|
+
const ID_RE = /(?:\btask\s+|#)(\d{1,5})\b|(?:\btask\s+|#)?(\d{6,7})\b/g;
|
|
260
|
+
|
|
261
|
+
// The id spaces to probe, newest first. Same precedent as diagram-facts.js: the import
|
|
262
|
+
// shifted every id by 1,000,000, so a pre-offset ref resolves by adding the offset.
|
|
263
|
+
const IMPORT_ID_OFFSETS = [0, 1_000_000];
|
|
264
|
+
|
|
265
|
+
// PURE — every id a single textual ref could mean, most likely first. A short ref
|
|
266
|
+
// yields the offset candidate too; a live-width ref is already resolved.
|
|
267
|
+
function idCandidates(raw) {
|
|
268
|
+
const n = Number(raw);
|
|
269
|
+
if (!Number.isInteger(n) || n <= 0) return [];
|
|
270
|
+
// A ref already in the LIVE width is resolved - probing an offset on it would invent
|
|
271
|
+
// a second id that is not what the text says (1001380 + 1e6 = 2001380, a row nobody
|
|
272
|
+
// wrote). The offset probe exists for the SHORT pre-offset form only.
|
|
273
|
+
if (String(n).length >= 6) return [String(n)];
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const off of IMPORT_ID_OFFSETS) {
|
|
276
|
+
const cand = String(n + off);
|
|
277
|
+
// Only offer a candidate that could BE an id: the live space is 6-7 digits.
|
|
278
|
+
if (cand.length >= 6 && cand.length <= 7 && !out.includes(cand)) out.push(cand);
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Ids that share the integer space but are NOT tasks. Measured, not guessed: the
|
|
284
|
+
// first run of this over the live board returned two hits and one was task 1002844's
|
|
285
|
+
// 'Source: idea 1000454' — an idea citation, whose id happens to look like a task's.
|
|
286
|
+
// Reporting it would have sent a human to look up a task that does not exist, which
|
|
287
|
+
// is the very failure this detector exists to stop.
|
|
288
|
+
const OTHER_ENTITY_RE = /\b(?:idea|blocker|report|learning|claim|builder|goal|version|PR|pull\s+request)\s+#?$/i;
|
|
289
|
+
|
|
290
|
+
// PURE — the ids this text appears to declare a GATE on. Exported so the rule is
|
|
291
|
+
// testable without a DB: the risk here is a matcher that quietly matches nothing (a
|
|
292
|
+
// clean report) or everything (a report nobody reads).
|
|
293
|
+
function proseGatedIds(text) {
|
|
294
|
+
const src = String(text || '');
|
|
295
|
+
const out = new Set();
|
|
296
|
+
for (const m of src.matchAll(ID_RE)) {
|
|
297
|
+
const rawId = m[1] || m[2];
|
|
298
|
+
const before = src.slice(Math.max(0, m.index - GATE_WINDOW), m.index);
|
|
299
|
+
// An id belonging to another entity is not a task gate, whatever governs it.
|
|
300
|
+
if (OTHER_ENTITY_RE.test(before)) continue;
|
|
301
|
+
// The window stops at a SENTENCE BOUNDARY. Also measured: the same false
|
|
302
|
+
// positive had 'needs' in the preceding sentence ('…the first thing that task
|
|
303
|
+
// needs. Source: idea 1000454'), so a fixed-width window let a verb govern across
|
|
304
|
+
// a full stop. A gate and its id belong to one sentence.
|
|
305
|
+
// A liveness assertion binds TIGHTLY to the id on either side, so it needs no
|
|
306
|
+
// window and no sentence rule.
|
|
307
|
+
const after = src.slice(m.index + m[0].length, m.index + m[0].length + 60);
|
|
308
|
+
if (LIVENESS_BEFORE_RE.test(before) || LIVENESS_AFTER_RE.test(after)) { for (const c of idCandidates(rawId)) out.add(c); continue; }
|
|
309
|
+
// A GATE may be a few words back, but the window stops at a SENTENCE BOUNDARY:
|
|
310
|
+
// measured, a fixed-width window let 'needs' govern across a full stop and a
|
|
311
|
+
// semicolon into an unrelated clause.
|
|
312
|
+
const lastStop = Math.max(before.lastIndexOf('. '), before.lastIndexOf('\n'), before.lastIndexOf('; '));
|
|
313
|
+
const governing = lastStop === -1 ? before : before.slice(lastStop + 1);
|
|
314
|
+
// Only the text BEFORE the id, so 'task 1001380 needs review' is not read as a
|
|
315
|
+
// gate ON 1001380 — the verb has to govern the id, not follow it.
|
|
316
|
+
if (GATE_PHRASE_RE.test(governing)) for (const c of idCandidates(rawId)) out.add(c);
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// PURE — live tasks whose PROSE names a dead task as a gate. `tasks` are rows with
|
|
322
|
+
// { id, status, description }; `statusById` answers what an id's status is.
|
|
323
|
+
// Deliberately mirrors findStrandedTasks' shape so audit-deps can print one answer
|
|
324
|
+
// to "what is stranded" rather than two.
|
|
325
|
+
function findProseGatedTasks(tasks, statusById) {
|
|
326
|
+
const out = [];
|
|
327
|
+
for (const t of Array.isArray(tasks) ? tasks : []) {
|
|
328
|
+
if (!t || !LIVE_STATUSES.has(String(t.status))) continue;
|
|
329
|
+
const dead = [];
|
|
330
|
+
for (const id of proseGatedIds(t.description)) {
|
|
331
|
+
if (String(id) === String(t.id)) continue; // a task citing itself is not a gate
|
|
332
|
+
const st = statusById(id);
|
|
333
|
+
if (st && DEAD_DEP_STATUSES.has(String(st))) dead.push({ id: String(id), status: String(st) });
|
|
334
|
+
}
|
|
335
|
+
if (dead.length) out.push({ task_id: t.id, status: t.status, title: t.title, prose_dead_deps: dead.sort((a, b) => Number(a.id) - Number(b.id)) });
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
201
340
|
module.exports = {
|
|
202
341
|
DEAD_DEP_STATUSES, LIVE_STATUSES,
|
|
203
342
|
absorbedInto, depRefusalRow, splitDeadDeps, deadDepHint, findStrandedTasks,
|
|
343
|
+
proseGatedIds, findProseGatedTasks, idCandidates, GATE_PHRASE_RE, GATE_WINDOW, OTHER_ENTITY_RE,
|
|
344
|
+
LIVENESS_BEFORE_RE, LIVENESS_AFTER_RE, IMPORT_ID_OFFSETS,
|
|
204
345
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.823",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.823",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.823",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -17,14 +17,15 @@
|
|
|
17
17
|
// gate's notion of "overlap".
|
|
18
18
|
//
|
|
19
19
|
// node scripts/gds/audit-deps.js [--version GDS-V3] [--min high|medium|low] [--json]
|
|
20
|
-
// node scripts/gds/audit-deps.js --stranded [--json] # the INVERSE: dead edges (task 1003153)
|
|
20
|
+
// node scripts/gds/audit-deps.js --stranded [--json] # the INVERSE: dead edges (task 1003153),
|
|
21
|
+
// # plus prose-declared gates naming a dead task (task 1003857, advisory)
|
|
21
22
|
|
|
22
23
|
const { touchesOverlap } = require('../../src/bongos/path-match.js');
|
|
23
24
|
const { cliClient } = require('./cli-lib');
|
|
24
25
|
const { resolveDefaultVersion } = require('./version-select.js');
|
|
25
26
|
// task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
|
|
26
27
|
// "abandoned dependency" means one thing across the CLI, the route and this audit.
|
|
27
|
-
const { findStrandedTasks } = require('../../modules/lifecycle/dead-deps.js');
|
|
28
|
+
const { findStrandedTasks, findProseGatedTasks } = require('../../modules/lifecycle/dead-deps.js');
|
|
28
29
|
// task 1003752 — the route's page bound + the first-page offset rule, declared once.
|
|
29
30
|
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
|
|
30
31
|
|
|
@@ -129,6 +130,16 @@ async function reportStranded({ asJson }) {
|
|
|
129
130
|
process.exit(1);
|
|
130
131
|
}
|
|
131
132
|
const stranded = findStrandedTasks(all);
|
|
133
|
+
// task 1003857: the same question asked of PROSE. The structured graph above
|
|
134
|
+
// reads blocked_by edges; a gate written in a description is unpoliced, and stales
|
|
135
|
+
// identically — task 1002839 told a builder to coordinate with "existing open task
|
|
136
|
+
// 1001380", abandoned since 2026-08-18. Reported HERE rather than in a second tool
|
|
137
|
+
// so there is one answer to "what is stranded".
|
|
138
|
+
const statusById = (() => {
|
|
139
|
+
const byId = new Map(all.map((t) => [String(t.id), String(t.status)]));
|
|
140
|
+
return (id) => byId.get(String(id)) || null;
|
|
141
|
+
})();
|
|
142
|
+
const proseGated = findProseGatedTasks(all, statusById);
|
|
132
143
|
|
|
133
144
|
// Context worth stating even at zero: abandoned tasks that still CARRY
|
|
134
145
|
// dependents are the latent trap. They report clean only because every
|
|
@@ -139,7 +150,10 @@ async function reportStranded({ asJson }) {
|
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
if (asJson) {
|
|
142
|
-
console.log(JSON.stringify({ stranded, abandoned_deps_with_dependents: [...gatingAbandoned] }, null, 2));
|
|
153
|
+
console.log(JSON.stringify({ stranded, prose_gated: proseGated, abandoned_deps_with_dependents: [...gatingAbandoned] }, null, 2));
|
|
154
|
+
// ADVISORY: a prose match never changes the exit code. It is weaker evidence than
|
|
155
|
+
// an edge — the text may be history, a citation, or simply wrong — so it reports
|
|
156
|
+
// candidates for a human and must not gate a sweep or a CI leg.
|
|
143
157
|
return stranded.length ? 1 : 0;
|
|
144
158
|
}
|
|
145
159
|
console.log(`audit-deps --stranded: ${all.length} task(s) scanned — ${stranded.length} LIVE task(s) stranded by an abandoned dependency\n`);
|
|
@@ -149,6 +163,14 @@ async function reportStranded({ asJson }) {
|
|
|
149
163
|
console.log(` fix: drop the edge (DELETE /dependencies {"from_kind":"task","from_id":${s.task_id},"to_kind":"task","to_id":${s.dead_deps[0].id}})`);
|
|
150
164
|
console.log(` or restore the dep (POST /tasks/${s.dead_deps[0].id}/promote)\n`);
|
|
151
165
|
}
|
|
166
|
+
if (proseGated.length) {
|
|
167
|
+
console.log(`Prose-declared gates naming a dead task: ${proseGated.length} (ADVISORY — a human reads these; they never gate a claim)\n`);
|
|
168
|
+
for (const p of proseGated) {
|
|
169
|
+
console.log(`[PROSE] #${p.task_id} [${p.status}] ${(p.title || '').slice(0, 70)}`);
|
|
170
|
+
for (const d of p.prose_dead_deps) console.log(` body names → #${d.id} [${d.status}]`);
|
|
171
|
+
console.log(' fix: correct the sentence (PATCH /tasks/' + p.task_id + ' {"description":…}), or promote the named task if the gate is real\n');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
152
174
|
if (!stranded.length) {
|
|
153
175
|
console.log('Nothing stranded — no live task is gated by an abandoned dependency.');
|
|
154
176
|
console.log(`Latent exposure: ${gatingAbandoned.size} abandoned task(s) still carry dependents; they read clean only`);
|
|
@@ -321,7 +321,11 @@ async function main() {
|
|
|
321
321
|
if (hasFlag('--line')) {
|
|
322
322
|
const ms = Number(arg('--timeout-ms')) || DEFAULT_TIMEOUT_MS;
|
|
323
323
|
try {
|
|
324
|
-
|
|
324
|
+
// Shared across concurrent session starts (task 1003689): four sessions opened
|
|
325
|
+
// seconds apart asked this same question four times and all four timed out, which
|
|
326
|
+
// emits NO line — and silence from a truth-teller reads as "nothing to report".
|
|
327
|
+
const { cached } = require('./probe-cache.js');
|
|
328
|
+
const { value: one } = await cached('deploy-divergence-line', async () => line(await check({ timeoutMs: ms })));
|
|
325
329
|
if (one) console.log(one);
|
|
326
330
|
} catch { /* offline, no git, unreadable module-api — all silence */ }
|
|
327
331
|
process.exitCode = 0;
|
|
@@ -36,10 +36,16 @@ function writeAtomic(targetPath, contents) {
|
|
|
36
36
|
fs.renameSync(tmp, targetPath);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// The network half, on its own so probe-cache can own it: cached() records a value
|
|
40
|
+
// only when its producer RESOLVES, so a failed fetch leaves nothing cached and the
|
|
41
|
+
// next session retries instead of inheriting a minute of "already done".
|
|
42
|
+
//
|
|
43
|
+
// Returns { soundData, eventData } or null. Null is an ordinary outcome — offline, a
|
|
44
|
+
// timeout, or both endpoints refusing — and the caller writes nothing, which leaves
|
|
45
|
+
// the prefs file exactly as the last good run left it (fail-open).
|
|
46
|
+
async function fetchPrefs() {
|
|
40
47
|
const sess = readSessionToken();
|
|
41
|
-
if (!sess) return;
|
|
42
|
-
|
|
48
|
+
if (!sess) return null;
|
|
43
49
|
const ctrl = new AbortController();
|
|
44
50
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
45
51
|
let soundData = null;
|
|
@@ -57,15 +63,44 @@ async function main() {
|
|
|
57
63
|
signal: ctrl.signal,
|
|
58
64
|
}),
|
|
59
65
|
]);
|
|
60
|
-
if (!soundRes.ok && !eventRes.ok) return;
|
|
66
|
+
if (!soundRes.ok && !eventRes.ok) return null;
|
|
61
67
|
if (soundRes.ok) soundData = await soundRes.json();
|
|
62
68
|
if (eventRes.ok) eventData = await eventRes.json();
|
|
63
69
|
} catch (_) {
|
|
64
|
-
return; // network/timeout — no-op
|
|
70
|
+
return null; // network/timeout — no-op
|
|
65
71
|
} finally {
|
|
66
72
|
clearTimeout(timer);
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
return { soundData, eventData };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function main() {
|
|
79
|
+
const sess = readSessionToken();
|
|
80
|
+
if (!sess) return; // not signed in — player fails open
|
|
81
|
+
|
|
82
|
+
// SHARED ACROSS CONCURRENT SESSION STARTS (task 1003689). This is the third of the
|
|
83
|
+
// three SessionStart hooks that call the server, and when four sessions open within
|
|
84
|
+
// seconds they contend and every hook in all four hits its timeout. Unlike the
|
|
85
|
+
// truth-tellers, a lost run here is invisible rather than merely silent: the prefs
|
|
86
|
+
// file simply keeps its previous contents.
|
|
87
|
+
//
|
|
88
|
+
// Which is also why skipping the write on a cache hit is correct — the file already
|
|
89
|
+
// holds what the previous run fetched, and these prefs change when a builder edits
|
|
90
|
+
// them in Settings, not between two sessions opened three seconds apart.
|
|
91
|
+
// The cache must be marked fresh only by a SUCCESSFUL fetch. The first cut cached
|
|
92
|
+
// Date.now() BEFORE fetching, so a failed or timed-out fetch still recorded 'done'
|
|
93
|
+
// and blocked every retry for the whole TTL - turning a transient network error into
|
|
94
|
+
// a minute of silently stale prefs. cached() writes its record only when the producer
|
|
95
|
+
// RESOLVES, so the work belongs inside it, the way strand-watch and deploy-divergence
|
|
96
|
+
// already do it.
|
|
97
|
+
const { cached } = require('./probe-cache.js');
|
|
98
|
+
const { value: fetched, from } = await cached('sound-prefs', async () => fetchPrefs());
|
|
99
|
+
if (from !== 'fresh') return; // another session already fetched and wrote the file
|
|
100
|
+
if (!fetched) return; // not signed in, offline, or both requests failed
|
|
101
|
+
const { soundData, eventData } = fetched;
|
|
102
|
+
|
|
103
|
+
|
|
69
104
|
// effective_map is { <sound>: <bool> } for every known sound (defaults
|
|
70
105
|
// already applied server-side); volume is the master 0-100 (#788). The local
|
|
71
106
|
// file shape is { sounds: {...}, volume: N, event_sounds: {...} } — play.js
|
|
Binary file
|
|
@@ -245,7 +245,14 @@ async function main() {
|
|
|
245
245
|
// deploy-divergence's identical comment).
|
|
246
246
|
if (hasFlag('--line')) {
|
|
247
247
|
try {
|
|
248
|
-
|
|
248
|
+
// SHARED ACROSS CONCURRENT SESSION STARTS (task 1003689). Four sessions opened
|
|
249
|
+
// within three seconds — one worktree per claim, the normal pattern here — used
|
|
250
|
+
// to make this same call four times and ALL of them timed out, which produces no
|
|
251
|
+
// line at all. Silence from a truth-teller is indistinguishable from 'nothing to
|
|
252
|
+
// report', so the brief was lost without a symptom. The first session to ask now
|
|
253
|
+
// answers for the rest.
|
|
254
|
+
const { cached } = require('./probe-cache.js');
|
|
255
|
+
const { value: one } = await cached('strand-watch-line', async () => line(await check(opts)));
|
|
249
256
|
if (one) console.log(one);
|
|
250
257
|
} catch { /* no session, offline, unreadable — all silence */ }
|
|
251
258
|
process.exitCode = 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.823'; // 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');
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/probe_cache.mjs — one answer, shared by the sessions that start together
|
|
2
|
+
// (task 1003689).
|
|
3
|
+
//
|
|
4
|
+
// THE MEASUREMENT THIS EXISTS FOR. SessionStart runs six hooks, three of which call the
|
|
5
|
+
// server. Four sessions opened within three seconds — one worktree per claim, the normal
|
|
6
|
+
// pattern here — and every hook in all four hit its 10-15s timeout, while the sessions
|
|
7
|
+
// immediately before and after ran clean at ~5s on identical config.
|
|
8
|
+
//
|
|
9
|
+
// A timed-out SessionStart hook emits NO LINE, so the truth-teller briefs vanish. Silence
|
|
10
|
+
// is indistinguishable from "nothing to report", which is the one thing these probes are
|
|
11
|
+
// built not to be.
|
|
12
|
+
//
|
|
13
|
+
// The cache must therefore fail toward DOING THE WORK, never toward silence: every error
|
|
14
|
+
// path below returns the producer's answer rather than nothing.
|
|
15
|
+
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { execFileSync } 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 { createRequire } from 'node:module';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const pc = require('../scripts/gds/probe-cache.js');
|
|
27
|
+
|
|
28
|
+
// fileURLToPath, never URL.pathname (fitness check 30 / task 1002889): a %20 or a
|
|
29
|
+
// Windows drive slash makes require() throw at load, and CI's spaceless checkout
|
|
30
|
+
// cannot see it.
|
|
31
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const fresh = () => `k-${Math.random().toString(36).slice(2)}`;
|
|
33
|
+
|
|
34
|
+
test('the second asker does not repeat the call', async () => {
|
|
35
|
+
const k = fresh();
|
|
36
|
+
let calls = 0;
|
|
37
|
+
const produce = async () => { calls += 1; return `answer-${calls}`; };
|
|
38
|
+
const a = await pc.cached(k, produce, { ttlMs: 5000 });
|
|
39
|
+
const b = await pc.cached(k, produce, { ttlMs: 5000 });
|
|
40
|
+
assert.equal(calls, 1, 'the whole point: four sessions, one question');
|
|
41
|
+
assert.equal(a.from, 'fresh');
|
|
42
|
+
assert.equal(b.from, 'cache');
|
|
43
|
+
assert.equal(b.value, a.value);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('an expired answer is asked again', async () => {
|
|
47
|
+
const k = fresh();
|
|
48
|
+
let calls = 0;
|
|
49
|
+
const produce = async () => { calls += 1; return calls; };
|
|
50
|
+
await pc.cached(k, produce, { ttlMs: 1 });
|
|
51
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
52
|
+
const b = await pc.cached(k, produce, { ttlMs: 1 });
|
|
53
|
+
assert.equal(calls, 2, 'a cache that never expires is a lie, not a cache');
|
|
54
|
+
assert.equal(b.from, 'fresh');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a record from the FUTURE is treated as stale, not trusted forever', () => {
|
|
58
|
+
// A clock that jumped would otherwise pin a stale answer permanently.
|
|
59
|
+
assert.equal(pc.isFresh({ at: 5_000 }, 1_000, 4_000), false);
|
|
60
|
+
assert.equal(pc.isFresh({ at: 3_500 }, 1_000, 4_000), true);
|
|
61
|
+
assert.equal(pc.isFresh(null, 1_000, 4_000), false);
|
|
62
|
+
assert.equal(pc.isFresh({}, 1_000, 4_000), false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('two checkouts of the same instance do not read each other\'s answers', async () => {
|
|
66
|
+
const k = fresh();
|
|
67
|
+
const a = await pc.cached(k, async () => 'from-A', { ttlMs: 5000, scope: '/repo/a' });
|
|
68
|
+
const b = await pc.cached(k, async () => 'from-B', { ttlMs: 5000, scope: '/repo/b' });
|
|
69
|
+
assert.equal(a.value, 'from-A');
|
|
70
|
+
assert.equal(b.value, 'from-B', 'the scope is part of the key — a worktree must not inherit another\'s brief');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a producer that throws is NOT cached, and the error reaches the caller', async () => {
|
|
74
|
+
const k = fresh();
|
|
75
|
+
await assert.rejects(() => pc.cached(k, async () => { throw new Error('probe failed'); }, { ttlMs: 5000 }));
|
|
76
|
+
// and the next asker gets a real attempt, not a cached failure
|
|
77
|
+
const r = await pc.cached(k, async () => 'recovered', { ttlMs: 5000 });
|
|
78
|
+
assert.equal(r.value, 'recovered');
|
|
79
|
+
assert.equal(r.from, 'fresh', 'a failure must not be remembered as an answer');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---- the real scenario: four processes at once -----------------------------
|
|
83
|
+
//
|
|
84
|
+
// The unit cases above all run in ONE process, where a lock is trivially uncontended.
|
|
85
|
+
// The measured failure is four SEPARATE processes starting within three seconds on a
|
|
86
|
+
// cold cache — and the first cut of this cache did NOT fix it: with nothing cached and
|
|
87
|
+
// the lock held, every waiter fell through and made the call anyway, so all four still
|
|
88
|
+
// ran. This is the case that caught it.
|
|
89
|
+
test('four concurrent PROCESSES on a cold cache produce ONE call, and all four get the answer', async () => {
|
|
90
|
+
const counter = path.join(os.tmpdir(), `bongos-probe-test-${Date.now()}.txt`);
|
|
91
|
+
try { fs.rmSync(counter, { force: true }); } catch { /* not there */ }
|
|
92
|
+
execFileSync(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'probe-cache.js'), '--clear'], { encoding: 'utf8' });
|
|
93
|
+
|
|
94
|
+
const child = `
|
|
95
|
+
const pc = require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'probe-cache.js'))});
|
|
96
|
+
const fs = require('fs');
|
|
97
|
+
(async () => {
|
|
98
|
+
const r = await pc.cached('probe-cache-concurrency-test', async () => {
|
|
99
|
+
fs.appendFileSync(${JSON.stringify(counter)}, 'ran\\n');
|
|
100
|
+
await new Promise((res) => setTimeout(res, 400));
|
|
101
|
+
return 'the-answer';
|
|
102
|
+
}, { ttlMs: 30000 });
|
|
103
|
+
process.stdout.write(r.value);
|
|
104
|
+
})();
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
const { spawn } = await import('node:child_process');
|
|
108
|
+
const results = await Promise.all([0, 1, 2, 3].map(() => new Promise((resolve) => {
|
|
109
|
+
const p = spawn(process.execPath, ['-e', child]);
|
|
110
|
+
let out = '';
|
|
111
|
+
p.stdout.on('data', (d) => { out += d; });
|
|
112
|
+
p.on('close', () => resolve(out.trim()));
|
|
113
|
+
})));
|
|
114
|
+
|
|
115
|
+
const ran = fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length : 0;
|
|
116
|
+
try { fs.rmSync(counter, { force: true }); } catch { /* best effort */ }
|
|
117
|
+
|
|
118
|
+
assert.equal(ran, 1, `the probe must run ONCE across four concurrent starts; it ran ${ran}`);
|
|
119
|
+
assert.deepEqual(results, ['the-answer', 'the-answer', 'the-answer', 'the-answer'],
|
|
120
|
+
'and every session must still get its brief — trading a timeout for a silence would be no fix at all');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---- FOUR DIFFERENT WORKTREES (grade round 1) ------------------------------
|
|
124
|
+
//
|
|
125
|
+
// The test above spawns four processes from ONE directory, and it passed while the
|
|
126
|
+
// cache was keyed on process.cwd() — which meant the fix did nothing for the case it
|
|
127
|
+
// was built for. The measured failure is four sessions in FOUR DIFFERENT WORKTREES,
|
|
128
|
+
// one per claim, so four cwds hashed to four keys and nothing was ever shared.
|
|
129
|
+
//
|
|
130
|
+
// A test that does not model the scenario proves the wrong thing. This one runs each
|
|
131
|
+
// child from a different directory of the SAME checkout.
|
|
132
|
+
test('four processes in DIFFERENT worktrees of one checkout still share the answer', async () => {
|
|
133
|
+
const counter = path.join(os.tmpdir(), `bongos-probe-wt-${Date.now()}.txt`);
|
|
134
|
+
try { fs.rmSync(counter, { force: true }); } catch { /* not there */ }
|
|
135
|
+
execFileSync(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'probe-cache.js'), '--clear'], { encoding: 'utf8' });
|
|
136
|
+
|
|
137
|
+
// Four real directories inside this checkout — different cwds, one git common dir,
|
|
138
|
+
// which is exactly the shape of four claim worktrees.
|
|
139
|
+
const cwds = [REPO_ROOT, path.join(REPO_ROOT, 'scripts'), path.join(REPO_ROOT, 'tests'), path.join(REPO_ROOT, 'docs')]
|
|
140
|
+
.filter((d) => fs.existsSync(d));
|
|
141
|
+
assert.equal(cwds.length, 4, 'need four real directories in this checkout to model four worktrees');
|
|
142
|
+
|
|
143
|
+
const child = `
|
|
144
|
+
const pc = require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'probe-cache.js'))});
|
|
145
|
+
const fs = require('fs');
|
|
146
|
+
(async () => {
|
|
147
|
+
const r = await pc.cached('probe-cache-worktree-test', async () => {
|
|
148
|
+
fs.appendFileSync(${JSON.stringify(counter)}, 'ran\\n');
|
|
149
|
+
await new Promise((res) => setTimeout(res, 400));
|
|
150
|
+
return 'shared';
|
|
151
|
+
}, { ttlMs: 30000 });
|
|
152
|
+
process.stdout.write(r.value);
|
|
153
|
+
})();
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
const { spawn } = await import('node:child_process');
|
|
157
|
+
const results = await Promise.all(cwds.map((cwd) => new Promise((resolve) => {
|
|
158
|
+
const p = spawn(process.execPath, ['-e', child], { cwd });
|
|
159
|
+
let out = '';
|
|
160
|
+
p.stdout.on('data', (d) => { out += d; });
|
|
161
|
+
p.on('close', () => resolve(out.trim()));
|
|
162
|
+
})));
|
|
163
|
+
|
|
164
|
+
const ran = fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length : 0;
|
|
165
|
+
try { fs.rmSync(counter, { force: true }); } catch { /* best effort */ }
|
|
166
|
+
|
|
167
|
+
assert.equal(ran, 1, `four DIFFERENT working directories must still share one answer; the probe ran ${ran} times`);
|
|
168
|
+
assert.deepEqual(results, ['shared', 'shared', 'shared', 'shared']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('the scope is the CHECKOUT, not the directory', () => {
|
|
172
|
+
// Every worktree of one checkout reports the same git common dir; two unrelated
|
|
173
|
+
// checkouts report different ones. That is the distinction that matters, because the
|
|
174
|
+
// probes ask one server about one repo.
|
|
175
|
+
const scope = pc.defaultScope();
|
|
176
|
+
assert.ok(scope && typeof scope === 'string');
|
|
177
|
+
assert.ok(/\.git$|\.git[\\/]/.test(scope) || scope === process.cwd(),
|
|
178
|
+
`expected a git common dir (or the documented cwd fallback), got ${scope}`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('clear() with no argument actually empties the cache', async () => {
|
|
182
|
+
// It used to take a scope and delete only when that was exactly null — so calling it
|
|
183
|
+
// with its own default deleted nothing while reporting success.
|
|
184
|
+
const k = fresh();
|
|
185
|
+
let calls = 0;
|
|
186
|
+
await pc.cached(k, async () => { calls += 1; return calls; }, { ttlMs: 60_000 });
|
|
187
|
+
pc.clear();
|
|
188
|
+
await pc.cached(k, async () => { calls += 1; return calls; }, { ttlMs: 60_000 });
|
|
189
|
+
assert.equal(calls, 2, 'after clear(), the next asker must do the work again');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('the three SessionStart hooks that call the server all go through the cache', () => {
|
|
193
|
+
// The wiring is the deliverable; the cache alone changes nothing. These are the three
|
|
194
|
+
// that make network calls — the other three SessionStart hooks are local git.
|
|
195
|
+
for (const rel of ['scripts/gds/strand-watch.js', 'scripts/gds/deploy-divergence.js', 'scripts/gds/fetch-sound-prefs.js']) {
|
|
196
|
+
const src = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8');
|
|
197
|
+
assert.match(src, /require\('\.\/probe-cache\.js'\)/, `${rel} must share its answer with concurrent sessions`);
|
|
198
|
+
assert.match(src, /cached\(/, `${rel} must actually call cached(), not merely import it`);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// tests/prose_gated_deps.mjs — a gate written in a task's DESCRIPTION goes stale the
|
|
2
|
+
// same way an edge does, and nothing was watching (task 1003857).
|
|
3
|
+
//
|
|
4
|
+
// The structured detector (task 1003153) reads blocked_by edges and never touches a
|
|
5
|
+
// description. Task 1002839 sat at `ready` telling a builder to coordinate with
|
|
6
|
+
// "existing open task 1001380" — abandoned since 2026-08-18, superseded by ADR 0089.
|
|
7
|
+
//
|
|
8
|
+
// EVERY REGEX HERE WAS SIZED BY MEASUREMENT, not by reading the spec, and the cases
|
|
9
|
+
// below are the measurements. The first cut over the live board (3,647 tasks) returned
|
|
10
|
+
// two hits and BOTH taught something: one was an `idea` id wearing a task-shaped
|
|
11
|
+
// number, and the true positive only matched because a verb leaked across a semicolon.
|
|
12
|
+
// Tightening that correctly dropped the true positive too — which is how the liveness
|
|
13
|
+
// phrasing was found, because the real sentence is not a dependency at all.
|
|
14
|
+
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { proseGatedIds, findProseGatedTasks, idCandidates, LIVE_STATUSES } = require('../modules/lifecycle/dead-deps.js');
|
|
21
|
+
|
|
22
|
+
const ids = (t) => [...proseGatedIds(t)];
|
|
23
|
+
|
|
24
|
+
test('the LIVE sentence from task 1002839 is caught', () => {
|
|
25
|
+
// Verbatim. This is a liveness assertion, not a dependency — "existing open task N".
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
ids('this is a gate-review HARD FLOOR surface under ADR 0043 and needs owner approval even for an Archon; and existing open task 1001380 covers overlapping ground with stale touches[]'),
|
|
28
|
+
['1001380'],
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a liveness assertion AFTER the id is caught too', () => {
|
|
33
|
+
assert.deepEqual(ids('task 1001380 (Unify the grader: one multimodal engine) is ready to claim'), ['1001380']);
|
|
34
|
+
assert.deepEqual(ids('task 1001380 remains open'), ['1001380']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('an ordinary dependency phrasing is caught', () => {
|
|
38
|
+
assert.deepEqual(ids('needs task 1001380 to land first'), ['1001380']);
|
|
39
|
+
assert.deepEqual(ids('depends on #1001380'), ['1001380']);
|
|
40
|
+
assert.deepEqual(ids('blocked by 1001380'), ['1001380']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an ANOTHER-ENTITY id is not a task gate', () => {
|
|
44
|
+
// Measured: 'Source: idea 1000454' was one of two hits on the first live run.
|
|
45
|
+
// Reporting it sends a human to look up a task that does not exist — the exact
|
|
46
|
+
// failure this detector exists to prevent.
|
|
47
|
+
assert.deepEqual(ids('the first thing that task needs. Source: idea 1000454'), []);
|
|
48
|
+
assert.deepEqual(ids('needs idea 1000454'), []);
|
|
49
|
+
assert.deepEqual(ids('blocked by blocker 1000082'), []);
|
|
50
|
+
assert.deepEqual(ids('after PR 1000454 lands'), []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a gate verb does NOT reach across a sentence or clause boundary', () => {
|
|
54
|
+
// Measured: a fixed-width window let 'needs' govern an id in the next clause.
|
|
55
|
+
assert.deepEqual(ids('this needs owner approval; see 1001380 for history'), []);
|
|
56
|
+
assert.deepEqual(ids('this needs approval. Background in 1001380.'), []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('history and citation are not gates', () => {
|
|
60
|
+
assert.deepEqual(ids('this supersedes task 1001380'), []);
|
|
61
|
+
assert.deepEqual(ids('task 1001380 needs review'), [], 'the verb must govern the id, not follow it');
|
|
62
|
+
assert.deepEqual(ids('open questions remain; see 1001380'), []);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a bare number that is not a task id is ignored', () => {
|
|
66
|
+
assert.deepEqual(ids('needs the 2026 rollout and 4784 commits'), []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ---- OFFSET-AWARENESS (task 1003857) ---------------------------------------
|
|
70
|
+
//
|
|
71
|
+
// THE TRAP THE TASK NAMES, and which I walked straight into by reading only the first
|
|
72
|
+
// part of the description. Task 1000528's own gate is written as a bare PRE-OFFSET
|
|
73
|
+
// short-form ref (`#513`, `task 506`), not the live id — so a live-id-only matcher
|
|
74
|
+
// misses the very case that motivated the original row. The import shifted every id by
|
|
75
|
+
// 1,000,000; diagram-facts.js's IMPORT_ID_OFFSETS is the existing precedent for probing
|
|
76
|
+
// both spaces.
|
|
77
|
+
|
|
78
|
+
test('a SHORT pre-offset ref resolves to its live id', () => {
|
|
79
|
+
assert.deepEqual(ids('needs task 528 to land first'), ['1000528']);
|
|
80
|
+
assert.deepEqual(ids('blocked by #528'), ['1000528']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a LIVE-width ref is NOT offset a second time', () => {
|
|
84
|
+
// 1001380 + 1e6 = 2001380, a row nobody ever wrote. Probing an already-resolved id
|
|
85
|
+
// invents a second candidate that the text does not say.
|
|
86
|
+
assert.deepEqual(ids('and existing open task 1001380 covers overlapping ground'), ['1001380']);
|
|
87
|
+
assert.deepEqual(idCandidates('1001380'), ['1001380']);
|
|
88
|
+
assert.deepEqual(idCandidates('528'), ['1000528']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a short ref needs an explicit task/# prefix — a bare small number is not an id', () => {
|
|
92
|
+
// This runs over every description on the board, where a 3-digit number is a count, a
|
|
93
|
+
// line number or a year far more often than it is a task.
|
|
94
|
+
assert.deepEqual(ids('needs 3 more reviews'), []);
|
|
95
|
+
assert.deepEqual(ids('needs 528 lines changed'), []);
|
|
96
|
+
assert.deepEqual(ids('needs task 528'), ['1000528'], 'the prefix is what makes it a ref');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the offset probe still respects the gate/liveness rules', () => {
|
|
100
|
+
assert.deepEqual(ids('this supersedes task 528'), [], 'history is not a gate, at either width');
|
|
101
|
+
assert.deepEqual(ids('needs idea 454'), [], 'another entity is not a task, at either width');
|
|
102
|
+
assert.deepEqual(ids('existing open task 528 covers this'), ['1000528'], 'liveness works at short width too');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('findProseGatedTasks reports only LIVE rows gated on a DEAD one', () => {
|
|
106
|
+
const statusById = (id) => ({ 1001380: 'abandoned', 1001381: 'shipped' })[id] || null;
|
|
107
|
+
const rows = [
|
|
108
|
+
{ id: '1', status: 'ready', title: 'a', description: 'needs task 1001380' },
|
|
109
|
+
{ id: '2', status: 'ready', title: 'b', description: 'needs task 1001381' }, // dep alive
|
|
110
|
+
{ id: '3', status: 'shipped', title: 'c', description: 'needs task 1001380' }, // row not live
|
|
111
|
+
];
|
|
112
|
+
const out = findProseGatedTasks(rows, statusById);
|
|
113
|
+
assert.equal(out.length, 1);
|
|
114
|
+
assert.equal(out[0].task_id, '1');
|
|
115
|
+
assert.deepEqual(out[0].prose_dead_deps, [{ id: '1001380', status: 'abandoned' }]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a task citing ITSELF is not gated on itself', () => {
|
|
119
|
+
const out = findProseGatedTasks(
|
|
120
|
+
[{ id: '1001380', status: 'ready', title: 'x', description: 'needs task 1001380' }],
|
|
121
|
+
() => 'abandoned',
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(out, []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an unknown id is not reported — absence of a row is not death', () => {
|
|
127
|
+
const out = findProseGatedTasks(
|
|
128
|
+
[{ id: '1', status: 'ready', title: 'x', description: 'needs task 1009999' }],
|
|
129
|
+
() => null,
|
|
130
|
+
);
|
|
131
|
+
assert.deepEqual(out, [], 'a task the caller cannot see may simply be outside the page it fetched');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('LIVE_STATUSES is the shared notion, not a second copy', () => {
|
|
135
|
+
// If this detector grew its own status set, the two halves of --stranded would
|
|
136
|
+
// disagree about what "live" means and nobody would notice.
|
|
137
|
+
assert.ok(LIVE_STATUSES.has('ready') && LIVE_STATUSES.has('backlog'));
|
|
138
|
+
assert.ok(!LIVE_STATUSES.has('shipped') && !LIVE_STATUSES.has('abandoned'));
|
|
139
|
+
});
|