@bongos/core 1.19.590 → 1.19.592
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 +35 -15
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_233_versions_one_planning_idx.sql +47 -0
- package/modules/lifecycle/routes/version-route-authz.js +41 -1
- package/modules/lifecycle/routes/versions.js +52 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gds-literal-scan.js +7 -0
- package/scripts/gds/rename-history-check.js +369 -0
- package/src/module-api.js +1 -1
- package/tests/one_planning_version.mjs +25 -8
- package/tests/rename_history_restraint.mjs +236 -0
- package/tests/version_build_slot.mjs +157 -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.592",
|
|
6
|
+
"core_contract": "1.19.592",
|
|
7
|
+
"source_commit": "0a1abcd19a9b10e8a397d736bca30bf3cde53e12",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-08T02:
|
|
9
|
+
"built_at": "2026-09-08T02:51:06.758Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 457,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2065,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2546,
|
|
20
|
+
"tree_sha256": "25f1a2e82d0354d67b93d0215ffb20b0afae0240e24c02eb2683ac075c62e755",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2727,7 +2727,7 @@
|
|
|
2727
2727
|
{
|
|
2728
2728
|
"path": "docs/module-api-changelog.md",
|
|
2729
2729
|
"mode": "0000644",
|
|
2730
|
-
"sha256": "
|
|
2730
|
+
"sha256": "2b198dcf7147b6d736ec28fe28fb7b4c97727df4573dcb3017fd00e8efc756a8"
|
|
2731
2731
|
},
|
|
2732
2732
|
{
|
|
2733
2733
|
"path": "docs/modules-contract.md",
|
|
@@ -3729,6 +3729,11 @@
|
|
|
3729
3729
|
"mode": "0000644",
|
|
3730
3730
|
"sha256": "5ed2ff8117e6650de621a1576062b27f2d6e84faf8860f9cb2aed45323a85aa6"
|
|
3731
3731
|
},
|
|
3732
|
+
{
|
|
3733
|
+
"path": "migrations/core_233_versions_one_planning_idx.sql",
|
|
3734
|
+
"mode": "0000644",
|
|
3735
|
+
"sha256": "ca03e583bc418f600c1b1ba464bcaf4e380916033ec06cd68990cf153184d883"
|
|
3736
|
+
},
|
|
3732
3737
|
{
|
|
3733
3738
|
"path": "modules/agents/lib/validate.js",
|
|
3734
3739
|
"mode": "0000644",
|
|
@@ -5792,12 +5797,12 @@
|
|
|
5792
5797
|
{
|
|
5793
5798
|
"path": "modules/lifecycle/routes/version-route-authz.js",
|
|
5794
5799
|
"mode": "0000644",
|
|
5795
|
-
"sha256": "
|
|
5800
|
+
"sha256": "a978720f7e0fa63626978c507e4a6a001faeab95989bc0ceb3cedd02f36b40d3"
|
|
5796
5801
|
},
|
|
5797
5802
|
{
|
|
5798
5803
|
"path": "modules/lifecycle/routes/versions.js",
|
|
5799
5804
|
"mode": "0000644",
|
|
5800
|
-
"sha256": "
|
|
5805
|
+
"sha256": "432dc7d632e2f528bc885348bd878ea19dc94d1395d5e6f51b39ebf39c5f91ca"
|
|
5801
5806
|
},
|
|
5802
5807
|
{
|
|
5803
5808
|
"path": "modules/lifecycle/routes/visuals.js",
|
|
@@ -7602,12 +7607,12 @@
|
|
|
7602
7607
|
{
|
|
7603
7608
|
"path": "package-lock.json",
|
|
7604
7609
|
"mode": "0000644",
|
|
7605
|
-
"sha256": "
|
|
7610
|
+
"sha256": "ef993eb06f7e089d4a9cfed05d07ec4bc19aa542517543b2bff2d8d2259242f5"
|
|
7606
7611
|
},
|
|
7607
7612
|
{
|
|
7608
7613
|
"path": "package.json",
|
|
7609
7614
|
"mode": "0000644",
|
|
7610
|
-
"sha256": "
|
|
7615
|
+
"sha256": "a8f9e814cd551d7a4ec38257da9398e9f93b361f1a27e7837c94a2cb794f693b"
|
|
7611
7616
|
},
|
|
7612
7617
|
{
|
|
7613
7618
|
"path": "public-docs/index.html",
|
|
@@ -8087,7 +8092,7 @@
|
|
|
8087
8092
|
{
|
|
8088
8093
|
"path": "scripts/gds/gds-literal-scan.js",
|
|
8089
8094
|
"mode": "0000644",
|
|
8090
|
-
"sha256": "
|
|
8095
|
+
"sha256": "3f873da74a012f73e30e659f070b3476eda81ac55f802913307494a645d8ee88"
|
|
8091
8096
|
},
|
|
8092
8097
|
{
|
|
8093
8098
|
"path": "scripts/gds/gen-api-client.js",
|
|
@@ -8454,6 +8459,11 @@
|
|
|
8454
8459
|
"mode": "0000644",
|
|
8455
8460
|
"sha256": "4cbb7baf533e1aa2eced1c828f37bbd11f7183846a0f3862f9fb72b3f504be66"
|
|
8456
8461
|
},
|
|
8462
|
+
{
|
|
8463
|
+
"path": "scripts/gds/rename-history-check.js",
|
|
8464
|
+
"mode": "0000644",
|
|
8465
|
+
"sha256": "06ad6a69304cfc66509f4ab283e7f76196ac7edcac97f8eba55ea3ab92c3eecb"
|
|
8466
|
+
},
|
|
8457
8467
|
{
|
|
8458
8468
|
"path": "scripts/gds/render-ideas.js",
|
|
8459
8469
|
"mode": "0000644",
|
|
@@ -9317,7 +9327,7 @@
|
|
|
9317
9327
|
{
|
|
9318
9328
|
"path": "src/module-api.js",
|
|
9319
9329
|
"mode": "0000644",
|
|
9320
|
-
"sha256": "
|
|
9330
|
+
"sha256": "f089d18d9173dcae6228221928c87d6d0142ca1cfdd7b0d8743ad87d67e8054d"
|
|
9321
9331
|
},
|
|
9322
9332
|
{
|
|
9323
9333
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11512,7 +11522,7 @@
|
|
|
11512
11522
|
{
|
|
11513
11523
|
"path": "tests/one_planning_version.mjs",
|
|
11514
11524
|
"mode": "0000644",
|
|
11515
|
-
"sha256": "
|
|
11525
|
+
"sha256": "347d8856d88246e08abcdfcaf744b491821f95f0daaab0291c4f9bb2a427ed6b"
|
|
11516
11526
|
},
|
|
11517
11527
|
{
|
|
11518
11528
|
"path": "tests/origin_containment.mjs",
|
|
@@ -11959,6 +11969,11 @@
|
|
|
11959
11969
|
"mode": "0000644",
|
|
11960
11970
|
"sha256": "a7a0a61260a4ee78f5452b76ee6d8ef732104cf9c32a3c05bfa25517bf883aeb"
|
|
11961
11971
|
},
|
|
11972
|
+
{
|
|
11973
|
+
"path": "tests/rename_history_restraint.mjs",
|
|
11974
|
+
"mode": "0000644",
|
|
11975
|
+
"sha256": "2cbc4981c0f33a6597445f268055d10107b6696be4de42e5790e7efefc087edd"
|
|
11976
|
+
},
|
|
11962
11977
|
{
|
|
11963
11978
|
"path": "tests/render_prefs.mjs",
|
|
11964
11979
|
"mode": "0000644",
|
|
@@ -12644,6 +12659,11 @@
|
|
|
12644
12659
|
"mode": "0000644",
|
|
12645
12660
|
"sha256": "808a5fb2e14fb03ded6ed5ea0b3c0a0a7f53bfb885e853acb225f7b439a06ac3"
|
|
12646
12661
|
},
|
|
12662
|
+
{
|
|
12663
|
+
"path": "tests/version_build_slot.mjs",
|
|
12664
|
+
"mode": "0000644",
|
|
12665
|
+
"sha256": "795946106f5e562a55f0ad04f3226884ee7815a86f8c20b94220e8069e192c41"
|
|
12666
|
+
},
|
|
12647
12667
|
{
|
|
12648
12668
|
"path": "tests/version_close.mjs",
|
|
12649
12669
|
"mode": "0000644",
|
|
@@ -1629,5 +1629,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1629
1629
|
landed since 1.19.588 with no explicit bump. run 34179479729. (task 1002620)
|
|
1630
1630
|
1.19.590 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1631
1631
|
landed since 1.19.589 with no explicit bump. run 34180363432. (task 1002620)
|
|
1632
|
+
1.19.591 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1633
|
+
landed since 1.19.590 with no explicit bump. run 34181177013. (task 1002620)
|
|
1634
|
+
1.19.592 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1635
|
+
landed since 1.19.591 with no explicit bump. run 34181434332. (task 1002620)
|
|
1632
1636
|
---------------------------------------------------------------------------
|
|
1633
1637
|
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- core_233_versions_one_planning_idx.sql — exactly one version may sit in
|
|
2
|
+
-- `planning` (BV1.R19, task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT THIS CLOSES. R04 (task 1003592) shipped the one-planning-version rule as a
|
|
5
|
+
-- check-then-insert in `routes/versions.js`, and documented IN THE ROUTE that no
|
|
6
|
+
-- database constraint backs it: two concurrent POST /versions with status=planning
|
|
7
|
+
-- could both pass, because unlike the id collision above it, nothing catches this
|
|
8
|
+
-- one. That comment names this task as the place to fix it. This index is that fix
|
|
9
|
+
-- — the race now resolves the way the id collision already does, with a 23505 the
|
|
10
|
+
-- route translates into the same clean named 409.
|
|
11
|
+
--
|
|
12
|
+
-- WHY ONLY THE PLANNING INDEX, WHEN ADR 0263 §9 ASKS FOR TWO.
|
|
13
|
+
-- The ADR is right that both rules have the same shape and should be backed the
|
|
14
|
+
-- same way. It could not see that the live database VIOLATES the building rule
|
|
15
|
+
-- today: BONGOS-V1 and CB-V1 are both `building`, which is the exact violation
|
|
16
|
+
-- R27 (task 1003614) exists to resolve, and which R19's own brief acknowledges
|
|
17
|
+
-- ("R27 resolves the existing violation").
|
|
18
|
+
--
|
|
19
|
+
-- A migration is applied ONCE and tracked by filename stem (scripts/migrate.sh),
|
|
20
|
+
-- so the two ways to write the building index here are both wrong:
|
|
21
|
+
-- * unconditionally — CREATE UNIQUE INDEX raises on the duplicate rows, the
|
|
22
|
+
-- migration fails, and every deploy of the live instance wedges behind a data
|
|
23
|
+
-- cutover that has not happened yet;
|
|
24
|
+
-- * conditionally, skipping with a NOTICE — it lands "applied", never runs
|
|
25
|
+
-- again, and the constraint silently never arms. That is worse than not
|
|
26
|
+
-- shipping it, because the route comment would then point at a guard that
|
|
27
|
+
-- does not exist.
|
|
28
|
+
-- So the building index ships with R27, in the same migration as the data fix
|
|
29
|
+
-- that makes it legal. Constraint and cutover land together or neither does.
|
|
30
|
+
-- R19's own deliverable — "the server refuses a second building version; a named
|
|
31
|
+
-- 409; a test covers it" — is the ROUTE rule, which lands complete here.
|
|
32
|
+
--
|
|
33
|
+
-- ON "per project": this instance's `versions` table has no project column
|
|
34
|
+
-- (migration 003) — a Bongos database IS one project — so "one version plans per
|
|
35
|
+
-- project" and "one version plans in this table" are the same statement. If a
|
|
36
|
+
-- project column ever lands, this becomes a partial index on (project_id).
|
|
37
|
+
--
|
|
38
|
+
-- CORE, not modules/lifecycle/migrations/: `versions` is a CORE table (migration
|
|
39
|
+
-- 003), and a module migration may only touch its own <key>_-prefixed objects —
|
|
40
|
+
-- fitness.js checkModuleMigrationsAdditive hard-fails an `ALTER TABLE versions`
|
|
41
|
+
-- from a module (ADR 0083 Decision #5).
|
|
42
|
+
--
|
|
43
|
+
-- Legal on the live data as written: zero versions are `planning` today.
|
|
44
|
+
|
|
45
|
+
CREATE UNIQUE INDEX IF NOT EXISTS versions_one_planning_idx
|
|
46
|
+
ON versions ((status))
|
|
47
|
+
WHERE status = 'planning';
|
|
@@ -53,4 +53,44 @@ function authorizeVersionCreate({ status, planning = [] }) {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// authorizeVersionBuild — may a version ENTER `building` in this state?
|
|
57
|
+
// (BV1.R19, task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9.)
|
|
58
|
+
//
|
|
59
|
+
// THE RULE: exactly one version builds at a time. This is the invariant every
|
|
60
|
+
// other rule in this goal stands on, and the one the live instance breaks today:
|
|
61
|
+
// BONGOS-V1 and CB-V1 are both `building`, which is why "the current version" has
|
|
62
|
+
// no meaning here. `currentBuildingVersionId` resolves that ambiguity with an
|
|
63
|
+
// `ORDER BY started_at DESC ... LIMIT 1` — it picks one and says nothing — so
|
|
64
|
+
// R03's gate ("is this version still in planning?") silently has two answers
|
|
65
|
+
// depending on which row won. Task 1003614 (R27) resolves the existing violation;
|
|
66
|
+
// this stops a third from ever being created.
|
|
67
|
+
//
|
|
68
|
+
// THE TWIN OF authorizeVersionCreate, and deliberately a SEPARATE function rather
|
|
69
|
+
// than a second branch inside it. The two guard different slots, and this one has
|
|
70
|
+
// a second caller coming: the close flow promotes the planning version to
|
|
71
|
+
// `building` in its own transaction (task 1003607, R20), where there is no
|
|
72
|
+
// caller-supplied `status` to branch on. One function taking a status would force
|
|
73
|
+
// that path to pass a value it is not setting.
|
|
74
|
+
//
|
|
75
|
+
// `building` is the versions already in that state (id + name), read by the
|
|
76
|
+
// caller. Passing the rows rather than a boolean lets the refusal NAME the
|
|
77
|
+
// version holding the slot — the caller's next move is to close it or scope onto
|
|
78
|
+
// it, and neither is answerable from a bare "no".
|
|
79
|
+
//
|
|
80
|
+
// Returns { ok: true } or { ok: false, status, body } — the exact HTTP status and
|
|
81
|
+
// JSON the route sends.
|
|
82
|
+
function authorizeVersionBuild({ building = [] }) {
|
|
83
|
+
const existing = Array.isArray(building) ? building.filter(Boolean) : [];
|
|
84
|
+
if (existing.length === 0) return { ok: true };
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
status: 409,
|
|
88
|
+
body: {
|
|
89
|
+
error: 'building_version_exists',
|
|
90
|
+
message: `Version '${existing[0].id}' is already building — close it first, or scope this work onto it. One version builds at a time.`,
|
|
91
|
+
building: existing,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { authorizeVersionCreate, authorizeVersionBuild };
|
|
@@ -23,7 +23,7 @@ const auth = api;
|
|
|
23
23
|
const db = require('../db');
|
|
24
24
|
const versionClose = require('../version-close');
|
|
25
25
|
const doneWhen = require('../done-when');
|
|
26
|
-
const { authorizeVersionCreate } = require('./version-route-authz.js');
|
|
26
|
+
const { authorizeVersionCreate, authorizeVersionBuild } = require('./version-route-authz.js');
|
|
27
27
|
const { asyncHandler, validateOrRespond } = api;
|
|
28
28
|
|
|
29
29
|
// versions.status CHECK (migration 003). A new version starts 'planning' or
|
|
@@ -128,15 +128,11 @@ module.exports = function buildVersionsRouter() {
|
|
|
128
128
|
// sibling of R03's authorizeGoalCreate — the route gathers the facts and
|
|
129
129
|
// renders the verdict, it does not carry the rule.
|
|
130
130
|
//
|
|
131
|
-
// RACE,
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// migration this task did not reserve a number for; task 1003606 (R19) adds
|
|
137
|
-
// the second-BUILDING-version rule of the same shape and is the natural place
|
|
138
|
-
// to add the index for both. Until then the exposure is bounded by the route
|
|
139
|
-
// being Archon-gated and used a few times a year — small, not zero.
|
|
131
|
+
// THE RACE IS CLOSED (BV1.R19, task 1003606, migration core_233): this is
|
|
132
|
+
// still check-then-insert, but `versions_one_planning_idx` now backs it, so a
|
|
133
|
+
// race-loser gets a 23505 that the catch below translates into this same
|
|
134
|
+
// refusal. R04 shipped this check documenting that nothing caught it; that is
|
|
135
|
+
// no longer true for the planning slot.
|
|
140
136
|
const versionDecision = authorizeVersionCreate({
|
|
141
137
|
status,
|
|
142
138
|
planning: status === 'planning' ? await db.versionsWithStatus('planning') : [],
|
|
@@ -144,6 +140,27 @@ module.exports = function buildVersionsRouter() {
|
|
|
144
140
|
if (!versionDecision.ok) {
|
|
145
141
|
return res.status(versionDecision.status).json(versionDecision.body);
|
|
146
142
|
}
|
|
143
|
+
|
|
144
|
+
// BV1.R19 (task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9): the other
|
|
145
|
+
// half of the same rule. Exactly one version builds at a time — with two,
|
|
146
|
+
// "the current version" has no meaning and R03's goal-set gate has two
|
|
147
|
+
// answers depending on which row `currentBuildingVersionId`'s LIMIT 1 picked.
|
|
148
|
+
//
|
|
149
|
+
// NOT BACKED BY AN INDEX YET, and that is a data problem rather than an
|
|
150
|
+
// oversight: BONGOS-V1 and CB-V1 are both `building` on the live instance
|
|
151
|
+
// today, so `versions_one_building_idx` cannot be created until task 1003614
|
|
152
|
+
// (R27) folds them. core_233's header records why shipping it early — either
|
|
153
|
+
// unconditionally, which wedges the deploy, or skipped-with-a-NOTICE, which
|
|
154
|
+
// marks the migration applied and never arms — is worse than shipping it with
|
|
155
|
+
// the cutover. Until then this check-then-insert carries the rule alone, with
|
|
156
|
+
// the same bounded exposure R04 accepted: an Archon-gated route used a few
|
|
157
|
+
// times a year.
|
|
158
|
+
const buildDecision = authorizeVersionBuild({
|
|
159
|
+
building: status === 'building' ? await db.versionsWithStatus('building') : [],
|
|
160
|
+
});
|
|
161
|
+
if (!buildDecision.ok) {
|
|
162
|
+
return res.status(buildDecision.status).json(buildDecision.body);
|
|
163
|
+
}
|
|
147
164
|
// Normalize criteria: each { criterion_id, criterion_md } (+ optional sort_order).
|
|
148
165
|
const rawCriteria = Array.isArray(body.criteria) ? body.criteria : [];
|
|
149
166
|
const criteria = [];
|
|
@@ -176,6 +193,31 @@ module.exports = function buildVersionsRouter() {
|
|
|
176
193
|
// returns, instead of letting asyncHandler surface a 500 (hacker dispatch,
|
|
177
194
|
// task 1204).
|
|
178
195
|
if (err && err.code === '23505') {
|
|
196
|
+
// TWO unique constraints can raise here now, and they mean different
|
|
197
|
+
// things (BV1.R19, migration core_233). `versions_one_planning_idx` is
|
|
198
|
+
// the race-loser of the one-planning-version rule — the pre-check above
|
|
199
|
+
// passed because the winner had not committed yet — and must render as
|
|
200
|
+
// that rule's refusal, not as "this id is taken", which would send the
|
|
201
|
+
// caller off to rename a version that was never the problem. Matched on
|
|
202
|
+
// the constraint name rather than the message text, which is localised.
|
|
203
|
+
if (String(err.constraint || '') === 'versions_one_planning_idx') {
|
|
204
|
+
// Re-read rather than reuse the pre-check's rows: the winner has
|
|
205
|
+
// committed by now, so this read is what names it.
|
|
206
|
+
//
|
|
207
|
+
// The refusal is still rendered BY THE GATE, not written here — this
|
|
208
|
+
// route must never carry a second copy of the rule's wording, which is
|
|
209
|
+
// what tests/one_planning_version.mjs pins. The index only raises when
|
|
210
|
+
// a planning row exists, so the re-read is non-empty in every real
|
|
211
|
+
// case; the fallback row covers the one interleaving where the winner
|
|
212
|
+
// was deleted between the raise and this read, and exists solely so the
|
|
213
|
+
// gate cannot be handed an empty list and return `ok` with no body.
|
|
214
|
+
const planningNow = await db.versionsWithStatus('planning');
|
|
215
|
+
const raced = authorizeVersionCreate({
|
|
216
|
+
status: 'planning',
|
|
217
|
+
planning: planningNow.length ? planningNow : [{ id: '(a concurrent create)', name: '' }],
|
|
218
|
+
});
|
|
219
|
+
return res.status(raced.status).json(raced.body);
|
|
220
|
+
}
|
|
179
221
|
return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
|
|
180
222
|
}
|
|
181
223
|
throw err;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.592",
|
|
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.592",
|
|
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.592",
|
|
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",
|
|
@@ -69,6 +69,13 @@ const GDS_RULE_FILES = new Set([
|
|
|
69
69
|
'scripts/gds/fitness-ratchets.js', // the gate: names the metric and carries the builder-facing hint
|
|
70
70
|
'tests/fitness_gds_ratchet.mjs', // its test, which must write the literal to prove the gate fires
|
|
71
71
|
'config/fitness-baselines.json', // the baseline's own _comment explains what is counted
|
|
72
|
+
// The RESTRAINT half of the same rename (task 1003697, criterion C5). It fails
|
|
73
|
+
// the build when the old name is ERASED from the frozen record, so it has to
|
|
74
|
+
// name both the literal and the version ids it pins — the same reason this
|
|
75
|
+
// file is exempt. A gate that cannot state its own rule cannot explain itself.
|
|
76
|
+
'scripts/gds/rename-history-check.js',
|
|
77
|
+
'tests/rename_history_restraint.mjs',
|
|
78
|
+
'config/rename-history-baseline.json',
|
|
72
79
|
]);
|
|
73
80
|
|
|
74
81
|
// A version id is a row key, not vocabulary. Stripped before matching so it is
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// rename-history-check.js — the RESTRAINT half of the GDS→Bongos rename
|
|
4
|
+
// (criterion C5 of goal 1000073, "history is intact and the alias still
|
|
5
|
+
// answers"; task 1003697).
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS EXISTS, AND WHY IT IS THE MIRROR OF THE RATCHET. Its sibling
|
|
8
|
+
// gds-literal-scan.js + the `legacy_gds_literals` ratchet fail the build when
|
|
9
|
+
// the old name COMES BACK. This module fails the build when the old name is
|
|
10
|
+
// erased from somewhere it must stay. The goal's rule needs both halves:
|
|
11
|
+
//
|
|
12
|
+
// rename what the project says about itself GOING FORWARD;
|
|
13
|
+
// leave what it recorded about its PAST.
|
|
14
|
+
//
|
|
15
|
+
// A ratchet alone rewards deletion — the cheapest way to lower a literal count
|
|
16
|
+
// is to sweep the record clean, which is precisely the failure this guards.
|
|
17
|
+
//
|
|
18
|
+
// WHY IT LANDS BEFORE CRITERION C3. C3 renames scripts/gds/ → scripts/bongos/
|
|
19
|
+
// with a codemod across ~1,000 referencing files. The goal calls C3 "the risky
|
|
20
|
+
// criterion" and says C5 is "verified THROUGHOUT the goal, not at the end" —
|
|
21
|
+
// which is not possible without a test. This is that test, built first so the
|
|
22
|
+
// risky pass has something to be verified against.
|
|
23
|
+
//
|
|
24
|
+
// THE FOUR PROPERTIES, and the concrete damage each one prevents:
|
|
25
|
+
//
|
|
26
|
+
// 1. FROZEN FILENAMES — every migration filename, core and module-owned.
|
|
27
|
+
// schema_migrations is keyed by the filename STEM, so a renamed applied
|
|
28
|
+
// migration reads as unapplied and RE-RUNS. This is an outage, not an
|
|
29
|
+
// aesthetic. Two stems in this repo literally carry the old name and sit
|
|
30
|
+
// directly in a codemod's path:
|
|
31
|
+
// migrations/019_rename_pms_v3_to_gds_v3.sql
|
|
32
|
+
// modules/economy/migrations/economy_001_fix_gds_shipper_description.sql
|
|
33
|
+
// They are the reason this check is a pinned list rather than a pattern.
|
|
34
|
+
//
|
|
35
|
+
// 2. FROZEN LITERAL FLOORS — per-file minimum occurrence counts under the
|
|
36
|
+
// frozen record (docs/adr, docs/session-logs, docs/audits, limitations).
|
|
37
|
+
// A FLOOR, not a ceiling: the count may rise as new records are written,
|
|
38
|
+
// never fall. An ADR rewritten to today's vocabulary destroys the evidence
|
|
39
|
+
// that the words ever changed, and a per-file floor catches that even
|
|
40
|
+
// while new files push the directory total up.
|
|
41
|
+
//
|
|
42
|
+
// 3. VERSION ID FLOORS — GDS-V3 / GDS-V4 are row KEYS in the versions table,
|
|
43
|
+
// not vocabulary. gds-literal-scan.js deliberately strips them before
|
|
44
|
+
// counting so they stay legal anywhere; the corollary is that nothing else
|
|
45
|
+
// notices if a blanket find-replace rewrites them. This does.
|
|
46
|
+
//
|
|
47
|
+
// 4. ALIAS MOUNTS — '/api/gds' is a PERMANENT alias (src/bongos/api-prefix.js
|
|
48
|
+
// says never to remove it: shipped Dev Box binaries and live dev boxes
|
|
49
|
+
// call it and cannot be force-updated). Asserted against the real exported
|
|
50
|
+
// mount list. The DISPATCH half — that the alias is a live mount and so
|
|
51
|
+
// gets no corrective 404 hint — is already covered by
|
|
52
|
+
// tests/api_path_404.mjs and is deliberately not duplicated here.
|
|
53
|
+
//
|
|
54
|
+
// EVERY CHECKER IS PURE and takes its inputs explicitly, so the test can name
|
|
55
|
+
// exactly the paths and counts under test without writing into the repo. That
|
|
56
|
+
// is the gds-literal-scan.js precedent, and it is not tidiness: the unit lane
|
|
57
|
+
// runs test files in parallel, and a test that edits the tree corrupts whatever
|
|
58
|
+
// suite happens to be scanning it.
|
|
59
|
+
//
|
|
60
|
+
// Usage:
|
|
61
|
+
// node scripts/gds/rename-history-check.js # verify against the baseline
|
|
62
|
+
// node scripts/gds/rename-history-check.js --json
|
|
63
|
+
// node scripts/gds/rename-history-check.js --write # re-pin the baseline (see below)
|
|
64
|
+
//
|
|
65
|
+
// RE-PINNING. --write is legitimate ONLY when the record genuinely grew — a new
|
|
66
|
+
// migration, a new ADR. It is never the fix for a failing check: if a floor
|
|
67
|
+
// dropped, something erased history, and lowering the floor hides it.
|
|
68
|
+
|
|
69
|
+
const fs = require('node:fs');
|
|
70
|
+
const path = require('node:path');
|
|
71
|
+
const { execFileSync } = require('node:child_process');
|
|
72
|
+
|
|
73
|
+
const { countInText, GDS_RULE_FILES } = require('./gds-literal-scan.js');
|
|
74
|
+
|
|
75
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
76
|
+
const BASELINE_PATH = path.join(ROOT, 'config', 'rename-history-baseline.json');
|
|
77
|
+
|
|
78
|
+
// The frozen record. Matched against the repo-relative path at any depth, so an
|
|
79
|
+
// instance's own docs/ and a module-owned migrations/ fall under the same rule
|
|
80
|
+
// as the core's. Kept in step with GDS_EXCLUDED_PATHS in gds-literal-scan.js —
|
|
81
|
+
// the same set of paths, read the opposite way: excluded from the ratchet
|
|
82
|
+
// BECAUSE they are pinned here.
|
|
83
|
+
const FROZEN_RECORD_PATHS = [
|
|
84
|
+
/(^|\/)docs\/adr\//,
|
|
85
|
+
/(^|\/)docs\/session-logs\//,
|
|
86
|
+
/(^|\/)docs\/audits\//,
|
|
87
|
+
/(^|\/)limitations\//,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Any path segment named migrations/ — core and module-owned alike.
|
|
91
|
+
const MIGRATION_PATH_RE = /(^|\/)migrations\/[^/]+\.sql$/;
|
|
92
|
+
|
|
93
|
+
// Version ids that must survive as row keys. Deliberately a literal list and
|
|
94
|
+
// not a pattern: the point is that THESE specific keys still resolve.
|
|
95
|
+
const PINNED_VERSION_IDS = ['GDS-V3', 'GDS-V4'];
|
|
96
|
+
|
|
97
|
+
// Mounts the server must keep answering on.
|
|
98
|
+
const REQUIRED_ALIAS_MOUNTS = ['/api/gds'];
|
|
99
|
+
|
|
100
|
+
function isFrozenRecordPath(relPath) {
|
|
101
|
+
return FROZEN_RECORD_PATHS.some((re) => re.test(relPath));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isMigrationPath(relPath) {
|
|
105
|
+
return MIGRATION_PATH_RE.test(relPath);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- the four checkers (pure) ----------
|
|
109
|
+
|
|
110
|
+
// checkFrozenFilenames — every pinned migration filename must still exist under
|
|
111
|
+
// exactly that path. A rename shows up as a miss, which is the whole point:
|
|
112
|
+
// schema_migrations keys on the stem, so a rename is a silent re-run.
|
|
113
|
+
function checkFrozenFilenames({ present, pinned }) {
|
|
114
|
+
const have = new Set(present);
|
|
115
|
+
const missing = pinned.filter((p) => !have.has(p));
|
|
116
|
+
return { ok: missing.length === 0, missing };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// checkFrozenLiteralFloors — a per-file FLOOR on occurrences of the old name in
|
|
120
|
+
// the frozen record. A file that vanished counts as zero, so deleting a record
|
|
121
|
+
// fails exactly as loudly as rewriting one.
|
|
122
|
+
function checkFrozenLiteralFloors({ counts, floors }) {
|
|
123
|
+
const dropped = [];
|
|
124
|
+
for (const [relPath, floor] of Object.entries(floors)) {
|
|
125
|
+
const now = Object.prototype.hasOwnProperty.call(counts, relPath) ? counts[relPath] : 0;
|
|
126
|
+
if (now < floor) dropped.push({ path: relPath, was: floor, now });
|
|
127
|
+
}
|
|
128
|
+
return { ok: dropped.length === 0, dropped };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// checkVersionIdFloors — the version ids are row keys; they may be referenced
|
|
132
|
+
// more often over time, never fewer.
|
|
133
|
+
function checkVersionIdFloors({ counts, floors }) {
|
|
134
|
+
const dropped = [];
|
|
135
|
+
for (const [id, floor] of Object.entries(floors)) {
|
|
136
|
+
const now = counts[id] || 0;
|
|
137
|
+
if (now < floor) dropped.push({ id, was: floor, now });
|
|
138
|
+
}
|
|
139
|
+
return { ok: dropped.length === 0, dropped };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// checkAliasMounts — the permanent alias must still be in the mount list.
|
|
143
|
+
function checkAliasMounts({ mounts, required }) {
|
|
144
|
+
const have = new Set(mounts);
|
|
145
|
+
const missing = required.filter((m) => !have.has(m));
|
|
146
|
+
return { ok: missing.length === 0, missing };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// runAll — the four checkers over one set of gathered inputs. Returns every
|
|
150
|
+
// failure rather than short-circuiting: a codemod that broke one property
|
|
151
|
+
// usually broke several, and reporting them one release at a time is useless.
|
|
152
|
+
function runAll({ observed, baseline }) {
|
|
153
|
+
const results = {
|
|
154
|
+
frozenFilenames: checkFrozenFilenames({
|
|
155
|
+
present: observed.migrationFiles,
|
|
156
|
+
pinned: baseline.migrationFiles,
|
|
157
|
+
}),
|
|
158
|
+
frozenLiteralFloors: checkFrozenLiteralFloors({
|
|
159
|
+
counts: observed.frozenLiteralCounts,
|
|
160
|
+
floors: baseline.frozenLiteralFloors,
|
|
161
|
+
}),
|
|
162
|
+
versionIdFloors: checkVersionIdFloors({
|
|
163
|
+
counts: observed.versionIdCounts,
|
|
164
|
+
floors: baseline.versionIdFloors,
|
|
165
|
+
}),
|
|
166
|
+
aliasMounts: checkAliasMounts({
|
|
167
|
+
mounts: observed.aliasMounts,
|
|
168
|
+
required: REQUIRED_ALIAS_MOUNTS,
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
return { ok: Object.values(results).every((r) => r.ok), results };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------- collectors (touch the repo; kept out of the checkers) ----------
|
|
175
|
+
|
|
176
|
+
function trackedFiles() {
|
|
177
|
+
const out = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
178
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readRepoFile(relPath) {
|
|
182
|
+
return fs.readFileSync(path.join(ROOT, relPath), 'utf8');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Compiled ONCE, not per file. observe() reads every tracked file in the repo,
|
|
186
|
+
// so a `new RegExp` inside that loop is thousands of needless compilations on
|
|
187
|
+
// every unit-gate run. Reuse is safe: String.prototype.match with a global
|
|
188
|
+
// regex ignores lastIndex and returns all matches, so the shared objects carry
|
|
189
|
+
// no state between files.
|
|
190
|
+
const VERSION_ID_RES = PINNED_VERSION_IDS.map((id) => [
|
|
191
|
+
id,
|
|
192
|
+
new RegExp(id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
// observe — everything the checkers compare against, gathered once.
|
|
196
|
+
// `files` and `readFile` are injectable for the same reason gds-literal-scan.js
|
|
197
|
+
// makes them injectable: so a test can exercise the real aggregation without
|
|
198
|
+
// writing to a tree other suites are reading in parallel.
|
|
199
|
+
function observe({ files = null, readFile = null, aliasMounts = null } = {}) {
|
|
200
|
+
const read = readFile || readRepoFile;
|
|
201
|
+
const all = files || trackedFiles();
|
|
202
|
+
|
|
203
|
+
const migrationFiles = all.filter(isMigrationPath).sort();
|
|
204
|
+
|
|
205
|
+
const frozenLiteralCounts = {};
|
|
206
|
+
const versionIdCounts = {};
|
|
207
|
+
for (const id of PINNED_VERSION_IDS) versionIdCounts[id] = 0;
|
|
208
|
+
|
|
209
|
+
for (const relPath of all) {
|
|
210
|
+
let text;
|
|
211
|
+
try {
|
|
212
|
+
text = read(relPath);
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (text.includes(String.fromCharCode(0))) continue; // binary (a NUL byte)
|
|
217
|
+
|
|
218
|
+
if (isFrozenRecordPath(relPath)) {
|
|
219
|
+
const n = countInText(text);
|
|
220
|
+
if (n) frozenLiteralCounts[relPath] = n;
|
|
221
|
+
}
|
|
222
|
+
// The files that STATE the rule are not part of the record they protect.
|
|
223
|
+
// This module and its test name the version ids as fixtures; counting those
|
|
224
|
+
// would pin a floor to test data, so editing a test could later red the
|
|
225
|
+
// build as if history had been erased. Same exemption, same reason, as
|
|
226
|
+
// GDS_RULE_FILES in the ratchet.
|
|
227
|
+
if (!GDS_RULE_FILES.has(relPath)) {
|
|
228
|
+
for (const [id, re] of VERSION_ID_RES) {
|
|
229
|
+
versionIdCounts[id] += (text.match(re) || []).length;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let mounts = aliasMounts;
|
|
235
|
+
if (!mounts) {
|
|
236
|
+
// Read the real exported mount list rather than grepping for the string —
|
|
237
|
+
// a comment mentioning the alias must not satisfy the check.
|
|
238
|
+
mounts = require('../../src/bongos/api-prefix.js').ALL_API_PREFIXES;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { migrationFiles, frozenLiteralCounts, versionIdCounts, aliasMounts: mounts };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function loadBaseline() {
|
|
245
|
+
return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function baselineFrom(observed) {
|
|
249
|
+
return {
|
|
250
|
+
_comment: [
|
|
251
|
+
'Pinned by scripts/gds/rename-history-check.js (task 1003697, criterion C5 of goal 1000073).',
|
|
252
|
+
'These are FLOORS on the historical record, the mirror of the legacy_gds_literals ratchet.',
|
|
253
|
+
'migrationFiles: every migration filename must keep existing verbatim — schema_migrations',
|
|
254
|
+
'is keyed by the filename stem, so a rename reads as unapplied and RE-RUNS the migration.',
|
|
255
|
+
'frozenLiteralFloors / versionIdFloors: counts may RISE as the record grows, never fall.',
|
|
256
|
+
'Re-pin with --write only when the record genuinely grew. A dropped floor means something',
|
|
257
|
+
'erased history; lowering it hides the erasure instead of fixing it.',
|
|
258
|
+
],
|
|
259
|
+
migrationFiles: observed.migrationFiles,
|
|
260
|
+
frozenLiteralFloors: observed.frozenLiteralCounts,
|
|
261
|
+
versionIdFloors: observed.versionIdCounts,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------- CLI ----------
|
|
266
|
+
|
|
267
|
+
// describe — the failure report. Names the damage, not just the diff, because
|
|
268
|
+
// the reader is a builder mid-codemod who needs to know what to restore.
|
|
269
|
+
function describe(report) {
|
|
270
|
+
const lines = [];
|
|
271
|
+
const { results } = report;
|
|
272
|
+
if (results.frozenFilenames.missing.length) {
|
|
273
|
+
lines.push(`frozen filenames MISSING (${results.frozenFilenames.missing.length}) — a renamed applied migration RE-RUNS:`);
|
|
274
|
+
for (const p of results.frozenFilenames.missing.slice(0, 20)) lines.push(` ${p}`);
|
|
275
|
+
}
|
|
276
|
+
if (results.frozenLiteralFloors.dropped.length) {
|
|
277
|
+
lines.push(`frozen record ERASED in ${results.frozenLiteralFloors.dropped.length} file(s) — history was rewritten:`);
|
|
278
|
+
for (const d of results.frozenLiteralFloors.dropped.slice(0, 20)) {
|
|
279
|
+
lines.push(` ${d.path} ${d.was} → ${d.now}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (results.versionIdFloors.dropped.length) {
|
|
283
|
+
lines.push('version ids REWRITTEN — these are row keys, not vocabulary:');
|
|
284
|
+
for (const d of results.versionIdFloors.dropped) lines.push(` ${d.id} ${d.was} → ${d.now}`);
|
|
285
|
+
}
|
|
286
|
+
if (results.aliasMounts.missing.length) {
|
|
287
|
+
lines.push(`alias mount REMOVED: ${results.aliasMounts.missing.join(', ')} — shipped clients call it and cannot be updated (src/bongos/api-prefix.js)`);
|
|
288
|
+
}
|
|
289
|
+
return lines;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function main() {
|
|
293
|
+
const argv = process.argv.slice(2);
|
|
294
|
+
const observed = observe();
|
|
295
|
+
|
|
296
|
+
if (argv.includes('--write')) {
|
|
297
|
+
// Print what re-pinning would ERASE before doing it. --write is legitimate
|
|
298
|
+
// when the record grew, and is the wrong move when a floor dropped — so the
|
|
299
|
+
// one thing it must never do is lower a floor silently. Everything under
|
|
300
|
+
// "removes" below is exactly what the check would have caught.
|
|
301
|
+
let prior = null;
|
|
302
|
+
try {
|
|
303
|
+
prior = loadBaseline();
|
|
304
|
+
} catch {
|
|
305
|
+
prior = null;
|
|
306
|
+
}
|
|
307
|
+
if (prior) {
|
|
308
|
+
const lost = describe(runAll({ observed, baseline: prior }));
|
|
309
|
+
if (lost.length) {
|
|
310
|
+
console.log('re-pinning would LOWER the record — read this before committing:\n');
|
|
311
|
+
for (const line of lost) console.log(` ${line}`);
|
|
312
|
+
console.log('\nIf that was not deliberate, restore what was renamed or erased instead.\n');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(baselineFrom(observed), null, 2)}\n`);
|
|
317
|
+
console.log(`re-pinned ${path.relative(ROOT, BASELINE_PATH)}`);
|
|
318
|
+
const priorMigrations = prior ? prior.migrationFiles.length : 0;
|
|
319
|
+
const priorFrozen = prior ? Object.keys(prior.frozenLiteralFloors).length : 0;
|
|
320
|
+
console.log(` ${observed.migrationFiles.length} migration filename(s) (was ${priorMigrations})`);
|
|
321
|
+
console.log(` ${Object.keys(observed.frozenLiteralCounts).length} frozen-record file(s) (was ${priorFrozen})`);
|
|
322
|
+
console.log(` version ids: ${Object.entries(observed.versionIdCounts).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const baseline = loadBaseline();
|
|
327
|
+
const report = runAll({ observed, baseline });
|
|
328
|
+
|
|
329
|
+
if (argv.includes('--json')) {
|
|
330
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
331
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (report.ok) {
|
|
336
|
+
console.log('rename restraint: OK — history intact, alias still mounted');
|
|
337
|
+
console.log(` ${baseline.migrationFiles.length} migration filename(s) unchanged`);
|
|
338
|
+
console.log(` ${Object.keys(baseline.frozenLiteralFloors).length} frozen-record file(s) at or above their floor`);
|
|
339
|
+
console.log(` version ids: ${Object.entries(observed.versionIdCounts).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
340
|
+
console.log(` mounts: ${observed.aliasMounts.join(' ')}`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.error('rename restraint: FAILED — the record changed where it must not\n');
|
|
345
|
+
for (const line of describe(report)) console.error(` ${line}`);
|
|
346
|
+
console.error('\nThis is not fixed by re-pinning the baseline. Restore what was renamed or erased.');
|
|
347
|
+
process.exitCode = 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (require.main === module) main();
|
|
351
|
+
|
|
352
|
+
module.exports = {
|
|
353
|
+
FROZEN_RECORD_PATHS,
|
|
354
|
+
MIGRATION_PATH_RE,
|
|
355
|
+
PINNED_VERSION_IDS,
|
|
356
|
+
REQUIRED_ALIAS_MOUNTS,
|
|
357
|
+
isFrozenRecordPath,
|
|
358
|
+
isMigrationPath,
|
|
359
|
+
checkFrozenFilenames,
|
|
360
|
+
checkFrozenLiteralFloors,
|
|
361
|
+
checkVersionIdFloors,
|
|
362
|
+
checkAliasMounts,
|
|
363
|
+
runAll,
|
|
364
|
+
observe,
|
|
365
|
+
baselineFrom,
|
|
366
|
+
loadBaseline,
|
|
367
|
+
describe,
|
|
368
|
+
BASELINE_PATH,
|
|
369
|
+
};
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.592'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -141,14 +141,31 @@ test('the default status is planning, so an unqualified create hits the gate', (
|
|
|
141
141
|
assert.match(ROUTE, /const status = body\.status \|\| 'planning';/);
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
test('the
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
test('the planning race is now BACKED, and the comment says so honestly', () => {
|
|
145
|
+
// WHAT THIS TEST USED TO ASSERT, AND WHY IT FLIPPED. Until BV1.R19 (task
|
|
146
|
+
// 1003606) this pinned the words "RACE, ACCEPTED AND UNMITIGATED", because an
|
|
147
|
+
// earlier draft of the comment claimed the race was "left to a partial unique
|
|
148
|
+
// index" when no such index existed — a future reader taking that at face
|
|
149
|
+
// value would have believed two concurrent creates could not both slip
|
|
150
|
+
// through. R19 shipped the index (migration core_233), so the old wording is
|
|
151
|
+
// now the dishonest one. The TEST'S PURPOSE is unchanged: the comment must
|
|
152
|
+
// describe the mitigation that actually exists, no more and no less.
|
|
153
|
+
assert.match(ROUTE, /THE RACE IS CLOSED/);
|
|
154
|
+
assert.match(ROUTE, /versions_one_planning_idx/,
|
|
155
|
+
'the comment must name the index that backs it, so the claim is checkable');
|
|
156
|
+
assert.equal(/RACE, ACCEPTED AND UNMITIGATED/.test(ROUTE), false,
|
|
157
|
+
'the planning race is mitigated as of R19 — the old wording would now under-claim');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('the BUILDING half is still unmitigated, and the comment does not pretend otherwise', () => {
|
|
161
|
+
// The other half of the same honesty rule, and the one that matters now. R19
|
|
162
|
+
// could only ship ONE of ADR 0263 §9's two indexes: the live instance has two
|
|
163
|
+
// `building` versions, so `versions_one_building_idx` cannot be created until
|
|
164
|
+
// task 1003614 (R27) folds them. The building slot is therefore guarded by a
|
|
165
|
+
// check-then-insert with nothing behind it — exactly the state the planning
|
|
166
|
+
// slot was in before — and a reader who assumed symmetry would be wrong.
|
|
167
|
+
assert.match(ROUTE, /NOT BACKED BY AN INDEX YET/);
|
|
168
|
+
assert.match(ROUTE, /1003614/, 'the comment must name the task that makes the index legal');
|
|
152
169
|
});
|
|
153
170
|
|
|
154
171
|
// ---- the rule is one half of a pair ----------------------------------------
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// tests/rename_history_restraint.mjs — the RESTRAINT half of the GDS→Bongos
|
|
2
|
+
// rename (task 1003697, criterion C5 of goal 1000073: "history is intact and
|
|
3
|
+
// the alias still answers").
|
|
4
|
+
//
|
|
5
|
+
// WHY THE NEGATIVE CASES ARE THE LOAD-BEARING ONES. Its sibling
|
|
6
|
+
// tests/fitness_gds_ratchet.mjs makes the same argument and it applies twice as
|
|
7
|
+
// hard here: a restraint check that only asserts today's tree is clean would
|
|
8
|
+
// pass identically if every checker returned "ok" unconditionally. So each of
|
|
9
|
+
// the four properties gets a synthetic FORBIDDEN change and an assertion that
|
|
10
|
+
// the checker FAILS it. The positive case is the cheap half.
|
|
11
|
+
//
|
|
12
|
+
// The checkers are pure and take their inputs explicitly, so every case names
|
|
13
|
+
// its own paths and counts. Nothing here writes to the repo — the unit lane
|
|
14
|
+
// runs test files in parallel, and an earlier version of the ratchet test
|
|
15
|
+
// proved what happens when a suite edits a tree another suite is scanning.
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const check = require(path.join(ROOT, 'scripts', 'gds', 'rename-history-check.js'));
|
|
25
|
+
const scan = require(path.join(ROOT, 'scripts', 'gds', 'gds-literal-scan.js'));
|
|
26
|
+
|
|
27
|
+
const LITERAL = ['G', 'D', 'S'].join(''); // built, not written, so the fixtures below carry no vocabulary of their own
|
|
28
|
+
|
|
29
|
+
// ---------- 1. frozen filenames: a renamed applied migration RE-RUNS ----------
|
|
30
|
+
|
|
31
|
+
test('a migration that kept its name passes', () => {
|
|
32
|
+
const pinned = ['migrations/019_rename_pms_v3_to_gds_v3.sql'];
|
|
33
|
+
const r = check.checkFrozenFilenames({ present: pinned, pinned });
|
|
34
|
+
assert.equal(r.ok, true);
|
|
35
|
+
assert.deepEqual(r.missing, []);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a RENAMED migration fails and is named — schema_migrations keys on the stem', () => {
|
|
39
|
+
const pinned = [
|
|
40
|
+
'migrations/019_rename_pms_v3_to_gds_v3.sql',
|
|
41
|
+
'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql',
|
|
42
|
+
];
|
|
43
|
+
// exactly what a scripts/gds/ → scripts/bongos/ codemod would do to these two
|
|
44
|
+
const present = [
|
|
45
|
+
'migrations/019_rename_pms_v3_to_bongos_v3.sql',
|
|
46
|
+
'modules/economy/migrations/economy_001_fix_bongos_shipper_description.sql',
|
|
47
|
+
];
|
|
48
|
+
const r = check.checkFrozenFilenames({ present, pinned });
|
|
49
|
+
assert.equal(r.ok, false, 'a renamed migration MUST fail — it reads as unapplied and re-runs');
|
|
50
|
+
assert.deepEqual(r.missing, pinned);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a DELETED migration fails the same way as a renamed one', () => {
|
|
54
|
+
const r = check.checkFrozenFilenames({ present: [], pinned: ['migrations/001_init.sql'] });
|
|
55
|
+
assert.equal(r.ok, false);
|
|
56
|
+
assert.deepEqual(r.missing, ['migrations/001_init.sql']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a NEW migration alongside the pinned ones is fine — the list is a floor', () => {
|
|
60
|
+
const r = check.checkFrozenFilenames({
|
|
61
|
+
present: ['migrations/001_init.sql', 'migrations/999_new.sql'],
|
|
62
|
+
pinned: ['migrations/001_init.sql'],
|
|
63
|
+
});
|
|
64
|
+
assert.equal(r.ok, true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------- 2. frozen literal floors: a rewritten ADR erases the evidence ----------
|
|
68
|
+
|
|
69
|
+
test('an unchanged record passes, and MORE occurrences pass — the floor only stops erasure', () => {
|
|
70
|
+
const floors = { 'docs/adr/0064-rename.md': 12 };
|
|
71
|
+
assert.equal(check.checkFrozenLiteralFloors({ counts: { 'docs/adr/0064-rename.md': 12 }, floors }).ok, true);
|
|
72
|
+
assert.equal(check.checkFrozenLiteralFloors({ counts: { 'docs/adr/0064-rename.md': 30 }, floors }).ok, true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an ADR REWRITTEN to today’s vocabulary fails, with the before/after', () => {
|
|
76
|
+
const r = check.checkFrozenLiteralFloors({
|
|
77
|
+
counts: { 'docs/adr/0064-rename.md': 3 },
|
|
78
|
+
floors: { 'docs/adr/0064-rename.md': 12 },
|
|
79
|
+
});
|
|
80
|
+
assert.equal(r.ok, false, 'rewriting the record MUST fail — it destroys the evidence the words changed');
|
|
81
|
+
assert.deepEqual(r.dropped, [{ path: 'docs/adr/0064-rename.md', was: 12, now: 3 }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a DELETED record counts as zero — deleting fails as loudly as rewriting', () => {
|
|
85
|
+
const r = check.checkFrozenLiteralFloors({
|
|
86
|
+
counts: {},
|
|
87
|
+
floors: { 'docs/session-logs/2026-09-06-something.md': 4 },
|
|
88
|
+
});
|
|
89
|
+
assert.equal(r.ok, false);
|
|
90
|
+
assert.deepEqual(r.dropped, [{ path: 'docs/session-logs/2026-09-06-something.md', was: 4, now: 0 }]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('every dropped file is reported, not just the first', () => {
|
|
94
|
+
const r = check.checkFrozenLiteralFloors({
|
|
95
|
+
counts: { 'docs/adr/a.md': 0, 'docs/adr/b.md': 1 },
|
|
96
|
+
floors: { 'docs/adr/a.md': 5, 'docs/adr/b.md': 5, 'docs/adr/c.md': 5 },
|
|
97
|
+
});
|
|
98
|
+
assert.equal(r.dropped.length, 3, 'a codemod breaks many files at once; one-at-a-time reporting is useless');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------- 3. version ids are row keys, not vocabulary ----------
|
|
102
|
+
|
|
103
|
+
test('version id floors pass when the keys survive, fail on a blanket find-replace', () => {
|
|
104
|
+
const floors = { 'GDS-V3': 183, 'GDS-V4': 188 };
|
|
105
|
+
assert.equal(check.checkVersionIdFloors({ counts: { 'GDS-V3': 183, 'GDS-V4': 200 }, floors }).ok, true);
|
|
106
|
+
|
|
107
|
+
const r = check.checkVersionIdFloors({ counts: { 'GDS-V3': 0, 'GDS-V4': 188 }, floors });
|
|
108
|
+
assert.equal(r.ok, false, 'rewriting a version id MUST fail — it is a row key and stops resolving');
|
|
109
|
+
assert.deepEqual(r.dropped, [{ id: 'GDS-V3', was: 183, now: 0 }]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the ratchet deliberately ignores version ids, so THIS check is the only thing watching them', () => {
|
|
113
|
+
// gds-literal-scan strips version ids before counting — that is correct for
|
|
114
|
+
// the ratchet and is exactly why the floors above have to exist.
|
|
115
|
+
assert.equal(scan.countInText(`${LITERAL}-V3 and ${LITERAL}-V4`), 0);
|
|
116
|
+
assert.deepEqual(check.PINNED_VERSION_IDS, ['GDS-V3', 'GDS-V4']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ---------- 4. the permanent alias mount ----------
|
|
120
|
+
|
|
121
|
+
test('the alias mount passes while present and FAILS when dropped', () => {
|
|
122
|
+
const required = ['/api/gds'];
|
|
123
|
+
assert.equal(check.checkAliasMounts({ mounts: ['/api/bongos/v1', '/api/bongos', '/api/gds'], required }).ok, true);
|
|
124
|
+
|
|
125
|
+
const r = check.checkAliasMounts({ mounts: ['/api/bongos/v1', '/api/bongos'], required });
|
|
126
|
+
assert.equal(r.ok, false, 'shipped Dev Box binaries call the alias and cannot be force-updated');
|
|
127
|
+
assert.deepEqual(r.missing, ['/api/gds']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the alias is asserted against the real exported mount list, not a grep', () => {
|
|
131
|
+
// A comment mentioning the alias must not be able to satisfy the check.
|
|
132
|
+
const { ALL_API_PREFIXES } = require(path.join(ROOT, 'src', 'bongos', 'api-prefix.js'));
|
|
133
|
+
assert.ok(ALL_API_PREFIXES.includes('/api/gds'), 'src/bongos/api-prefix.js still mounts the permanent alias');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------- runAll: one broken codemod reports every property it broke ----------
|
|
137
|
+
|
|
138
|
+
test('runAll reports ALL four failures together', () => {
|
|
139
|
+
const observed = {
|
|
140
|
+
migrationFiles: [],
|
|
141
|
+
frozenLiteralCounts: {},
|
|
142
|
+
versionIdCounts: { 'GDS-V3': 0, 'GDS-V4': 0 },
|
|
143
|
+
aliasMounts: ['/api/bongos'],
|
|
144
|
+
};
|
|
145
|
+
const baseline = {
|
|
146
|
+
migrationFiles: ['migrations/001_init.sql'],
|
|
147
|
+
frozenLiteralFloors: { 'docs/adr/0064-rename.md': 12 },
|
|
148
|
+
versionIdFloors: { 'GDS-V3': 183, 'GDS-V4': 188 },
|
|
149
|
+
};
|
|
150
|
+
const report = check.runAll({ observed, baseline });
|
|
151
|
+
assert.equal(report.ok, false);
|
|
152
|
+
for (const key of ['frozenFilenames', 'frozenLiteralFloors', 'versionIdFloors', 'aliasMounts']) {
|
|
153
|
+
assert.equal(report.results[key].ok, false, `${key} should have failed`);
|
|
154
|
+
}
|
|
155
|
+
const text = check.describe(report).join('\n');
|
|
156
|
+
for (const fragment of ['RE-RUNS', 'ERASED', 'REWRITTEN', 'REMOVED']) {
|
|
157
|
+
assert.ok(text.includes(fragment), `the failure report should say what broke: ${fragment}`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------- observe(): the aggregation, on injected inputs ----------
|
|
162
|
+
|
|
163
|
+
test('observe classifies frozen vs go-forward files and counts version ids everywhere', () => {
|
|
164
|
+
const files = [
|
|
165
|
+
'docs/adr/0064-rename.md',
|
|
166
|
+
'migrations/019_rename_pms_v3_to_gds_v3.sql',
|
|
167
|
+
'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql',
|
|
168
|
+
'src/bongos/routes.js',
|
|
169
|
+
];
|
|
170
|
+
const texts = {
|
|
171
|
+
'docs/adr/0064-rename.md': `the ${LITERAL} acronym, twice: ${LITERAL}. and version GDS-V3`,
|
|
172
|
+
'migrations/019_rename_pms_v3_to_gds_v3.sql': '-- GDS-V3',
|
|
173
|
+
'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql': '-- nothing',
|
|
174
|
+
'src/bongos/routes.js': `a go-forward mention of ${LITERAL} that this check does NOT pin`,
|
|
175
|
+
};
|
|
176
|
+
const observed = check.observe({
|
|
177
|
+
files,
|
|
178
|
+
readFile: (p) => texts[p],
|
|
179
|
+
aliasMounts: ['/api/gds'],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// module-owned migrations are found by the same rule as the core's
|
|
183
|
+
assert.deepEqual(observed.migrationFiles, [
|
|
184
|
+
'migrations/019_rename_pms_v3_to_gds_v3.sql',
|
|
185
|
+
'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql',
|
|
186
|
+
]);
|
|
187
|
+
// the frozen record is floored; a go-forward file is NOT (that is the ratchet's job)
|
|
188
|
+
assert.deepEqual(Object.keys(observed.frozenLiteralCounts), ['docs/adr/0064-rename.md']);
|
|
189
|
+
assert.equal(observed.frozenLiteralCounts['docs/adr/0064-rename.md'], 2, 'the version id on the line is not counted as vocabulary');
|
|
190
|
+
// version ids are counted across every file, frozen or not
|
|
191
|
+
assert.equal(observed.versionIdCounts['GDS-V3'], 2);
|
|
192
|
+
assert.equal(observed.versionIdCounts['GDS-V4'], 0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the shared version-id regexes carry no state between files', () => {
|
|
196
|
+
// observe() compiles the version-id regexes ONCE and reuses them across every
|
|
197
|
+
// tracked file. That is only safe because String.match with a global regex
|
|
198
|
+
// ignores lastIndex — so count the same id across several files and assert
|
|
199
|
+
// none of them are silently skipped.
|
|
200
|
+
const files = ['a.md', 'b.md', 'c.md', 'd.md'];
|
|
201
|
+
const observed = check.observe({
|
|
202
|
+
files,
|
|
203
|
+
readFile: () => 'GDS-V3 GDS-V3 GDS-V4',
|
|
204
|
+
aliasMounts: ['/api/gds'],
|
|
205
|
+
});
|
|
206
|
+
assert.equal(observed.versionIdCounts['GDS-V3'], 8, 'two per file across four files');
|
|
207
|
+
assert.equal(observed.versionIdCounts['GDS-V4'], 4);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('observe skips a binary file instead of guessing at its contents', () => {
|
|
211
|
+
const observed = check.observe({
|
|
212
|
+
files: ['docs/adr/logo.png'],
|
|
213
|
+
readFile: () => `${String.fromCharCode(0)}${LITERAL}`,
|
|
214
|
+
aliasMounts: ['/api/gds'],
|
|
215
|
+
});
|
|
216
|
+
assert.deepEqual(observed.frozenLiteralCounts, {});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ---------- the committed baseline, and today's tree ----------
|
|
220
|
+
|
|
221
|
+
test('the committed baseline pins the two migrations that literally carry the old name', () => {
|
|
222
|
+
const baseline = check.loadBaseline();
|
|
223
|
+
for (const p of [
|
|
224
|
+
'migrations/019_rename_pms_v3_to_gds_v3.sql',
|
|
225
|
+
'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql',
|
|
226
|
+
]) {
|
|
227
|
+
assert.ok(baseline.migrationFiles.includes(p), `baseline must pin ${p} — it sits directly in a codemod's path`);
|
|
228
|
+
}
|
|
229
|
+
assert.ok(baseline.migrationFiles.length > 100, 'every migration filename is pinned, not a sample');
|
|
230
|
+
assert.ok(Object.keys(baseline.frozenLiteralFloors).length > 100, 'the frozen record is pinned per file');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('today’s tree passes — the cheap half, asserted last', () => {
|
|
234
|
+
const report = check.runAll({ observed: check.observe(), baseline: check.loadBaseline() });
|
|
235
|
+
assert.equal(report.ok, true, check.describe(report).join('\n'));
|
|
236
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// tests/version_build_slot.mjs — exactly one version builds at a time
|
|
2
|
+
// (BV1.R19, task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9).
|
|
3
|
+
//
|
|
4
|
+
// THE INVARIANT EVERY OTHER RULE IN THIS GOAL STANDS ON. R03 refuses a goal on a
|
|
5
|
+
// version that is not `planning`, which presumes "the version that is building"
|
|
6
|
+
// names ONE row. With two, it does not: `currentBuildingVersionId` resolves the
|
|
7
|
+
// ambiguity with `ORDER BY started_at DESC ... LIMIT 1`, so it picks one and says
|
|
8
|
+
// nothing, and R03's gate silently has two answers depending on which row won.
|
|
9
|
+
// The live instance is in exactly that state today (BONGOS-V1 and CB-V1 both
|
|
10
|
+
// `building`) — task 1003614 (R27) resolves it; this file pins that a third can
|
|
11
|
+
// never be created.
|
|
12
|
+
//
|
|
13
|
+
// WHY THE PREDICATE IS TESTED, NOT THE ROUTE. authorizeVersionBuild is pure — no
|
|
14
|
+
// express, no DB, no pool — so its whole truth table runs here, including the
|
|
15
|
+
// shapes a live-Postgres test would never bother to set up: a null in the rows, a
|
|
16
|
+
// non-array, an absent argument. The ROUTE's job is only to gather the facts and
|
|
17
|
+
// render the verdict verbatim, and the source assertions at the bottom pin that
|
|
18
|
+
// it does exactly that and nothing else. Inlining the rule in the handler would
|
|
19
|
+
// leave it testable only by string-matching the source, which passes just as
|
|
20
|
+
// happily on a wrong branch.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/version_build_slot.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
|
|
29
|
+
process.env.NODE_ENV = 'test';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { authorizeVersionBuild, authorizeVersionCreate } =
|
|
32
|
+
require('../modules/lifecycle/routes/version-route-authz.js');
|
|
33
|
+
|
|
34
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
35
|
+
const ROUTES = src('modules/lifecycle/routes/versions.js');
|
|
36
|
+
const MIGRATION = src('migrations/core_233_versions_one_planning_idx.sql');
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// The rule
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
test('an empty building slot allows the version', () => {
|
|
43
|
+
assert.deepEqual(authorizeVersionBuild({ building: [] }), { ok: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('no argument at all is allowed — the default is "nothing is building"', () => {
|
|
47
|
+
assert.deepEqual(authorizeVersionBuild({}), { ok: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('one building version refuses, with a named 409', () => {
|
|
51
|
+
const v = authorizeVersionBuild({ building: [{ id: 'BONGOS-V1', name: 'Cloud Bongos — platform (V1)' }] });
|
|
52
|
+
assert.equal(v.ok, false);
|
|
53
|
+
assert.equal(v.status, 409);
|
|
54
|
+
assert.equal(v.body.error, 'building_version_exists');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the refusal NAMES the version holding the slot, in the message and the payload', () => {
|
|
58
|
+
const v = authorizeVersionBuild({ building: [{ id: 'BONGOS-V1', name: 'platform' }] });
|
|
59
|
+
// The whole reason the rows are passed instead of a boolean: a caller told only
|
|
60
|
+
// "no" has to go look up which version to close, and a caller who looks it up
|
|
61
|
+
// is one step from cutting a version to get around the gate anyway.
|
|
62
|
+
assert.match(v.body.message, /BONGOS-V1/);
|
|
63
|
+
assert.deepEqual(v.body.building, [{ id: 'BONGOS-V1', name: 'platform' }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the live two-building-version violation refuses, and names the first', () => {
|
|
67
|
+
const v = authorizeVersionBuild({
|
|
68
|
+
building: [{ id: 'BONGOS-V1', name: 'platform' }, { id: 'CB-V1', name: 'dogfood' }],
|
|
69
|
+
});
|
|
70
|
+
assert.equal(v.ok, false);
|
|
71
|
+
assert.equal(v.body.building.length, 2);
|
|
72
|
+
assert.match(v.body.message, /BONGOS-V1/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Shapes a live-Postgres test would not set up
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
test('nulls in the rows do not count as a version', () => {
|
|
80
|
+
assert.deepEqual(authorizeVersionBuild({ building: [null, undefined] }), { ok: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a non-array building is treated as empty, not as truthy', () => {
|
|
84
|
+
// A caller that hands over a bare count, or a bad DB read that returns an
|
|
85
|
+
// object, must not be able to LOOSEN the rule by accident — but neither should
|
|
86
|
+
// it hard-refuse a legitimate create. Both degrade to "nothing is building",
|
|
87
|
+
// which is the state the pre-check will then re-derive from the real table.
|
|
88
|
+
for (const bad of [1, 'yes', {}, null, undefined]) {
|
|
89
|
+
assert.deepEqual(authorizeVersionBuild({ building: bad }), { ok: true }, `building=${JSON.stringify(bad)}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('the two slot rules are independent — building says nothing about planning', () => {
|
|
94
|
+
// Guards against a future refactor collapsing them into one status-branching
|
|
95
|
+
// function: the close flow (task 1003607, R20) promotes a version to `building`
|
|
96
|
+
// with no caller-supplied status to branch on.
|
|
97
|
+
assert.deepEqual(authorizeVersionBuild({ building: [] }), { ok: true });
|
|
98
|
+
assert.equal(authorizeVersionCreate({ status: 'planning', planning: [{ id: 'X' }] }).ok, false);
|
|
99
|
+
assert.equal(authorizeVersionCreate({ status: 'building', planning: [{ id: 'X' }] }).ok, true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// The wiring: the route gathers the facts and renders the verdict
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
test('POST /versions consults the building slot, and only when status is building', () => {
|
|
107
|
+
assert.match(ROUTES, /authorizeVersionBuild\(\{/);
|
|
108
|
+
// The read is CONDITIONAL on the requested status, mirroring the planning one:
|
|
109
|
+
// creating a `shipped` or `frozen` row is archival bookkeeping and must not pay
|
|
110
|
+
// for a query, nor be refused by a rule about a slot it is not entering.
|
|
111
|
+
assert.match(ROUTES, /status === 'building' \? await db\.versionsWithStatus\('building'\) : \[\]/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('the route renders the refusal verbatim — it does not restate the rule', () => {
|
|
115
|
+
assert.match(ROUTES, /if \(!buildDecision\.ok\) \{\s*return res\.status\(buildDecision\.status\)\.json\(buildDecision\.body\);/);
|
|
116
|
+
// No second copy of the message anywhere in the route.
|
|
117
|
+
assert.equal(ROUTES.includes('One version builds at a time'), false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a 23505 on the planning index renders as the planning rule, not "id taken"', () => {
|
|
121
|
+
// The race-loser's pre-check passed because the winner had not committed. If
|
|
122
|
+
// this fell through to `version_exists` the caller would be told to rename a
|
|
123
|
+
// version that was never the problem, and would rename and retry forever.
|
|
124
|
+
assert.match(ROUTES, /err\.constraint\s*\|\|\s*''\)\s*===\s*'versions_one_planning_idx'/);
|
|
125
|
+
// And it renders that refusal BY RE-RUNNING THE GATE, never by writing the
|
|
126
|
+
// wording a second time — tests/one_planning_version.mjs pins the absence of
|
|
127
|
+
// the literal `planning_version_exists` in this handler, and an earlier draft
|
|
128
|
+
// of this very test asserted its presence, which is how the two suites caught
|
|
129
|
+
// each other. The gate owns the words; the route owns only the plumbing.
|
|
130
|
+
assert.match(ROUTES, /const raced = authorizeVersionCreate\(\{/);
|
|
131
|
+
assert.match(ROUTES, /return res\.status\(raced\.status\)\.json\(raced\.body\);/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// The migration
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
test('core_233 creates the planning index and NOT the building one', () => {
|
|
139
|
+
assert.match(MIGRATION, /CREATE UNIQUE INDEX IF NOT EXISTS versions_one_planning_idx/);
|
|
140
|
+
assert.match(MIGRATION, /WHERE status = 'planning'/);
|
|
141
|
+
// The deliberate omission ADR 0263 §9 asked for and the live data forbids. If a
|
|
142
|
+
// later edit adds it here, this test is the reminder that the migration will
|
|
143
|
+
// fail on any instance with two building versions — and that a skipped-with-a-
|
|
144
|
+
// NOTICE variant marks itself applied and never arms.
|
|
145
|
+
assert.equal(MIGRATION.includes('versions_one_building_idx'), false,
|
|
146
|
+
'the building index ships with R27 (task 1003614), in the migration that makes it legal');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('the migration records WHY the building index is not here', () => {
|
|
150
|
+
// The reasoning is the artifact: without it the next reader files this as an
|
|
151
|
+
// oversight and "fixes" it into a wedged deploy.
|
|
152
|
+
assert.match(MIGRATION, /1003614/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('the route points at the same reason', () => {
|
|
156
|
+
assert.match(ROUTES, /1003614/);
|
|
157
|
+
});
|