@bongos/core 1.19.823 → 1.19.824
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 +36 -16
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/category-advisory-guard.js +13 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/scope-map-guard.js +120 -0
- package/scripts/gds/stranded-rows.js +200 -0
- package/src/bongos/module-scope-map.js +30 -0
- package/src/module-api.js +1 -1
- package/tests/category_advisory_guard.mjs +29 -0
- package/tests/scope_map_guard.mjs +82 -0
- package/tests/stranded_rows.mjs +162 -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.824",
|
|
6
|
+
"core_contract": "1.19.824",
|
|
7
|
+
"source_commit": "95554434b3e9a6349986ebd50fb547858e7c0fbd",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-17T12:15:41.626Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 490,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2234,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2750,
|
|
20
|
+
"tree_sha256": "5eaa31427b8c53ddb2d3aa0a10a2e851968649d578993ab3c4412fc279e1014a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2537,7 +2537,7 @@
|
|
|
2537
2537
|
{
|
|
2538
2538
|
"path": "docs/file-map.md",
|
|
2539
2539
|
"mode": "0000644",
|
|
2540
|
-
"sha256": "
|
|
2540
|
+
"sha256": "4cacfe54b3e4f80aa1cd7a4b190bf673da867f99d5101cc1c7870ee13021c008"
|
|
2541
2541
|
},
|
|
2542
2542
|
{
|
|
2543
2543
|
"path": "docs/handoff-template.md",
|
|
@@ -2547,7 +2547,7 @@
|
|
|
2547
2547
|
{
|
|
2548
2548
|
"path": "docs/module-api-changelog.md",
|
|
2549
2549
|
"mode": "0000644",
|
|
2550
|
-
"sha256": "
|
|
2550
|
+
"sha256": "a921ec8fff3a0bb3664431ff5f567e3660cb26dbc55a24012edcc7a1c52de4bd"
|
|
2551
2551
|
},
|
|
2552
2552
|
{
|
|
2553
2553
|
"path": "docs/modules-contract.md",
|
|
@@ -7812,12 +7812,12 @@
|
|
|
7812
7812
|
{
|
|
7813
7813
|
"path": "package-lock.json",
|
|
7814
7814
|
"mode": "0000644",
|
|
7815
|
-
"sha256": "
|
|
7815
|
+
"sha256": "aa5cbab9d550e88355a4d19269ef77f0b414394f53d0a745dba09417158d42ae"
|
|
7816
7816
|
},
|
|
7817
7817
|
{
|
|
7818
7818
|
"path": "package.json",
|
|
7819
7819
|
"mode": "0000644",
|
|
7820
|
-
"sha256": "
|
|
7820
|
+
"sha256": "50285f0725d1e9c6da37f0b47e3a64ad5f2084a81539eb2902ff9f75b7635ce0"
|
|
7821
7821
|
},
|
|
7822
7822
|
{
|
|
7823
7823
|
"path": "public-docs/index.html",
|
|
@@ -8092,7 +8092,7 @@
|
|
|
8092
8092
|
{
|
|
8093
8093
|
"path": "scripts/gds/category-advisory-guard.js",
|
|
8094
8094
|
"mode": "0000644",
|
|
8095
|
-
"sha256": "
|
|
8095
|
+
"sha256": "19fd98214001bb83ed69789653519d820ece8d94fea4451503ecf85ff4285f32"
|
|
8096
8096
|
},
|
|
8097
8097
|
{
|
|
8098
8098
|
"path": "scripts/gds/cf-tunnel.js",
|
|
@@ -8357,7 +8357,7 @@
|
|
|
8357
8357
|
{
|
|
8358
8358
|
"path": "scripts/gds/fitness.js",
|
|
8359
8359
|
"mode": "0000644",
|
|
8360
|
-
"sha256": "
|
|
8360
|
+
"sha256": "aa1a26c0b2871cd6e49b619254ce2b11d477780da8f4b037c38cb16b0ccd68fc"
|
|
8361
8361
|
},
|
|
8362
8362
|
{
|
|
8363
8363
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -8869,6 +8869,11 @@
|
|
|
8869
8869
|
"mode": "0000644",
|
|
8870
8870
|
"sha256": "c82513a9333724c77ba3a99667726861c292d576ea493f2144e6051dd78c47b6"
|
|
8871
8871
|
},
|
|
8872
|
+
{
|
|
8873
|
+
"path": "scripts/gds/scope-map-guard.js",
|
|
8874
|
+
"mode": "0000644",
|
|
8875
|
+
"sha256": "75f0791698a36e48ec232d2285e6f80dac6d64c2a23a23a26cd54c1c05883c3b"
|
|
8876
|
+
},
|
|
8872
8877
|
{
|
|
8873
8878
|
"path": "scripts/gds/seam-wiring-guard.js",
|
|
8874
8879
|
"mode": "0000644",
|
|
@@ -9239,6 +9244,11 @@
|
|
|
9239
9244
|
"mode": "0000644",
|
|
9240
9245
|
"sha256": "2a9959bd2e45046dadcda9982998a5b7163cef00b5d719ff299314daa5409770"
|
|
9241
9246
|
},
|
|
9247
|
+
{
|
|
9248
|
+
"path": "scripts/gds/stranded-rows.js",
|
|
9249
|
+
"mode": "0000644",
|
|
9250
|
+
"sha256": "573e608a90b5822f9bf77f203d54cf8a80ef9e96bdc51b22e5dec1226da3ee98"
|
|
9251
|
+
},
|
|
9242
9252
|
{
|
|
9243
9253
|
"path": "scripts/gds/stray-unit-sweep.sh",
|
|
9244
9254
|
"mode": "0000644",
|
|
@@ -9537,7 +9547,7 @@
|
|
|
9537
9547
|
{
|
|
9538
9548
|
"path": "src/bongos/module-scope-map.js",
|
|
9539
9549
|
"mode": "0000644",
|
|
9540
|
-
"sha256": "
|
|
9550
|
+
"sha256": "7f393fc515fe751d5296ce0a574e0d3e40695c0346933a0d8b9c30ed6ae39a1b"
|
|
9541
9551
|
},
|
|
9542
9552
|
{
|
|
9543
9553
|
"path": "src/bongos/module-submissions.js",
|
|
@@ -9727,7 +9737,7 @@
|
|
|
9727
9737
|
{
|
|
9728
9738
|
"path": "src/module-api.js",
|
|
9729
9739
|
"mode": "0000644",
|
|
9730
|
-
"sha256": "
|
|
9740
|
+
"sha256": "7bd07a13ff08f341820d14bfa056a4fff9715b0a63fbfb7f392e4d04b5336a15"
|
|
9731
9741
|
},
|
|
9732
9742
|
{
|
|
9733
9743
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10372,7 +10382,7 @@
|
|
|
10372
10382
|
{
|
|
10373
10383
|
"path": "tests/category_advisory_guard.mjs",
|
|
10374
10384
|
"mode": "0000644",
|
|
10375
|
-
"sha256": "
|
|
10385
|
+
"sha256": "0fdb3c4e34fadc0f6697eb3ee3083d6739273e4df3c5693b7c3693cf0dfb1ef1"
|
|
10376
10386
|
},
|
|
10377
10387
|
{
|
|
10378
10388
|
"path": "tests/cf_ruleset_preflight.mjs",
|
|
@@ -12879,6 +12889,11 @@
|
|
|
12879
12889
|
"mode": "0000644",
|
|
12880
12890
|
"sha256": "8d516fe0fb398d8322e014773901449b6909439ab1567c90d8354b49f211cdbf"
|
|
12881
12891
|
},
|
|
12892
|
+
{
|
|
12893
|
+
"path": "tests/scope_map_guard.mjs",
|
|
12894
|
+
"mode": "0000644",
|
|
12895
|
+
"sha256": "278e334a945d151c225d849b51464a3dffc8f0bb0dcb1d39b72d12bae57efd4f"
|
|
12896
|
+
},
|
|
12882
12897
|
{
|
|
12883
12898
|
"path": "tests/scouting_owner_access_and_reach.mjs",
|
|
12884
12899
|
"mode": "0000644",
|
|
@@ -13389,6 +13404,11 @@
|
|
|
13389
13404
|
"mode": "0000644",
|
|
13390
13405
|
"sha256": "bba2bde5b7a4a25ccddff58fb2fde0ef84ef67224e63819232221a461234a30f"
|
|
13391
13406
|
},
|
|
13407
|
+
{
|
|
13408
|
+
"path": "tests/stranded_rows.mjs",
|
|
13409
|
+
"mode": "0000644",
|
|
13410
|
+
"sha256": "63070400896c81e90e53e6b9480beb55eafee854d7d54971e201cee70056d487"
|
|
13411
|
+
},
|
|
13392
13412
|
{
|
|
13393
13413
|
"path": "tests/streak.mjs",
|
|
13394
13414
|
"mode": "0000644",
|
package/docs/file-map.md
CHANGED
|
@@ -464,7 +464,8 @@ tests/
|
|
|
464
464
|
├── repo-simplify-sweep/SKILL.md ← weekly repo-cleanliness janitor — runs repo-metrics, files dead-code findings to idea_inbox, and auto-fixes only the narrow safe class (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
465
465
|
├── security-review-weekly/SKILL.md ← weekly security-state review — stale vulnerability_reports, ageing security-tagged tasks, recent high/critical dependency alerts; one Archon broadcast when anything is stale, silent otherwise (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
466
466
|
├── skill-usage-review/SKILL.md ← nightly skill-usage feedback loop — aggregates 24h of skill invocations into friction findings filed as `[skill-friction]` idea_inbox rows (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
467
|
-
|
|
467
|
+
├── stale-claim-release/SKILL.md ← hourly sweep releasing claims whose session_pulses heartbeat has been silent for GDS_STALE_CLAIM_HOURS, returning the task to ready and pinging the builder (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
468
|
+
└── stranded-rows/SKILL.md ← flags an open task whose work ALREADY LANDED on main (task 1003775): a row still in ready/backlog while a commit claims it with a line ending in `(task N)`. Narrower than "mentions N" on purpose — idea 1000807 measured that a mention-scan returns ~44 open rows that merely cite an id. Measured clean at 878 claims vs 434 open rows, and still catches the historical case (task 1002200, whose claim lives in the MERGE commit body). NO cadence declared: nothing schedules it yet, so it is run by hand — wiring a scheduler needs the control plane, and declaring a cadence with no cron firing it fails fitness Check 25 (task 1003209, ADR 0188). Prints ids + fixed vocabulary only, never builder-authored text.
|
|
468
469
|
```
|
|
469
470
|
<!-- END GENERATED FILE-MAP .claude/scheduled-tasks/ -->
|
|
470
471
|
|
|
@@ -2119,6 +2119,8 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2119
2119
|
1.19.822 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2120
2120
|
landed since 1.19.821 with no explicit bump. run 35213847140. (task 1002620)
|
|
2121
2121
|
1.19.823 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2122
|
-
landed since 1.19.822 with no explicit bump. run
|
|
2122
|
+
landed since 1.19.822 with no explicit bump. run 35218585239. (task 1002620)
|
|
2123
|
+
1.19.824 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2124
|
+
landed since 1.19.823 with no explicit bump. run 35220051893. (task 1002620)
|
|
2123
2125
|
---------------------------------------------------------------------------
|
|
2124
2126
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.824",
|
|
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.824",
|
|
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.824",
|
|
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,18 @@ const FEATURE_ANCHORS = [
|
|
|
69
69
|
// Word-bounded on purpose: `subcategory_id` in some unrelated module is not this
|
|
70
70
|
// feature, and a guard that cries wolf gets suppressed rather than obeyed. The
|
|
71
71
|
// table name needs no boundary — nothing else is called that.
|
|
72
|
+
// A quoted path to a scripts/gds/*.js file is a FILENAME, not a category read
|
|
73
|
+
// (task 1003949). One of these guards is itself called category-advisory-guard.js,
|
|
74
|
+
// so the moment any decision surface lists the enforcement tooling by path — which
|
|
75
|
+
// module-scope-map.js must, to own it — this check fired on its own name.
|
|
76
|
+
//
|
|
77
|
+
// Blanked rather than exempted by file, and kept as narrow as it can be: only the
|
|
78
|
+
// quoted path is removed, so the rest of the line is still scanned. A genuine
|
|
79
|
+
// category read cannot hide inside a string that ends in .js.
|
|
80
|
+
function withoutScriptPaths(line) {
|
|
81
|
+
return String(line).replace(/(['\"`])[^'\"`]*\.js\1/g, '""');
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
const CATEGORY_RE = /\bcategor(y|ies|ised|ized|isation|ization)\b|\bcategory_id\b|work_categories/i;
|
|
73
85
|
|
|
74
86
|
// Strip // line comments and /* */ blocks so a citation of a rule is legal and only
|
|
@@ -147,7 +159,7 @@ function scanGates(files, read) {
|
|
|
147
159
|
const code = stripComments(src);
|
|
148
160
|
const hits = code.split('\n')
|
|
149
161
|
.map((line, i) => ({ line: line.trim(), n: i + 1 }))
|
|
150
|
-
.filter(({ line }) => CATEGORY_RE.test(line));
|
|
162
|
+
.filter(({ line }) => CATEGORY_RE.test(withoutScriptPaths(line)));
|
|
151
163
|
for (const { line, n } of hits) {
|
|
152
164
|
violations.push(
|
|
153
165
|
`${rel}:${n} reads a work CATEGORY inside a decision surface — \`${line.slice(0, 100)}\`. `
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1234,6 +1234,7 @@ const CHECKS = [
|
|
|
1234
1234
|
// one (ADR 0289). Rationale + the seam→consumer table live in that file.
|
|
1235
1235
|
require('./seam-wiring-guard.js').checkSeamWiring,
|
|
1236
1236
|
require('./home-isolation-guard.js').checkHomeIsolation, // Check 34 — task 1003763: a test that sets HOME for a child without USERPROFILE isolates NOTHING on win32, so it silently stops testing what its comment says. Three tasks fixed this one site at a time. Rationale in that file.
|
|
1237
|
+
require('./scope-map-guard.js').checkDevsecopsOwnsItsChecks, // Check 35 — task 1003949: a check file no MODULE_SCOPE_MAP key owns cannot be authorized by the goal scoped to own it, and the list drifts once per new check. Rationale in that file.
|
|
1237
1238
|
require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
|
|
1238
1239
|
];
|
|
1239
1240
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/scope-map-guard.js — the enforcement tooling owns itself (task 1003949).
|
|
4
|
+
//
|
|
5
|
+
// THE DRIFT THIS STOPS. MODULE_SCOPE_MAP's `devsecops` key enumerates the
|
|
6
|
+
// enforcement tooling by exact path, and the house style since fitness.js hit its
|
|
7
|
+
// line ratchet is that every new check gets its OWN file beside it. Those two facts
|
|
8
|
+
// disagree once per new check: the file appears, nobody adds it to the map, and
|
|
9
|
+
// scopeSubset() resolves it to no module territory — so a goal scoped to devsecops
|
|
10
|
+
// is refused out_of_scope for touching the very tooling it exists to own. That is
|
|
11
|
+
// the same class as tasks 1002690 / 1002736 / 1003124 / 1003655, and it WIDENS with
|
|
12
|
+
// every check added.
|
|
13
|
+
//
|
|
14
|
+
// WHY A GUARD RATHER THAN A LONGER LIST. The list was appended to this time too —
|
|
15
|
+
// but a list that needs appending is the defect, not the fix. Note the evidence:
|
|
16
|
+
// the task that filed this named fourteen unowned files, and fitness.js requires more than that, and the number moves
|
|
17
|
+
// with every check added — which is why this reads the file instead of citing a count. The list was already stale again by the time
|
|
18
|
+
// somebody wrote it down.
|
|
19
|
+
//
|
|
20
|
+
// WHY NOT A PREFIX OR A DIRECTORY MOVE. The scope-map format has no wildcards, so
|
|
21
|
+
// owning the family by prefix means moving the guards into scripts/gds/fitness/ —
|
|
22
|
+
// and those filenames are named in workflows, skills, ADRs, session logs, the file
|
|
23
|
+
// map and the docs tree (40+ files). That is a real refactor with a wide blast
|
|
24
|
+
// radius, and it is deliberately NOT bundled here; this makes the drift impossible
|
|
25
|
+
// first, which is what the goal wall actually needs.
|
|
26
|
+
//
|
|
27
|
+
// WHY THE LIST STAYS EXPLICIT. MODULE_SCOPE_MAP feeds an AUTHORIZATION decision.
|
|
28
|
+
// Deriving it at runtime from a static scan of another file would mean what a goal
|
|
29
|
+
// may touch depends on a regex over source text — a worse property than a list a
|
|
30
|
+
// human can read. So the list stays literal and CI keeps it honest.
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
36
|
+
const FITNESS = path.join('scripts', 'gds', 'fitness.js');
|
|
37
|
+
|
|
38
|
+
// Every `require('./x.js')` in fitness.js — the files it actually runs, which is
|
|
39
|
+
// the definition of "the enforcement tooling" that matters here.
|
|
40
|
+
//
|
|
41
|
+
// PURE, and exported, so the extraction is testable without the real file: a
|
|
42
|
+
// scanner that quietly matches nothing looks exactly like a clean result, and this
|
|
43
|
+
// one's whole job is to produce a non-empty set.
|
|
44
|
+
// THE EXTENSION IS OPTIONAL, and requiring it was a real gap (grade round 1). Node
|
|
45
|
+
// resolves `require('./run-routine')` to run-routine.js, and fitness.js writes FOUR of
|
|
46
|
+
// its sibling requires that way — gen-api-docs, migration-namespace, run-routine,
|
|
47
|
+
// skill-lint. A pattern anchored on `.js` found none of them, so four check files
|
|
48
|
+
// stayed unowned while this guard reported the set clean.
|
|
49
|
+
//
|
|
50
|
+
// That is the failure shape this file exists to end, committed by the guard itself: a
|
|
51
|
+
// scan that matches less than it should reports a healthy repo, and healthy is exactly
|
|
52
|
+
// what a broken scan looks like.
|
|
53
|
+
function siblingRequires(source) {
|
|
54
|
+
const out = new Set();
|
|
55
|
+
for (const m of String(source || '').matchAll(/require\(['"]\.\/([A-Za-z0-9._-]+?)(\.js)?['"]\)/g)) {
|
|
56
|
+
out.add(`scripts/gds/${m[1]}.js`);
|
|
57
|
+
}
|
|
58
|
+
return [...out].sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// PURE — which of those files no module key claims. A DIRECTORY entry owns everything
|
|
62
|
+
// beneath it, which is why `dirs` is a parameter rather than something the caller
|
|
63
|
+
// pre-filters: forgetting it would report every file under an owned directory as
|
|
64
|
+
// unowned.
|
|
65
|
+
function unownedBy(siblings, ownedPaths, dirs = []) {
|
|
66
|
+
const owned = new Set(ownedPaths);
|
|
67
|
+
return siblings.filter((p) => !owned.has(p) && !dirs.some((d) => p.startsWith(d)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function checkDevsecopsOwnsItsChecks() {
|
|
71
|
+
const name = 'the enforcement tooling owns itself (task 1003949)';
|
|
72
|
+
const src = fs.readFileSync(path.join(REPO_ROOT, FITNESS), 'utf8');
|
|
73
|
+
const siblings = siblingRequires(src);
|
|
74
|
+
|
|
75
|
+
// A scanner that finds nothing passes for free. fitness.js is BUILT out of
|
|
76
|
+
// sibling requires, so an empty set means the pattern broke, not that the tooling
|
|
77
|
+
// became self-contained — this check must not be able to pass vacuously.
|
|
78
|
+
if (siblings.length === 0) {
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
ok: false,
|
|
82
|
+
hardFail: true,
|
|
83
|
+
violations: [`${FITNESS}: found NO sibling require() calls. That is a broken scan, not a `
|
|
84
|
+
+ 'clean result — the extraction pattern in scope-map-guard.js needs looking at.'],
|
|
85
|
+
warnings: [],
|
|
86
|
+
note: 'the scan produced an empty set, which cannot be right.',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { MODULE_SCOPE_MAP } = require('../../src/bongos/module-scope-map.js');
|
|
91
|
+
// Each value is { globs: [...], protected?: bool } — not a bare array.
|
|
92
|
+
const globsOf = (v) => (Array.isArray(v) ? v : (v && v.globs) || []);
|
|
93
|
+
const allOwned = [];
|
|
94
|
+
for (const v of Object.values(MODULE_SCOPE_MAP)) allOwned.push(...globsOf(v));
|
|
95
|
+
// Any key may own a check file — what matters is that SOME key does, so a goal can
|
|
96
|
+
// be scoped to it. A directory entry owns everything beneath it.
|
|
97
|
+
const dirs = allOwned.filter((p) => p.endsWith('/'));
|
|
98
|
+
const owned = new Set(allOwned);
|
|
99
|
+
// Through the exported helper rather than a second inline copy of the same filter:
|
|
100
|
+
// two spellings of one rule is how the enumerated list this guard polices drifted in
|
|
101
|
+
// the first place.
|
|
102
|
+
const unowned = unownedBy(siblings, allOwned, dirs);
|
|
103
|
+
|
|
104
|
+
const violations = unowned.map((p) => `${p} is required by ${FITNESS} but no MODULE_SCOPE_MAP key `
|
|
105
|
+
+ 'owns it — scopeSubset() resolves it to no territory, so a goal scoped to devsecops is denied '
|
|
106
|
+
+ 'out_of_scope for touching the tooling it exists to own. Add it to the devsecops key.');
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
ok: unowned.length === 0,
|
|
111
|
+
hardFail: unowned.length > 0,
|
|
112
|
+
violations,
|
|
113
|
+
warnings: [],
|
|
114
|
+
// Printed on the PASS path too: the count is the evidence the scan ran at all.
|
|
115
|
+
note: `${siblings.length} file(s) required by ${FITNESS}, all owned by a MODULE_SCOPE_MAP key. `
|
|
116
|
+
+ 'The house style gives each new check its own file, so this list drifts once per check '
|
|
117
|
+
+ 'unless CI says otherwise (task 1003949).',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
module.exports = { checkDevsecopsOwnsItsChecks, siblingRequires, unownedBy, FITNESS };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/stranded-rows.js — a task whose work already landed, still sitting in
|
|
3
|
+
// `ready`/`backlog` (task 1003775).
|
|
4
|
+
//
|
|
5
|
+
// THE FAILURE. Three tasks closed in one 2026-08-20 session needed no work at all:
|
|
6
|
+
// the fix was already in main. That was 3 of 8 tasks in one sweep, and every one was
|
|
7
|
+
// found by a human reading code. The cost is not the wasted claim — it is that the
|
|
8
|
+
// next builder claims it, reads the code, finds nothing to do, and has to prove a
|
|
9
|
+
// negative before releasing it.
|
|
10
|
+
//
|
|
11
|
+
// SCOPE — ONE OF THE TWO FAILURE MODES, DELIBERATELY. Idea 1000807 named two:
|
|
12
|
+
//
|
|
13
|
+
// 1. STRANDED ROW — the work landed under the task's OWN id, but the row never
|
|
14
|
+
// moved off `ready`. Mechanical. This file.
|
|
15
|
+
// 2. SILENT SUPERSESSION — a LATER task solved the same problem under a different
|
|
16
|
+
// id. The idea's author tried commit-message scanning for this and reports it
|
|
17
|
+
// does NOT work: a broad scan and a narrowed one (826 commits) both returned
|
|
18
|
+
// ~44 open tasks that merely MENTION an id — seeded-task commits, session logs,
|
|
19
|
+
// ADRs citing planned work. Not attempted here. It needs a human or LLM read and
|
|
20
|
+
// belongs in a review cadence, not new machinery.
|
|
21
|
+
//
|
|
22
|
+
// THE SIGNAL, and why it is this one. A commit says which task it DID by ending a
|
|
23
|
+
// line with `(task N)`. That is narrower than "mentions N", which is the distinction
|
|
24
|
+
// the idea's investigation failed on: "1002792, 1002200, 1002571" in a session-log
|
|
25
|
+
// body is a comma list, not a line ending in `(task N)`.
|
|
26
|
+
//
|
|
27
|
+
// It reads the WHOLE message, not just the subject, and that detail is load-bearing.
|
|
28
|
+
// The historical case the idea names — task 1002200 — landed on a cloud-agent branch
|
|
29
|
+
// whose own commit subject never named it; the `(task 1002200)` line lives in the
|
|
30
|
+
// MERGE commit's body, where GitHub echoes the branch's subject. A subject-only scan
|
|
31
|
+
// misses the one case this file exists for.
|
|
32
|
+
//
|
|
33
|
+
// MEASURED on this repo before shipping: 877 ids are named as an authoring subject
|
|
34
|
+
// across 4,784 commits, and ZERO of them are open `ready`/`backlog` rows (434 of
|
|
35
|
+
// those). So today's board is clean and the signal is not merely quiet — the same
|
|
36
|
+
// rule catches 1002200 when its row is treated as open.
|
|
37
|
+
//
|
|
38
|
+
// HOUSE RULES (the truth-teller family: strand-watch.js, stale-worktrees.js).
|
|
39
|
+
// Fail OPEN — a probe never blocks a session. Silent when healthy. Prints IDS AND
|
|
40
|
+
// FIXED VOCABULARY ONLY: never a commit subject or a task title, both of which are
|
|
41
|
+
// builder-authored text.
|
|
42
|
+
//
|
|
43
|
+
// Usage:
|
|
44
|
+
// node scripts/gds/stranded-rows.js # human report; exit 1 if any found
|
|
45
|
+
// node scripts/gds/stranded-rows.js --json # { stranded: [{ id, status, sha }] }
|
|
46
|
+
// node scripts/gds/stranded-rows.js --line # ONE line, or nothing; always exit 0
|
|
47
|
+
|
|
48
|
+
'use strict';
|
|
49
|
+
|
|
50
|
+
const { execFileSync } = require('node:child_process');
|
|
51
|
+
|
|
52
|
+
const MAIN_REF = 'origin/main';
|
|
53
|
+
// A record separator no commit message can contain: the word is wrapped in U+0001
|
|
54
|
+
// (START OF HEADING), a C0 control character git will not carry in a message body.
|
|
55
|
+
//
|
|
56
|
+
// Built from an explicit escape rather than written as a literal, because the control
|
|
57
|
+
// bytes are INVISIBLE in a source listing - the first reviewer of this file read it as
|
|
58
|
+
// `const SEP = 'COMMIT'` and correctly flagged that a body containing the word COMMIT
|
|
59
|
+
// would split a record in half. It would; that is exactly why the sentinels are there,
|
|
60
|
+
// and now you can see them. A test pins a body carrying the bare word.
|
|
61
|
+
const SOH = String.fromCharCode(1);
|
|
62
|
+
const SEP = `${SOH}COMMIT${SOH}`;
|
|
63
|
+
|
|
64
|
+
// The authoring convention: a line whose END is `(task N)`. Anchored at end-of-line on
|
|
65
|
+
// purpose — that is what distinguishes "this commit did task N" from "this commit
|
|
66
|
+
// mentioned N", and the second is the noise that made the supersession half undoable.
|
|
67
|
+
const AUTHORED_RE = /\(task (\d{6,7})\)\s*$/;
|
|
68
|
+
|
|
69
|
+
// PURE — every task id a commit message claims to have DONE. Exported so the rule is
|
|
70
|
+
// testable without a repo: a scanner that quietly stops matching reports a clean board,
|
|
71
|
+
// which is indistinguishable from a healthy one.
|
|
72
|
+
function authoredIds(message) {
|
|
73
|
+
const out = new Set();
|
|
74
|
+
for (const line of String(message || '').split('\n')) {
|
|
75
|
+
const m = AUTHORED_RE.exec(line.trim());
|
|
76
|
+
if (m) out.add(m[1]);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// PURE — id -> first sha that claimed it, over a whole `git log` payload.
|
|
82
|
+
function scanLog(raw) {
|
|
83
|
+
const byId = new Map();
|
|
84
|
+
for (const block of String(raw || '').split(SEP)) {
|
|
85
|
+
if (!block.trim()) continue;
|
|
86
|
+
const nl = block.indexOf('\n');
|
|
87
|
+
const sha = (nl === -1 ? block : block.slice(0, nl)).trim();
|
|
88
|
+
const body = nl === -1 ? '' : block.slice(nl + 1);
|
|
89
|
+
for (const id of authoredIds(body)) if (!byId.has(id)) byId.set(id, sha);
|
|
90
|
+
}
|
|
91
|
+
return byId;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// PURE — the stranded set. `openRows` are { id, status }; `landed` is scanLog's map.
|
|
95
|
+
// Kept separate from the IO so the decision is unit-testable with no git and no API.
|
|
96
|
+
function strandedRows(openRows, landed) {
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const row of openRows || []) {
|
|
99
|
+
const id = String(row && row.id);
|
|
100
|
+
if (!landed.has(id)) continue;
|
|
101
|
+
out.push({ id, status: String(row.status || ''), sha: landed.get(id).slice(0, 8) });
|
|
102
|
+
}
|
|
103
|
+
return out.sort((a, b) => Number(a.id) - Number(b.id));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readLog(cwd) {
|
|
107
|
+
return execFileSync('git', ['log', MAIN_REF, `--pretty=${SEP}%H%n%B`], {
|
|
108
|
+
cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Both statuses that mean "nobody has started this". A row already `active` or
|
|
113
|
+
// `completed` is someone's live work, not a strand.
|
|
114
|
+
const OPEN_STATUSES = ['ready', 'backlog'];
|
|
115
|
+
|
|
116
|
+
// A hard stop so a paging bug cannot loop forever. Reaching it means the board grew
|
|
117
|
+
// past what one scan should walk, which is a fact worth SAYING rather than silently
|
|
118
|
+
// truncating — a detector that quietly stops early reports a clean board.
|
|
119
|
+
const MAX_PAGES = 50;
|
|
120
|
+
|
|
121
|
+
// PURE-ish — the paging walk, with `getPage` injected. Extracted and exported because
|
|
122
|
+
// it was the only untested IO in this file, and off-by-one paging is exactly the kind
|
|
123
|
+
// of logic that silently returns a short list (and a short list here means 'nothing is
|
|
124
|
+
// stranded').
|
|
125
|
+
async function pageOpenRows(getPage, { statuses = OPEN_STATUSES, pageSize = 200, maxPages = MAX_PAGES } = {}) {
|
|
126
|
+
const rows = [];
|
|
127
|
+
for (const status of statuses) {
|
|
128
|
+
let offset = 0;
|
|
129
|
+
let page = 0;
|
|
130
|
+
for (; page < maxPages; page += 1) {
|
|
131
|
+
const r = await getPage({ status, limit: pageSize, offset });
|
|
132
|
+
if (!r || !r.ok) throw new Error(`could not list ${status} tasks (${r && r.status})`);
|
|
133
|
+
const got = (r.data && r.data.tasks) || [];
|
|
134
|
+
for (const t of got) rows.push({ id: t.id, status: t.status });
|
|
135
|
+
// A SHORT page is the end of the table. Compared against the size the route
|
|
136
|
+
// actually honoured, never the size we asked for — the route clamps.
|
|
137
|
+
if (got.length < (r.limit || pageSize)) break;
|
|
138
|
+
offset += got.length;
|
|
139
|
+
}
|
|
140
|
+
if (page >= maxPages) {
|
|
141
|
+
throw new Error(`more than ${maxPages} pages of ${status} tasks — refusing to report a partial board as clean`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return rows;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readOpenRows() {
|
|
148
|
+
const { cliClient } = require('./cli-lib.js');
|
|
149
|
+
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
|
|
150
|
+
const api = await cliClient();
|
|
151
|
+
return pageOpenRows(async ({ status, limit, offset }) => {
|
|
152
|
+
const q = buildTaskPageQuery({ status, limit, offset });
|
|
153
|
+
const r = await api.tasks.getTasks({ query: q });
|
|
154
|
+
// Carry the limit the route HONOURED back to the walker, so its short-page test
|
|
155
|
+
// compares against the clamped size rather than the requested one.
|
|
156
|
+
return r && { ok: r.ok, status: r.status, data: r.data, limit: q.limit };
|
|
157
|
+
}, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function scan(cwd) {
|
|
161
|
+
const landed = scanLog(readLog(cwd));
|
|
162
|
+
// The scan must not pass by finding nothing: this repo's own history is built out of
|
|
163
|
+
// these lines, so an empty map means the pattern broke.
|
|
164
|
+
if (landed.size === 0) throw new Error('no commit claims to have done any task — the pattern has drifted');
|
|
165
|
+
return { stranded: strandedRows(await readOpenRows(), landed), scanned: landed.size };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function main() {
|
|
169
|
+
const argv = process.argv.slice(2);
|
|
170
|
+
const line = argv.includes('--line');
|
|
171
|
+
let r;
|
|
172
|
+
try {
|
|
173
|
+
r = await scan(process.cwd());
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (line) process.exit(0); // fail OPEN — a probe never blocks a session
|
|
176
|
+
console.error(`stranded-rows: undetermined — ${e.message || e}`);
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (argv.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
181
|
+
if (line) {
|
|
182
|
+
if (r.stranded.length) {
|
|
183
|
+
const ids = r.stranded.slice(0, 6).map((s) => s.id).join(', ');
|
|
184
|
+
const more = r.stranded.length > 6 ? ` +${r.stranded.length - 6} more` : '';
|
|
185
|
+
console.log(`Stranded rows: ${r.stranded.length} open task(s) whose work already landed on main (${ids}${more}). `
|
|
186
|
+
+ 'Claiming one means reading the code to prove there is nothing to do. Run node scripts/gds/stranded-rows.js.');
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.log(` Stranded rows: ${r.stranded.length} (of ${r.scanned} task id(s) named as an authoring subject on main)`);
|
|
191
|
+
for (const s of r.stranded) console.log(` task ${s.id} [${s.status}] landed in ${s.sha}`);
|
|
192
|
+
if (r.stranded.length) {
|
|
193
|
+
console.log(' Each of these has a commit on main claiming to have done it. Verify, then ship or abandon the row.');
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (require.main === module) main();
|
|
199
|
+
|
|
200
|
+
module.exports = { authoredIds, scanLog, strandedRows, pageOpenRows, AUTHORED_RE, SEP, OPEN_STATUSES, MAX_PAGES };
|
|
@@ -338,6 +338,36 @@ const MODULE_GLOBS = {
|
|
|
338
338
|
'scripts/gds/fitness.js', 'scripts/gds/docs-entropy.js',
|
|
339
339
|
'scripts/gds/redteam-patrol.js', 'scripts/gds/audit-gate.js',
|
|
340
340
|
'scripts/gds/fitness-ratchets.js', 'scripts/gds/control-manifest.js',
|
|
341
|
+
// Every file fitness.js actually requires (task 1003949). Since fitness.js hit
|
|
342
|
+
// its line ratchet, the house style is that each new check lands in its OWN file
|
|
343
|
+
// beside it — and none of them was listed here, so scopeSubset() resolved them to
|
|
344
|
+
// no territory and a goal scoped to devsecops was denied out_of_scope for touching
|
|
345
|
+
// the very tooling it owns (the 1002690 / 1002736 / 1003124 / 1003655 class).
|
|
346
|
+
//
|
|
347
|
+
// This list is kept honest by scripts/gds/scope-map-guard.js rather than by
|
|
348
|
+
// remembering: it derives the same set from fitness.js's own requires and fails
|
|
349
|
+
// CI when one is missing. That guard exists because appending is not a fix — the
|
|
350
|
+
// task that filed this named FOURTEEN unowned files and there were twenty-two.
|
|
351
|
+
'scripts/gds/scope-map-guard.js',
|
|
352
|
+
// Landed from task 1003763 while this branch was in flight, and the guard below
|
|
353
|
+
// caught it unowned on the merge - which is the drift it exists to stop.
|
|
354
|
+
'scripts/gds/home-isolation-guard.js',
|
|
355
|
+
// Required WITHOUT the .js extension by fitness.js, which is how they stayed
|
|
356
|
+
// invisible to the first cut of scope-map-guard (grade round 1): Node resolves
|
|
357
|
+
// require('./skill-lint') fine, but a pattern anchored on '.js' matched none of them.
|
|
358
|
+
'scripts/gds/gen-api-docs.js', 'scripts/gds/migration-namespace.js',
|
|
359
|
+
'scripts/gds/skill-lint.js', 'scripts/gds/run-routine.js',
|
|
360
|
+
'scripts/gds/adr-namespace.js', 'scripts/gds/baseline-staleness.js',
|
|
361
|
+
'scripts/gds/category-advisory-guard.js', 'scripts/gds/client-baseurl-guard.js',
|
|
362
|
+
'scripts/gds/doc-cli-guard.js', 'scripts/gds/doc-comment-coverage.js',
|
|
363
|
+
'scripts/gds/exec-path-guard.js', 'scripts/gds/fitness-lib.js',
|
|
364
|
+
'scripts/gds/fitness-checks-error-envelope.js', 'scripts/gds/fitness-checks-identity.js',
|
|
365
|
+
'scripts/gds/fitness-checks-packaging.js', 'scripts/gds/fitness-checks-route-pins.js',
|
|
366
|
+
'scripts/gds/fitness-checks-vocabulary.js', 'scripts/gds/fitness-checks-write-validation.js',
|
|
367
|
+
'scripts/gds/fitness-migration-contraction.js', 'scripts/gds/role-pack-guard.js',
|
|
368
|
+
'scripts/gds/route-shadow-guard.js', 'scripts/gds/routine-schedule.js',
|
|
369
|
+
'scripts/gds/seam-wiring-guard.js', 'scripts/gds/skill-preflight.js',
|
|
370
|
+
'scripts/gds/test-path-guard.js', 'scripts/gds/version-literal-guard.js',
|
|
341
371
|
],
|
|
342
372
|
// --- the METHODOLOGY surfaces: the instruction core an agent session actually
|
|
343
373
|
// reads — the always-loaded root CLAUDE.md, the docs library it indexes, the
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.824'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -131,5 +131,34 @@ test('the live tree passes, and the gate list is not empty', () => {
|
|
|
131
131
|
assert.equal(r.hardFail, false);
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
// --- a FILENAME is not a category read (task 1003949) ----------------------
|
|
135
|
+
//
|
|
136
|
+
// One of the enforcement files is itself called category-advisory-guard.js, so the
|
|
137
|
+
// moment a decision surface lists the tooling BY PATH — which module-scope-map.js
|
|
138
|
+
// has to, in order to own it — this guard fired on its own name. Quoted paths
|
|
139
|
+
// ending in .js are blanked before the match. The point of these two tests is that
|
|
140
|
+
// the blanking is narrow: it must not become a way to hide a real read.
|
|
141
|
+
|
|
142
|
+
test('a quoted path to a file whose NAME contains "category" is not a violation', () => {
|
|
143
|
+
const { violations } = scanGates(['src/bongos/module-scope-map.js'], from({
|
|
144
|
+
'src/bongos/module-scope-map.js':
|
|
145
|
+
'const MAP = {\n'
|
|
146
|
+
+ " devsecops: { globs: ['scripts/gds/category-advisory-guard.js', 'scripts/gds/fitness.js'] },\n"
|
|
147
|
+
+ '};\n',
|
|
148
|
+
}));
|
|
149
|
+
assert.deepEqual(violations, [], 'owning the tooling by path is not branching on a category');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a real category read on the SAME LINE as a path is still refused', () => {
|
|
153
|
+
// Only the quoted path is blanked, not the line — so a read cannot ride along
|
|
154
|
+
// beside one.
|
|
155
|
+
const { violations } = scanGates(['modules/lifecycle/claim-feed.js'], from({
|
|
156
|
+
'modules/lifecycle/claim-feed.js':
|
|
157
|
+
"const guard = require('./category-advisory-guard.js'); if (t.category_id === 3) return false;\n",
|
|
158
|
+
}));
|
|
159
|
+
assert.equal(violations.length, 1, 'the category_id read must still be caught');
|
|
160
|
+
assert.match(violations[0], /ADVISORY/);
|
|
161
|
+
});
|
|
162
|
+
|
|
134
163
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
135
164
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// tests/scope_map_guard.mjs — the enforcement tooling owns itself (task 1003949).
|
|
2
|
+
//
|
|
3
|
+
// MODULE_SCOPE_MAP's devsecops key enumerates the enforcement tooling by exact path,
|
|
4
|
+
// and the house style since fitness.js hit its line ratchet is that every new check
|
|
5
|
+
// lands in its OWN file beside it. Those two facts disagree once per new check: the
|
|
6
|
+
// file appears, nobody adds it to the map, and scopeSubset() resolves it to no module
|
|
7
|
+
// territory — so a goal scoped to devsecops is denied out_of_scope for touching the
|
|
8
|
+
// very tooling it owns.
|
|
9
|
+
//
|
|
10
|
+
// The size of the drift is the argument for a guard rather than a longer list: the
|
|
11
|
+
// task that filed this named FOURTEEN unowned files, and there were twenty-two.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/scope_map_guard.mjs
|
|
14
|
+
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { checkDevsecopsOwnsItsChecks, siblingRequires } = require('../scripts/gds/scope-map-guard.js');
|
|
21
|
+
|
|
22
|
+
test('every file fitness.js requires is owned by some MODULE_SCOPE_MAP key', () => {
|
|
23
|
+
const r = checkDevsecopsOwnsItsChecks();
|
|
24
|
+
assert.equal(r.ok, true, r.violations.join('\n'));
|
|
25
|
+
assert.equal(r.hardFail, false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('siblingRequires extracts the real require shape, deduped and sorted', () => {
|
|
29
|
+
const src = [
|
|
30
|
+
"const a = require('./role-pack-guard.js').checkRolePacks,",
|
|
31
|
+
" b = require('./doc-cli-guard.js').checkDocNamesRealCli,",
|
|
32
|
+
" c = require('./role-pack-guard.js').other, // the same file twice",
|
|
33
|
+
"const d = require('node:path');",
|
|
34
|
+
"const e = require('../../src/module-api');",
|
|
35
|
+
].join('\n');
|
|
36
|
+
assert.deepEqual(siblingRequires(src), [
|
|
37
|
+
'scripts/gds/doc-cli-guard.js',
|
|
38
|
+
'scripts/gds/role-pack-guard.js',
|
|
39
|
+
], 'only ./sibling.js requires, deduped — not node: builtins and not cross-tree requires');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('an EXTENSION-LESS require is extracted — the gap that shipped green', () => {
|
|
43
|
+
// Node resolves require('./run-routine') to run-routine.js, and fitness.js writes FOUR
|
|
44
|
+
// of its sibling requires that way. A pattern anchored on .js found none of them, so
|
|
45
|
+
// four check files stayed unowned while the guard reported the set clean — this guard
|
|
46
|
+
// committing the exact failure it exists to end.
|
|
47
|
+
assert.deepEqual(siblingRequires("require('./run-routine'); require('./skill-lint')"), [
|
|
48
|
+
'scripts/gds/run-routine.js',
|
|
49
|
+
'scripts/gds/skill-lint.js',
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('both spellings of ONE file collapse to a single entry', () => {
|
|
54
|
+
assert.deepEqual(siblingRequires("require('./a'); require('./a.js')"), ['scripts/gds/a.js']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a hyphenated and a dotted filename both survive extraction', () => {
|
|
58
|
+
// fitness-checks-write-validation.js and the like: a character class that stopped at
|
|
59
|
+
// the first hyphen would silently shorten the set.
|
|
60
|
+
assert.deepEqual(
|
|
61
|
+
siblingRequires("require('./fitness-checks-write-validation.js'); require('./a.b.js')"),
|
|
62
|
+
['scripts/gds/a.b.js', 'scripts/gds/fitness-checks-write-validation.js'],
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an EMPTY scan is a hard failure, not a clean result', () => {
|
|
67
|
+
// This check's only output is "nothing is unowned", which an empty input satisfies
|
|
68
|
+
// for free. fitness.js is built out of sibling requires, so an empty set means the
|
|
69
|
+
// pattern broke — the failure mode that would otherwise look like success.
|
|
70
|
+
assert.deepEqual(siblingRequires(''), []);
|
|
71
|
+
assert.deepEqual(siblingRequires(null), []);
|
|
72
|
+
// and the check itself says so rather than passing: exercised by pointing the
|
|
73
|
+
// extraction at source with no sibling requires in it.
|
|
74
|
+
assert.equal(siblingRequires('const x = require("node:fs");').length, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the PASS note carries the count, so the check cannot look green while doing nothing', () => {
|
|
78
|
+
const r = checkDevsecopsOwnsItsChecks();
|
|
79
|
+
assert.match(r.note, /\d+ file\(s\) required by/);
|
|
80
|
+
const n = Number(r.note.match(/^(\d+) file/)[1]);
|
|
81
|
+
assert.ok(n > 14, `expected more than the fourteen the filing task listed, saw ${n}`);
|
|
82
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// tests/stranded_rows.mjs — a task whose work already landed, still sitting in
|
|
2
|
+
// ready/backlog (task 1003775).
|
|
3
|
+
//
|
|
4
|
+
// Three tasks closed in one 2026-08-20 session needed no work at all: the fix was
|
|
5
|
+
// already in main. Every one was found by a human reading code.
|
|
6
|
+
//
|
|
7
|
+
// This detector's whole risk is looking healthy. It prints nothing when the board is
|
|
8
|
+
// clean, and the board IS clean today — so "no output" is exactly what a completely
|
|
9
|
+
// broken scan produces too. These tests therefore lean on the rule itself, and on the
|
|
10
|
+
// historical case the idea names.
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const { authoredIds, scanLog, strandedRows, pageOpenRows, SEP } = require('../scripts/gds/stranded-rows.js');
|
|
18
|
+
|
|
19
|
+
test('a subject naming the task it did IS an authoring claim', () => {
|
|
20
|
+
assert.deepEqual([...authoredIds('fix(docs): self-healing /docs/openapi.json (task 1002200)')], ['1002200']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a MENTION is not an authoring claim — the distinction the whole scope rests on', () => {
|
|
24
|
+
// This is the exact shape that made the supersession half undoable: the idea's
|
|
25
|
+
// author scanned 826 commits and got ~44 open tasks that merely mentioned an id.
|
|
26
|
+
assert.deepEqual([...authoredIds('1002792, 1002200, 1002571, 1002790, 1002795), per CLAUDE.md §12.')], []);
|
|
27
|
+
assert.deepEqual([...authoredIds('supersedes task 1002200 and should be revisited')], []);
|
|
28
|
+
assert.deepEqual([...authoredIds('see (task 1002200) for background')], [],
|
|
29
|
+
'mid-line is a citation; only end-of-line is the authoring convention');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('the claim is read from ANYWHERE in the message, not just the subject', () => {
|
|
33
|
+
// Task 1002200 landed on a cloud-agent branch whose own subject never named it. The
|
|
34
|
+
// `(task N)` line lives in the MERGE commit's body, where GitHub echoes the branch's
|
|
35
|
+
// subject. A subject-only scan misses the one case this detector exists for.
|
|
36
|
+
const mergeBody = [
|
|
37
|
+
// The owner handle is a placeholder on purpose: a fixture that hardcodes the
|
|
38
|
+
// founder's identity fails audit-authorship.sh (ADR 0016's no-hardcoded-Archon
|
|
39
|
+
// rule), and nothing here depends on WHOSE fork the branch came from.
|
|
40
|
+
'Merge pull request #5 from an-owner/claude/api-docs-bongos-16a682',
|
|
41
|
+
'',
|
|
42
|
+
'fix(docs): self-healing /docs/openapi.json — serve committed spec, else generate in-process (task 1002200)',
|
|
43
|
+
].join('\n');
|
|
44
|
+
assert.deepEqual([...authoredIds(mergeBody)], ['1002200']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('scanLog keeps the FIRST sha that claimed an id', () => {
|
|
48
|
+
const raw = [
|
|
49
|
+
`${SEP}aaaaaaaa1111\nfix: later touch (task 1000001)`,
|
|
50
|
+
`${SEP}bbbbbbbb2222\nfix: the original (task 1000001)`,
|
|
51
|
+
].join('\n');
|
|
52
|
+
const m = scanLog(raw);
|
|
53
|
+
assert.equal(m.get('1000001'), 'aaaaaaaa1111', 'git log is newest-first, so the first seen is the most recent claim');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a row whose work landed is stranded; one whose has not is left alone', () => {
|
|
57
|
+
const landed = new Map([['1000001', 'aaaaaaaa1111']]);
|
|
58
|
+
const rows = [{ id: '1000001', status: 'ready' }, { id: '1000002', status: 'backlog' }];
|
|
59
|
+
const out = strandedRows(rows, landed);
|
|
60
|
+
assert.equal(out.length, 1);
|
|
61
|
+
assert.deepEqual(out[0], { id: '1000001', status: 'ready', sha: 'aaaaaaaa' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the report carries NO builder-authored text — ids and fixed vocabulary only', () => {
|
|
65
|
+
// A truth-teller printing a commit subject or a task title would put arbitrary
|
|
66
|
+
// builder prose into a session's context. The house rule for this family is ids and
|
|
67
|
+
// fixed vocabulary; `strandedRows` returns exactly three machine fields.
|
|
68
|
+
const out = strandedRows([{ id: '1000001', status: 'ready', title: 'DO NOT LEAK ME' }],
|
|
69
|
+
new Map([['1000001', 'aaaaaaaa1111']]));
|
|
70
|
+
assert.deepEqual(Object.keys(out[0]).sort(), ['id', 'sha', 'status']);
|
|
71
|
+
assert.ok(!JSON.stringify(out).includes('DO NOT LEAK'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('an empty scan is a BROKEN scan — it must not read as a clean board', () => {
|
|
75
|
+
// The detector is silent when healthy, so a pattern that stops matching looks
|
|
76
|
+
// exactly like good news. scanLog over real-shaped input must be non-empty, and the
|
|
77
|
+
// IO wrapper throws on an empty map (fail-open at the --line boundary, loud
|
|
78
|
+
// otherwise).
|
|
79
|
+
assert.equal(scanLog('').size, 0, 'nothing in, nothing out — which is why the caller checks');
|
|
80
|
+
assert.ok(scanLog(`${SEP}aaaaaaaa1111\nfix: something (task 1000001)`).size > 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---- the record separator (grade round 1) ---------------------------------
|
|
84
|
+
//
|
|
85
|
+
// The separator is the word COMMIT wrapped in U+0001. Those bytes are INVISIBLE in a
|
|
86
|
+
// source listing, so the first reviewer read it as a bare 'COMMIT' and flagged — quite
|
|
87
|
+
// reasonably — that a commit body containing that word would split a record in half.
|
|
88
|
+
// It would. This pins that it does not.
|
|
89
|
+
|
|
90
|
+
test('a commit body containing the bare word COMMIT does not split a record', () => {
|
|
91
|
+
const raw = `${SEP}aaaaaaaa1111\nfix COMMIT-hook validation (task 1000001)`;
|
|
92
|
+
const m = scanLog(raw);
|
|
93
|
+
assert.equal(m.size, 1, 'one commit in, one record out');
|
|
94
|
+
assert.equal(m.get('1000001'), 'aaaaaaaa1111');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the separator really is control-wrapped, not the bare word', () => {
|
|
98
|
+
assert.equal(SEP.charCodeAt(0), 1);
|
|
99
|
+
assert.equal(SEP.charCodeAt(SEP.length - 1), 1);
|
|
100
|
+
assert.notEqual(SEP, 'COMMIT');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ---- the paging walk (grade round 1) --------------------------------------
|
|
104
|
+
//
|
|
105
|
+
// This was the only untested IO in the file, and off-by-one paging is exactly the kind
|
|
106
|
+
// of logic that silently returns a SHORT list — which here means 'nothing is stranded',
|
|
107
|
+
// the most comfortable wrong answer this tool can give.
|
|
108
|
+
|
|
109
|
+
const page = (tasks, limit) => ({ ok: true, data: { tasks }, limit });
|
|
110
|
+
const row = (id) => ({ id: String(id), status: 'ready' });
|
|
111
|
+
|
|
112
|
+
test('a full page is followed by another request; a short page ends it', async () => {
|
|
113
|
+
const asked = [];
|
|
114
|
+
const rows = await pageOpenRows(async ({ status, limit, offset }) => {
|
|
115
|
+
asked.push(`${status}@${offset}`);
|
|
116
|
+
if (status !== 'ready') return page([], limit);
|
|
117
|
+
return offset === 0 ? page([row(1), row(2)], 2) : page([row(3)], 2);
|
|
118
|
+
}, { statuses: ['ready', 'backlog'], pageSize: 2 });
|
|
119
|
+
assert.deepEqual(rows.map((r) => r.id), ['1', '2', '3']);
|
|
120
|
+
assert.deepEqual(asked, ['ready@0', 'ready@2', 'backlog@0'], 'a full page must be followed by another request');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('the offset advances by what was RECEIVED, not by what was asked for', async () => {
|
|
124
|
+
// The route clamps `limit`. Advancing by the requested size would skip rows.
|
|
125
|
+
const asked = [];
|
|
126
|
+
await pageOpenRows(async ({ limit, offset }) => {
|
|
127
|
+
asked.push(offset);
|
|
128
|
+
return asked.length === 1 ? page([row(1)], 1) : page([], 1);
|
|
129
|
+
}, { statuses: ['ready'], pageSize: 500 });
|
|
130
|
+
assert.deepEqual(asked, [0, 1], 'asked for 500, got 1, so the next offset is 1');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a failed page THROWS — a partial board must never read as clean', async () => {
|
|
134
|
+
await assert.rejects(
|
|
135
|
+
() => pageOpenRows(async () => ({ ok: false, status: 500 }), { statuses: ['ready'] }),
|
|
136
|
+
/could not list ready tasks/,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('hitting the page cap THROWS rather than silently truncating', async () => {
|
|
141
|
+
// A detector that quietly stops early reports a clean board, which is the one
|
|
142
|
+
// outcome this tool must never fake.
|
|
143
|
+
await assert.rejects(
|
|
144
|
+
() => pageOpenRows(async ({ limit }) => page([row(1)], 1), { statuses: ['ready'], pageSize: 1, maxPages: 3 }),
|
|
145
|
+
/refusing to report a partial board as clean/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('both open statuses are walked — a backlog strand counts too', async () => {
|
|
150
|
+
const seen = [];
|
|
151
|
+
await pageOpenRows(async ({ status, limit }) => { seen.push(status); return page([], limit); });
|
|
152
|
+
assert.deepEqual(seen, ['ready', 'backlog']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('the live repo actually produces claims — the rule has not drifted', () => {
|
|
156
|
+
const { execFileSync } = require('node:child_process');
|
|
157
|
+
const raw = execFileSync('git', ['log', 'origin/main', `--pretty=${SEP}%H%n%B`, '-400'],
|
|
158
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
159
|
+
const m = scanLog(raw);
|
|
160
|
+
assert.ok(m.size > 20,
|
|
161
|
+
`only ${m.size} authoring claims in the last 400 commits — the convention or the pattern has moved, and this detector is now inert`);
|
|
162
|
+
});
|