@bongos/core 1.19.589 → 1.19.591
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 -20
- package/clients/bongos-client/index.d.ts +3 -2
- package/docs/api/openapi.json +30 -4
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +126 -1
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/goal-route-authz.js +134 -0
- package/modules/lifecycle/routes/goals.js +106 -8
- 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/goal_archive_disposition.mjs +207 -6
- package/tests/goal_archive_route_e2e.mjs +135 -0
- package/tests/rename_history_restraint.mjs +236 -0
|
@@ -49,6 +49,14 @@ const goalConflicts = require('../goal-conflicts');
|
|
|
49
49
|
const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
|
|
50
50
|
|
|
51
51
|
const VALID_GOAL_STATUSES = ['open', 'achieved', 'archived'];
|
|
52
|
+
|
|
53
|
+
// ARCHIVE_DISPOSITION_MAX — how many unfinished tasks one archive call will
|
|
54
|
+
// disposition (BV1.R14). A ceiling rather than an unbounded read because the
|
|
55
|
+
// whole plan is validated in memory and applied in ONE transaction, and a goal
|
|
56
|
+
// holding thousands of open tasks is a data problem to fix before archiving, not
|
|
57
|
+
// a payload to stream. Sized well above anything real: the largest bucket on the
|
|
58
|
+
// live instance is a per-version catch-all at ~53 (ADR 0250 §7).
|
|
59
|
+
const ARCHIVE_DISPOSITION_MAX = 500;
|
|
52
60
|
const VALID_MEMBERSHIP_KINDS = ['lead', 'member'];
|
|
53
61
|
const METIC_PLUS = ['metic', 'archon'];
|
|
54
62
|
const RESOLVE_ACTIONS = ['sequence', 'flag_release', 'rescope'];
|
|
@@ -63,7 +71,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
|
|
|
63
71
|
// proceed; a false result means "not a member" → the route should 403. This is the
|
|
64
72
|
// membership half of the wall; the protected-module rank check is enforced per-route
|
|
65
73
|
// (scope is never self-granted).
|
|
66
|
-
const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
74
|
+
const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
67
75
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
68
76
|
const log = api.logger('lifecycle');
|
|
69
77
|
module.exports = function buildGoalsRouter() {
|
|
@@ -1075,6 +1083,17 @@ module.exports = function buildGoalsRouter() {
|
|
|
1075
1083
|
// skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64).
|
|
1076
1084
|
// rank: metic+archon — a goal member (Metic+) or an Archon dispositions the goal.
|
|
1077
1085
|
router.post('/goals/:id/archive', auth.requireBuilder, auth.requirePermission('goal.archive'), async (req, res) => {
|
|
1086
|
+
// ADR 0118 / R12: every write route that reads a body declares it, so an
|
|
1087
|
+
// unknown field is REJECTED rather than silently ignored — a typo'd
|
|
1088
|
+
// `disposition` (singular) must not read as "no disposition given" and take
|
|
1089
|
+
// the refusal branch, which is exactly the shape that trains a caller to
|
|
1090
|
+
// believe the map was applied. `dispositions` is a dynamic-key map (task id →
|
|
1091
|
+
// verb) so the schema bounds its TYPE here and its CONTENTS are validated by
|
|
1092
|
+
// planGoalArchiveDispositions, which refuses every malformed entry by name.
|
|
1093
|
+
if (validateOrRespond(req, res, {
|
|
1094
|
+
reason: { type: 'string', maxLength: 2000 },
|
|
1095
|
+
dispositions: { type: 'object' },
|
|
1096
|
+
})) return;
|
|
1078
1097
|
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
1079
1098
|
if (id === null) return;
|
|
1080
1099
|
try {
|
|
@@ -1100,17 +1119,96 @@ module.exports = function buildGoalsRouter() {
|
|
|
1100
1119
|
// "not without saying what happens to these", not a silent discard.
|
|
1101
1120
|
//
|
|
1102
1121
|
// A goal holding nothing open stays a single call, unchanged.
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1122
|
+
//
|
|
1123
|
+
// BV1.R14 (task 1003601) is the second half: the caller comes back with a
|
|
1124
|
+
// `dispositions` map saying what happens to each open task, and the archive
|
|
1125
|
+
// APPLIES it. R10 shipped the refusal one task ahead of its own remedy, so
|
|
1126
|
+
// between the two every archive of a goal holding work was impossible —
|
|
1127
|
+
// which is also why the ADR 0264 goal cut could not start until this landed.
|
|
1128
|
+
//
|
|
1129
|
+
// The decision is the pure planGoalArchiveDispositions (the sibling of
|
|
1130
|
+
// authorizeGoalCreate / authorizeVersionCreate): the route gathers the
|
|
1131
|
+
// facts, that decides, the route renders the verdict verbatim.
|
|
1132
|
+
//
|
|
1133
|
+
// THE CAP MATTERS HERE AND NOT IN R10. `openTasksInGoal` defaults to 50 rows
|
|
1134
|
+
// (with the true count in `.total`), which is right for a refusal that only
|
|
1135
|
+
// has to show the caller what is in the way. This call must plan over EVERY
|
|
1136
|
+
// open task — a plan built from a truncated list would validate as complete,
|
|
1137
|
+
// and the transaction's final re-count would then roll the whole archive back
|
|
1138
|
+
// with a confusing "still holds N" on a caller who dispositioned everything
|
|
1139
|
+
// they were shown. So: read wide when a disposition is being applied, and
|
|
1140
|
+
// refuse above the ceiling with a code that says what to do.
|
|
1141
|
+
const wantsDisposition = req.body?.dispositions != null;
|
|
1142
|
+
const held = await db.openTasksInGoal(id, wantsDisposition ? { cap: ARCHIVE_DISPOSITION_MAX } : undefined);
|
|
1143
|
+
if (wantsDisposition && held.total > ARCHIVE_DISPOSITION_MAX) {
|
|
1144
|
+
return res.fail('too_many_open_tasks', {
|
|
1106
1145
|
status: 409,
|
|
1107
|
-
message: `Goal #${id}
|
|
1108
|
-
details: { total: held.total,
|
|
1146
|
+
message: `Goal #${id} holds ${held.total} unfinished tasks — more than the ${ARCHIVE_DISPOSITION_MAX} this call dispositions at once. Move or abandon some first, then archive.`,
|
|
1147
|
+
details: { total: held.total, max: ARCHIVE_DISPOSITION_MAX },
|
|
1109
1148
|
});
|
|
1110
1149
|
}
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1150
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
|
|
1151
|
+
const plan = planGoalArchiveDispositions({
|
|
1152
|
+
goalId: id, openTasks: held.tasks, dispositions: req.body?.dispositions ?? null, reason,
|
|
1153
|
+
});
|
|
1154
|
+
if (!plan.ok) return res.status(plan.status).json(plan.body);
|
|
1155
|
+
|
|
1156
|
+
// A goal holding nothing open never had a plan to apply — keep it the one
|
|
1157
|
+
// untouched call it has always been, rather than routing it through a
|
|
1158
|
+
// transaction that would do nothing.
|
|
1159
|
+
if (plan.plan.length === 0) {
|
|
1160
|
+
const updated = await db.setGoalStatus({ goalId: id, status: 'archived' });
|
|
1161
|
+
return res.json({ ok: true, goal: updated, applied: [] });
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Move targets are resolved HERE, before the write, so a bad target is a
|
|
1165
|
+
// 400 about the caller's request rather than a rollback mid-archive. The
|
|
1166
|
+
// open test is goalNotOpenRefusal — the ONE definition of "this goal takes
|
|
1167
|
+
// no new work" (BV1.R05) — never a second spelling of the status list.
|
|
1168
|
+
const targets = [...new Set(plan.plan.filter((p) => p.verb === 'move').map((p) => p.toGoalId))];
|
|
1169
|
+
for (const t of targets) {
|
|
1170
|
+
const dest = await db.getGoal(t);
|
|
1171
|
+
if (!dest) {
|
|
1172
|
+
return res.fail('bad_disposition', { status: 400, message: `Move target goal #${t} does not exist.`, details: { goal_id: String(t) } });
|
|
1173
|
+
}
|
|
1174
|
+
const notOpen = goalNotOpenRefusal(dest);
|
|
1175
|
+
if (notOpen) {
|
|
1176
|
+
return res.fail('bad_disposition', {
|
|
1177
|
+
status: 409,
|
|
1178
|
+
message: `Move target goal #${t} is '${dest.status}' — a task cannot be moved into a closed goal.`,
|
|
1179
|
+
details: { goal_id: String(t), goal_status: dest.status },
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const result = await db.archiveGoalWithDispositions({ goalId: id, plan: plan.plan, reason });
|
|
1185
|
+
return res.json({ ok: true, goal: result.goal, applied: result.applied });
|
|
1113
1186
|
} catch (err) {
|
|
1187
|
+
// An actively-claimed task must not be abandoned out from under whoever is
|
|
1188
|
+
// working it — the same refusal POST /tasks/:id/abandon makes, surfaced with
|
|
1189
|
+
// the task named so the caller knows which claim to chase.
|
|
1190
|
+
if (err && err.code === 'TASK_HAS_ACTIVE_CLAIM') {
|
|
1191
|
+
return res.fail('task_has_active_claim', {
|
|
1192
|
+
status: 409,
|
|
1193
|
+
message: `Task #${err.taskId} has an active claim — release it before archiving this goal.`,
|
|
1194
|
+
details: { task_id: err.taskId, task_title: err.taskTitle },
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
if (err && (err.code === 'MOVE_TARGET_NOT_FOUND' || err.code === 'MOVE_TARGET_NOT_OPEN')) {
|
|
1198
|
+
return res.fail('bad_disposition', {
|
|
1199
|
+
status: 409,
|
|
1200
|
+
message: `Move target goal #${err.toGoalId} changed while the archive was running — nothing was applied. Re-read and retry.`,
|
|
1201
|
+
details: { goal_id: err.toGoalId, goal_status: err.targetStatus ?? null },
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
if (err && err.code === 'DISPOSITION_INCOMPLETE_AFTER_APPLY') {
|
|
1205
|
+
return res.fail('goal_holds_open_tasks', {
|
|
1206
|
+
status: 409,
|
|
1207
|
+
message: `Goal #${id} still held ${err.remaining} unfinished task(s) after the dispositions were applied — nothing was changed. Re-read the goal and retry.`,
|
|
1208
|
+
details: { remaining: err.remaining },
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
if (err && err.code === 'GOAL_NOT_FOUND') return res.fail('goal_not_found', 404);
|
|
1114
1212
|
log.error('[gds] POST /goals/:id/archive', err);
|
|
1115
1213
|
return res.fail('archive_failed', { status: 500, message: 'internal error' });
|
|
1116
1214
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.591",
|
|
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.591",
|
|
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.591",
|
|
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.591'; // 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');
|