@bongos/core 1.19.817 → 1.19.819
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 +48 -28
- package/.claude/skills/strand-fix/SKILL.md +11 -2
- package/.gitattributes +9 -0
- package/docs/adr/0298-the-reader-lens-is-advisory-and-waits-on-a-voice-guide.md +121 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/gds-db-backup.md +46 -0
- package/modules/discord/bug-attachments.js +36 -1
- package/modules/lifecycle/cascade.js +18 -5
- package/modules/lifecycle/tests/cascade.mjs +11 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bug-attachment-audit.js +143 -0
- package/scripts/gds/capture.js +19 -0
- package/scripts/gds/db-backup-nightly.sh +37 -1
- package/scripts/gds/linkify-refs.js +9 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/scripts/gds/skill-preflight.js +28 -4
- package/src/module-api.js +1 -1
- package/tests/bug_attachment_audit.mjs +122 -0
- package/tests/cli_surface.mjs +31 -2
- package/tests/linkify_refs.mjs +17 -0
- package/tests/module_api_lazy.mjs +32 -1
- package/tests/routine_capture_kind.mjs +147 -0
- package/tests/skill_preflight.mjs +45 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/bug-attachment-audit.js — do the images our task descriptions
|
|
3
|
+
// promise actually exist? (task 1003918)
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. A re-hosted #bugs screenshot is the one artefact in this
|
|
6
|
+
// system that cannot be regenerated: the Discord CDN URL it was copied from is
|
|
7
|
+
// signed and expires in about a day, so our copy IS the image. They live on disk
|
|
8
|
+
// under var/bug-attachments — outside Postgres, outside git — and nothing
|
|
9
|
+
// noticed when all five this instance had ever stored disappeared in the
|
|
10
|
+
// 2026-07-05 droplet move. The loss surfaced on 2026-09-14, two months later,
|
|
11
|
+
// because somebody happened to open an old bug task and saw a broken image.
|
|
12
|
+
//
|
|
13
|
+
// So: read the descriptions, extract every attachment link they promise, and ask
|
|
14
|
+
// the disk whether it can keep that promise. A count of broken promises is the
|
|
15
|
+
// thing that should have been non-zero and visible in July.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// node scripts/gds/bug-attachment-audit.js # human report; exit 1 if any are missing
|
|
19
|
+
// node scripts/gds/bug-attachment-audit.js --json # { described, present, missing: [...] }
|
|
20
|
+
// node scripts/gds/bug-attachment-audit.js --line # one line, or nothing; always exit 0
|
|
21
|
+
//
|
|
22
|
+
// Read-only. It never writes, never deletes, and never touches Discord.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
// The link shape the bot embeds in a description. Deliberately matched against
|
|
30
|
+
// the SERVE ROUTE's path rather than any filename found on disk: what matters is
|
|
31
|
+
// what a reader will click, and a file nothing links to is not a broken promise.
|
|
32
|
+
const LINK_RE = /\/api\/(?:gds|bongos)\/bug-attachments\/(bug-[a-z0-9]{1,40}-[0-9a-f]{16}\.(?:png|jpg|gif|webp))/gi;
|
|
33
|
+
|
|
34
|
+
// PURE — every attachment name a body of text promises, deduped, in first-seen
|
|
35
|
+
// order. Exported so the extraction is testable without a database or a disk.
|
|
36
|
+
function describedAttachments(text) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const m of String(text || '').matchAll(LINK_RE)) {
|
|
40
|
+
const name = m[1];
|
|
41
|
+
if (!seen.has(name)) { seen.add(name); out.push(name); }
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// PURE — split the promised names into kept and broken, given an existence test.
|
|
47
|
+
// `rows` are { id, title, description }; the result names the TASK for each
|
|
48
|
+
// missing file, because "three files are gone" is not actionable and "the image
|
|
49
|
+
// in task N is gone" is.
|
|
50
|
+
function auditRows(rows, exists) {
|
|
51
|
+
const described = [];
|
|
52
|
+
const missing = [];
|
|
53
|
+
for (const row of rows || []) {
|
|
54
|
+
for (const name of describedAttachments(row && row.description)) {
|
|
55
|
+
described.push({ name, taskId: row.id });
|
|
56
|
+
if (!exists(name)) missing.push({ name, taskId: row.id, title: row.title || '' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { described, missing, present: described.length - missing.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// EVERY status, explicitly, and paged. Two ways to get a comfortable zero here,
|
|
63
|
+
// both of which I hit while building this:
|
|
64
|
+
//
|
|
65
|
+
// - GET /tasks omits abandoned rows unless you ask for them. The five files this
|
|
66
|
+
// audit exists for are described in tasks that are now abandoned or shipped, so
|
|
67
|
+
// the obvious query returned '0 linked, 0 missing' — indistinguishable from
|
|
68
|
+
// 'nothing is broken'.
|
|
69
|
+
// - the route clamps `limit`, so one page is not the table.
|
|
70
|
+
//
|
|
71
|
+
// A probe whose failure mode is a clean bill of health is worse than no probe, so
|
|
72
|
+
// the status list is spelled out rather than defaulted.
|
|
73
|
+
// 'claimed' is not one — claimTask sets status='active' (db-claims.js). Listing it
|
|
74
|
+
// only bought a no-op query, and a comment promising EVERY status should be right.
|
|
75
|
+
const TASK_STATUSES = ['backlog', 'ready', 'active', 'completed', 'confirmed', 'shipped', 'abandoned'];
|
|
76
|
+
|
|
77
|
+
async function fetchRows() {
|
|
78
|
+
const { cliClient } = require('./cli-lib');
|
|
79
|
+
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
|
|
80
|
+
const api = await cliClient();
|
|
81
|
+
const byId = new Map();
|
|
82
|
+
for (const status of TASK_STATUSES) {
|
|
83
|
+
let offset = 0;
|
|
84
|
+
for (let page = 0; page < 100; page++) {
|
|
85
|
+
const q = buildTaskPageQuery({ kind: 'bug', status, limit: TASK_LIST_MAX_LIMIT, offset });
|
|
86
|
+
const r = await api.tasks.getTasks({ query: q });
|
|
87
|
+
if (!r || !r.ok) throw new Error(`could not list ${status} tasks (${r && r.status})`);
|
|
88
|
+
const rows = (r.data && r.data.tasks) || [];
|
|
89
|
+
for (const t of rows) byId.set(String(t.id), t);
|
|
90
|
+
// A short page is the end. Compare against the limit the route HONOURED.
|
|
91
|
+
if (rows.length < q.limit) break;
|
|
92
|
+
offset += q.limit;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...byId.values()];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function main() {
|
|
99
|
+
const argv = process.argv.slice(2);
|
|
100
|
+
const dir = require('../../modules/discord/bug-attachments.js').attachmentsDir();
|
|
101
|
+
let rows;
|
|
102
|
+
try {
|
|
103
|
+
rows = await fetchRows();
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (argv.includes('--line')) process.exit(0); // a probe never blocks a session
|
|
106
|
+
console.error(`bug-attachment-audit: undetermined — ${e.message || e}`);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
// Through the serve route's OWN resolver, not a bare path.join. The name pattern
|
|
110
|
+
// already excludes slashes and dots, and a test pins that '../../etc/passwd' is
|
|
111
|
+
// never extracted — but the route that actually opens these files defends itself
|
|
112
|
+
// with a resolved-path prefix check, and a probe that reads the same store should
|
|
113
|
+
// not hold itself to a weaker rule than the thing it audits.
|
|
114
|
+
const { resolveSafe } = require('../../modules/discord/bug-attachments.js');
|
|
115
|
+
const r = auditRows(rows, (name) => {
|
|
116
|
+
const p = resolveSafe(name);
|
|
117
|
+
return Boolean(p) && fs.existsSync(p);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (argv.includes('--json')) {
|
|
121
|
+
console.log(JSON.stringify({ dir, described: r.described.length, present: r.present, missing: r.missing }, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (argv.includes('--line')) {
|
|
125
|
+
if (r.missing.length) {
|
|
126
|
+
console.log(`Bug attachments: ${r.missing.length} of ${r.described.length} linked screenshot(s) are MISSING from ${dir} `
|
|
127
|
+
+ '— every task describing one renders a broken image. These cannot be re-fetched (the Discord URL has expired).');
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.log(` Bug attachments: ${r.described.length} linked, ${r.present} present, ${r.missing.length} missing`);
|
|
132
|
+
console.log(` Store: ${dir}`);
|
|
133
|
+
for (const m of r.missing) console.log(` missing ${m.name} (task ${m.taskId})`);
|
|
134
|
+
if (r.missing.length) {
|
|
135
|
+
console.log(' These cannot be re-fetched — the Discord CDN URL they were copied from has expired.');
|
|
136
|
+
console.log(' Restore from a nightly attachments archive: /var/backups/gds/<db>-attachments-*.tar.gz');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (require.main === module) main();
|
|
142
|
+
|
|
143
|
+
module.exports = { describedAttachments, auditRows, LINK_RE };
|
package/scripts/gds/capture.js
CHANGED
|
@@ -282,6 +282,25 @@ async function main() {
|
|
|
282
282
|
const noPrompt = hasFlag('--no-prompt', args);
|
|
283
283
|
const goalArg = arg('--goal', args);
|
|
284
284
|
|
|
285
|
+
// A BAD --kind used to reach the server and come back a 400 (task 1003694).
|
|
286
|
+
// That is not merely late, it is invisible where it matters: a SCHEDULED routine
|
|
287
|
+
// filing its findings gets the 400 into a log nobody reads, the audit work still
|
|
288
|
+
// runs, and only the step that turns a finding into a durable row fails — so no
|
|
289
|
+
// queue accumulates to make the absence noticeable. dep-audit-nightly filed with
|
|
290
|
+
// `--kind security`, which is not a kind, and had therefore NEVER filed anything.
|
|
291
|
+
//
|
|
292
|
+
// Refusing here makes the same typo surface at authoring time, on the machine of
|
|
293
|
+
// whoever wrote the command, with the valid list in the message.
|
|
294
|
+
if (kindFromArg !== null) {
|
|
295
|
+
const { VALID_KINDS } = require('../../modules/ideas/inbox.js');
|
|
296
|
+
if (!VALID_KINDS.includes(kindFromArg)) {
|
|
297
|
+
console.error(`--kind "${kindFromArg}" is not a valid kind. The server would refuse this with a 400.`);
|
|
298
|
+
console.error(`Valid kinds: ${VALID_KINDS.join(', ')}`);
|
|
299
|
+
console.error('Adding a new kind is a schema change (the idea_inbox CHECK constraint) and needs its own migration + ADR.');
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
285
304
|
if (parseGoalArg(goalArg).kind === 'invalid') {
|
|
286
305
|
console.error(`--goal must be a positive goal id, or the literal "none" to file homeless (got "${goalArg}").`);
|
|
287
306
|
process.exit(2);
|
|
@@ -170,6 +170,42 @@ if [ "$BYTES" -gt "$WARN_BYTES" ]; then
|
|
|
170
170
|
echo "[$(date -u +%FT%TZ)] gds-db-backup: WARN latest dump is ${BYTES} bytes (> ${WARN_BYTES} threshold) — review what grew; is it a regenerable table excludable like search_chunks?" >&2
|
|
171
171
|
fi
|
|
172
172
|
|
|
173
|
+
# ---- bug attachments (task 1003918) -----------------------------------------
|
|
174
|
+
|
|
175
|
+
# The DB dump above is not the whole state. Re-hosted #bugs screenshots live on
|
|
176
|
+
# disk under var/bug-attachments, outside Postgres and outside git, and they are
|
|
177
|
+
# the one artefact here that CANNOT be regenerated: the Discord CDN URL they were
|
|
178
|
+
# copied from is signed and expires in about a day, so our copy IS the image.
|
|
179
|
+
#
|
|
180
|
+
# Nothing backed them up. All five this instance ever stored are gone — the
|
|
181
|
+
# 2026-07-05 droplet move carried the database and not var/ — and every task
|
|
182
|
+
# description embedding one now renders a broken image in the hall.
|
|
183
|
+
#
|
|
184
|
+
# Tarred beside the dump, same timestamp, same retention, so a restore drill that
|
|
185
|
+
# picks a date gets the database and the images that go with it. Best-effort by
|
|
186
|
+
# design: a missing or empty attachments dir is the ordinary case on a fresh
|
|
187
|
+
# instance and must never fail a night of database backup.
|
|
188
|
+
ATTACH_DIR="${BUG_ATTACHMENTS_DIR:-$REPO_ROOT/var/bug-attachments}"
|
|
189
|
+
ATTACH_OUT="${BACKUP_DIR}/${DB_NAME}-attachments-${TS}.tar.gz"
|
|
190
|
+
ATTACH_GLOB="${DB_NAME}-attachments-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D}Z.tar.gz"
|
|
191
|
+
|
|
192
|
+
if [ -d "$ATTACH_DIR" ]; then
|
|
193
|
+
ATTACH_COUNT=$(find "$ATTACH_DIR" -maxdepth 1 -type f | wc -l)
|
|
194
|
+
if [ "$ATTACH_COUNT" -gt 0 ]; then
|
|
195
|
+
if tar -czf "$ATTACH_OUT.partial" -C "$ATTACH_DIR" . 2>/dev/null; then
|
|
196
|
+
mv "$ATTACH_OUT.partial" "$ATTACH_OUT"
|
|
197
|
+
echo "[$(date -u +%FT%TZ)] gds-db-backup: wrote ${ATTACH_OUT} (${ATTACH_COUNT} attachment(s))"
|
|
198
|
+
else
|
|
199
|
+
rm -f "$ATTACH_OUT.partial"
|
|
200
|
+
echo "[$(date -u +%FT%TZ)] gds-db-backup: WARN could not archive ${ATTACH_DIR} — database dump is unaffected" >&2
|
|
201
|
+
fi
|
|
202
|
+
else
|
|
203
|
+
echo "[$(date -u +%FT%TZ)] gds-db-backup: no bug attachments to archive"
|
|
204
|
+
fi
|
|
205
|
+
else
|
|
206
|
+
echo "[$(date -u +%FT%TZ)] gds-db-backup: no bug-attachment dir at ${ATTACH_DIR} — nothing to archive"
|
|
207
|
+
fi
|
|
208
|
+
|
|
173
209
|
# ---- retention prune --------------------------------------------------------
|
|
174
210
|
|
|
175
211
|
# Delete dumps older than RETENTION_DAYS. -mtime +N means strictly older
|
|
@@ -177,7 +213,7 @@ fi
|
|
|
177
213
|
# Done AFTER the new dump lands so a streak of failed runs can't erase
|
|
178
214
|
# everything: we never delete on a day we didn't successfully dump.
|
|
179
215
|
PRUNED=$(find "$BACKUP_DIR" -maxdepth 1 -type f \
|
|
180
|
-
-name "$DUMP_GLOB" \
|
|
216
|
+
\( -name "$DUMP_GLOB" -o -name "$ATTACH_GLOB" \) \
|
|
181
217
|
-mtime +"${RETENTION_DAYS}" \
|
|
182
218
|
-print -delete | wc -l)
|
|
183
219
|
|
|
@@ -115,6 +115,14 @@ const { API_BASE } = require('./cli-lib');
|
|
|
115
115
|
// the root CLAUDE.md/README.md plus all of docs/** (session-logs included). A
|
|
116
116
|
// "GENERATED FILE" marker means the bare refs reappear on the next regen, so
|
|
117
117
|
// the generator — not its output — is where those get fixed; skip them here.
|
|
118
|
+
// PATHS COME BACK POSIX-STYLE, on every platform (task 1003935). path.join gives
|
|
119
|
+
// OS-native separators, so on Windows this used to return 'docs\\adr\\0001-….md'.
|
|
120
|
+
// Three things read these paths and only one of them tolerates that: fs accepts
|
|
121
|
+
// either separator on Windows, but the --check report PRINTS them (so CI output
|
|
122
|
+
// differed by platform for identical content) and the test that pins recursion
|
|
123
|
+
// into docs/ compares against 'docs/' — which is how a permanently-red case hid
|
|
124
|
+
// for months in a file no lane ran. Normalising here, at the one boundary that
|
|
125
|
+
// produces them, is cheaper than teaching every consumer to be separator-aware.
|
|
118
126
|
function discoverDocs() {
|
|
119
127
|
const out = [];
|
|
120
128
|
const walk = (rel) => {
|
|
@@ -123,7 +131,7 @@ function discoverDocs() {
|
|
|
123
131
|
if (fs.statSync(abs).isDirectory()) {
|
|
124
132
|
for (const name of fs.readdirSync(abs).sort()) walk(path.join(rel, name));
|
|
125
133
|
} else if (rel.endsWith('.md')) {
|
|
126
|
-
out.push(rel);
|
|
134
|
+
out.push(rel.split(path.sep).join('/'));
|
|
127
135
|
}
|
|
128
136
|
};
|
|
129
137
|
for (const root of ['CLAUDE.md', 'README.md', 'docs']) walk(root);
|
|
@@ -131,7 +131,14 @@ const INTEGRATION = new Set([
|
|
|
131
131
|
// the --integration lane never saw them either, because it runs only this set. A
|
|
132
132
|
// test that cannot fail in either lane is a file that looks like a gate.
|
|
133
133
|
'handle_machinery', 'hub_profile_leak', 'profile_fields',
|
|
134
|
-
'linkify_refs'
|
|
134
|
+
// task 1003935: 'linkify_refs' was swept in with the three above without the
|
|
135
|
+
// no-DB check they were being fixed for. It touches no Postgres at all — pure
|
|
136
|
+
// string work plus a filesystem walk — so the DB-free lane skipped it and the
|
|
137
|
+
// --integration lane, which runs only this set, never had a reason to. It ran in
|
|
138
|
+
// NEITHER lane, which is the exact failure the comment above describes, and it
|
|
139
|
+
// is why one permanently-red Windows case sat unnoticed. It is now in the unit
|
|
140
|
+
// gate, where it is the only coverage linkifyText has.
|
|
141
|
+
'memory_isolation',
|
|
135
142
|
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
|
|
136
143
|
// task 1003145: whether the PLANNER uses the migration 225 index for the recall
|
|
137
144
|
// freshness probe. The DB-free sibling (recall_corpus_health) pins the query
|
|
@@ -120,13 +120,36 @@ const CAPABILITIES = {
|
|
|
120
120
|
},
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
// EVERY read of a SKILL.md goes through here (task 1003754).
|
|
124
|
+
//
|
|
125
|
+
// These files are CRLF in a Windows checkout — .gitattributes pins *.js, *.json,
|
|
126
|
+
// *.sh and *.sql, but Markdown falls through `* text=auto`. Every regex below is
|
|
127
|
+
// anchored on \n, starting with the frontmatter fence /^---\n/, so on Windows the
|
|
128
|
+
// frontmatter simply did not match and parseRequires returned `declared: false`
|
|
129
|
+
// for EVERY skill.
|
|
130
|
+
//
|
|
131
|
+
// That is worse than one red test. Fitness check G03 compares what a skill's fenced
|
|
132
|
+
// commands imply against what it declares — and with nothing parsed as declared, the
|
|
133
|
+
// check reported '53 skill(s) scanned; 0 declare a machine contract' and had nothing
|
|
134
|
+
// to compare. The guard against a skill's ops commands outrunning its declaration
|
|
135
|
+
// was inert on any Windows box.
|
|
136
|
+
//
|
|
137
|
+
// Normalising once at the boundary is deliberately not 'make each regex CRLF-aware':
|
|
138
|
+
// there are six of them and the next one added would forget.
|
|
139
|
+
function readSkillMd(p) {
|
|
140
|
+
return String(fs.readFileSync(p, 'utf8')).replace(/\r\n/g, '\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
// ---- the declaration ------------------------------------------------------
|
|
124
144
|
// Read `requires:` out of a skill's YAML frontmatter. Accepts the inline form
|
|
125
145
|
// (`requires: [gh, main-checkout]`) and the dash-list form. Returns
|
|
126
146
|
// { requires, declared, error }: `declared` distinguishes "declared nothing"
|
|
127
147
|
// from "declared an empty list", which the fitness check needs to tell apart.
|
|
128
148
|
function parseRequires(md) {
|
|
129
|
-
|
|
149
|
+
// Normalised here too, not only in readSkillMd: this is exported and a caller may
|
|
150
|
+
// hand it raw file text (the fitness check's injected `read` does exactly that).
|
|
151
|
+
const src = String(md || '').replace(/\r\n/g, '\n');
|
|
152
|
+
const m = /^---\n([\s\S]*?)\n---/.exec(src);
|
|
130
153
|
if (!m) return { requires: [], declared: false, error: null };
|
|
131
154
|
const fm = m[1];
|
|
132
155
|
const inline = /^requires:[ \t]*\[(.*?)\][ \t]*$/m.exec(fm);
|
|
@@ -176,7 +199,8 @@ function fencedCode(md) {
|
|
|
176
199
|
}
|
|
177
200
|
|
|
178
201
|
function inferRequired(md) {
|
|
179
|
-
|
|
202
|
+
// Same reason as parseRequires: the SIGNALS below are ^-anchored per line.
|
|
203
|
+
const code = fencedCode(String(md || '').replace(/\r\n/g, '\n'));
|
|
180
204
|
const out = new Set();
|
|
181
205
|
for (const [rx, cap] of SIGNALS) if (rx.test(code)) out.add(cap);
|
|
182
206
|
return [...out].sort();
|
|
@@ -224,7 +248,7 @@ function preflightSkill(name, caps = CAPABILITIES) {
|
|
|
224
248
|
return { ok: false, requires: [], missing: [{ name: 'skill', why: `"${name}" is not a skill name — it must be a single directory name under .claude/skills/.` }], unknown: [], checked: [] };
|
|
225
249
|
}
|
|
226
250
|
let md;
|
|
227
|
-
try { md =
|
|
251
|
+
try { md = readSkillMd(p); } catch {
|
|
228
252
|
return { ok: false, missing: [{ name: 'skill', why: `no skill named "${name}" at .claude/skills/${name}/SKILL.md` }], unknown: [], checked: [] };
|
|
229
253
|
}
|
|
230
254
|
const { requires, error } = parseRequires(md);
|
|
@@ -263,7 +287,7 @@ function checkOpsSkillsDeclareMachine({ dir = SKILLS_DIR, read = null, names: in
|
|
|
263
287
|
};
|
|
264
288
|
}
|
|
265
289
|
const readOne = read || ((n) => {
|
|
266
|
-
try { return
|
|
290
|
+
try { return readSkillMd(path.join(dir, n, 'SKILL.md')); } catch { return null; }
|
|
267
291
|
});
|
|
268
292
|
|
|
269
293
|
let declaring = 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.819'; // 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,122 @@
|
|
|
1
|
+
// tests/bug_attachment_audit.mjs — the described-but-missing probe (task 1003918).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. Five re-hosted #bugs screenshots — every one this instance
|
|
4
|
+
// ever stored — disappeared in the 2026-07-05 droplet move, and nobody noticed for
|
|
5
|
+
// two months. They cannot be re-fetched: the Discord CDN URL they were copied from
|
|
6
|
+
// is signed and expires in about a day, so our copy IS the image. The probe is what
|
|
7
|
+
// should have said so in July.
|
|
8
|
+
//
|
|
9
|
+
// A probe like this fails by finding nothing, and a clean bill of health looks
|
|
10
|
+
// identical to working — which is exactly what happened while building it: the
|
|
11
|
+
// first cut queried GET /tasks without naming a status, the route omits abandoned
|
|
12
|
+
// rows by default, and every task describing these files is now abandoned or
|
|
13
|
+
// shipped. It printed "0 linked, 0 missing". So the cases below lean on the ways
|
|
14
|
+
// the extraction can go quietly empty.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/bug_attachment_audit.mjs
|
|
17
|
+
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { describedAttachments, auditRows } = require('../scripts/gds/bug-attachment-audit.js');
|
|
24
|
+
const { attachmentsDir } = require('../modules/discord/bug-attachments.js');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
|
|
27
|
+
// The real link shape, taken verbatim from task 1003718's description.
|
|
28
|
+
const REAL = '/api/gds/bug-attachments/bug-1523688670492627115-8bb16de1f5b4c20e.png';
|
|
29
|
+
|
|
30
|
+
test('extracts the attachment name from a real embedded link', () => {
|
|
31
|
+
const got = describedAttachments(``);
|
|
32
|
+
assert.deepEqual(got, ['bug-1523688670492627115-8bb16de1f5b4c20e.png']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('the 19-digit Discord message id is not too long for the name pattern', () => {
|
|
36
|
+
// The id in every real filename here is 19 characters. A pattern sized for
|
|
37
|
+
// something shorter would match nothing and report a clean board.
|
|
38
|
+
assert.equal(describedAttachments(REAL).length, 1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('both /gds/ and /bongos/ spellings of the route are recognised', () => {
|
|
42
|
+
const a = describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.png');
|
|
43
|
+
const b = describedAttachments('/api/bongos/bug-attachments/bug-1-0123456789abcdef.png');
|
|
44
|
+
assert.deepEqual(a, b, 'the route was renamed; a description can carry either');
|
|
45
|
+
assert.equal(a.length, 1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the same file linked twice in one description is counted once', () => {
|
|
49
|
+
const text = ` and again `;
|
|
50
|
+
assert.equal(describedAttachments(text).length, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a description with no attachment yields nothing — and that is not an error', () => {
|
|
54
|
+
assert.deepEqual(describedAttachments('a bug about the hall sidebar'), []);
|
|
55
|
+
assert.deepEqual(describedAttachments(null), []);
|
|
56
|
+
assert.deepEqual(describedAttachments(undefined), []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a malformed name is NOT extracted — the pattern is the traversal defence too', () => {
|
|
60
|
+
assert.deepEqual(describedAttachments('/api/gds/bug-attachments/../../etc/passwd'), []);
|
|
61
|
+
assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-short.png'), []);
|
|
62
|
+
assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.exe'), []);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('auditRows names the TASK for each missing file, not just the file', () => {
|
|
66
|
+
const rows = [
|
|
67
|
+
{ id: '1003718', title: 'a re-filed bug', description: `` },
|
|
68
|
+
{ id: '1003719', title: 'no images here', description: 'plain prose' },
|
|
69
|
+
];
|
|
70
|
+
const r = auditRows(rows, () => false);
|
|
71
|
+
assert.equal(r.described.length, 1);
|
|
72
|
+
assert.equal(r.missing.length, 1);
|
|
73
|
+
assert.equal(r.missing[0].taskId, '1003718', '"a file is gone" is not actionable; "the image in task N is gone" is');
|
|
74
|
+
assert.equal(r.present, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a present file is not reported missing', () => {
|
|
78
|
+
const rows = [{ id: '1', title: 't', description: `` }];
|
|
79
|
+
const r = auditRows(rows, () => true);
|
|
80
|
+
assert.deepEqual(r.missing, []);
|
|
81
|
+
assert.equal(r.present, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the same file described by two tasks is reported for BOTH', () => {
|
|
85
|
+
// task 1002076 was re-filed as 1003718 and both descriptions carry the links;
|
|
86
|
+
// each renders a broken image, so each is worth naming.
|
|
87
|
+
const rows = [
|
|
88
|
+
{ id: '1002076', title: 'original', description: `` },
|
|
89
|
+
{ id: '1003718', title: 're-file', description: `` },
|
|
90
|
+
];
|
|
91
|
+
const r = auditRows(rows, () => false);
|
|
92
|
+
assert.equal(r.missing.length, 2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the store resolves against the INSTANCE root, not the core package (task 1003918)', () => {
|
|
96
|
+
// The old default was __dirname/../../var, which on a vendored core is
|
|
97
|
+
// node_modules/@bongos/core/var — wiped by every reinstall, and these files
|
|
98
|
+
// cannot be regenerated.
|
|
99
|
+
const prev = process.env.BUG_ATTACHMENTS_DIR;
|
|
100
|
+
delete process.env.BUG_ATTACHMENTS_DIR;
|
|
101
|
+
try {
|
|
102
|
+
const dir = attachmentsDir();
|
|
103
|
+
assert.ok(!dir.includes('node_modules'), `the store must not live inside an installed package: ${dir}`);
|
|
104
|
+
assert.equal(path.basename(dir), 'bug-attachments');
|
|
105
|
+
assert.equal(path.basename(path.dirname(dir)), 'var');
|
|
106
|
+
} finally {
|
|
107
|
+
if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
|
|
108
|
+
else process.env.BUG_ATTACHMENTS_DIR = prev;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('BUG_ATTACHMENTS_DIR still wins outright', () => {
|
|
113
|
+
const prev = process.env.BUG_ATTACHMENTS_DIR;
|
|
114
|
+
process.env.BUG_ATTACHMENTS_DIR = '/srv/persistent/bug-attachments';
|
|
115
|
+
try {
|
|
116
|
+
assert.equal(attachmentsDir(), '/srv/persistent/bug-attachments',
|
|
117
|
+
'on a box whose instance root is impermanent, the override is the answer');
|
|
118
|
+
} finally {
|
|
119
|
+
if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
|
|
120
|
+
else process.env.BUG_ATTACHMENTS_DIR = prev;
|
|
121
|
+
}
|
|
122
|
+
});
|
package/tests/cli_surface.mjs
CHANGED
|
@@ -169,18 +169,47 @@ function runGuidance(fn, env) {
|
|
|
169
169
|
const code =
|
|
170
170
|
`const l=require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'))});` +
|
|
171
171
|
`Promise.resolve(l[${JSON.stringify(fn)}]()).catch(()=>{});`;
|
|
172
|
+
// HOME ALONE IS NOT ISOLATION ON WINDOWS (task 1003941). cli-lib resolves the
|
|
173
|
+
// session through os.homedir(), which on win32 reads USERPROFILE and ignores
|
|
174
|
+
// HOME entirely — so the child found the DEVELOPER'S REAL session, requireSession
|
|
175
|
+
// succeeded, and the guidance these tests exist to read was never printed. That
|
|
176
|
+
// failed on any Windows box whose builder happened to be signed in, and went
|
|
177
|
+
// green again once their CLI session expired, which made it read as a flake.
|
|
178
|
+
//
|
|
179
|
+
// HOMEDRIVE/HOMEPATH are os.homedir()'s second win32 source, so all four go.
|
|
180
|
+
const HOMELESS = path.join(REPO_ROOT, 'no-such-home-for-tests');
|
|
172
181
|
const r = spawnSync(process.execPath, ['-e', code], {
|
|
173
182
|
encoding: 'utf8',
|
|
174
|
-
env: {
|
|
183
|
+
env: {
|
|
184
|
+
...process.env,
|
|
185
|
+
...env,
|
|
186
|
+
HOME: HOMELESS,
|
|
187
|
+
USERPROFILE: HOMELESS,
|
|
188
|
+
HOMEDRIVE: '',
|
|
189
|
+
HOMEPATH: HOMELESS,
|
|
190
|
+
},
|
|
175
191
|
});
|
|
176
192
|
return `${r.stdout || ''}${r.stderr || ''}`;
|
|
177
193
|
}
|
|
178
194
|
|
|
195
|
+
// The isolation above either worked or it did not, and the difference is visible:
|
|
196
|
+
// a child with no session PRINTS guidance, and a child that found one prints
|
|
197
|
+
// nothing. Every assertion below that looks for something ABSENT is satisfied for
|
|
198
|
+
// free by that silence, so the silence is checked for first (task 1003941).
|
|
199
|
+
function guidanceOutput(fn, env) {
|
|
200
|
+
const out = runGuidance(fn, env);
|
|
201
|
+
assert.ok(out.trim().length > 0,
|
|
202
|
+
`${fn} printed NOTHING. That means the child found a real CLI session instead of `
|
|
203
|
+
+ 'the blanked home, so this test is reading an unisolated environment and every '
|
|
204
|
+
+ '"must not contain" assertion here is vacuous (task 1003941).');
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
179
208
|
const GUIDANCE = ['printUnauthGuidanceAndExit', 'printBoxScopeGuidanceAndExit', 'requireSession'];
|
|
180
209
|
|
|
181
210
|
test('no guidance path speaks slash commands to a terminal reader', () => {
|
|
182
211
|
for (const fn of GUIDANCE) {
|
|
183
|
-
const out =
|
|
212
|
+
const out = guidanceOutput(fn, { BONGOS_SURFACE: 'terminal' });
|
|
184
213
|
const leaks = [...out.matchAll(/\/builder-[a-z-]+/g)].map((m) => m[0]);
|
|
185
214
|
assert.deepEqual(leaks, [], `${fn} leaked to a terminal reader: ${leaks.join(', ')}\n${out}`);
|
|
186
215
|
assert.ok(!/node scripts\/gds\//.test(out), `${fn} told a terminal reader to run a repo script:\n${out}`);
|
package/tests/linkify_refs.mjs
CHANGED
|
@@ -290,6 +290,23 @@ t('a known idea link is never unwrapped by the past-mislink pass', () => {
|
|
|
290
290
|
assert.equal(runIds(s, ids), s);
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
+
// task 1003935: the separator contract, stated rather than assumed.
|
|
294
|
+
//
|
|
295
|
+
// discoverDocs builds with path.join, so on Windows it returned
|
|
296
|
+
// 'docs\\adr\\0001-….md' and the recursion assertion below — which compares against
|
|
297
|
+
// 'docs/' — was permanently red. Nobody saw it: this file was in run-unit-tests'
|
|
298
|
+
// INTEGRATION exclusion set while touching no database, so it ran in neither lane
|
|
299
|
+
// (fixed in the same task). The assertion was right and the walker was wrong to
|
|
300
|
+
// hand it OS-native paths, so the fix is here at the boundary; this test exists so
|
|
301
|
+
// the next reader does not have to rediscover which of the two was at fault.
|
|
302
|
+
t('discoverDocs returns POSIX-style paths on every platform (task 1003935)', () => {
|
|
303
|
+
const docs = discoverDocs();
|
|
304
|
+
const native = docs.filter((d) => d.includes('\\'));
|
|
305
|
+
assert.equal(native.length, 0,
|
|
306
|
+
`paths must not carry OS-native separators; got ${JSON.stringify(native.slice(0, 3))}`);
|
|
307
|
+
assert.ok(docs.some((d) => d.includes('/')), 'and nested paths must still be nested');
|
|
308
|
+
});
|
|
309
|
+
|
|
293
310
|
t('discoverDocs finds CLAUDE.md, recurses docs/ subdirs (only .md), skips the generated ideas file', () => {
|
|
294
311
|
const docs = discoverDocs();
|
|
295
312
|
assert.ok(docs.includes('CLAUDE.md'), 'includes CLAUDE.md');
|
|
@@ -35,8 +35,13 @@ function loadedAfter(spec, read = null) {
|
|
|
35
35
|
const ROOT = ${JSON.stringify(ROOT)};
|
|
36
36
|
const m = require(path.join(ROOT, ${JSON.stringify(spec)}));
|
|
37
37
|
${read ? `void m[${JSON.stringify(read)}];` : ''}
|
|
38
|
+
// POSIX-normalised before filtering (task 1003941). path.relative returns
|
|
39
|
+
// backslashes on win32, so 'src/bongos/' matched NOTHING and every list came
|
|
40
|
+
// back empty — which made the two laziness assertions below pass VACUOUSLY on
|
|
41
|
+
// Windows: they assert that a filtered-empty list is empty. An eager require
|
|
42
|
+
// reintroduced at the doorway would have shipped green.
|
|
38
43
|
const files = Object.keys(require.cache)
|
|
39
|
-
.map((f) => path.relative(ROOT, f))
|
|
44
|
+
.map((f) => path.relative(ROOT, f).split(path.sep).join('/'))
|
|
40
45
|
.filter((f) => f.startsWith('src/bongos/'));
|
|
41
46
|
process.stdout.write(JSON.stringify(files));
|
|
42
47
|
`;
|
|
@@ -50,6 +55,32 @@ function loadedAfter(spec, read = null) {
|
|
|
50
55
|
return JSON.parse(out);
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
// CALIBRATION — can the instrument see anything at all? (task 1003941)
|
|
59
|
+
//
|
|
60
|
+
// Every other assertion in this file has the shape "nothing heavy is in this
|
|
61
|
+
// list", which an EMPTY list satisfies for free. So an empty list is not evidence
|
|
62
|
+
// of laziness; it is evidence of nothing, and the two tests that guard the ADR
|
|
63
|
+
// 0083 doorway invariant cannot tell the difference.
|
|
64
|
+
//
|
|
65
|
+
// That is not hypothetical. loadedAfter filtered on a 'src/bongos/' prefix after
|
|
66
|
+
// path.relative, which returns BACKSLASHES on win32 — so on Windows the filter
|
|
67
|
+
// matched nothing, every list was empty, and both doorway tests passed vacuously.
|
|
68
|
+
// An eager require reintroduced at the doorway would have shipped green.
|
|
69
|
+
//
|
|
70
|
+
// The fix normalises separators, but a normalisation can break again silently, and
|
|
71
|
+
// asserting the doorway's own list is non-empty would be wrong — loading NOTHING
|
|
72
|
+
// from src/bongos/ is precisely what the doorway is supposed to do. So the probe
|
|
73
|
+
// is calibrated against a module that certainly does load one. If this test fails,
|
|
74
|
+
// every emptiness assertion below is meaningless and should be read as untested.
|
|
75
|
+
test('CALIBRATION: the probe can see a src/bongos/ require at all', () => {
|
|
76
|
+
const files = loadedAfter('src/bongos/pool.js');
|
|
77
|
+
assert.ok(files.includes('src/bongos/pool.js'),
|
|
78
|
+
'requiring src/bongos/pool.js must show up in the probe\'s own list. It does not, so '
|
|
79
|
+
+ 'the list is empty for reasons unrelated to laziness — check the path normalisation '
|
|
80
|
+
+ 'in loadedAfter (task 1003941). Until this passes, every "loads nothing" assertion '
|
|
81
|
+
+ `in this file is vacuous. Saw: ${JSON.stringify(files.slice(0, 5))}`);
|
|
82
|
+
});
|
|
83
|
+
|
|
53
84
|
test('importing the doorway loads no server: no pool, no auth, no routes', () => {
|
|
54
85
|
const files = loadedAfter('src/module-api.js');
|
|
55
86
|
const heavy = files.filter((f) => HEAVY.test(f));
|