@bongos/core 1.19.606 → 1.19.608
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/gds-literal-scan.js +6 -0
- package/scripts/gds/prove-strict-versioning.js +284 -0
- package/scripts/gds/rename-ledger-prose.js +482 -0
- package/src/module-api.js +1 -1
- package/tests/rename_ledger_prose.mjs +192 -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.608",
|
|
6
|
+
"core_contract": "1.19.608",
|
|
7
|
+
"source_commit": "f112591192f3c6ab69e73f3703bd2f2e2f6f746e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-09T00:10:53.531Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 458,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2080,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2562,
|
|
20
|
+
"tree_sha256": "53d8a68171007b0b61fd34bff5aaf036110d1cb584228a250f606a4300cabf7d",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2732,7 +2732,7 @@
|
|
|
2732
2732
|
{
|
|
2733
2733
|
"path": "docs/module-api-changelog.md",
|
|
2734
2734
|
"mode": "0000644",
|
|
2735
|
-
"sha256": "
|
|
2735
|
+
"sha256": "afddf5268d6326d2d128069afcb0e0ed2bc06cd6040bfa7e7f8531227a9ca891"
|
|
2736
2736
|
},
|
|
2737
2737
|
{
|
|
2738
2738
|
"path": "docs/modules-contract.md",
|
|
@@ -7632,12 +7632,12 @@
|
|
|
7632
7632
|
{
|
|
7633
7633
|
"path": "package-lock.json",
|
|
7634
7634
|
"mode": "0000644",
|
|
7635
|
-
"sha256": "
|
|
7635
|
+
"sha256": "021b578efabb07b661729dd8120695a9ff976b409e355f1ffd35e3c1f3f22a40"
|
|
7636
7636
|
},
|
|
7637
7637
|
{
|
|
7638
7638
|
"path": "package.json",
|
|
7639
7639
|
"mode": "0000644",
|
|
7640
|
-
"sha256": "
|
|
7640
|
+
"sha256": "22dc9b83baf7d5765ca7ab8ec400e15e0ac5ada03b98895c33f9bd5d4dc0cc62"
|
|
7641
7641
|
},
|
|
7642
7642
|
{
|
|
7643
7643
|
"path": "public-docs/index.html",
|
|
@@ -8117,7 +8117,7 @@
|
|
|
8117
8117
|
{
|
|
8118
8118
|
"path": "scripts/gds/gds-literal-scan.js",
|
|
8119
8119
|
"mode": "0000644",
|
|
8120
|
-
"sha256": "
|
|
8120
|
+
"sha256": "30a05f9d8d476e83740f0630e17d1accc3e29d67cebfc74d19abfeae6608e514"
|
|
8121
8121
|
},
|
|
8122
8122
|
{
|
|
8123
8123
|
"path": "scripts/gds/gen-api-client.js",
|
|
@@ -8399,6 +8399,11 @@
|
|
|
8399
8399
|
"mode": "0000644",
|
|
8400
8400
|
"sha256": "9898b6f8e60b9f5ee4aeb9a023d1b68bb5bfbdf589eed10923a8e492716340ed"
|
|
8401
8401
|
},
|
|
8402
|
+
{
|
|
8403
|
+
"path": "scripts/gds/prove-strict-versioning.js",
|
|
8404
|
+
"mode": "0000644",
|
|
8405
|
+
"sha256": "dbaec451d7e67cfce811287ebb2289582ec47d73b1fbff92125e7991b734c807"
|
|
8406
|
+
},
|
|
8402
8407
|
{
|
|
8403
8408
|
"path": "scripts/gds/provision-config.js",
|
|
8404
8409
|
"mode": "0000644",
|
|
@@ -8489,6 +8494,11 @@
|
|
|
8489
8494
|
"mode": "0000644",
|
|
8490
8495
|
"sha256": "06ad6a69304cfc66509f4ab283e7f76196ac7edcac97f8eba55ea3ab92c3eecb"
|
|
8491
8496
|
},
|
|
8497
|
+
{
|
|
8498
|
+
"path": "scripts/gds/rename-ledger-prose.js",
|
|
8499
|
+
"mode": "0000644",
|
|
8500
|
+
"sha256": "f73e495ab63fbca755e652d284e378d9b2afef67fe43c4c5a66e2962c92cef1d"
|
|
8501
|
+
},
|
|
8492
8502
|
{
|
|
8493
8503
|
"path": "scripts/gds/render-ideas.js",
|
|
8494
8504
|
"mode": "0000644",
|
|
@@ -9352,7 +9362,7 @@
|
|
|
9352
9362
|
{
|
|
9353
9363
|
"path": "src/module-api.js",
|
|
9354
9364
|
"mode": "0000644",
|
|
9355
|
-
"sha256": "
|
|
9365
|
+
"sha256": "d5e97cf6a460b1d69da907cfdfa0acf06bbb010067bf23f07de1940dcf36336a"
|
|
9356
9366
|
},
|
|
9357
9367
|
{
|
|
9358
9368
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12009,6 +12019,11 @@
|
|
|
12009
12019
|
"mode": "0000644",
|
|
12010
12020
|
"sha256": "2cbc4981c0f33a6597445f268055d10107b6696be4de42e5790e7efefc087edd"
|
|
12011
12021
|
},
|
|
12022
|
+
{
|
|
12023
|
+
"path": "tests/rename_ledger_prose.mjs",
|
|
12024
|
+
"mode": "0000644",
|
|
12025
|
+
"sha256": "39eaa32121eae07fa7f508d60324e896e4c1b98c53ff390bb8bdca4b952c874f"
|
|
12026
|
+
},
|
|
12012
12027
|
{
|
|
12013
12028
|
"path": "tests/render_prefs.mjs",
|
|
12014
12029
|
"mode": "0000644",
|
|
@@ -1661,5 +1661,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1661
1661
|
landed since 1.19.604 with no explicit bump. run 34290892691. (task 1002620)
|
|
1662
1662
|
1.19.606 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1663
1663
|
landed since 1.19.605 with no explicit bump. run 34291868670. (task 1002620)
|
|
1664
|
+
1.19.607 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1665
|
+
landed since 1.19.606 with no explicit bump. run 34292436274. (task 1002620)
|
|
1666
|
+
1.19.608 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1667
|
+
landed since 1.19.607 with no explicit bump. run 34293848928. (task 1002620)
|
|
1664
1668
|
---------------------------------------------------------------------------
|
|
1665
1669
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.608",
|
|
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.608",
|
|
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.608",
|
|
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",
|
|
@@ -76,6 +76,12 @@ const GDS_RULE_FILES = new Set([
|
|
|
76
76
|
'scripts/gds/rename-history-check.js',
|
|
77
77
|
'tests/rename_history_restraint.mjs',
|
|
78
78
|
'config/rename-history-baseline.json',
|
|
79
|
+
// The LEDGER pass (task 1003698, criterion C4). It has to spell out every
|
|
80
|
+
// identifier it protects and every phrase shape it rewrites, so it names the
|
|
81
|
+
// literal more often than any other file in the tree — and its test names it
|
|
82
|
+
// once per case. Same exemption, same reason.
|
|
83
|
+
'scripts/gds/rename-ledger-prose.js',
|
|
84
|
+
'tests/rename_ledger_prose.mjs',
|
|
79
85
|
]);
|
|
80
86
|
|
|
81
87
|
// A version id is a row key, not vocabulary. Stripped before matching so it is
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/prove-strict-versioning.js — does strict versioning actually hold
|
|
3
|
+
// on THIS instance? (BV1.R30a, task 1003716, goal 1000086; the harness BV1.R30
|
|
4
|
+
// runs. ADR 0250, ADR 0263, ADR 0265.)
|
|
5
|
+
//
|
|
6
|
+
// ADR 0250 built five rules and ADR 0265 added a sixth, all enforced server-side.
|
|
7
|
+
// Nothing could tell you whether they HOLD on a given instance — R30's brief said
|
|
8
|
+
// "run each query against live and capture its output verbatim", which in practice
|
|
9
|
+
// meant hand-assembling a dozen API calls and reading the results by eye. That is
|
|
10
|
+
// how a proof ends up asserting what someone hoped rather than what the instance
|
|
11
|
+
// returned.
|
|
12
|
+
//
|
|
13
|
+
// THE CORE SHIPS TO ANY INSTANCE, so this is not a one-off for the BONGOS-V1
|
|
14
|
+
// cutover. "Is my scope actually closed?" is a question every instance will ask,
|
|
15
|
+
// and its answer should not depend on someone reproducing a call sequence.
|
|
16
|
+
//
|
|
17
|
+
// Two halves, and the split is deliberate:
|
|
18
|
+
//
|
|
19
|
+
// CHECKS are read-only and always run. They count the live rows and say what
|
|
20
|
+
// they found, whether or not it is what the rules promise.
|
|
21
|
+
//
|
|
22
|
+
// PROBES (--probe) attempt writes that MUST be refused. Each one is chosen so a
|
|
23
|
+
// refusal is the only legal outcome, and it is SKIPPED WITH A STATED REASON when
|
|
24
|
+
// the preconditions for a guaranteed refusal are absent — a probe that could
|
|
25
|
+
// succeed is a mutation, not a proof. Each also RE-READS afterwards: a 409 that
|
|
26
|
+
// nevertheless mutated is the failure mode worth catching, and no amount of
|
|
27
|
+
// reading the status code finds it.
|
|
28
|
+
//
|
|
29
|
+
// Exit code is non-zero if any check fails, so this can become a gate.
|
|
30
|
+
//
|
|
31
|
+
// Usage:
|
|
32
|
+
// node scripts/gds/prove-strict-versioning.js [--probe] [--json]
|
|
33
|
+
// CLOUDBONGOS_API_BASE=https://cloudbongos.com node scripts/gds/prove-strict-versioning.js --probe
|
|
34
|
+
|
|
35
|
+
// apiCall is the ONE HTTP client for this directory: it resolves the instance
|
|
36
|
+
// base and bearer, returns { status, ok, data } WITHOUT throwing on an HTTP
|
|
37
|
+
// status (exactly what a refusal probe needs), and routes through the
|
|
38
|
+
// no-keep-alive dispatcher — a bare fetch() leaves sockets armed and turns a
|
|
39
|
+
// natural exit into a hang.
|
|
40
|
+
const { apiCall, hasFlag, cliExit, API_BASE } = require('./cli-lib');
|
|
41
|
+
|
|
42
|
+
// The statuses that mean a task is FINISHED — one vocabulary, declared once
|
|
43
|
+
// (BV1.R01, task 1003588). Everything else holds its goal, and therefore its
|
|
44
|
+
// version, open. Read from the module rather than re-listed here so this file
|
|
45
|
+
// cannot drift from the rule it is checking.
|
|
46
|
+
const { TERMINAL_STATUSES } = require('../../modules/lifecycle/task-status');
|
|
47
|
+
const NON_TERMINAL = ['backlog', 'ready', 'active', 'blocked', 'completed', 'confirmed']
|
|
48
|
+
.filter((s) => !TERMINAL_STATUSES.includes(s));
|
|
49
|
+
|
|
50
|
+
// A goal that is no longer open must hold no unfinished work (ADR 0250 D2/D3).
|
|
51
|
+
const CLOSED_GOAL_STATUSES = ['achieved', 'archived'];
|
|
52
|
+
|
|
53
|
+
// A version's catch-all goal, by the two shapes the codebase has used. The
|
|
54
|
+
// em-dash form is what `generalGoalTitle` matches; "Work with no goal yet" is
|
|
55
|
+
// BONGOS-V1's, and the mismatch between them is exactly why 47 tasks went
|
|
56
|
+
// homeless unnoticed (R31, task 1003711). Match BOTH, or this proof inherits the
|
|
57
|
+
// blind spot it exists to close.
|
|
58
|
+
const CATCH_ALL = (title, versionId) =>
|
|
59
|
+
title === `${versionId} — general` || /^work with no goal yet$/i.test(String(title || '').trim());
|
|
60
|
+
|
|
61
|
+
// The API returns a refusal code in two shapes depending on which `res.fail`
|
|
62
|
+
// overload the route used — a bare string, or an object carrying `.code`/`.error`.
|
|
63
|
+
// Rendering the object straight gives `[object Object]`, which tells a reader
|
|
64
|
+
// nothing about WHICH rule fired. Normalise both to the code.
|
|
65
|
+
function errorCode(body) {
|
|
66
|
+
const e = body && body.error;
|
|
67
|
+
if (!e) return null;
|
|
68
|
+
if (typeof e === 'string') return e;
|
|
69
|
+
return e.code || e.error || e.message || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** GET a Bongos API path, returning the parsed body. Throws on a non-2xx. */
|
|
73
|
+
async function get(path) {
|
|
74
|
+
const r = await apiCall('GET', `/api/gds${path}`);
|
|
75
|
+
if (!r.ok) throw new Error(`GET ${path} → ${r.status}`);
|
|
76
|
+
return r.data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** POST a probe. Returns the status WITHOUT throwing — the status IS the proof. */
|
|
80
|
+
async function probePost(path, body) {
|
|
81
|
+
const r = await apiCall('POST', `/api/gds${path}`, body || {});
|
|
82
|
+
return { status: r.status, body: r.data, why: errorCode(r.data) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every non-terminal task on the instance, one status at a time (the list route filters by one). */
|
|
86
|
+
async function allNonTerminalTasks() {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const status of NON_TERMINAL) {
|
|
89
|
+
const j = await get(`/tasks?status=${encodeURIComponent(status)}&limit=1000`);
|
|
90
|
+
out.push(...(j.tasks || []));
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const results = [];
|
|
96
|
+
/** Record one check. `ok` false makes the whole run exit non-zero. */
|
|
97
|
+
function record(id, ok, headline, detail) {
|
|
98
|
+
results.push({ id, ok, headline, detail: detail || null });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function runChecks() {
|
|
102
|
+
const [versionsRes, goalsRes, tasks] = await Promise.all([
|
|
103
|
+
get('/versions'), get('/goals'), allNonTerminalTasks(),
|
|
104
|
+
]);
|
|
105
|
+
const versions = versionsRes.versions || [];
|
|
106
|
+
const goals = goalsRes.goals || [];
|
|
107
|
+
const byGoal = new Map(goals.map((g) => [String(g.id), g]));
|
|
108
|
+
const byVersion = new Map(versions.map((v) => [String(v.id), v]));
|
|
109
|
+
|
|
110
|
+
// C1 — a closed goal holding unfinished work (ADR 0250 D2/D3).
|
|
111
|
+
const c1 = tasks.filter((t) => {
|
|
112
|
+
const g = t.goal_id == null ? null : byGoal.get(String(t.goal_id));
|
|
113
|
+
return g && CLOSED_GOAL_STATUSES.includes(g.status);
|
|
114
|
+
});
|
|
115
|
+
const c1Goals = [...new Set(c1.map((t) => String(t.goal_id)))];
|
|
116
|
+
record('C1', c1.length === 0,
|
|
117
|
+
`${c1Goals.length} closed goal(s) holding ${c1.length} unfinished task(s)`,
|
|
118
|
+
c1Goals.map((id) => `goal ${id} (${byGoal.get(id).status}) holds ${c1.filter((t) => String(t.goal_id) === id).length}`));
|
|
119
|
+
|
|
120
|
+
// C2 — every task has a real goal (ADR 0250 D4). The two populations are
|
|
121
|
+
// reported apart because they fail for different reasons: a catch-all is a
|
|
122
|
+
// goal nobody chose, a null is a goal nobody assigned, and only the first is
|
|
123
|
+
// visible to anything that reasons per goal.
|
|
124
|
+
const nullGoal = tasks.filter((t) => t.goal_id == null);
|
|
125
|
+
const inCatchAll = tasks.filter((t) => {
|
|
126
|
+
const g = t.goal_id == null ? null : byGoal.get(String(t.goal_id));
|
|
127
|
+
return g && CATCH_ALL(g.title, g.version_id);
|
|
128
|
+
});
|
|
129
|
+
record('C2', nullGoal.length === 0 && inCatchAll.length === 0,
|
|
130
|
+
`${nullGoal.length} task(s) with no goal, ${inCatchAll.length} in a catch-all goal`,
|
|
131
|
+
[`null goal_id: ${nullGoal.length}`, `catch-all: ${inCatchAll.length}`,
|
|
132
|
+
...[...new Set(inCatchAll.map((t) => String(t.goal_id)))].map((id) => ` catch-all goal ${id} — ${byGoal.get(id).title}`)]);
|
|
133
|
+
|
|
134
|
+
// C3 — one version builds, at most one plans (ADR 0250 D5; R04 + R19).
|
|
135
|
+
const building = versions.filter((v) => v.status === 'building');
|
|
136
|
+
const planning = versions.filter((v) => v.status === 'planning');
|
|
137
|
+
record('C3', building.length === 1 && planning.length <= 1,
|
|
138
|
+
`${building.length} building, ${planning.length} planning`,
|
|
139
|
+
[`building: ${building.map((v) => v.id).join(', ') || '(none)'}`,
|
|
140
|
+
`planning: ${planning.map((v) => v.id).join(', ') || '(none)'}`]);
|
|
141
|
+
|
|
142
|
+
// C4 — the next version is scoped: every goal on the planning version carries
|
|
143
|
+
// at least one done-when criterion (R29b). A criterion-less goal can never
|
|
144
|
+
// achieve, so it entombs its version the way the catch-alls entombed V1.
|
|
145
|
+
if (planning.length === 0) {
|
|
146
|
+
record('C4', true, 'no planning version — nothing to scope yet (not a failure)', null);
|
|
147
|
+
} else {
|
|
148
|
+
const pv = planning[0];
|
|
149
|
+
const pvGoals = goals.filter((g) => String(g.version_id) === String(pv.id) && g.status === 'open');
|
|
150
|
+
const detail = [];
|
|
151
|
+
let missing = 0;
|
|
152
|
+
for (const g of pvGoals) {
|
|
153
|
+
const full = await get(`/goals/${g.id}`);
|
|
154
|
+
const n = (full.criteria || []).length;
|
|
155
|
+
if (n === 0) { missing++; detail.push(`goal ${g.id} — NO criteria — ${g.title}`); }
|
|
156
|
+
}
|
|
157
|
+
record('C4', missing === 0,
|
|
158
|
+
`${pv.id}: ${pvGoals.length - missing}/${pvGoals.length} goal(s) carry a done-when criterion`, detail);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// C5 — beyond R30's original four. R32 (task 1003713) made a close refuse to
|
|
162
|
+
// leave work behind; this is the same rule read from the other end, and it
|
|
163
|
+
// catches what closed BEFORE that rule existed.
|
|
164
|
+
const onShipped = tasks.filter((t) => {
|
|
165
|
+
const v = byVersion.get(String(t.version_id));
|
|
166
|
+
return v && v.status === 'shipped';
|
|
167
|
+
});
|
|
168
|
+
record('C5', onShipped.length === 0,
|
|
169
|
+
`${onShipped.length} unfinished task(s) stranded on a shipped version`,
|
|
170
|
+
onShipped.slice(0, 20).map((t) => `task ${t.id} (${t.status}) on ${t.version_id}`));
|
|
171
|
+
|
|
172
|
+
// C6 — a task and its goal must be on the SAME version. `PATCH /tasks/:id`
|
|
173
|
+
// refuses a cross-version link with 409 `goal_version_mismatch`, but nothing
|
|
174
|
+
// has ever checked the rows that predate that rule — and the hierarchy is
|
|
175
|
+
// version → goal → task, so a split row is counted by one version's rollup
|
|
176
|
+
// while appearing under the other version's goal. It is also how C5 and a
|
|
177
|
+
// by-goal count can honestly disagree about the same task, which is exactly
|
|
178
|
+
// how this check got written.
|
|
179
|
+
const split = tasks.filter((t) => {
|
|
180
|
+
const g = t.goal_id == null ? null : byGoal.get(String(t.goal_id));
|
|
181
|
+
return g && String(g.version_id) !== String(t.version_id);
|
|
182
|
+
});
|
|
183
|
+
record('C6', split.length === 0,
|
|
184
|
+
`${split.length} task(s) whose goal lives on a different version`,
|
|
185
|
+
split.slice(0, 20).map((t) => `task ${t.id} on ${t.version_id}, but goal ${t.goal_id} is on ${byGoal.get(String(t.goal_id)).version_id}`));
|
|
186
|
+
|
|
187
|
+
return { versions, goals, tasks, building, planning, byGoal };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runProbes(ctx) {
|
|
191
|
+
const { goals, tasks, building, byGoal } = ctx;
|
|
192
|
+
const out = [];
|
|
193
|
+
/** A probe is only a proof when refusal is the sole legal outcome. */
|
|
194
|
+
const skip = (id, why) => out.push({ id, skipped: true, why });
|
|
195
|
+
|
|
196
|
+
// P1 — R03: a goal cannot join a version that is already building.
|
|
197
|
+
if (building.length === 0) {
|
|
198
|
+
skip('P1', 'no building version, so a refusal is not guaranteed');
|
|
199
|
+
} else {
|
|
200
|
+
const before = (await get('/goals')).goals.length;
|
|
201
|
+
const r = await probePost('/goals', { version_id: building[0].id, title: '__probe__ R03 refusal — must never be created' });
|
|
202
|
+
const after = (await get('/goals')).goals.length;
|
|
203
|
+
out.push({ absent: r.status === 404, id: 'P1', rule: 'R03 — goal onto a building version', status: r.status,
|
|
204
|
+
refused: r.status === 409, error: r.why, mutated: after !== before });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// P2 — R10: archive refuses without a disposition. Aimed at a goal VERIFIED to
|
|
208
|
+
// hold open tasks, because a goal holding none archives legitimately — that is
|
|
209
|
+
// the one shape of this probe that would destroy something.
|
|
210
|
+
const held = new Map();
|
|
211
|
+
for (const t of tasks) if (t.goal_id != null) held.set(String(t.goal_id), (held.get(String(t.goal_id)) || 0) + 1);
|
|
212
|
+
const target = goals.find((g) => g.status === 'open' && (held.get(String(g.id)) || 0) > 0);
|
|
213
|
+
if (!target) {
|
|
214
|
+
skip('P2', 'no open goal holds unfinished tasks, so a bare archive could succeed');
|
|
215
|
+
} else {
|
|
216
|
+
const r = await probePost(`/goals/${target.id}/archive`, {});
|
|
217
|
+
const after = (await get(`/goals/${target.id}`)).goal;
|
|
218
|
+
out.push({ absent: r.status === 404, id: 'P2', rule: `R10 — bare archive of goal ${target.id} (holds ${held.get(String(target.id))})`,
|
|
219
|
+
status: r.status, refused: r.status === 409, error: r.why, mutated: after.status !== 'open' });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// P3 — R12: a version holding open goals refuses a close with no dispositions.
|
|
223
|
+
if (building.length === 0) {
|
|
224
|
+
skip('P3', 'no building version to close');
|
|
225
|
+
} else {
|
|
226
|
+
const v = building[0];
|
|
227
|
+
const openGoals = goals.filter((g) => String(g.version_id) === String(v.id) && g.status === 'open');
|
|
228
|
+
if (openGoals.length === 0) {
|
|
229
|
+
skip('P3', `${v.id} holds no open goals, so a bare close could succeed`);
|
|
230
|
+
} else {
|
|
231
|
+
const r = await probePost(`/versions/${v.id}/close`, {});
|
|
232
|
+
const after = (await get('/versions')).versions.find((x) => String(x.id) === String(v.id));
|
|
233
|
+
out.push({ absent: r.status === 404, id: 'P3', rule: `R12 — bare close of ${v.id} (holds ${openGoals.length} open goals)`,
|
|
234
|
+
status: r.status, refused: r.status === 409, error: r.why, mutated: after.status !== 'building' });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
(async () => {
|
|
241
|
+
const base = API_BASE;
|
|
242
|
+
const wantProbe = hasFlag('--probe');
|
|
243
|
+
const asJson = hasFlag('--json');
|
|
244
|
+
|
|
245
|
+
const ctx = await runChecks();
|
|
246
|
+
const probes = wantProbe ? await runProbes(ctx) : [];
|
|
247
|
+
|
|
248
|
+
if (asJson) {
|
|
249
|
+
console.log(JSON.stringify({ instance: base, at: new Date().toISOString(), checks: results, probes }, null, 2));
|
|
250
|
+
} else {
|
|
251
|
+
console.log(`\nStrict-versioning proof — ${base}`);
|
|
252
|
+
console.log(` ${new Date().toISOString()}\n`);
|
|
253
|
+
for (const r of results) {
|
|
254
|
+
console.log(` ${r.ok ? 'HOLDS ' : 'FAILS '} ${r.id} ${r.headline}`);
|
|
255
|
+
if (r.detail) for (const d of r.detail) console.log(` ${d}`);
|
|
256
|
+
}
|
|
257
|
+
if (wantProbe) {
|
|
258
|
+
console.log('\n Refusal probes (each write below MUST be refused):');
|
|
259
|
+
for (const p of probes) {
|
|
260
|
+
if (p.skipped) { console.log(` SKIP ${p.id} ${p.why}`); continue; }
|
|
261
|
+
const verdict = p.absent ? 'ABSENT ' : (p.refused && !p.mutated ? 'REFUSED' : 'LEAKED ');
|
|
262
|
+
console.log(` ${verdict} ${p.id} ${p.rule} → ${p.status}${p.error ? ` ${p.error}` : ''}`);
|
|
263
|
+
if (p.absent) console.log(' the route does not exist on this instance — the rule is built but NOT DEPLOYED here.');
|
|
264
|
+
if (p.mutated) console.log(' ⚠ THE STATE CHANGED ANYWAY — the refusal is cosmetic.');
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
console.log('\n (read-only; pass --probe to also attempt the three refusals)');
|
|
268
|
+
}
|
|
269
|
+
console.log('');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const failedChecks = results.filter((r) => !r.ok).length;
|
|
273
|
+
// A 404 is NOT a leak — the rule simply is not on this instance yet. Counting it
|
|
274
|
+
// as a failure would make the harness cry wolf on every instance running an
|
|
275
|
+
// older core, and the honest signal ("built, not deployed") is already printed.
|
|
276
|
+
const leakedProbes = probes.filter((p) => !p.skipped && !p.absent && (!p.refused || p.mutated)).length;
|
|
277
|
+
// cliExit, never process.exit — a hard exit while the request machinery is still
|
|
278
|
+
// tearing down aborts natively on Windows/Node 24 AFTER stdout has flushed, so
|
|
279
|
+
// the run looks fine and then exits 127 (this directory's CLAUDE.md; task 1002734).
|
|
280
|
+
cliExit(failedChecks + leakedProbes > 0 ? 1 : 0);
|
|
281
|
+
})().catch((err) => {
|
|
282
|
+
console.error('prove-strict-versioning failed:', err.message);
|
|
283
|
+
cliExit(2);
|
|
284
|
+
});
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// rename-ledger-prose.js — criterion C4 of goal 1000073, "the live ledger reads
|
|
4
|
+
// Bongos" (task 1003698).
|
|
5
|
+
//
|
|
6
|
+
// WHAT IS LEFT TO RENAME, AND WHY IT IS THE LEDGER. The code and the API were
|
|
7
|
+
// renamed long ago (src/gds/ is src/bongos/, /api/bongos is canonical). What
|
|
8
|
+
// still says the old name to a live reader is the PROSE INSIDE DB ROWS — goal
|
|
9
|
+
// and task titles and descriptions, read every day in the hall and by every
|
|
10
|
+
// agent that claims work.
|
|
11
|
+
//
|
|
12
|
+
// THE ONE RULE, inherited from the goal: rename what the project says about
|
|
13
|
+
// itself GOING FORWARD; leave what it recorded about its PAST. Its enforcement
|
|
14
|
+
// is its sibling scripts/gds/rename-history-check.js (criterion C5), which
|
|
15
|
+
// fails the build if the frozen record is erased. This script is the other
|
|
16
|
+
// direction, and it is deliberately narrow about what it will touch.
|
|
17
|
+
//
|
|
18
|
+
// THREE KINDS OF OCCURRENCE, and only ONE of them is this script's business.
|
|
19
|
+
// Measured over all 3,369 task rows on 2026-09-07 — the goal's own estimate of
|
|
20
|
+
// ~119 rows was taken before this split was understood and is low by ~2.4x:
|
|
21
|
+
//
|
|
22
|
+
// 1. FROZEN ARCHIVES (157 rows). Anything whose version_id is GDS-V3 or
|
|
23
|
+
// GDS-V4. Those versions shipped; their rows are the record. Skipped by
|
|
24
|
+
// version, not by heuristic.
|
|
25
|
+
//
|
|
26
|
+
// 2. IDENTIFIERS (77 rows carry nothing else). `GDS_TOKEN`, `GDS_API_BASE`,
|
|
27
|
+
// `GDS_BRANDING_FILE`, `MEDUSA_GDS_ONLY`, the `gds_session` cookie, the
|
|
28
|
+
// `scripts/gds/` path, and `/api/gds`. These are NOT vocabulary — they are
|
|
29
|
+
// the true names of things that still exist under those names. Renaming
|
|
30
|
+
// the prose would make the ledger LIE about what the code does. The env
|
|
31
|
+
// names and paths belong to criterion C3, which renames the THING and the
|
|
32
|
+
// prose together, behind a fallback release; `/api/gds` is a permanent
|
|
33
|
+
// alias and is never renamed at all. So every one of these is masked out
|
|
34
|
+
// before substitution and restored afterwards, byte for byte.
|
|
35
|
+
//
|
|
36
|
+
// 3. VOCABULARY — the acronym used as a word: "the GDS", "GDS tasks",
|
|
37
|
+
// "GDS-internal", "the GDS DB". This, and only this, is what C4 rewrites.
|
|
38
|
+
//
|
|
39
|
+
// WHY A REVIEWED PROPOSAL AND NOT A FIND-REPLACE. "the GDS exists to support
|
|
40
|
+
// builders" must become "Bongos exists to support builders" — the article has
|
|
41
|
+
// to go — while "the GDS database" keeps it. No regex settles that reliably, so
|
|
42
|
+
// the rules below handle the shapes that recur and EXEMPT_ROWS/OVERRIDES carry
|
|
43
|
+
// the ones a rule would get wrong. The default mode prints every proposed diff
|
|
44
|
+
// and changes nothing; --apply is a separate, deliberate act.
|
|
45
|
+
//
|
|
46
|
+
// WRITES GO THROUGH THE API, NOT SQL. The builder box holds no DATABASE_URL and
|
|
47
|
+
// no psql by design, and PATCH /tasks/:id and PATCH /goals/:id are permission-
|
|
48
|
+
// checked and land every edit in audit_log. That is a better audit trail than
|
|
49
|
+
// hand-run SQL, which was the shape this task was originally filed with.
|
|
50
|
+
//
|
|
51
|
+
// Usage:
|
|
52
|
+
// node scripts/gds/rename-ledger-prose.js # dry run: every proposed diff
|
|
53
|
+
// node scripts/gds/rename-ledger-prose.js --summary # counts only
|
|
54
|
+
// node scripts/gds/rename-ledger-prose.js --json
|
|
55
|
+
// node scripts/gds/rename-ledger-prose.js --apply # write, one PATCH per row
|
|
56
|
+
|
|
57
|
+
const { execFileSync } = require('node:child_process');
|
|
58
|
+
const path = require('node:path');
|
|
59
|
+
|
|
60
|
+
// ONE detector, shared with the C2 ratchet. GDS_VERSION_ID_RE is the rule that
|
|
61
|
+
// says a version id is a row key rather than vocabulary; PINNED_VERSION_IDS in
|
|
62
|
+
// rename-history-check.js is the list those keys are pinned to. Importing both
|
|
63
|
+
// is what stops this pass and the two gates around it from disagreeing about
|
|
64
|
+
// what counts as history — a second, divergent copy is the failure the task
|
|
65
|
+
// named explicitly.
|
|
66
|
+
const { GDS_VERSION_ID_RE } = require('./gds-literal-scan.js');
|
|
67
|
+
const { PINNED_VERSION_IDS } = require('./rename-history-check.js');
|
|
68
|
+
|
|
69
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
70
|
+
|
|
71
|
+
// Versions whose rows ARE the record. Not a heuristic — a list.
|
|
72
|
+
// Derived from the C5 pin, not re-typed. If a version id is ever added to the
|
|
73
|
+
// frozen record, it becomes frozen HERE in the same commit rather than a
|
|
74
|
+
// release later.
|
|
75
|
+
const FROZEN_VERSION_IDS = new Set(PINNED_VERSION_IDS);
|
|
76
|
+
|
|
77
|
+
// Rows that STATE the rule, and so must keep naming what they retire. The
|
|
78
|
+
// ledger's analogue of GDS_RULE_FILES in gds-literal-scan.js: a goal that says
|
|
79
|
+
// "finish the GDS→Bongos rename" cannot be rewritten to say "finish the
|
|
80
|
+
// Bongos→Bongos rename" without becoming nonsense.
|
|
81
|
+
const EXEMPT_ROWS = new Set([
|
|
82
|
+
'goal:1000073', // Finish the GDS→Bongos rename — this goal
|
|
83
|
+
'goal:1000010', // "GDS core modularization (Config B)" — a shipped goal's own title
|
|
84
|
+
'task:1001933', // Part B2: rename internal gds→bongos names
|
|
85
|
+
'task:1003698', // this task
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
// ---------- the identifier mask ----------
|
|
89
|
+
|
|
90
|
+
// Longest / most specific first: GDS_API_BASE must be consumed before a bare
|
|
91
|
+
// GDS could match inside it. Each is a REAL NAME that still exists, so it is
|
|
92
|
+
// removed from the text before substitution and put back byte-for-byte after.
|
|
93
|
+
const IDENTIFIER_PATTERNS = [
|
|
94
|
+
// IMPORTED, not re-declared. The version-id rule is the one exclusion this
|
|
95
|
+
// script genuinely shares with the C2 ratchet, and two copies of it would
|
|
96
|
+
// drift the moment either side learned about a new version id. The ratchet's
|
|
97
|
+
// other exclusions are PATH-based (docs/adr, migrations) and have no meaning
|
|
98
|
+
// for a database row, so they are deliberately not pulled in here.
|
|
99
|
+
GDS_VERSION_ID_RE,
|
|
100
|
+
/\/api\/gds\b/gi, // the permanent alias (src/bongos/api-prefix.js)
|
|
101
|
+
/\bMEDUSA_GDS_[A-Z0-9_]+/g,
|
|
102
|
+
/\bGDS_[A-Z0-9_]+/g, // env names — criterion C3, with a fallback release
|
|
103
|
+
/\bOTB_GDS_[A-Z0-9_]+/g,
|
|
104
|
+
/\bgds_session\b/gi, // cookie name — C3
|
|
105
|
+
/\bgds-session(?:\.json)?\b/gi, // the CLI session file — C3
|
|
106
|
+
/\bscripts\/gds\b/gi, // paths — C3
|
|
107
|
+
/\bsrc\/gds\b/gi,
|
|
108
|
+
/\bapi\/gds\b/gi,
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const SENTINEL = 'MASK';
|
|
112
|
+
|
|
113
|
+
// maskIdentifiers — replace every real name with an opaque token, returning the
|
|
114
|
+
// masked text plus the list needed to restore it. Exported so the test can
|
|
115
|
+
// assert the round trip is lossless: an identifier that came back changed would
|
|
116
|
+
// be a silent corruption of the ledger, not a visible failure.
|
|
117
|
+
function maskIdentifiers(text) {
|
|
118
|
+
const masks = [];
|
|
119
|
+
let out = text;
|
|
120
|
+
for (const re of IDENTIFIER_PATTERNS) {
|
|
121
|
+
out = out.replace(re, (m) => {
|
|
122
|
+
masks.push(m);
|
|
123
|
+
return `${SENTINEL}${masks.length - 1}`;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { masked: out, masks };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function unmask(masked, masks) {
|
|
130
|
+
return masked.replace(/MASK(\d+)/g, (_, i) => masks[Number(i)]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------- mention vs use ----------
|
|
134
|
+
|
|
135
|
+
// THE FAILURE THIS PREVENTS, found by reading all 147 proposed diffs before
|
|
136
|
+
// applying any of them. Some rows do not USE the acronym as a name, they MENTION
|
|
137
|
+
// it as a token — and renaming a mention destroys the sentence:
|
|
138
|
+
//
|
|
139
|
+
// "GDS is a legacy term inherited from Primea" → "Bongos is a legacy term…" (now false)
|
|
140
|
+
// "the primer still says GDS (should be Bongos)" → "…says Bongos (should be Bongos)"
|
|
141
|
+
// "fails the build on a NEW literal `GDS`" → the gate's own rule, erased
|
|
142
|
+
// "records the bongos-as-GDS-successor intent" → nonsense
|
|
143
|
+
// "Nightly pg_dump of Cloud Bongos GDS Postgres" → "Cloud Bongos Bongos Postgres"
|
|
144
|
+
//
|
|
145
|
+
// This is the same principle as GDS_RULE_FILES in gds-literal-scan.js, applied
|
|
146
|
+
// to rows instead of files: text that STATES the rule has to be able to name
|
|
147
|
+
// what it retires. The bias is deliberate — a match SKIPS the row and reports
|
|
148
|
+
// it for a human, so the cost of a false positive is a row left alone and
|
|
149
|
+
// listed, never a corrupted one. Acceptance criterion 4 of the task asks for
|
|
150
|
+
// exactly that list.
|
|
151
|
+
const MENTION_MARKERS = [
|
|
152
|
+
/["'`«]\s*GDS\s*["'`»]/, // quoted or backticked: the token itself
|
|
153
|
+
/\bliteral\s+`?GDS/i,
|
|
154
|
+
/\bcalled\s+GDS\b/i,
|
|
155
|
+
/\bsays?\s+GDS\b/i,
|
|
156
|
+
/\bnamed?\s+GDS\b/i,
|
|
157
|
+
/\bterm\b[^.\n]{0,40}GDS/i,
|
|
158
|
+
/GDS[^.\n]{0,40}\blegacy\b/i,
|
|
159
|
+
/\blegacy\b[^.\n]{0,40}GDS/i,
|
|
160
|
+
/GDS\s*(?:→|->|to)\s*Bongos/i, // the rename, written out
|
|
161
|
+
/(?:PMS|Medusa)\s*(?:→|->)\s*GDS/i, // the PREVIOUS rename, which is history
|
|
162
|
+
/GDS-successor/i,
|
|
163
|
+
/\brename\b[^.\n]{0,60}GDS|GDS[^.\n]{0,60}\brename\b/i,
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
// mentionsRatherThanUses — true when the row is talking ABOUT the word.
|
|
167
|
+
function mentionsRatherThanUses(text) {
|
|
168
|
+
return MENTION_MARKERS.some((re) => re.test(text));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------- the vocabulary rules ----------
|
|
172
|
+
|
|
173
|
+
// THE ARTICLE PROBLEM, and why the default is to KEEP it.
|
|
174
|
+
//
|
|
175
|
+
// "the GDS router" must become "the Bongos router" — the acronym is modifying a
|
|
176
|
+
// noun. "the GDS knows everything" must become "Bongos knows everything" — the
|
|
177
|
+
// acronym IS the subject and the article has to go. Get it backwards in the
|
|
178
|
+
// first case and you write "mounted outside Bongos router", which is broken
|
|
179
|
+
// English; get it backwards in the second and you write "the Bongos knows
|
|
180
|
+
// everything", which merely reads oddly.
|
|
181
|
+
//
|
|
182
|
+
// So the asymmetry decides the default: KEEP the article unless the very next
|
|
183
|
+
// word is a finite verb. Dropping wrongly breaks a sentence; keeping wrongly
|
|
184
|
+
// never does. The verb list is taken from the 41 distinct "<determiner> GDS
|
|
185
|
+
// <word>" shapes actually present in the corpus, not invented.
|
|
186
|
+
const ARTICLE_DROP_VERBS = [
|
|
187
|
+
'is', 'are', 'was', 'were', 'has', 'have', 'holds', 'knows', 'pushes', 'pulls',
|
|
188
|
+
'exists', 'builds', 'says', 'records', 'enforces', 'sits', 'lives', 'treats',
|
|
189
|
+
'expects', 'refuses', 'returns', 'becomes', 'remains', 'auto-posts',
|
|
190
|
+
];
|
|
191
|
+
// The same reasoning covers what follows a bare mention: a preposition, a
|
|
192
|
+
// conjunction or an opening bracket all mean the acronym was the noun itself
|
|
193
|
+
// ("poll the GDS for the next task", "its home is the GDS (goal + task rows)").
|
|
194
|
+
const ARTICLE_DROP_FOLLOWERS = ['for', 'by', 'and', 'or', 'today', 'then', 'itself'];
|
|
195
|
+
const ARTICLE_DROP_RE = new RegExp(
|
|
196
|
+
`\\b[Tt]he\\s+GDS\\b(?=\\s+(?:${ARTICLE_DROP_VERBS.join('|')}|${ARTICLE_DROP_FOLLOWERS.join('|')})\\b|\\s*[(,.;:])`,
|
|
197
|
+
'g',
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// rewriteVocabulary — the substitution, on ALREADY-MASKED text. Pure, and
|
|
201
|
+
// exported so every rule has a test that names its own input.
|
|
202
|
+
function rewriteVocabulary(masked) {
|
|
203
|
+
let s = masked;
|
|
204
|
+
// 0. the product name already carries the word — "Cloud Bongos GDS Postgres"
|
|
205
|
+
// must not become "Cloud Bongos Bongos Postgres".
|
|
206
|
+
s = s.replace(/\bCloud Bongos GDS\b/g, 'Cloud Bongos');
|
|
207
|
+
// 1. "the GDS <verb>" — the acronym is the subject, so the article goes with
|
|
208
|
+
// the rename rather than being left stranded in front of it.
|
|
209
|
+
s = s.replace(ARTICLE_DROP_RE, 'Bongos');
|
|
210
|
+
// 2. everything else: the acronym as a name or a modifier. Any determiner in
|
|
211
|
+
// front of it is left exactly where it was ("the Bongos router").
|
|
212
|
+
s = s.replace(/\bGDS\b/g, 'Bongos');
|
|
213
|
+
return s;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// rewrite — the whole pipeline for one string: mask the real names, rename the
|
|
217
|
+
// vocabulary, restore the real names. Returns null when nothing changed.
|
|
218
|
+
function rewrite(text) {
|
|
219
|
+
if (!text || !text.includes('GDS')) return null;
|
|
220
|
+
const { masked, masks } = maskIdentifiers(text);
|
|
221
|
+
if (!masked.includes('GDS')) return null; // identifiers only — not this script's business
|
|
222
|
+
const out = unmask(rewriteVocabulary(masked), masks);
|
|
223
|
+
return out === text ? null : out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------- row selection ----------
|
|
227
|
+
|
|
228
|
+
// EDITABLE tells each kind which prose columns the API will accept, so the
|
|
229
|
+
// script cannot propose an edit the route would reject.
|
|
230
|
+
const EDITABLE = {
|
|
231
|
+
task: ['title', 'description'],
|
|
232
|
+
goal: ['title', 'subtitle', 'description'],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// proposeForRow — the pure half of selection: given one row, what would change.
|
|
236
|
+
function proposeForRow(kind, row) {
|
|
237
|
+
const key = `${kind}:${row.id}`;
|
|
238
|
+
if (EXEMPT_ROWS.has(key)) return { skipped: 'exempt', kind, id: String(row.id), key, title: row.title };
|
|
239
|
+
if (FROZEN_VERSION_IDS.has(row.version_id)) return null; // frozen archive — silent, it is the whole point
|
|
240
|
+
|
|
241
|
+
const whole = EDITABLE[kind].map((c) => row[c] || '').join('\n');
|
|
242
|
+
if (!whole.includes('GDS')) return null;
|
|
243
|
+
|
|
244
|
+
const patch = {};
|
|
245
|
+
for (const col of EDITABLE[kind]) {
|
|
246
|
+
const next = rewrite(row[col]);
|
|
247
|
+
if (next !== null) patch[col] = next;
|
|
248
|
+
}
|
|
249
|
+
if (!Object.keys(patch).length) return null; // identifiers only
|
|
250
|
+
|
|
251
|
+
// Both checks below run AFTER the patch is computed, so a row is only
|
|
252
|
+
// reported as needing a human when it would OTHERWISE have been rewritten.
|
|
253
|
+
// Order matters for honesty: testing the raw text first would flag rows whose
|
|
254
|
+
// only occurrences are protected identifiers, i.e. rows with nothing wrong.
|
|
255
|
+
if (mentionsRatherThanUses(whole)) {
|
|
256
|
+
return { skipped: 'mentions-the-word', kind, id: String(row.id), key, title: row.title };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// A goal that is no longer open is a completed record, and the server says so
|
|
260
|
+
// itself: PATCH /goals/:id answers 409 goal_not_open on anything but `open`.
|
|
261
|
+
// That rule and this goal's rule are the same rule — "leave what it recorded
|
|
262
|
+
// about its past" — so this is not an obstacle to be worked around by
|
|
263
|
+
// reopening the goal. Reported rather than skipped silently: those rows do
|
|
264
|
+
// still read the old way, and a human should know which ones.
|
|
265
|
+
if (kind === 'goal' && row.status && row.status !== 'open') {
|
|
266
|
+
return { skipped: `goal-${row.status}`, kind, id: String(row.id), key, title: row.title };
|
|
267
|
+
}
|
|
268
|
+
return { kind, id: String(row.id), key, title: row.title, patch, before: row };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------- the API (collectors; kept out of the pure half) ----------
|
|
272
|
+
|
|
273
|
+
function api(method, urlPath, body) {
|
|
274
|
+
const args = [path.join(ROOT, 'scripts', 'gds', 'api.js'), method, urlPath];
|
|
275
|
+
const opts = { cwd: ROOT, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 };
|
|
276
|
+
if (body !== undefined) {
|
|
277
|
+
args.push('--body-file', '-');
|
|
278
|
+
opts.input = JSON.stringify(body);
|
|
279
|
+
}
|
|
280
|
+
const out = execFileSync('node', args, opts);
|
|
281
|
+
const brace = out.indexOf('{');
|
|
282
|
+
return brace === -1 ? {} : JSON.parse(out.slice(brace));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The list route caps at 1000 and orders oldest-first, so the newest rows are
|
|
286
|
+
// on the LAST page. Paging is not optional here: stopping at one page would
|
|
287
|
+
// silently skip everything filed recently, which is exactly the class of bug
|
|
288
|
+
// filed as task 1003699 against sequence.js.
|
|
289
|
+
function allTasks() {
|
|
290
|
+
const rows = [];
|
|
291
|
+
let offset = 0;
|
|
292
|
+
for (;;) {
|
|
293
|
+
const q = offset ? `/api/gds/tasks?limit=1000&offset=${offset}` : '/api/gds/tasks?limit=1000';
|
|
294
|
+
const page = api('GET', q).tasks || [];
|
|
295
|
+
rows.push(...page);
|
|
296
|
+
if (page.length < 1000) break;
|
|
297
|
+
offset += 1000;
|
|
298
|
+
}
|
|
299
|
+
return rows;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function allGoals() {
|
|
303
|
+
const out = [];
|
|
304
|
+
for (const version of ['BONGOS-V1', 'CB-V1']) {
|
|
305
|
+
const r = api('GET', `/api/gds/goals?version=${version}`);
|
|
306
|
+
for (const g of r.goals || []) out.push(g);
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function collectProposals() {
|
|
312
|
+
const proposals = [];
|
|
313
|
+
const skipped = [];
|
|
314
|
+
const take = (p) => {
|
|
315
|
+
if (!p) return;
|
|
316
|
+
(p.skipped ? skipped : proposals).push(p);
|
|
317
|
+
};
|
|
318
|
+
for (const t of allTasks()) take(proposeForRow('task', t));
|
|
319
|
+
for (const g of allGoals()) take(proposeForRow('goal', g));
|
|
320
|
+
return { proposals, skipped };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------- write pacing ----------
|
|
324
|
+
|
|
325
|
+
// The server allows 30 writes per minute per IP (PER_IP_LIMIT in
|
|
326
|
+
// src/bongos/middleware/rate-limit.js). Learned the expensive way: the first
|
|
327
|
+
// --apply of this script fired 123 PATCHes flat out, landed the first ~60, and
|
|
328
|
+
// took a 429 for every one after that — leaving the ledger half-renamed, which
|
|
329
|
+
// is the one outcome worse than not starting. Pace under the limit and retry
|
|
330
|
+
// what still bounces, so a run either finishes or says exactly what it missed.
|
|
331
|
+
const WRITES_PER_MIN = 25; // under 30, with headroom for the reads
|
|
332
|
+
const WRITE_SPACING_MS = Math.ceil(60000 / WRITES_PER_MIN);
|
|
333
|
+
const MAX_RETRIES = 4;
|
|
334
|
+
|
|
335
|
+
// A synchronous sleep, because the whole script is execFileSync-shaped and one
|
|
336
|
+
// async conversion would buy nothing here.
|
|
337
|
+
function sleep(ms) {
|
|
338
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// patchWithRetry — one write, retried on 429 with a widening backoff. Any other
|
|
342
|
+
// error is returned immediately: a validation failure will not fix itself, and
|
|
343
|
+
// retrying it just burns the rate budget the remaining rows need.
|
|
344
|
+
function patchWithRetry(url, body) {
|
|
345
|
+
let wait = WRITE_SPACING_MS * 2;
|
|
346
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
|
347
|
+
try {
|
|
348
|
+
api('PATCH', url, body);
|
|
349
|
+
return { ok: true };
|
|
350
|
+
} catch (e) {
|
|
351
|
+
// api.js prints the status line to STDOUT and exits non-zero, so the
|
|
352
|
+
// 429 is in e.stdout, not in e.message. Reading only the message would
|
|
353
|
+
// silently turn every rate-limit into a permanent failure.
|
|
354
|
+
const message = `${e.message || ''}\n${e.stdout || ''}\n${e.stderr || ''}`;
|
|
355
|
+
if (!message.includes('429') || attempt === MAX_RETRIES) {
|
|
356
|
+
return { ok: false, message: message.slice(0, 300) };
|
|
357
|
+
}
|
|
358
|
+
console.log(` rate-limited, waiting ${Math.round(wait / 1000)}s`);
|
|
359
|
+
sleep(wait);
|
|
360
|
+
wait *= 2;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { ok: false, message: 'unreachable' };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------- reporting ----------
|
|
367
|
+
|
|
368
|
+
// changedLines — only the lines that actually differ, so a 4,000-character task
|
|
369
|
+
// description does not drown the one line under review.
|
|
370
|
+
function changedLines(before, after) {
|
|
371
|
+
const b = String(before).split('\n');
|
|
372
|
+
const a = String(after).split('\n');
|
|
373
|
+
const out = [];
|
|
374
|
+
for (let i = 0; i < Math.max(b.length, a.length); i += 1) {
|
|
375
|
+
if (b[i] !== a[i]) out.push({ before: b[i], after: a[i] });
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function printProposals(proposals) {
|
|
381
|
+
for (const p of proposals) {
|
|
382
|
+
console.log(`\n${p.kind} ${p.id} — ${String(p.title).slice(0, 76)}`);
|
|
383
|
+
for (const [col, next] of Object.entries(p.patch)) {
|
|
384
|
+
for (const line of changedLines(p.before[col], next)) {
|
|
385
|
+
console.log(` ${col}`);
|
|
386
|
+
console.log(` - ${String(line.before).trim().slice(0, 200)}`);
|
|
387
|
+
console.log(` + ${String(line.after).trim().slice(0, 200)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function summarize(proposals) {
|
|
394
|
+
const byKind = {};
|
|
395
|
+
let edits = 0;
|
|
396
|
+
for (const p of proposals) {
|
|
397
|
+
byKind[p.kind] = (byKind[p.kind] || 0) + 1;
|
|
398
|
+
edits += Object.keys(p.patch).length;
|
|
399
|
+
}
|
|
400
|
+
return { rows: proposals.length, edits, byKind };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------- CLI ----------
|
|
404
|
+
|
|
405
|
+
function printSkipped(skipped) {
|
|
406
|
+
if (!skipped.length) return;
|
|
407
|
+
console.log(`\nLEFT FOR A HUMAN — ${skipped.length} row(s) that would have been rewritten wrongly:`);
|
|
408
|
+
for (const s of skipped) {
|
|
409
|
+
console.log(` ${s.key} [${s.skipped}] ${String(s.title).slice(0, 76)}`);
|
|
410
|
+
}
|
|
411
|
+
console.log('\n mentions-the-word — the row talks ABOUT the acronym; renaming it breaks the sentence.');
|
|
412
|
+
console.log(' exempt — the row states the rename rule and must keep naming what it retires.');
|
|
413
|
+
console.log(' goal-achieved/archived — a closed goal is a completed record; the server refuses the edit');
|
|
414
|
+
console.log(' (409 goal_not_open), which is the same rule this goal is built on.');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function main() {
|
|
418
|
+
const argv = process.argv.slice(2);
|
|
419
|
+
const { proposals, skipped } = collectProposals();
|
|
420
|
+
const stats = summarize(proposals);
|
|
421
|
+
|
|
422
|
+
if (argv.includes('--json')) {
|
|
423
|
+
process.stdout.write(`${JSON.stringify({
|
|
424
|
+
stats,
|
|
425
|
+
proposals: proposals.map((p) => ({ kind: p.kind, id: p.id, patch: p.patch })),
|
|
426
|
+
skipped: skipped.map((s) => ({ key: s.key, reason: s.skipped, title: s.title })),
|
|
427
|
+
}, null, 2)}\n`);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!argv.includes('--apply')) {
|
|
432
|
+
if (!argv.includes('--summary')) printProposals(proposals);
|
|
433
|
+
printSkipped(skipped);
|
|
434
|
+
console.log(`\ndry run — nothing written.`);
|
|
435
|
+
console.log(` ${stats.rows} row(s), ${stats.edits} column edit(s): ${JSON.stringify(stats.byKind)}`);
|
|
436
|
+
console.log(` ${skipped.length} row(s) left for a human`);
|
|
437
|
+
console.log(` frozen versions skipped entirely: ${[...FROZEN_VERSION_IDS].join(', ')}`);
|
|
438
|
+
console.log('\n apply with: node scripts/gds/rename-ledger-prose.js --apply');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
console.log(`applying ${proposals.length} row(s) at ${WRITES_PER_MIN}/min — about ${Math.ceil(proposals.length / WRITES_PER_MIN)} minute(s).`);
|
|
443
|
+
let ok = 0;
|
|
444
|
+
const failed = [];
|
|
445
|
+
for (const [i, p] of proposals.entries()) {
|
|
446
|
+
const url = p.kind === 'task' ? `/api/gds/tasks/${p.id}` : `/api/gds/goals/${p.id}`;
|
|
447
|
+
if (i) sleep(WRITE_SPACING_MS);
|
|
448
|
+
const r = patchWithRetry(url, p.patch);
|
|
449
|
+
if (r.ok) {
|
|
450
|
+
ok += 1;
|
|
451
|
+
console.log(` ok ${String(i + 1).padStart(3)}/${proposals.length} ${p.kind} ${p.id}`);
|
|
452
|
+
} else {
|
|
453
|
+
failed.push({ key: p.key, message: r.message });
|
|
454
|
+
console.log(` FAIL ${p.kind} ${p.id}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
console.log(`\napplied ${ok}/${proposals.length} row(s).`);
|
|
458
|
+
if (failed.length) {
|
|
459
|
+
console.log('failed:');
|
|
460
|
+
for (const f of failed) console.log(` ${f.key}: ${f.message}`);
|
|
461
|
+
process.exitCode = 1;
|
|
462
|
+
}
|
|
463
|
+
console.log('re-run without --apply to confirm 0 remaining (the idempotency check).');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (require.main === module) main();
|
|
467
|
+
|
|
468
|
+
module.exports = {
|
|
469
|
+
FROZEN_VERSION_IDS,
|
|
470
|
+
EXEMPT_ROWS,
|
|
471
|
+
IDENTIFIER_PATTERNS,
|
|
472
|
+
EDITABLE,
|
|
473
|
+
MENTION_MARKERS,
|
|
474
|
+
mentionsRatherThanUses,
|
|
475
|
+
maskIdentifiers,
|
|
476
|
+
unmask,
|
|
477
|
+
rewriteVocabulary,
|
|
478
|
+
rewrite,
|
|
479
|
+
proposeForRow,
|
|
480
|
+
changedLines,
|
|
481
|
+
summarize,
|
|
482
|
+
};
|
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.608'; // 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');
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// tests/rename_ledger_prose.mjs — criterion C4 of goal 1000073, "the live
|
|
2
|
+
// ledger reads Bongos" (task 1003698).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS ACTUALLY AT RISK HERE. This script edits live rows through the API,
|
|
5
|
+
// so the expensive failure is not "it missed one" — it is "it rewrote something
|
|
6
|
+
// it should not have". Three kinds of thing must survive it byte for byte:
|
|
7
|
+
// identifiers that are still the true names of live things, the frozen GDS-V3 /
|
|
8
|
+
// GDS-V4 archives, and rows that MENTION the acronym rather than use it. Every
|
|
9
|
+
// one of those gets a case below, and they are the load-bearing ones.
|
|
10
|
+
//
|
|
11
|
+
// The pure half of the script takes its inputs explicitly, so nothing here
|
|
12
|
+
// touches the network or the tree.
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const L = require(path.join(ROOT, 'scripts', 'gds', 'rename-ledger-prose.js'));
|
|
22
|
+
|
|
23
|
+
// ---------- identifiers are not vocabulary ----------
|
|
24
|
+
|
|
25
|
+
test('every identifier shape survives a rewrite byte for byte', () => {
|
|
26
|
+
for (const kept of [
|
|
27
|
+
'GDS_API_BASE', 'GDS_TOKEN', 'GDS_BRANDING_FILE', 'MEDUSA_GDS_ONLY',
|
|
28
|
+
'/api/gds', '/api/gds/tasks', 'scripts/gds/ship.js', 'src/gds/db.js',
|
|
29
|
+
'gds_session', 'gds-session.json', 'GDS-V3', 'GDS-V4',
|
|
30
|
+
]) {
|
|
31
|
+
const text = `prose around ${kept} and more prose`;
|
|
32
|
+
const out = L.rewrite(text);
|
|
33
|
+
assert.ok(out === null || out.includes(kept), `${kept} must survive; got ${out}`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a row carrying ONLY identifiers proposes no change at all', () => {
|
|
38
|
+
assert.equal(L.rewrite('set GDS_API_BASE, hit /api/gds, read the gds_session cookie'), null);
|
|
39
|
+
assert.equal(L.rewrite('run scripts/gds/ship.js'), null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('an identifier and real vocabulary in the same sentence: one changes, one does not', () => {
|
|
43
|
+
const out = L.rewrite('the GDS holds it; set GDS_API_BASE first');
|
|
44
|
+
assert.equal(out, 'Bongos holds it; set GDS_API_BASE first');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the mask round-trips exactly — a corrupted identifier would be silent', () => {
|
|
48
|
+
const text = 'GDS_TOKEN and /api/gds and GDS-V4 and scripts/gds/x.js';
|
|
49
|
+
const { masked, masks } = L.maskIdentifiers(text);
|
|
50
|
+
assert.ok(!masked.includes('GDS_TOKEN'));
|
|
51
|
+
assert.equal(L.unmask(masked, masks), text);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the version-id rule is SHARED with the C2 ratchet and the C5 pin, not re-declared', () => {
|
|
55
|
+
// The task asked for exactly this: "REUSE it rather than writing a second,
|
|
56
|
+
// divergent rule". Two copies drift the moment either side learns a new
|
|
57
|
+
// version id, and the drift is silent — this asserts one source.
|
|
58
|
+
const scan = require(path.join(ROOT, 'scripts', 'gds', 'gds-literal-scan.js'));
|
|
59
|
+
const history = require(path.join(ROOT, 'scripts', 'gds', 'rename-history-check.js'));
|
|
60
|
+
assert.ok(L.IDENTIFIER_PATTERNS.includes(scan.GDS_VERSION_ID_RE), 'the version-id regex must be the ratchet’s own object');
|
|
61
|
+
assert.deepEqual([...L.FROZEN_VERSION_IDS], history.PINNED_VERSION_IDS, 'the frozen set must come from the C5 pin');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------- the article, which is where a regex usually breaks grammar ----------
|
|
65
|
+
|
|
66
|
+
test('the article is KEPT when the acronym modifies a noun', () => {
|
|
67
|
+
assert.equal(L.rewrite('mounted outside the GDS router'), 'mounted outside the Bongos router');
|
|
68
|
+
assert.equal(L.rewrite('a GDS task and a GDS blocker'), 'a Bongos task and a Bongos blocker');
|
|
69
|
+
assert.equal(L.rewrite('The GDS `dependencies[]` say what ships'), 'The Bongos `dependencies[]` say what ships');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the article is DROPPED when the acronym is the subject', () => {
|
|
73
|
+
assert.equal(L.rewrite('The GDS knows everything'), 'Bongos knows everything');
|
|
74
|
+
assert.equal(L.rewrite('the GDS is the ledger'), 'Bongos is the ledger');
|
|
75
|
+
assert.equal(L.rewrite('poll the GDS for the next task'), 'poll Bongos for the next task');
|
|
76
|
+
assert.equal(L.rewrite('its home is the GDS (goal rows)'), 'its home is Bongos (goal rows)');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('keeping the article is the DEFAULT, because dropping it wrongly breaks the sentence', () => {
|
|
80
|
+
// An unrecognised follower must not lose its article — "outside Bongos
|
|
81
|
+
// router" is broken English, while "the Bongos widget" merely reads plainly.
|
|
82
|
+
assert.equal(L.rewrite('inside the GDS widgetorium'), 'inside the Bongos widgetorium');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the product name is not doubled', () => {
|
|
86
|
+
assert.equal(L.rewrite('pg_dump of Cloud Bongos GDS Postgres DB'), 'pg_dump of Cloud Bongos Postgres DB');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------- mention vs use ----------
|
|
90
|
+
|
|
91
|
+
test('text that MENTIONS the word is detected, not rewritten', () => {
|
|
92
|
+
for (const mention of [
|
|
93
|
+
'GDS is a legacy term inherited from Primea',
|
|
94
|
+
'the primer still says GDS (should be Bongos)',
|
|
95
|
+
'fails the build on a NEW literal `GDS`',
|
|
96
|
+
'records the bongos-as-GDS-successor intent',
|
|
97
|
+
'a thing called GDS at the time',
|
|
98
|
+
'GDS → Bongos naming sweep',
|
|
99
|
+
'unchanged since the PMS→GDS rename',
|
|
100
|
+
'files under docs/ containing "GDS"',
|
|
101
|
+
]) {
|
|
102
|
+
assert.equal(L.mentionsRatherThanUses(mention), true, `should be treated as a mention: ${mention}`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('ordinary usage is NOT mistaken for a mention', () => {
|
|
107
|
+
for (const use of [
|
|
108
|
+
'the GDS router has no rate limiting',
|
|
109
|
+
'Writes GDS records only',
|
|
110
|
+
'poll the GDS for the next claimable task',
|
|
111
|
+
'a GDS task must be claimed first',
|
|
112
|
+
]) {
|
|
113
|
+
assert.equal(L.mentionsRatherThanUses(use), false, `should be treated as ordinary use: ${use}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------- row selection ----------
|
|
118
|
+
|
|
119
|
+
const taskRow = (over) => ({ id: '1', version_id: 'BONGOS-V1', title: 'the GDS holds it', description: 'x', ...over });
|
|
120
|
+
|
|
121
|
+
test('a frozen-archive row is skipped entirely, and silently — it IS the record', () => {
|
|
122
|
+
assert.equal(L.proposeForRow('task', taskRow({ version_id: 'GDS-V3' })), null);
|
|
123
|
+
assert.equal(L.proposeForRow('task', taskRow({ version_id: 'GDS-V4' })), null);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a live row is proposed, patching only the columns that changed', () => {
|
|
127
|
+
const p = L.proposeForRow('task', taskRow({ description: 'nothing to see' }));
|
|
128
|
+
assert.deepEqual(Object.keys(p.patch), ['title']);
|
|
129
|
+
assert.equal(p.patch.title, 'Bongos holds it');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('an exempt row is REPORTED, never silently dropped', () => {
|
|
133
|
+
const p = L.proposeForRow('goal', { id: '1000073', version_id: 'BONGOS-V1', title: 'Finish the GDS→Bongos rename' });
|
|
134
|
+
assert.equal(p.skipped, 'exempt');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a mention row is reported for a human instead of being rewritten', () => {
|
|
138
|
+
const p = L.proposeForRow('task', taskRow({ description: 'GDS is a legacy term inherited from Primea' }));
|
|
139
|
+
assert.equal(p.skipped, 'mentions-the-word');
|
|
140
|
+
assert.equal(p.patch, undefined, 'a skipped row must carry no patch — nothing can apply it by accident');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a closed goal is reported, not patched — the server refuses it and the goal rule agrees', () => {
|
|
144
|
+
for (const status of ['achieved', 'archived']) {
|
|
145
|
+
const p = L.proposeForRow('goal', { id: '9', version_id: 'BONGOS-V1', status, title: 'the GDS holds it' });
|
|
146
|
+
assert.equal(p.skipped, `goal-${status}`);
|
|
147
|
+
assert.equal(p.patch, undefined);
|
|
148
|
+
}
|
|
149
|
+
const open = L.proposeForRow('goal', { id: '9', version_id: 'BONGOS-V1', status: 'open', title: 'the GDS holds it' });
|
|
150
|
+
assert.equal(open.patch.title, 'Bongos holds it');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a closed goal whose only occurrences are IDENTIFIERS is not reported at all', () => {
|
|
154
|
+
// Reporting it would tell a human to look at a row with nothing wrong. The
|
|
155
|
+
// status check must run after the patch, not before it.
|
|
156
|
+
const p = L.proposeForRow('goal', {
|
|
157
|
+
id: '9', version_id: 'BONGOS-V1', status: 'achieved',
|
|
158
|
+
title: 'set GDS_API_BASE', description: 'and hit /api/gds',
|
|
159
|
+
});
|
|
160
|
+
assert.equal(p, null);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('only columns the API accepts are ever proposed', () => {
|
|
164
|
+
assert.deepEqual(L.EDITABLE.task, ['title', 'description']);
|
|
165
|
+
assert.deepEqual(L.EDITABLE.goal, ['title', 'subtitle', 'description']);
|
|
166
|
+
const p = L.proposeForRow('task', taskRow({ notes: 'the GDS holds it' }));
|
|
167
|
+
assert.ok(!('notes' in p.patch), 'PATCH /tasks/:id does not accept notes');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------- idempotency, by construction ----------
|
|
171
|
+
|
|
172
|
+
test('a rewritten row proposes nothing on a second pass', () => {
|
|
173
|
+
const once = L.rewrite('the GDS holds it; the GDS router is fine');
|
|
174
|
+
assert.equal(L.rewrite(once), null, 'the second --apply must change zero rows');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('summarize counts rows and column edits per kind', () => {
|
|
178
|
+
const stats = L.summarize([
|
|
179
|
+
{ kind: 'task', patch: { title: 'a', description: 'b' } },
|
|
180
|
+
{ kind: 'task', patch: { title: 'a' } },
|
|
181
|
+
{ kind: 'goal', patch: { subtitle: 'c' } },
|
|
182
|
+
]);
|
|
183
|
+
assert.equal(stats.rows, 3);
|
|
184
|
+
assert.equal(stats.edits, 4, 'edits counts COLUMNS, not rows — a row can change two');
|
|
185
|
+
assert.deepEqual(stats.byKind, { task: 2, goal: 1 });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('changedLines reports only the lines that differ', () => {
|
|
189
|
+
const lines = L.changedLines('same\nthe GDS\nsame', 'same\nBongos\nsame');
|
|
190
|
+
assert.equal(lines.length, 1);
|
|
191
|
+
assert.equal(lines[0].after, 'Bongos');
|
|
192
|
+
});
|