@bongos/core 1.19.558 → 1.19.560
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 +27 -12
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-ratchets.js +27 -2
- package/scripts/gds/gds-literal-scan.js +162 -0
- package/src/module-api.js +1 -1
- package/tests/fitness_gds_ratchet.mjs +152 -0
- package/tests/goal_closure_invariants.mjs +332 -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.560",
|
|
6
|
+
"core_contract": "1.19.560",
|
|
7
|
+
"source_commit": "a65212ce04681b336f274effbd8962cf32ac11b2",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-06T17:10:29.635Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 443,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2023,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2490,
|
|
20
|
+
"tree_sha256": "7c9bed5bb4565d0f0fdc718c676b3af9993ddf1a8c7ea463815469c4c24d38f5",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2657,7 +2657,7 @@
|
|
|
2657
2657
|
{
|
|
2658
2658
|
"path": "docs/module-api-changelog.md",
|
|
2659
2659
|
"mode": "0000644",
|
|
2660
|
-
"sha256": "
|
|
2660
|
+
"sha256": "56724ff4d04be88dd0a7a1f938d1620d71f843f5f3bc951b5b913e64f7794e08"
|
|
2661
2661
|
},
|
|
2662
2662
|
{
|
|
2663
2663
|
"path": "docs/modules-contract.md",
|
|
@@ -7477,12 +7477,12 @@
|
|
|
7477
7477
|
{
|
|
7478
7478
|
"path": "package-lock.json",
|
|
7479
7479
|
"mode": "0000644",
|
|
7480
|
-
"sha256": "
|
|
7480
|
+
"sha256": "9c4d3a611995e84c828f539bf2f87ca6a92b6c165cffc4754e985513b1010080"
|
|
7481
7481
|
},
|
|
7482
7482
|
{
|
|
7483
7483
|
"path": "package.json",
|
|
7484
7484
|
"mode": "0000644",
|
|
7485
|
-
"sha256": "
|
|
7485
|
+
"sha256": "3816fc5ab250ecfa35f68b9383e6357d900be8ddca3d3f6ae643fb48a2c5edca"
|
|
7486
7486
|
},
|
|
7487
7487
|
{
|
|
7488
7488
|
"path": "public-docs/index.html",
|
|
@@ -7917,7 +7917,7 @@
|
|
|
7917
7917
|
{
|
|
7918
7918
|
"path": "scripts/gds/fitness-ratchets.js",
|
|
7919
7919
|
"mode": "0000644",
|
|
7920
|
-
"sha256": "
|
|
7920
|
+
"sha256": "40f6cb65333235d35f6672c700ec91648549e57912ee7f522cff80af4b611fc2"
|
|
7921
7921
|
},
|
|
7922
7922
|
{
|
|
7923
7923
|
"path": "scripts/gds/fitness.js",
|
|
@@ -7954,6 +7954,11 @@
|
|
|
7954
7954
|
"mode": "0000644",
|
|
7955
7955
|
"sha256": "26a3dcc15c98128229df015292b8f9e9d3a41152c8803555f858fa4bb6a26d53"
|
|
7956
7956
|
},
|
|
7957
|
+
{
|
|
7958
|
+
"path": "scripts/gds/gds-literal-scan.js",
|
|
7959
|
+
"mode": "0000644",
|
|
7960
|
+
"sha256": "fe622c9abf848a668fe2672d699fef22141156f41bb2396523461d1484341ec1"
|
|
7961
|
+
},
|
|
7957
7962
|
{
|
|
7958
7963
|
"path": "scripts/gds/gen-api-client.js",
|
|
7959
7964
|
"mode": "0000644",
|
|
@@ -9177,7 +9182,7 @@
|
|
|
9177
9182
|
{
|
|
9178
9183
|
"path": "src/module-api.js",
|
|
9179
9184
|
"mode": "0000644",
|
|
9180
|
-
"sha256": "
|
|
9185
|
+
"sha256": "6a833a6e4e92f2675ea5e5a66a8134cb087bba01f6bba371109c953649535f0a"
|
|
9181
9186
|
},
|
|
9182
9187
|
{
|
|
9183
9188
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10159,6 +10164,11 @@
|
|
|
10159
10164
|
"mode": "0000644",
|
|
10160
10165
|
"sha256": "5e5b53d5a1dce6b7ffacbea7198375aac6f77259ee948c00c24eb157f62a034d"
|
|
10161
10166
|
},
|
|
10167
|
+
{
|
|
10168
|
+
"path": "tests/fitness_gds_ratchet.mjs",
|
|
10169
|
+
"mode": "0000644",
|
|
10170
|
+
"sha256": "10b3d6db55dd8d20de69fe8757575cf965ca8a14e91512c2f7b71b62ba3af855"
|
|
10171
|
+
},
|
|
10162
10172
|
{
|
|
10163
10173
|
"path": "tests/fitness_ratchets.mjs",
|
|
10164
10174
|
"mode": "0000644",
|
|
@@ -10269,6 +10279,11 @@
|
|
|
10269
10279
|
"mode": "0000644",
|
|
10270
10280
|
"sha256": "8d1f3511b8ecd0bb88f75a936ee29035d9f789a2be4f6aedabb46b9be5bd2385"
|
|
10271
10281
|
},
|
|
10282
|
+
{
|
|
10283
|
+
"path": "tests/goal_closure_invariants.mjs",
|
|
10284
|
+
"mode": "0000644",
|
|
10285
|
+
"sha256": "eace8ebcf39f40683bf9cbcfbc0e554b115e73d1f72894ca57a89e82d119d357"
|
|
10286
|
+
},
|
|
10272
10287
|
{
|
|
10273
10288
|
"path": "tests/goal_conflicts.mjs",
|
|
10274
10289
|
"mode": "0000644",
|
|
@@ -1565,5 +1565,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1565
1565
|
landed since 1.19.556 with no explicit bump. run 34045753301. (task 1002620)
|
|
1566
1566
|
1.19.558 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1567
1567
|
landed since 1.19.557 with no explicit bump. run 34046699120. (task 1002620)
|
|
1568
|
+
1.19.559 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1569
|
+
landed since 1.19.558 with no explicit bump. run 34047187381. (task 1002620)
|
|
1570
|
+
1.19.560 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1571
|
+
landed since 1.19.559 with no explicit bump. run 34047736326. (task 1002620)
|
|
1568
1572
|
---------------------------------------------------------------------------
|
|
1569
1573
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.560",
|
|
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.560",
|
|
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.560",
|
|
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",
|
|
@@ -276,6 +276,17 @@ function readBaselines() {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
// Pure ratchet verdict — exported for the hermetic test.
|
|
279
|
+
// A metric whose name does not, by itself, tell the builder who tripped it what
|
|
280
|
+
// to do gets one line of guidance here. Appended to the regression message only
|
|
281
|
+
// — the ratchet-down warning already says exactly what to do.
|
|
282
|
+
const METRIC_HINTS = {
|
|
283
|
+
legacy_gds_literals:
|
|
284
|
+
' — a NEW "GDS" literal entered the tree (goal 1000073 C5: the rename cannot come back). '
|
|
285
|
+
+ 'Write Bongos instead. Run `node scripts/gds/gds-literal-scan.js` to see every counted file. '
|
|
286
|
+
+ 'Version ids (GDS-V##), docs/adr, docs/session-logs, docs/audits, limitations and migrations '
|
|
287
|
+
+ 'are excluded by construction — if your literal is history, it belongs in one of those.',
|
|
288
|
+
};
|
|
289
|
+
|
|
279
290
|
function compareToBaselines(metrics, baselines) {
|
|
280
291
|
const violations = [];
|
|
281
292
|
const warnings = [];
|
|
@@ -285,7 +296,7 @@ function compareToBaselines(metrics, baselines) {
|
|
|
285
296
|
if (floor === undefined) {
|
|
286
297
|
warnings.push(`no baseline for "${name}" (value ${value}) — run --update-baselines`);
|
|
287
298
|
} else if (value > floor) {
|
|
288
|
-
violations.push(`${name} regressed: ${value} > baseline ${floor} — fix the regression; never raise the baseline to pass`);
|
|
299
|
+
violations.push(`${name} regressed: ${value} > baseline ${floor} — fix the regression; never raise the baseline to pass${METRIC_HINTS[name] || ''}`);
|
|
289
300
|
} else if (value < floor) {
|
|
290
301
|
warnings.push(`ratchet-down available: ${name} improved to ${value} (baseline ${floor}) — lower the baseline in config/fitness-baselines.json`);
|
|
291
302
|
}
|
|
@@ -293,6 +304,19 @@ function compareToBaselines(metrics, baselines) {
|
|
|
293
304
|
return { violations, warnings };
|
|
294
305
|
}
|
|
295
306
|
|
|
307
|
+
// legacy_gds_literals — criterion C5 of goal 1000073, "the rename cannot come
|
|
308
|
+
// back" (task 1003669). A shrink-only count of the GDS acronym outside the
|
|
309
|
+
// frozen record, so a NEW literal reds the build the way any other regression
|
|
310
|
+
// does. It rides the ratchet machinery rather than being its own check on
|
|
311
|
+
// purpose: --update-baselines, the floors, and baseline-staleness then all
|
|
312
|
+
// apply to it for free, and a metric outside `metrics` would be silently
|
|
313
|
+
// dropped the next time anyone refreshed the file. Detector + the reasons for
|
|
314
|
+
// every exclusion: scripts/gds/gds-literal-scan.js.
|
|
315
|
+
function gdsMetrics() {
|
|
316
|
+
const { scanGdsLiterals } = require('./gds-literal-scan.js');
|
|
317
|
+
return { legacy_gds_literals: scanGdsLiterals().total };
|
|
318
|
+
}
|
|
319
|
+
|
|
296
320
|
function computeMetrics({ withDocs = true } = {}) {
|
|
297
321
|
const files = trackedJsFiles();
|
|
298
322
|
const contents = new Map(files.map((f) => [f, readRepoFile(f)]));
|
|
@@ -301,6 +325,7 @@ function computeMetrics({ withDocs = true } = {}) {
|
|
|
301
325
|
duplicate_window_count: duplicateWindowCount(contents),
|
|
302
326
|
require_cycle_count: countCycles(requireGraph(contents)),
|
|
303
327
|
console_call_count: consoleCallCount(contents),
|
|
328
|
+
...gdsMetrics(),
|
|
304
329
|
...(withDocs ? docMetrics() : {}),
|
|
305
330
|
};
|
|
306
331
|
}
|
|
@@ -314,7 +339,7 @@ function checkQualityRatchets() {
|
|
|
314
339
|
// FIRST so it is the headline rather than buried under per-metric noise.
|
|
315
340
|
if (baselineError) violations.unshift(baselineError);
|
|
316
341
|
return {
|
|
317
|
-
name: 'quality ratchets — size, duplication, cycles, console, doc entropy only tighten (task 1003129)',
|
|
342
|
+
name: 'quality ratchets — size, duplication, cycles, console, doc entropy, legacy GDS literals only tighten (task 1003129)',
|
|
318
343
|
hardFail: violations.length > 0,
|
|
319
344
|
violations,
|
|
320
345
|
warnings,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// gds-literal-scan.js — the detector behind the GDS→Bongos rename ratchet
|
|
4
|
+
// (criterion C5 of goal 1000073, "the rename cannot come back"; task 1003669).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS. The rename is landing criterion by criterion, and the goal's
|
|
7
|
+
// own sequencing says the ratchet lands SECOND — immediately after the prose
|
|
8
|
+
// pass — because "a rename without a ratchet reappears within a release, which
|
|
9
|
+
// is exactly how this term survived this long". This module is the measurement;
|
|
10
|
+
// the gate is the `legacy_gds_literals` metric in fitness-ratchets.js, which
|
|
11
|
+
// fails the build when the count RISES.
|
|
12
|
+
//
|
|
13
|
+
// WHY A COUNT AND NOT ZERO. Measured after C1 shipped (2026-09-06) there are
|
|
14
|
+
// still ~1,400 GDS literals outside the historical record: the `GDS_*` env
|
|
15
|
+
// names and the `scripts/gds/` + `/api/gds` paths are criterion C2's job, and
|
|
16
|
+
// C2 deliberately lands BEHIND this ratchet because it is the risky one (hooks,
|
|
17
|
+
// cron routines, .service files and CI all name those paths). A zero-tolerance
|
|
18
|
+
// check could not be landed today and would red every unrelated ship. So this
|
|
19
|
+
// holds the line instead: the number may fall, never rise.
|
|
20
|
+
//
|
|
21
|
+
// WHAT IS NEVER COUNTED. Four kinds of thing, for four different reasons:
|
|
22
|
+
//
|
|
23
|
+
// 1. THE FROZEN RECORD (path-excluded). ADRs, session logs, audits, shipped
|
|
24
|
+
// scope archives. The goal's rule is "rename what the project says about
|
|
25
|
+
// itself going forward; leave what it recorded about its past" — an ADR
|
|
26
|
+
// rewritten to today's vocabulary destroys the evidence that the words
|
|
27
|
+
// changed.
|
|
28
|
+
// 2. APPLIED MIGRATION FILENAMES (path-excluded). schema_migrations is keyed
|
|
29
|
+
// by filename stem, so renaming an applied migration makes it look
|
|
30
|
+
// unapplied and RE-RUNS it. This one is an outage, not an aesthetic.
|
|
31
|
+
// 3. VERSION IDS (token-excluded). `GDS-V3` / `GDS-V4` are row keys in the
|
|
32
|
+
// versions table, not vocabulary. They are stripped from every line before
|
|
33
|
+
// counting, so they stay legal even inside a file that is otherwise
|
|
34
|
+
// counted.
|
|
35
|
+
//
|
|
36
|
+
// Usage:
|
|
37
|
+
// node scripts/gds/gds-literal-scan.js # count + the top offending files
|
|
38
|
+
// node scripts/gds/gds-literal-scan.js --files # every file with its count
|
|
39
|
+
// node scripts/gds/gds-literal-scan.js --json
|
|
40
|
+
|
|
41
|
+
const fs = require('node:fs');
|
|
42
|
+
const path = require('node:path');
|
|
43
|
+
const { execFileSync } = require('node:child_process');
|
|
44
|
+
|
|
45
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
46
|
+
|
|
47
|
+
// The frozen record + applied migration filenames. Matched against the
|
|
48
|
+
// repo-relative path, at any depth, so an instance's own migrations/ and a
|
|
49
|
+
// module-owned migrations/ are covered by the same rule as the core's.
|
|
50
|
+
const GDS_EXCLUDED_PATHS = [
|
|
51
|
+
/(^|\/)docs\/adr\//,
|
|
52
|
+
/(^|\/)docs\/session-logs\//,
|
|
53
|
+
/(^|\/)docs\/audits\//,
|
|
54
|
+
/(^|\/)limitations\//,
|
|
55
|
+
/(^|\/)migrations\//,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Vendored / generated / third-party trees the budgets must not count — the
|
|
59
|
+
// same exclusion fitness-ratchets.js applies to its own metrics.
|
|
60
|
+
const GDS_EXCLUDED_TREES = /(^|\/)(node_modules|public-docs)\/|\.min\.js$|(^|\/)vendor\//;
|
|
61
|
+
|
|
62
|
+
// The files that STATE the rule. Meta-commentary about a banned word has to be
|
|
63
|
+
// able to name it, or the gate cannot explain itself — the same principle as
|
|
64
|
+
// VOCAB_ALLOW's "the manifest states the rule itself" entry in
|
|
65
|
+
// fitness-checks-vocabulary.js. Without this the detector counts its own
|
|
66
|
+
// explanation, and improving a comment here would red the build.
|
|
67
|
+
const GDS_RULE_FILES = new Set([
|
|
68
|
+
'scripts/gds/gds-literal-scan.js', // this file
|
|
69
|
+
'scripts/gds/fitness-ratchets.js', // the gate: names the metric and carries the builder-facing hint
|
|
70
|
+
'tests/fitness_gds_ratchet.mjs', // its test, which must write the literal to prove the gate fires
|
|
71
|
+
'config/fitness-baselines.json', // the baseline's own _comment explains what is counted
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
// A version id is a row key, not vocabulary. Stripped before matching so it is
|
|
75
|
+
// legal on any line in any counted file.
|
|
76
|
+
const GDS_VERSION_ID_RE = /GDS-V\d+/g;
|
|
77
|
+
|
|
78
|
+
// The literal itself: case-sensitive, so the lowercase path/env spellings that
|
|
79
|
+
// criterion C2 owns (`scripts/gds/`, `/api/gds`, `gds_session`) are counted
|
|
80
|
+
// only where they appear in their SHOUTING form. Case-sensitivity is the point
|
|
81
|
+
// — `GDS` is the acronym a newcomer trips over.
|
|
82
|
+
const GDS_LITERAL_RE = /GDS/g;
|
|
83
|
+
|
|
84
|
+
function isExcludedPath(relPath) {
|
|
85
|
+
if (GDS_RULE_FILES.has(relPath)) return true;
|
|
86
|
+
if (GDS_EXCLUDED_TREES.test(relPath)) return true;
|
|
87
|
+
return GDS_EXCLUDED_PATHS.some((re) => re.test(relPath));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// countInText — occurrences of the literal in one file's text, with version ids
|
|
91
|
+
// removed first. Exported so the test and any future sweep share ONE detector
|
|
92
|
+
// (the checkErrorEnvelope precedent: a gate whose detector drifts from its
|
|
93
|
+
// sweep is worse than no gate).
|
|
94
|
+
function countInText(text) {
|
|
95
|
+
const stripped = text.replace(GDS_VERSION_ID_RE, '');
|
|
96
|
+
return (stripped.match(GDS_LITERAL_RE) || []).length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function trackedFiles() {
|
|
100
|
+
const out = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
101
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// scanGdsLiterals — { total, byFile } across every tracked, countable file.
|
|
105
|
+
// Unreadable and binary files are skipped rather than guessed at: a NUL byte
|
|
106
|
+
// means the "occurrences" would be meaningless anyway.
|
|
107
|
+
//
|
|
108
|
+
// `files` and `readFile` are both injectable so the test can exercise the real
|
|
109
|
+
// aggregation — exclusions, counting, per-file totals — WITHOUT writing to the
|
|
110
|
+
// repo. That is not tidiness: the unit lane runs test files in parallel, and an
|
|
111
|
+
// earlier version of the test proved the point by temporarily editing
|
|
112
|
+
// docs/architecture.md and breaking whichever suite happened to be scanning the
|
|
113
|
+
// tree at that moment.
|
|
114
|
+
function scanGdsLiterals({ files = null, readFile = null } = {}) {
|
|
115
|
+
const read = readFile || ((relPath) => fs.readFileSync(path.join(ROOT, relPath), 'utf8'));
|
|
116
|
+
const byFile = {};
|
|
117
|
+
let total = 0;
|
|
118
|
+
for (const relPath of files || trackedFiles()) {
|
|
119
|
+
if (isExcludedPath(relPath)) continue;
|
|
120
|
+
let text;
|
|
121
|
+
try {
|
|
122
|
+
text = read(relPath);
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (text.includes(String.fromCharCode(0))) continue; // binary (a NUL byte)
|
|
127
|
+
const n = countInText(text);
|
|
128
|
+
if (n) {
|
|
129
|
+
byFile[relPath] = n;
|
|
130
|
+
total += n;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { total, byFile };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function main() {
|
|
137
|
+
const { total, byFile } = scanGdsLiterals();
|
|
138
|
+
const ranked = Object.entries(byFile).sort((a, b) => b[1] - a[1]);
|
|
139
|
+
if (process.argv.includes('--json')) {
|
|
140
|
+
process.stdout.write(`${JSON.stringify({ total, byFile }, null, 2)}\n`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const show = process.argv.includes('--files') ? ranked : ranked.slice(0, 20);
|
|
144
|
+
console.log(`legacy GDS literals: ${total} across ${ranked.length} file(s)`);
|
|
145
|
+
console.log('(version ids GDS-V## are not counted; docs/adr, docs/session-logs, docs/audits, limitations and migrations are excluded by construction)');
|
|
146
|
+
console.log('');
|
|
147
|
+
for (const [f, n] of show) console.log(` ${String(n).padStart(5)} ${f}`);
|
|
148
|
+
if (show.length < ranked.length) console.log(` … and ${ranked.length - show.length} more file(s) — pass --files for all`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (require.main === module) main();
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
GDS_EXCLUDED_PATHS,
|
|
155
|
+
GDS_RULE_FILES,
|
|
156
|
+
GDS_EXCLUDED_TREES,
|
|
157
|
+
GDS_LITERAL_RE,
|
|
158
|
+
GDS_VERSION_ID_RE,
|
|
159
|
+
countInText,
|
|
160
|
+
isExcludedPath,
|
|
161
|
+
scanGdsLiterals,
|
|
162
|
+
};
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.560'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// tests/fitness_gds_ratchet.mjs — the GDS→Bongos rename ratchet (task 1003669,
|
|
2
|
+
// criterion C5 of goal 1000073, "the rename cannot come back").
|
|
3
|
+
//
|
|
4
|
+
// WHY. A gate nobody has watched FAIL is not a gate. The criterion's whole point
|
|
5
|
+
// is that the old name cannot creep back, so the load-bearing assertion here is
|
|
6
|
+
// the negative one: a newly-introduced literal must red the build, and the
|
|
7
|
+
// exclusions must NOT swallow it. The positive case (today's tree passes) is the
|
|
8
|
+
// cheap half — it would also pass if the detector were broken and returned zero.
|
|
9
|
+
//
|
|
10
|
+
// The detector is exercised directly rather than through the filesystem: it takes
|
|
11
|
+
// an explicit `files` list, so a case can name exactly the paths under test
|
|
12
|
+
// without writing into the repo or depending on what happens to be committed.
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const scan = require(path.join(ROOT, 'scripts', 'gds', 'gds-literal-scan.js'));
|
|
23
|
+
const ratchets = require(path.join(ROOT, 'scripts', 'gds', 'fitness-ratchets.js'));
|
|
24
|
+
|
|
25
|
+
const LITERAL = ['G', 'D', 'S'].join(''); // built, not written, so this file needs no exemption of its own
|
|
26
|
+
|
|
27
|
+
test('the detector COUNTS a bare literal — the gate is not a no-op', () => {
|
|
28
|
+
assert.equal(scan.countInText(`the ${LITERAL} is the old name`), 1);
|
|
29
|
+
assert.equal(scan.countInText(`${LITERAL} and ${LITERAL} again`), 2);
|
|
30
|
+
assert.equal(scan.countInText('Bongos all the way down'), 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a version id is NOT counted — it is a row key, not vocabulary', () => {
|
|
34
|
+
assert.equal(scan.countInText(`${LITERAL}-V3`), 0);
|
|
35
|
+
assert.equal(scan.countInText(`${LITERAL}-V4 shipped`), 0);
|
|
36
|
+
// …but a bare literal on the same line still counts: stripping the version id
|
|
37
|
+
// must not blind the detector to real drift beside it.
|
|
38
|
+
assert.equal(scan.countInText(`${LITERAL}-V3 and the ${LITERAL} acronym`), 1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('the frozen record is excluded — an ADR or session log may say the old name', () => {
|
|
42
|
+
for (const p of [
|
|
43
|
+
'docs/adr/0064-rename.md',
|
|
44
|
+
'docs/session-logs/2026-09-06-something.md',
|
|
45
|
+
'docs/audits/some-audit.md',
|
|
46
|
+
'limitations/v3-shipped.md',
|
|
47
|
+
'migrations/047_something.sql',
|
|
48
|
+
'modules/copy-desk/migrations/copy_desk_001_flags.sql', // a MODULE's migrations too
|
|
49
|
+
]) {
|
|
50
|
+
assert.equal(scan.isExcludedPath(p), true, `expected excluded: ${p}`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('ordinary source and docs are NOT excluded — otherwise the gate guards nothing', () => {
|
|
55
|
+
for (const p of [
|
|
56
|
+
'docs/architecture.md',
|
|
57
|
+
'docs/recipes/self-host.md',
|
|
58
|
+
'src/bongos/routes/live.js',
|
|
59
|
+
'modules/discord/discord-bot.js',
|
|
60
|
+
'.claude/skills/builder-ship/SKILL.md',
|
|
61
|
+
]) {
|
|
62
|
+
assert.equal(scan.isExcludedPath(p), false, `expected counted: ${p}`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// A fake tree, so these cases touch no real file. See the note on
|
|
67
|
+
// scanGdsLiterals's `readFile` parameter: the unit lane runs test files in
|
|
68
|
+
// parallel, and a test that edits the repo to make its point breaks whichever
|
|
69
|
+
// suite is reading the repo at the time.
|
|
70
|
+
function fakeTree(contents) {
|
|
71
|
+
return {
|
|
72
|
+
files: Object.keys(contents),
|
|
73
|
+
readFile: (relPath) => contents[relPath],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('a NEW literal in a counted file REDS the ratchet (the criterion itself)', () => {
|
|
78
|
+
const clean = fakeTree({ 'docs/architecture.md': 'The platform is Bongos.\n' });
|
|
79
|
+
const dirty = fakeTree({ 'docs/architecture.md': `The platform is Bongos.\nBut this line says ${LITERAL}.\n` });
|
|
80
|
+
|
|
81
|
+
const before = scan.scanGdsLiterals(clean).total;
|
|
82
|
+
const after = scan.scanGdsLiterals(dirty).total;
|
|
83
|
+
assert.equal(before, 0);
|
|
84
|
+
assert.equal(after, 1, 'a new literal must raise the count');
|
|
85
|
+
|
|
86
|
+
// …and a count above its baseline must be a VIOLATION, not a warning.
|
|
87
|
+
const { violations } = ratchets.compareToBaselines(
|
|
88
|
+
{ legacy_gds_literals: after },
|
|
89
|
+
{ legacy_gds_literals: before }
|
|
90
|
+
);
|
|
91
|
+
assert.equal(violations.length, 1, 'rising above the baseline must violate');
|
|
92
|
+
assert.match(violations[0], /legacy_gds_literals regressed/);
|
|
93
|
+
assert.match(violations[0], /rename cannot come back/, 'the message must tell the builder what rule they broke');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an edit to the FROZEN record never reds the build', () => {
|
|
97
|
+
const tree = fakeTree({
|
|
98
|
+
'docs/adr/README.md': `The ${LITERAL} was renamed; this line is history.\n`,
|
|
99
|
+
'docs/session-logs/2026-09-06-x.md': `A session that mentions ${LITERAL} twice: ${LITERAL}.\n`,
|
|
100
|
+
'migrations/047_x.sql': `-- ${LITERAL} era migration\n`,
|
|
101
|
+
});
|
|
102
|
+
assert.equal(
|
|
103
|
+
scan.scanGdsLiterals(tree).total,
|
|
104
|
+
0,
|
|
105
|
+
'the frozen record may name the old name freely — that is the goal\'s stated rule'
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the aggregation is per-file and additive across a mixed tree', () => {
|
|
110
|
+
const tree = fakeTree({
|
|
111
|
+
'docs/architecture.md': `${LITERAL} and ${LITERAL}\n`, // 2, counted
|
|
112
|
+
'src/bongos/routes/live.js': `// ${LITERAL}\n`, // 1, counted
|
|
113
|
+
'docs/adr/0064-rename.md': `${LITERAL} ${LITERAL}\n`, // excluded
|
|
114
|
+
'docs/recipes/self-host.md': 'Bongos only\n', // 0
|
|
115
|
+
});
|
|
116
|
+
const { total, byFile } = scan.scanGdsLiterals(tree);
|
|
117
|
+
assert.equal(total, 3);
|
|
118
|
+
assert.deepEqual(byFile, { 'docs/architecture.md': 2, 'src/bongos/routes/live.js': 1 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('the ratchet only tightens — a FALLING count is a warning, never a failure', () => {
|
|
122
|
+
const { violations, warnings } = ratchets.compareToBaselines(
|
|
123
|
+
{ legacy_gds_literals: 10 },
|
|
124
|
+
{ legacy_gds_literals: 20 }
|
|
125
|
+
);
|
|
126
|
+
assert.equal(violations.length, 0);
|
|
127
|
+
assert.equal(warnings.length, 1);
|
|
128
|
+
assert.match(warnings[0], /ratchet-down available/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the metric is MANAGED — it survives --update-baselines and has a committed baseline', () => {
|
|
132
|
+
// A metric outside computeMetrics() would be silently dropped the next time
|
|
133
|
+
// anyone refreshed the file, which is the failure mode this asserts against.
|
|
134
|
+
const metrics = ratchets.computeMetrics({ withDocs: false });
|
|
135
|
+
assert.ok(
|
|
136
|
+
Object.prototype.hasOwnProperty.call(metrics, 'legacy_gds_literals'),
|
|
137
|
+
'legacy_gds_literals must come from computeMetrics(), or --update-baselines will drop it'
|
|
138
|
+
);
|
|
139
|
+
assert.equal(typeof metrics.legacy_gds_literals, 'number');
|
|
140
|
+
|
|
141
|
+
const baselines = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'fitness-baselines.json'), 'utf8'));
|
|
142
|
+
assert.equal(typeof baselines.metrics.legacy_gds_literals, 'number', 'the baseline must be committed');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('the files that STATE the rule may name it', () => {
|
|
146
|
+
// Meta-commentary about a banned word has to be able to name the word, or the
|
|
147
|
+
// gate cannot explain itself to the person who tripped it.
|
|
148
|
+
for (const p of scan.GDS_RULE_FILES) {
|
|
149
|
+
assert.equal(scan.isExcludedPath(p), true, `rule-stating file must be exempt: ${p}`);
|
|
150
|
+
assert.ok(fs.existsSync(path.join(ROOT, p)), `rule-stating file must exist (stale exemption otherwise): ${p}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// tests/goal_closure_invariants.mjs — the C2 invariant, pinned across BOTH
|
|
2
|
+
// achievement writers (BV1.R13, task 1003600, goal 1000086, ADR 0250 D2).
|
|
3
|
+
//
|
|
4
|
+
// THE INVARIANT: a goal reaches `achieved` only when its criteria are satisfied
|
|
5
|
+
// AND no task in it is left in a non-terminal state. Terminal is `shipped` or
|
|
6
|
+
// deliberately `abandoned`; `backlog`, `ready`, `blocked`, `active`, `completed`
|
|
7
|
+
// and `confirmed` all hold the goal open.
|
|
8
|
+
//
|
|
9
|
+
// WHY A DEDICATED SUITE. The rule is written TWICE on purpose — `db-goals.js`
|
|
10
|
+
// `achieveGoalIfComplete` for the manual `POST /done-when/:id/satisfy`, and the
|
|
11
|
+
// cascade inside `done-when.js` `autoSatisfyShippedCriteria` for the path that
|
|
12
|
+
// fires on every ship and every reconciler tick. They cannot be collapsed into
|
|
13
|
+
// one helper (db-goals requires done-when; sharing creates a require cycle), so
|
|
14
|
+
// the failure mode is DRIFT: one writer moves, the other does not, and a goal
|
|
15
|
+
// closes correctly through one door and wrongly through the other. That is
|
|
16
|
+
// precisely how the two "finished" vocabularies drifted apart before task
|
|
17
|
+
// 1003588. This file states the invariant once and asserts it of both.
|
|
18
|
+
//
|
|
19
|
+
// WHAT THIS FILE IS, AND IS NOT. It runs both writers against a small in-memory
|
|
20
|
+
// model of the two statements they issue — the model implements the SQL's
|
|
21
|
+
// SEMANTICS (who is terminal, which goals qualify) by hand, so what is proven
|
|
22
|
+
// here is the behaviour and the wiring: which view each writer consults, what it
|
|
23
|
+
// decides, and what it leaves alone. The literal SQL TEXT is pinned separately —
|
|
24
|
+
// tests/auto_satisfy_criteria.mjs for the cascade, tests/goal_achievement.mjs for
|
|
25
|
+
// the manual path — and neither file replaces the other. A change that alters the
|
|
26
|
+
// SQL's meaning has to get past both.
|
|
27
|
+
//
|
|
28
|
+
// Run: node --test tests/goal_closure_invariants.mjs
|
|
29
|
+
|
|
30
|
+
import assert from 'node:assert/strict';
|
|
31
|
+
import { test } from 'node:test';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
process.env.NODE_ENV = 'test';
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const { achieveGoalIfComplete } = require('../modules/lifecycle/db.js');
|
|
37
|
+
const { autoSatisfyShippedCriteria, closeCompletedWorkForShip } =
|
|
38
|
+
require('../modules/lifecycle/done-when.js');
|
|
39
|
+
const { TERMINAL_STATUSES } = require('../modules/lifecycle/task-status.js');
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// The model. A `world` is { goals, criteria, tasks }, where a task carries
|
|
43
|
+
// { id, goal_id, status, title } and a criterion { id, goal_id, satisfied,
|
|
44
|
+
// task_ids }. `makeExec(world)` returns something shaped like a pg client whose
|
|
45
|
+
// two statements answer FROM THAT WORLD — so handing two writers two different
|
|
46
|
+
// worlds is how the uncommitted-flip case below is expressed.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const isTerminal = (t) => TERMINAL_STATUSES.includes(t.status);
|
|
50
|
+
const isSmoke = (t) => String(t.title || '').startsWith('__smoke__');
|
|
51
|
+
|
|
52
|
+
// The terminal set the STATEMENT asks for, read back out of its own text, so a
|
|
53
|
+
// change to task-status.js flows into the model instead of being asserted twice.
|
|
54
|
+
function terminalSetIn(sql) {
|
|
55
|
+
const m = sql.match(/t\.status NOT IN \(([^)]*)\)/);
|
|
56
|
+
assert.ok(m, 'the cascade must name a terminal set the model can read');
|
|
57
|
+
return m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, ''));
|
|
58
|
+
}
|
|
59
|
+
// "Holds the goal open": not terminal, and not a test fixture.
|
|
60
|
+
const holdsOpen = (t) => !isTerminal(t) && !isSmoke(t);
|
|
61
|
+
|
|
62
|
+
function world({ goalStatus = 'open', criteria = [], tasks = [] } = {}) {
|
|
63
|
+
return {
|
|
64
|
+
goals: [{ id: 77, title: 'Strict versioning', version_id: 'BONGOS-V1', status: goalStatus }],
|
|
65
|
+
criteria: criteria.map((c, i) => ({
|
|
66
|
+
id: 100 + i, goal_id: 77, version_id: 'BONGOS-V1',
|
|
67
|
+
criterion_id: 'c' + i, criterion_md: 'x', satisfied: false, task_ids: [], ...c,
|
|
68
|
+
})),
|
|
69
|
+
tasks: tasks.map((t, i) => ({ id: 200 + i, goal_id: 77, title: 'task ' + i, ...t })),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeExec(w) {
|
|
74
|
+
const calls = [];
|
|
75
|
+
const taskById = (id) => w.tasks.find((t) => t.id === id);
|
|
76
|
+
return {
|
|
77
|
+
calls,
|
|
78
|
+
world: w,
|
|
79
|
+
async query(sql, params) {
|
|
80
|
+
const s = String(sql).replace(/\s+/g, ' ').trim();
|
|
81
|
+
calls.push({ sql: s, params });
|
|
82
|
+
|
|
83
|
+
// (1) close every unsatisfied criterion whose linked tasks are ALL terminal
|
|
84
|
+
// and at least one of which actually shipped (the delivery guard).
|
|
85
|
+
if (/UPDATE done_when_criteria/.test(s)) {
|
|
86
|
+
const scoped = params[0] == null ? null : Number(params[0]);
|
|
87
|
+
const closed = w.criteria.filter((c) => {
|
|
88
|
+
if (c.satisfied) return false;
|
|
89
|
+
if (scoped != null && !c.task_ids.includes(scoped)) return false;
|
|
90
|
+
const linked = c.task_ids.map(taskById).filter(Boolean);
|
|
91
|
+
if (linked.length === 0) return false;
|
|
92
|
+
return linked.every(isTerminal) && linked.some((t) => t.status === 'shipped');
|
|
93
|
+
});
|
|
94
|
+
closed.forEach((c) => { c.satisfied = true; });
|
|
95
|
+
return { rows: closed.map((c) => ({ ...c, satisfied_by_task_id: scoped })) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// (2) the cascade. NOT hand-coded: the model reads the statement it was
|
|
99
|
+
// actually handed and honours only the clauses PRESENT in it — including
|
|
100
|
+
// the terminal set, parsed out of the SQL rather than imported. That is
|
|
101
|
+
// what keeps this suite non-vacuous. Delete the R09 clause from
|
|
102
|
+
// done-when.js and the model stops filtering on tasks, the goal closes,
|
|
103
|
+
// and these tests go red — which `the suite can actually fail` below
|
|
104
|
+
// demonstrates rather than asserts.
|
|
105
|
+
if (/UPDATE goals g/.test(s)) {
|
|
106
|
+
const ids = (params[0] || []).map(Number);
|
|
107
|
+
const flipped = w.goals.filter((g) => {
|
|
108
|
+
if (!ids.includes(g.id)) return false;
|
|
109
|
+
if (/g\.status = 'open'/.test(s) && g.status !== 'open') return false;
|
|
110
|
+
const crit = w.criteria.filter((c) => c.goal_id === g.id);
|
|
111
|
+
if (/EXISTS \(SELECT 1 FROM done_when_criteria/.test(s) && crit.length === 0) return false;
|
|
112
|
+
if (/AND NOT d\.satisfied\)/.test(s) && crit.some((c) => !c.satisfied)) return false;
|
|
113
|
+
if (/NOT EXISTS \(SELECT 1 FROM tasks t/.test(s)) {
|
|
114
|
+
const terminal = terminalSetIn(s);
|
|
115
|
+
const smokeFiltered = /t\.title NOT LIKE/.test(s);
|
|
116
|
+
const held = w.tasks.some((t) => t.goal_id === g.id
|
|
117
|
+
&& !terminal.includes(t.status)
|
|
118
|
+
&& !(smokeFiltered && isSmoke(t)));
|
|
119
|
+
if (held) return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
flipped.forEach((g) => { g.status = 'achieved'; });
|
|
124
|
+
return { rows: flipped.map((g) => ({ ...g })) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The manual path: getGoal, then the combined criteria + task counts, then
|
|
128
|
+
// the status flip.
|
|
129
|
+
if (/FROM goals WHERE id = \$1/.test(s)) {
|
|
130
|
+
const g = w.goals.find((x) => x.id === Number(params[0]));
|
|
131
|
+
return { rows: g ? [g] : [] };
|
|
132
|
+
}
|
|
133
|
+
if (/FROM done_when_criteria WHERE goal_id = \$1/.test(s)) {
|
|
134
|
+
const crit = w.criteria.filter((c) => c.goal_id === Number(params[0]));
|
|
135
|
+
return { rows: [{
|
|
136
|
+
total: crit.length,
|
|
137
|
+
unsatisfied: crit.filter((c) => !c.satisfied).length,
|
|
138
|
+
unfinished_tasks: w.tasks.filter((t) => t.goal_id === Number(params[0]) && holdsOpen(t)).length,
|
|
139
|
+
}] };
|
|
140
|
+
}
|
|
141
|
+
if (/^UPDATE goals/.test(s)) {
|
|
142
|
+
const g = w.goals.find((x) => x.id === Number(params[0]));
|
|
143
|
+
g.status = params[1];
|
|
144
|
+
return { rows: [{ ...g }] };
|
|
145
|
+
}
|
|
146
|
+
return { rows: [] };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const asPool = (exec) => ({ query: (sql, p) => exec.query(sql, p), connect: async () => exec, calls: exec.calls });
|
|
151
|
+
|
|
152
|
+
const SHIPPED = (over) => ({ status: 'shipped', ...over });
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// 1. The invariant holds on BOTH writers, for every non-terminal status
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
const HOLDS_OPEN = ['backlog', 'ready', 'blocked', 'active', 'completed', 'confirmed'];
|
|
159
|
+
|
|
160
|
+
for (const status of HOLDS_OPEN) {
|
|
161
|
+
test(`a '${status}' sibling holds the goal open — manual path`, async () => {
|
|
162
|
+
const w = world({
|
|
163
|
+
criteria: [{ satisfied: true, task_ids: [200] }],
|
|
164
|
+
tasks: [SHIPPED(), { status }],
|
|
165
|
+
});
|
|
166
|
+
const out = await achieveGoalIfComplete({ goalId: 77 }, { pool: asPool(makeExec(w)) });
|
|
167
|
+
assert.equal(out.achieved, false, `'${status}' is not finished work`);
|
|
168
|
+
assert.equal(w.goals[0].status, 'open', 'and the row is not flipped');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test(`a '${status}' sibling holds the goal open — cascade`, async () => {
|
|
172
|
+
const w = world({
|
|
173
|
+
criteria: [{ satisfied: false, task_ids: [200] }],
|
|
174
|
+
tasks: [SHIPPED(), { status }],
|
|
175
|
+
});
|
|
176
|
+
const out = await autoSatisfyShippedCriteria(makeExec(w), { taskId: 200 });
|
|
177
|
+
assert.equal(out.criteria.length, 1, 'the criterion itself still closes — ADR 0183 is untouched');
|
|
178
|
+
assert.deepEqual(out.goals, [], 'but the goal does not');
|
|
179
|
+
assert.equal(w.goals[0].status, 'open');
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const status of TERMINAL_STATUSES) {
|
|
184
|
+
test(`a '${status}' sibling does NOT hold the goal open — both writers agree`, async () => {
|
|
185
|
+
const manual = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
|
|
186
|
+
const m = await achieveGoalIfComplete({ goalId: 77 }, { pool: asPool(makeExec(manual)) });
|
|
187
|
+
assert.equal(m.achieved, true, `'${status}' is finished with the project`);
|
|
188
|
+
|
|
189
|
+
const casc = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
|
|
190
|
+
const c = await autoSatisfyShippedCriteria(makeExec(casc), { taskId: 200 });
|
|
191
|
+
assert.equal(c.goals.length, 1, 'the cascade reaches the same verdict');
|
|
192
|
+
assert.equal(casc.goals[0].status, 'achieved');
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test('a __smoke__ fixture cannot wedge a real goal open — both writers', async () => {
|
|
197
|
+
const smoke = { status: 'backlog', title: '__smoke__ probe' };
|
|
198
|
+
const manual = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED(), smoke] });
|
|
199
|
+
assert.equal((await achieveGoalIfComplete({ goalId: 77 }, { pool: asPool(makeExec(manual)) })).achieved, true);
|
|
200
|
+
|
|
201
|
+
const casc = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED(), smoke] });
|
|
202
|
+
assert.equal((await autoSatisfyShippedCriteria(makeExec(casc), { taskId: 200 })).goals.length, 1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('the rule does not become "goals never close": clean goal still achieves', async () => {
|
|
206
|
+
const w = world({ criteria: [{ satisfied: false, task_ids: [200, 201] }], tasks: [SHIPPED(), SHIPPED()] });
|
|
207
|
+
const out = await autoSatisfyShippedCriteria(makeExec(w), { taskId: 200 });
|
|
208
|
+
assert.equal(out.goals.length, 1, 'every task terminal + every criterion satisfied = achieved');
|
|
209
|
+
assert.equal(w.goals[0].status, 'achieved');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('a task linked to NO criterion still holds the goal open — the original hole', async () => {
|
|
213
|
+
// This is the exact shape that produced 14 wrongly-closed goals on the live
|
|
214
|
+
// instance: achievement was computed from criteria alone, and an unlinked task
|
|
215
|
+
// was therefore invisible to it.
|
|
216
|
+
const w = world({
|
|
217
|
+
criteria: [{ satisfied: false, task_ids: [200] }],
|
|
218
|
+
tasks: [SHIPPED(), { status: 'backlog' }], // 201 is linked to nothing
|
|
219
|
+
});
|
|
220
|
+
const out = await autoSatisfyShippedCriteria(makeExec(w), { taskId: 200 });
|
|
221
|
+
assert.equal(out.criteria.length, 1, 'the criterion is satisfied — its own tasks all shipped');
|
|
222
|
+
assert.deepEqual(out.goals, [], 'and the unlinked backlog task still holds the goal open');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// 2. The cascade sees the ship transaction's UNCOMMITTED flip
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
test('the cascade reads through the exec it was handed, so it sees the ship\'s own uncommitted flip', async () => {
|
|
230
|
+
// Two views of the same instant, mid-ship: the transaction has already flipped
|
|
231
|
+
// task 200 confirmed→shipped but has not committed, so anything reading the
|
|
232
|
+
// module pool still sees 'confirmed' — non-terminal.
|
|
233
|
+
const inTxn = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED()] });
|
|
234
|
+
const outsideTxn = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [{ status: 'confirmed' }] });
|
|
235
|
+
|
|
236
|
+
const viaTxn = await autoSatisfyShippedCriteria(makeExec(inTxn), { taskId: 200 });
|
|
237
|
+
assert.equal(viaTxn.goals.length, 1,
|
|
238
|
+
"the shipping task is terminal inside its own transaction, so the goal closes");
|
|
239
|
+
assert.equal(inTxn.goals[0].status, 'achieved');
|
|
240
|
+
|
|
241
|
+
const viaPool = await autoSatisfyShippedCriteria(makeExec(outsideTxn), { taskId: 200 });
|
|
242
|
+
assert.deepEqual(viaPool.goals, [],
|
|
243
|
+
'and on a pre-commit view it would NOT — which is the bug passing the pool would cause');
|
|
244
|
+
assert.equal(outsideTxn.goals[0].status, 'open');
|
|
245
|
+
|
|
246
|
+
// Both halves matter: the first alone would pass even if the function ignored
|
|
247
|
+
// its exec and the world were globally shipped. The pair proves the OUTCOME
|
|
248
|
+
// tracks the view handed in — i.e. that the reads go through `exec`.
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('every statement goes through the caller exec — none reaches the module pool', async () => {
|
|
252
|
+
const exec = makeExec(world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED()] }));
|
|
253
|
+
await autoSatisfyShippedCriteria(exec, { taskId: 200 });
|
|
254
|
+
assert.equal(exec.calls.length, 2, 'the criterion close and the cascade, both on exec');
|
|
255
|
+
assert.match(exec.calls[0].sql, /UPDATE done_when_criteria/);
|
|
256
|
+
assert.match(exec.calls[1].sql, /UPDATE goals g/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// 3. A ship still LANDS while its goal is held open
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
test('a ship lands even though a sibling backlog task holds its goal open', async () => {
|
|
264
|
+
// The ship-time hook is the only thing the closure rule touches on the ship
|
|
265
|
+
// path. It must complete quietly: the criterion closes, the goal does not, and
|
|
266
|
+
// nothing is thrown into the transaction that is landing the task.
|
|
267
|
+
const w = world({
|
|
268
|
+
criteria: [{ satisfied: false, task_ids: [200] }],
|
|
269
|
+
tasks: [SHIPPED(), { status: 'backlog' }],
|
|
270
|
+
});
|
|
271
|
+
const logged = [];
|
|
272
|
+
const line = await closeCompletedWorkForShip(makeExec(w), 200, (m) => logged.push(m));
|
|
273
|
+
assert.ok(line, 'it reports what it closed');
|
|
274
|
+
assert.match(line, /criteri/i, 'namely the criterion');
|
|
275
|
+
assert.equal(w.goals[0].status, 'open', 'the goal is held open by the sibling');
|
|
276
|
+
assert.equal(w.tasks[0].status, 'shipped', 'and the ship itself is untouched');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('the hook swallows a failure rather than failing the ship', async () => {
|
|
280
|
+
// Belt and braces on the same guarantee: even if the cascade throws outright,
|
|
281
|
+
// the ship must not. The reconciler's unscoped sweep retries every tick.
|
|
282
|
+
const exploding = { query: async () => { throw new Error('deadlock detected'); } };
|
|
283
|
+
const logged = [];
|
|
284
|
+
const line = await closeCompletedWorkForShip(exploding, 200, (m) => logged.push(m));
|
|
285
|
+
assert.match(line, /auto-satisfy skipped \(sweep retries\): deadlock detected/,
|
|
286
|
+
'the failure is reported, not raised');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// 4. The two writers may not drift apart
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
test('the suite can actually fail: strip the R09 clause and the goal wrongly closes', async () => {
|
|
294
|
+
// A model that answers from its own logic would pass these tests no matter what
|
|
295
|
+
// the code said. This one interprets the statement it is handed, so removing the
|
|
296
|
+
// clause is observable — demonstrated here against the SAME world that is held
|
|
297
|
+
// open above, with the clause edited out of the SQL on its way in.
|
|
298
|
+
const w = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED(), { status: 'backlog' }] });
|
|
299
|
+
const base = makeExec(w);
|
|
300
|
+
const stripTaskClause = (sql) => {
|
|
301
|
+
const text = String(sql);
|
|
302
|
+
const from = text.indexOf('AND NOT EXISTS (SELECT 1 FROM tasks t');
|
|
303
|
+
if (from === -1) return text;
|
|
304
|
+
const to = text.indexOf('RETURNING', from);
|
|
305
|
+
assert.notEqual(to, -1, 'the clause is expected inside the cascade UPDATE');
|
|
306
|
+
return text.slice(0, from) + text.slice(to);
|
|
307
|
+
};
|
|
308
|
+
const crippled = { query: (sql, params) => base.query(stripTaskClause(sql), params) };
|
|
309
|
+
const out = await autoSatisfyShippedCriteria(crippled, { taskId: 200 });
|
|
310
|
+
assert.equal(out.goals.length, 1,
|
|
311
|
+
'without the clause the backlog sibling is invisible and the goal closes — the pre-R09 bug');
|
|
312
|
+
assert.equal(w.goals[0].status, 'achieved',
|
|
313
|
+
'so the passing tests above are load-bearing, not decorative');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('both writers reach the SAME verdict on every status, one status at a time', async () => {
|
|
317
|
+
// The anti-drift assertion, stated as behaviour rather than as matching source
|
|
318
|
+
// text: for each task status, ask both writers and require they agree.
|
|
319
|
+
const ALL = [...HOLDS_OPEN, ...TERMINAL_STATUSES];
|
|
320
|
+
const disagreements = [];
|
|
321
|
+
for (const status of ALL) {
|
|
322
|
+
const m = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
|
|
323
|
+
const manual = (await achieveGoalIfComplete({ goalId: 77 }, { pool: asPool(makeExec(m)) })).achieved;
|
|
324
|
+
|
|
325
|
+
const c = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
|
|
326
|
+
const cascade = (await autoSatisfyShippedCriteria(makeExec(c), { taskId: 200 })).goals.length > 0;
|
|
327
|
+
|
|
328
|
+
if (manual !== cascade) disagreements.push(`${status}: manual=${manual} cascade=${cascade}`);
|
|
329
|
+
}
|
|
330
|
+
assert.deepEqual(disagreements, [],
|
|
331
|
+
'the duplicate is deliberate; the DRIFT is the bug — both writers or neither');
|
|
332
|
+
});
|