@bongos/core 1.19.772 → 1.19.773

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 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.772",
6
- "core_contract": "1.19.772",
7
- "source_commit": "b760a843bd9ef9990658204a899ecdfbbb21984a",
5
+ "core_version": "1.19.773",
6
+ "core_contract": "1.19.773",
7
+ "source_commit": "a58cd0e0635965862bd715269833ceeb95f75229",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-16T15:47:40.573Z",
9
+ "built_at": "2026-09-16T15:57:38.889Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 483,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2129,
14
+ "functional_verbatim": 2131,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2637,
20
- "tree_sha256": "42385d02b98d9325f196149f21e9a2fd17b345ea9deee3d2d8282ea0cfc7d76b",
19
+ "file_count": 2639,
20
+ "tree_sha256": "abe2f27e82d25368e2b9a578270d5b71dd9eb1a14e1f130be4ded50be1cbeb11",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2512,7 +2512,7 @@
2512
2512
  {
2513
2513
  "path": "docs/module-api-changelog.md",
2514
2514
  "mode": "0000644",
2515
- "sha256": "f9b63223f5f1b08e1289d0e4c7161e15d0622ee0d35420e896c924fe9ec71118"
2515
+ "sha256": "8e08eec750e9c463fc5bc71fd41fbedb782cc7f5a19f4ae735d1af390754bf6e"
2516
2516
  },
2517
2517
  {
2518
2518
  "path": "docs/modules-contract.md",
@@ -5477,7 +5477,7 @@
5477
5477
  {
5478
5478
  "path": "modules/lifecycle/db-tasks.js",
5479
5479
  "mode": "0000644",
5480
- "sha256": "381e3bf734537972e33bc7ca3f23097f3f264f42bf33d29cb0c5378b01459c68"
5480
+ "sha256": "b26ba2c82bea0ee71e3db888bf531cee605aac90fa5ded2bcffe548cce650f4f"
5481
5481
  },
5482
5482
  {
5483
5483
  "path": "modules/lifecycle/db-versions.js",
@@ -5492,7 +5492,7 @@
5492
5492
  {
5493
5493
  "path": "modules/lifecycle/dead-deps.js",
5494
5494
  "mode": "0000644",
5495
- "sha256": "e083c4849b4f2e9e7fe25d6c0e7857c15412b7308bca7f5a8f74e71b5c36df84"
5495
+ "sha256": "e379969ef290aab09abeef50f2e4996e2df2fa16e6d4ea9fbd268e23518b3147"
5496
5496
  },
5497
5497
  {
5498
5498
  "path": "modules/lifecycle/done-when.js",
@@ -5774,6 +5774,11 @@
5774
5774
  "mode": "0000644",
5775
5775
  "sha256": "9299590b71b194087174ff0212dc70f5f28fbafd5678923c81a3db13c6b78db5"
5776
5776
  },
5777
+ {
5778
+ "path": "modules/lifecycle/task-list-limits.js",
5779
+ "mode": "0000644",
5780
+ "sha256": "923a8116ce5ee7965a26d5d8534cbe4d19de52fbf9c4ea92d1f25d2f9eb6180b"
5781
+ },
5777
5782
  {
5778
5783
  "path": "modules/lifecycle/task-recommendations.js",
5779
5784
  "mode": "0000644",
@@ -7577,12 +7582,12 @@
7577
7582
  {
7578
7583
  "path": "package-lock.json",
7579
7584
  "mode": "0000644",
7580
- "sha256": "eb46c35abaa5066936ca26fc582b1d89c48ba1d7f19ae954fcde8958e3f1b855"
7585
+ "sha256": "44af4cb0a2f98b8943466ce51e7c00c534aa64c1eb345bf2c231a3c605a1eb40"
7581
7586
  },
7582
7587
  {
7583
7588
  "path": "package.json",
7584
7589
  "mode": "0000644",
7585
- "sha256": "097af55c66444a76588929a7bbdbc939325afaf82e405f241292b9bb0d3d75ac"
7590
+ "sha256": "f627191fb5139a43b5d8e728d3d924e7f0316a00f104e5b5a615bd1be15599c2"
7586
7591
  },
7587
7592
  {
7588
7593
  "path": "public-docs/index.html",
@@ -7692,7 +7697,7 @@
7692
7697
  {
7693
7698
  "path": "scripts/gds/audit-deps.js",
7694
7699
  "mode": "0000644",
7695
- "sha256": "f0905213e5e8ba18ea4c3eab7e11bb7c00401a5653ac00990be15c65f361959d"
7700
+ "sha256": "891aadcbea8ef257f68f20f140fa988c45ac06d4caa19802136c372d403db7ba"
7696
7701
  },
7697
7702
  {
7698
7703
  "path": "scripts/gds/audit-exports.js",
@@ -8772,7 +8777,7 @@
8772
8777
  {
8773
8778
  "path": "scripts/gds/ship-flow.js",
8774
8779
  "mode": "0000644",
8775
- "sha256": "4cb2edc48eb4a5693af7b63485ca9fe3cee2caa8cbbd86e3bd4b03d5b1f345dc"
8780
+ "sha256": "ebf0f8c4e2dd7464e255c16ff1c0484acbb1a4e1047f64f08994950004a0ca3d"
8776
8781
  },
8777
8782
  {
8778
8783
  "path": "scripts/gds/ship-git.js",
@@ -8827,7 +8832,7 @@
8827
8832
  {
8828
8833
  "path": "scripts/gds/ship-resume.js",
8829
8834
  "mode": "0000644",
8830
- "sha256": "20b91c52c75fae6355165941d1467081424614d46063f87137870ef291d442b9"
8835
+ "sha256": "f86f6e8c0e9e45b7f6d8720a82cf0058f4142fa51c47cad628d47c92de817f2e"
8831
8836
  },
8832
8837
  {
8833
8838
  "path": "scripts/gds/ship-session-upload.js",
@@ -9417,7 +9422,7 @@
9417
9422
  {
9418
9423
  "path": "src/module-api.js",
9419
9424
  "mode": "0000644",
9420
- "sha256": "99de59a938b51c3c1696a2b9396e55a7a819104d7e5c523d44fb965f4476a9f6"
9425
+ "sha256": "47d80453ed17576c4c45c70e07de63093d4d4e420d266b634b07bddb62faf115"
9421
9426
  },
9422
9427
  {
9423
9428
  "path": "src/module-loader/catalog.js",
@@ -12692,7 +12697,7 @@
12692
12697
  {
12693
12698
  "path": "tests/ship_resume.mjs",
12694
12699
  "mode": "0000644",
12695
- "sha256": "e5305fa140de31d7e646e328b4a3956f845f62b335bf14285a91cc38a20f8e0f"
12700
+ "sha256": "68c8f499281fc99cce68c6abf63eba2b5221eca3bd0edb05158b2e7d46a404e9"
12696
12701
  },
12697
12702
  {
12698
12703
  "path": "tests/ship_smoke_gate.mjs",
@@ -12914,6 +12919,11 @@
12914
12919
  "mode": "0000644",
12915
12920
  "sha256": "bc89502f571659477178d68047098cf6098030491ac5c5a82b46802474a48387"
12916
12921
  },
12922
+ {
12923
+ "path": "tests/task_list_paging.mjs",
12924
+ "mode": "0000644",
12925
+ "sha256": "989f26372c7236e84601609966149133bae43f889084b153ea8c3d6b365b58c0"
12926
+ },
12917
12927
  {
12918
12928
  "path": "tests/task_merge.mjs",
12919
12929
  "mode": "0000644",
@@ -2018,5 +2018,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2018
2018
  landed since 1.19.770 with no explicit bump. run 35116254725. (task 1002620)
2019
2019
  1.19.772 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2020
2020
  landed since 1.19.771 with no explicit bump. run 35117652607. (task 1002620)
2021
+ 1.19.773 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2022
+ landed since 1.19.772 with no explicit bump. run 35118731311. (task 1002620)
2021
2023
  ---------------------------------------------------------------------------
2022
2024
  ```
@@ -715,7 +715,13 @@ async function listTasks({ versionId, status, kind, discipline, goalId, createdB
715
715
  pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
716
716
  FROM tasks t` +
717
717
  (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
718
- ` ORDER BY priority NULLS LAST, created_at`;
718
+ // `id` is the tiebreak, not decoration (task 1003752): `priority, created_at`
719
+ // is not a TOTAL order — a planning session seeds many tasks inside one
720
+ // transaction, so they share both a priority and a created_at — and under
721
+ // LIMIT/OFFSET paging an undefined order between two rows can show the same
722
+ // row on two pages and no page at all to another. Same rule, same reason, as
723
+ // `ORDER BY started_at DESC NULLS LAST, id` in pickDefaultVersion.
724
+ ` ORDER BY priority NULLS LAST, created_at, t.id`;
719
725
  // BOUND, not interpolated. Both call sites validate to a finite non-negative
720
726
  // integer today, so string interpolation would not be exploitable — but a
721
727
  // query builder that is only safe because of who happens to call it is one
@@ -72,8 +72,16 @@ function deadDepHint(deps, taskId = null) {
72
72
  // gated by an abandoned dependency? `tasks` are rows carrying { id, status,
73
73
  // title, blocked_by: [{ id, status, title }] } — the GET /tasks?include=deps
74
74
  // shape. A dependent that is itself terminal is NOT stranded (nothing is
75
- // waiting), which is why the live corpus reports zero today even though 17
76
- // abandoned tasks still carry dependents. Pure + exported for tests.
75
+ // waiting) — which is the whole reason the count stays small.
76
+ //
77
+ // This comment used to say the live corpus 'reports zero today'. Nothing had
78
+ // ever measured that: the only caller, audit-deps.js --stranded, asked for
79
+ // limit=3000 against a route cap of 1000 and so 400'd on every run it ever
80
+ // made (task 1003752). The first real measurement, once it could run, was
81
+ // 2026-09-16: 3611 tasks scanned, ONE live task stranded (1001702, behind
82
+ // abandoned 1001523), and 57 — not 17 — abandoned tasks still carrying
83
+ // dependents. Treat any count written in this header as the date it carries,
84
+ // not as a standing fact. Pure + exported for tests.
77
85
  const LIVE_STATUSES = new Set(['backlog', 'ready', 'active', 'blocked']);
78
86
 
79
87
  function findStrandedTasks(tasks) {
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/task-list-limits.js — the GET /tasks page bound, declared
4
+ // once (task 1003752).
5
+ //
6
+ // THE DEFECT THIS EXISTS FOR. The cap was a bare `1000` inside the route and the
7
+ // page size was a bare `3000` inside a script, and nothing connected them. So
8
+ // `audit-deps.js --stranded` asked for 3000 against a cap of 1000 and every run
9
+ // 400'd before reading a single task — for months, silently, because the only
10
+ // thing that looks for tasks stranded behind an abandoned dependency could not
11
+ // start. A number two files must agree on, written twice, is a bug waiting for
12
+ // one of them to move; the task-status.js precedent applies here too.
13
+ //
14
+ // TWO THINGS A PAGER MUST KNOW, both learned the hard way on task 1003752:
15
+ //
16
+ // 1. `limit` is capped at TASK_LIST_MAX_LIMIT. Asking for more is a 400, not a
17
+ // silent clamp — so a caller that wants the whole table must PAGE, and the
18
+ // corpus is already well past one page (3610 rows on 2026-09-16).
19
+ //
20
+ // 2. `offset` must be STRICTLY POSITIVE. The route validates it with the same
21
+ // positive-integer helper as `limit`, so `offset=0` is rejected as
22
+ // `bad_offset` — the naive `offset = page * pageSize` pager 400s on its
23
+ // very first request. The first page must omit `offset` entirely, and
24
+ // buildTaskPageQuery() below is the one place that knows this.
25
+
26
+ // The largest `limit` GET /tasks (and GET /tasks/claimable) will accept. The cap
27
+ // is the point of the parameter: an unbounded limit is the whole-table read this
28
+ // axis exists to stop.
29
+ const TASK_LIST_MAX_LIMIT = 1000;
30
+
31
+ // The query object for one page of a full-corpus read. Clamps `limit` to the cap
32
+ // — a caller asking for more gets a page it can actually have rather than a 400
33
+ // — and omits `offset` on the first page, because 0 is not a positive integer
34
+ // and the route says so.
35
+ function buildTaskPageQuery({ limit = TASK_LIST_MAX_LIMIT, offset = 0, ...rest } = {}) {
36
+ const asked = Number(limit);
37
+ const size = Number.isFinite(asked) && asked > 0 ? Math.min(asked, TASK_LIST_MAX_LIMIT) : TASK_LIST_MAX_LIMIT;
38
+ const q = { ...rest, limit: size };
39
+ const skip = Number(offset);
40
+ if (Number.isFinite(skip) && skip > 0) q.offset = skip;
41
+ return q;
42
+ }
43
+
44
+ module.exports = { TASK_LIST_MAX_LIMIT, buildTaskPageQuery };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.772",
3
+ "version": "1.19.773",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.772",
9
+ "version": "1.19.773",
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.772",
3
+ "version": "1.19.773",
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",
@@ -25,6 +25,8 @@ const { resolveDefaultVersion } = require('./version-select.js');
25
25
  // task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
26
26
  // "abandoned dependency" means one thing across the CLI, the route and this audit.
27
27
  const { findStrandedTasks } = require('../../modules/lifecycle/dead-deps.js');
28
+ // task 1003752 — the route's page bound + the first-page offset rule, declared once.
29
+ const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
28
30
 
29
31
  // Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
30
32
  const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
@@ -81,6 +83,34 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
81
83
  return out;
82
84
  }
83
85
 
86
+ // Read the WHOLE task corpus, one allowed page at a time (task 1003752). This
87
+ // was a single `limit: 3000` read, which GET /tasks caps at TASK_LIST_MAX_LIMIT
88
+ // (1000) — so it 400'd on every run and the detector below never saw a single
89
+ // task. Paging is not a nicety here: the corpus passed one page long ago (3610
90
+ // rows on 2026-09-16), so even a legal `limit: 1000` would silently UNDER-read,
91
+ // which in a detector is the same failure wearing a success exit code.
92
+ //
93
+ // `getPage(query)` is injected rather than closed over so the loop is testable
94
+ // with no API; it returns the { ok, status, data } shape cliClient gives back.
95
+ // Dedupes by id: listTasks orders by `priority NULLS LAST, created_at, id`, so
96
+ // pages are stable — but a detector whose whole job is surfacing rows nobody
97
+ // can see must not be the thing that loses one.
98
+ async function fetchAllTasks(getPage, { query = {}, pageSize = TASK_LIST_MAX_LIMIT, maxPages = 100 } = {}) {
99
+ const byId = new Map();
100
+ let offset = 0;
101
+ for (let page = 0; page < maxPages; page++) {
102
+ const q = buildTaskPageQuery({ ...query, limit: pageSize, offset });
103
+ const r = await getPage(q);
104
+ if (!r || !r.ok) throw new Error(`HTTP ${r && r.status}`);
105
+ const rows = (r.data && r.data.tasks) || [];
106
+ for (const t of rows) byId.set(String(t.id), t);
107
+ // A short page is the end of the table. Compare against the CLAMPED size
108
+ // the route actually honoured, never the size the caller asked for.
109
+ if (rows.length < q.limit) return [...byId.values()];
110
+ offset += q.limit;
111
+ }
112
+ throw new Error(`more than ${maxPages} pages of tasks — refusing to loop forever`);
113
+ }
84
114
  // --stranded (task 1003153): the INVERSE audit. findMissingDeps asks "which edge
85
115
  // is MISSING"; this asks "which existing edge is DEAD" — a live task gated by an
86
116
  // ABANDONED dependency. That edge can never satisfy (an abandoned task can never
@@ -91,9 +121,13 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
91
121
  // Exits 1 when anything is stranded, so a sweep or CI leg can gate on it.
92
122
  async function reportStranded({ asJson }) {
93
123
  const api = await cliClient();
94
- const r = await api.tasks.getTasks({ query: { include: 'deps', limit: 3000 } });
95
- if (!r.ok) { console.error(`audit-deps: could not list tasks (${r.status})`); process.exit(1); }
96
- const all = r.data.tasks || [];
124
+ let all;
125
+ try {
126
+ all = await fetchAllTasks((query) => api.tasks.getTasks({ query }), { query: { include: 'deps' } });
127
+ } catch (e) {
128
+ console.error(`audit-deps: could not list tasks (${e.message})`);
129
+ process.exit(1);
130
+ }
97
131
  const stranded = findStrandedTasks(all);
98
132
 
99
133
  // Context worth stating even at zero: abandoned tasks that still CARRY
@@ -174,7 +208,7 @@ async function main() {
174
208
  console.log('NB: advisory only. Backfill `high` edges by hand (POST /tasks/:id/dependencies) after reading both tasks; a wrong edge gates the dependent.');
175
209
  }
176
210
 
177
- module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded };
211
+ module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded, fetchAllTasks };
178
212
 
179
213
  if (require.main === module) {
180
214
  main().catch((e) => { console.error('audit-deps:', e.message); process.exit(1); });
@@ -865,7 +865,16 @@ async function regradeMain() {
865
865
  // nothing to read, so there is no verdict to dodge. Confirming is additionally
866
866
  // permission-gated server-side on task.confirm.grade_bypass, so this widens
867
867
  // no authority — it only stops the ONE shape that had no exit at all.
868
- if (shouldConfirmWithoutGrade({ committedFileCount: committedFiles.length, args })) {
868
+ // task 1003944: the porcelain is MEASURED here and handed to the decision, so a
869
+ // re-grade weighs the same evidence a first ship does. Without it the guard saw
870
+ // only the flag and the (empty) committed diff, and an UNTRACKED deliverable —
871
+ // invisible to preflight's trackedDirty — confirmed straight through to shipped.
872
+ const emptyShip = shouldConfirmWithoutGrade({
873
+ committedFileCount: committedFiles.length,
874
+ porcelain: gitOk('git status --porcelain'),
875
+ args,
876
+ });
877
+ if (emptyShip.confirm) {
869
878
  console.log('');
870
879
  console.log(`Nothing to grade (the committed diff vs ${graderBaseline || 'baseline'} is empty) and this ship`);
871
880
  console.log('declares itself artifact-free — manual-confirming via POST /tasks/:id/confirm.');
@@ -906,6 +915,19 @@ async function regradeMain() {
906
915
  });
907
916
  return;
908
917
  }
918
+ // task 1003944: the two refusals are DIFFERENT advice. 'uncommitted-work' means
919
+ // the deliverable exists and was never committed — offering --allow-empty there
920
+ // would be telling the builder to assert the opposite of what the tree shows,
921
+ // which is how task 1363 shipped nothing and called it done. Only the genuinely
922
+ // clean tree gets pointed at the flag.
923
+ if (emptyShip.reason === 'uncommitted-work') {
924
+ console.error('Nothing to grade: nothing is committed on this branch, but your working tree has uncommitted changes:');
925
+ for (const f of emptyShip.uncommitted) console.error(` - ${f}`);
926
+ console.error('');
927
+ console.error('Your work was never committed — confirming now would mark the task "shipped" with NO artifact on main (the ledger would lie).');
928
+ console.error('Stage + commit your work (e.g. `git add -A && git commit`), then re-run --regrade.');
929
+ process.exit(1);
930
+ }
909
931
  console.error(`Nothing to grade: the committed diff vs ${graderBaseline || 'baseline'} is empty.`);
910
932
  console.error('The grader reads committed history, not the working tree — commit your fixes on this branch, then re-run --regrade.');
911
933
  console.error('If this task is DELIBERATELY artifact-free (a DB-only close, an ops verification), say so:');
@@ -9,6 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const { cliClient } = require('./cli-lib');
12
+ const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
12
13
  const { preflight } = require('./ship-preflight-steps.js');
13
14
  const { playShipBell } = require('./ship-io.js');
14
15
  const { strandNextStep } = require('./ship-land.js');
@@ -121,10 +122,37 @@ function evaluateResumeAction({ task, lastClaim, builder }) {
121
122
  // A bare re-run (neither flag) keeps dead-ending, deliberately: the overwhelmingly
122
123
  // common cause of an empty diff is uncommitted work, and silently confirming that
123
124
  // would ship nothing while reporting success.
124
- function shouldConfirmWithoutGrade({ committedFileCount, args = [] }) {
125
- const emptyDiff = Number(committedFileCount) === 0;
126
- const declaredNoArtifact = args.includes('--allow-empty') || args.includes('--skip-grade');
127
- return emptyDiff && declaredNoArtifact;
125
+ //
126
+ // task 1003944 — THE FIRST CUT ASKED THE WRONG TWO QUESTIONS, and both answers let
127
+ // the task-1363 phantom ship back in on this path:
128
+ //
129
+ // (a) It never looked at the WORKING TREE. The only tree check a re-grade runs is
130
+ // preflight(), which uses trackedDirty and — by its own comment — deliberately
131
+ // ignores UNTRACKED files. So a deliverable that was never `git add`ed is
132
+ // invisible to both, and `--allow-empty` confirmed it, awarded credits, and
133
+ // (via finishShip's hardcoded dbOnly) landed the row at SHIPPED with HEAD
134
+ // never moved. That is task 1363 exactly: the ledger lying, CLAUDE.md §8.
135
+ // classifyEmptyShip already states the rule this violated — a dirty tree
136
+ // refuses EVEN UNDER --allow-empty, because the flag asserts there is no
137
+ // artifact and the tree proves there is one.
138
+ //
139
+ // (b) It accepted `--skip-grade` as the declaration. That flag declares nothing
140
+ // about artifacts — ship.js's usage block documents it as a grader-outage
141
+ // override, "NOT a routine flag" — and task 1365 had already REMOVED it from
142
+ // the first-ship empty-diff decision for precisely that reason. Task 1003146's
143
+ // recovery is unaffected: `--allow-empty` is the flag both refusal messages
144
+ // prescribe, and it still works.
145
+ //
146
+ // So this no longer carries a second, weaker copy of the rule: it hands the
147
+ // measured tree to classifyEmptyShip — the SAME function the first ship calls — and
148
+ // returns its verdict, so the two paths cannot drift apart again. `porcelain` is
149
+ // `git status --porcelain` output, passed in by the caller to keep this pure.
150
+ // `reason` is classifyEmptyShip's ('uncommitted-work' | 'nothing-to-ship' |
151
+ // 'allow-empty'), plus 'has-diff' when there is real committed work to grade.
152
+ function shouldConfirmWithoutGrade({ committedFileCount, porcelain = '', args = [] }) {
153
+ if (Number(committedFileCount) !== 0) return { confirm: false, reason: 'has-diff', uncommitted: [] };
154
+ const verdict = classifyEmptyShip(porcelain, { allowEmpty: args.includes('--allow-empty') });
155
+ return { confirm: !verdict.refuse, reason: verdict.reason, uncommitted: verdict.uncommitted || [] };
128
156
  }
129
157
 
130
158
  // task 941: resume a `confirmed`-but-unshipped task — the merge/deploy leg only
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.772'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.773'; // 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');
@@ -133,25 +133,88 @@ test('completed + owner with low rank still resumes (ownership, not rank)', () =
133
133
  // 'completed' task into the re-grade before reading a flag. The manual-confirm
134
134
  // path a no-artifact task needs is unreachable from 'completed' any other way.
135
135
 
136
- test('empty diff + --allow-empty → confirm instead of bailing (the 1003146 shape)', () => {
137
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--allow-empty'] }), true);
138
- });
139
-
140
- test('empty diff + --skip-grade → confirm (the other documented no-artifact flag)', () => {
141
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--skip-grade'] }), true);
136
+ test('empty diff + --allow-empty + a CLEAN tree → confirm instead of bailing (the 1003146 shape)', () => {
137
+ const v = shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--allow-empty'] });
138
+ assert.equal(v.confirm, true);
139
+ assert.equal(v.reason, 'allow-empty');
142
140
  });
143
141
 
144
142
  test('a REAL diff + --allow-empty still grades — the flag is not a grader bypass', () => {
145
143
  // The hole this guard exists to avoid: if a declaration alone were enough, a task
146
144
  // whose grade FAILED could dodge the verdict by re-running with --allow-empty.
147
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 3, args: ['1003146', '--allow-empty'] }), false);
148
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 1, args: ['1003146', '--skip-grade'] }), false);
145
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 3, porcelain: '', args: ['1003146', '--allow-empty'] }).confirm, false);
146
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 1, porcelain: '', args: ['1003146', '--allow-empty'] }).reason, 'has-diff');
149
147
  });
150
148
 
151
149
  test('empty diff with NO flag keeps dead-ending — uncommitted work must not "ship"', () => {
152
150
  // The overwhelmingly common cause of an empty diff is forgetting to commit.
153
151
  // Confirming that silently would report success having shipped nothing.
154
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146'] }), false);
152
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146'] }).confirm, false);
153
+ });
154
+
155
+ // ---- task 1003944: the same guard had re-opened the task-1363 phantom ship ----
156
+ // The first cut asked only "is the diff empty?" and "was a flag passed?", which is
157
+ // strictly WEAKER than the decision the FIRST-ship path makes at the same moment.
158
+ // Two consequences, both reachable in one command, both fixed here.
159
+
160
+ test('an UNTRACKED deliverable is refused even under --allow-empty (task 1363)', () => {
161
+ // The whole point of task 1363: a new file left untracked is invisible to
162
+ // preflight (trackedDirty ignores untracked), so this decision was the ONLY thing
163
+ // standing between "you forgot to commit" and a row marked shipped with HEAD
164
+ // never moved. --allow-empty asserts there is no artifact; the tree proves there
165
+ // is one, and the tree wins.
166
+ const v = shouldConfirmWithoutGrade({
167
+ committedFileCount: 0, porcelain: '?? docs/the-deliverable.md', args: ['1003146', '--allow-empty'],
168
+ });
169
+ assert.equal(v.confirm, false, 'an untracked deliverable must never manual-confirm');
170
+ assert.equal(v.reason, 'uncommitted-work');
171
+ assert.deepEqual(v.uncommitted, ['docs/the-deliverable.md'], 'and the builder is told WHICH file');
172
+ });
173
+
174
+ test('a tracked-but-uncommitted edit is refused under --allow-empty too', () => {
175
+ const v = shouldConfirmWithoutGrade({
176
+ committedFileCount: 0, porcelain: ' M scripts/gds/ship.js', args: ['1003146', '--allow-empty'],
177
+ });
178
+ assert.equal(v.confirm, false);
179
+ assert.equal(v.reason, 'uncommitted-work');
180
+ });
181
+
182
+ test('--skip-grade is NOT an artifact declaration — it declares nothing about artifacts', () => {
183
+ // ship.js documents it as a grader-outage override, "NOT a routine flag", and
184
+ // task 1365 removed it from the first-ship empty-diff decision for exactly this
185
+ // reason. Accepting it here re-opened the half of task 1363 that fix had closed.
186
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--skip-grade'] }).confirm,
187
+ false, '--skip-grade must not confirm an empty ship on its own');
188
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--skip-grade', '--allow-empty'] }).confirm,
189
+ true, 'but it must not BLOCK a properly declared one either');
190
+ });
191
+
192
+ test('the refusal names the PATH, not the porcelain status codes', () => {
193
+ // A rename line is "R old -> new" and a quoted path is wrapped in "…". The
194
+ // builder has to be told which file to commit, so the reported name must be the
195
+ // path. (.gitignored files never reach here at all: `git status --porcelain`
196
+ // omits them unless --ignored is passed, which the call site does not pass — the
197
+ // call-site test below pins the exact command.)
198
+ const v = shouldConfirmWithoutGrade({
199
+ committedFileCount: 0, porcelain: 'R docs/old.md -> docs/new.md\n?? "docs/a b.md"', args: ['1003146', '--allow-empty'],
200
+ });
201
+ assert.equal(v.confirm, false);
202
+ assert.deepEqual(v.uncommitted, ['docs/new.md', 'docs/a b.md']);
203
+ });
204
+
205
+ test('the re-grade decision and the FIRST-ship decision agree on the same tree', () => {
206
+ // The defect was a second, weaker copy of one rule. This asserts they are the
207
+ // same rule: for every tree, the re-grade verdict must equal what classifyEmptyShip
208
+ // — the function the first ship calls — says about it. A re-divergence reds here.
209
+ const { classifyEmptyShip } = require('../modules/lifecycle/ship-preflight');
210
+ for (const porcelain of ['', '?? docs/new.md', ' M scripts/gds/ship.js', '!! node_modules/x', 'A a.js\n?? b.js']) {
211
+ for (const args of [['--allow-empty'], []]) {
212
+ const first = classifyEmptyShip(porcelain, { allowEmpty: args.includes('--allow-empty') });
213
+ const resume = shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain, args });
214
+ assert.equal(resume.confirm, !first.refuse, `divergence on ${JSON.stringify(porcelain)} ${JSON.stringify(args)}`);
215
+ assert.equal(resume.reason, first.reason, `reason divergence on ${JSON.stringify(porcelain)}`);
216
+ }
217
+ }
155
218
  });
156
219
 
157
220
  test('the empty-diff guard is actually WIRED to the decision, not just exporting it', () => {
@@ -171,10 +234,21 @@ test('the empty-diff guard is actually WIRED to the decision, not just exporting
171
234
  'and reaching it must land on the manual-confirm route a no-artifact task needs');
172
235
  });
173
236
 
237
+ test('the call site MEASURES the working tree — the truth table alone cannot prove this', () => {
238
+ // task 1003944, and the reason this assertion is separate from the wiring test
239
+ // above: `porcelain` DEFAULTS to '' — a clean tree. Drop the argument at the call
240
+ // site and every truth-table case above stays green while the real ship goes back
241
+ // to confirming untracked work. The evidence has to be shown to be COLLECTED.
242
+ const src = shipCliSource();
243
+ assert.match(src, /shouldConfirmWithoutGrade\(\{[^}]*porcelain:\s*gitOk\('git status --porcelain'\)/,
244
+ 'the re-grade guard must pass the REAL `git status --porcelain`, not fall back to the clean-tree default');
245
+ });
246
+
174
247
  test('shouldConfirmWithoutGrade: defaults and odd inputs never throw', () => {
175
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0 }), false, 'no args → no declaration');
176
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: '0', args: ['--allow-empty'] }), true, 'a numeric string still counts as empty');
177
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: undefined, args: ['--allow-empty'] }), false, 'an unknown count is not an empty one');
248
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0 }).confirm, false, 'no args → no declaration');
249
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: '0', args: ['--allow-empty'] }).confirm, true, 'a numeric string still counts as empty');
250
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: undefined, args: ['--allow-empty'] }).confirm, false, 'an unknown count is not an empty one');
251
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: null, args: ['--allow-empty'] }).confirm, true, 'a null porcelain reads as a clean tree');
178
252
  });
179
253
 
180
254
  console.log('');
@@ -0,0 +1,178 @@
1
+ // tests/task_list_paging.mjs — the GET /tasks page bound, and the pager that
2
+ // has to respect it (task 1003752).
3
+ //
4
+ // THE BUG THIS PINS. audit-deps.js --stranded asked for `limit: 3000` against a
5
+ // route that caps `limit` at 1000. Every run 400'd before reading one task, so
6
+ // the ONLY detector for work stranded behind an abandoned dependency had never
7
+ // once produced a result — silently, for months, because a 400 on a read looks
8
+ // exactly like a clean report to anyone not reading the exit code.
9
+ //
10
+ // So this file covers the CLASS, not just the instance:
11
+ // - the cap the route enforces and the page size the client asks for are held
12
+ // equal, so they cannot drift apart again (routes/tasks.js is a NEEDS_TRUST
13
+ // gate surface and the route was never the bug, so the pin lives here);
14
+ // - the fake here enforces the route's real rules (cap, and `offset` must be
15
+ // strictly positive — `offset=0` is a 400, the landmine under every naive
16
+ // `page * size` pager), so a pager that would 400 in production fails here;
17
+ // - the pager is driven with MORE than one page of rows, because a limit bug
18
+ // is invisible against a fixture that fits in a single page.
19
+ //
20
+ // Hermetic: no DB, no API, no network. Run: node tests/task_list_paging.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+
31
+ const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../modules/lifecycle/task-list-limits.js');
32
+ const { fetchAllTasks } = require('../scripts/gds/audit-deps.js');
33
+
34
+ let passed = 0, failed = 0;
35
+ const t = async (n, f) => {
36
+ try { await f(); passed++; console.log(` PASS ${n}`); }
37
+ catch (e) { failed++; console.log(` FAIL ${n}\n ${e.message}`); }
38
+ };
39
+
40
+ // A stand-in for GET /tasks that refuses everything the real route refuses.
41
+ // `rows` is the whole corpus; the fake slices it the way listTasks does.
42
+ function fakeRoute(rows, { cap = TASK_LIST_MAX_LIMIT } = {}) {
43
+ const calls = [];
44
+ const get = async (query) => {
45
+ calls.push({ ...query });
46
+ const { limit, offset } = query;
47
+ // routes/tasks.js: posInt(limitRaw, 'bad_limit', cap)
48
+ if (limit === undefined || !Number.isInteger(limit) || limit <= 0 || limit > cap) {
49
+ return { ok: false, status: 400, data: { error: { code: 'bad_limit' } } };
50
+ }
51
+ // routes/tasks.js: posInt(offsetRaw, 'bad_offset') — POSITIVE, so 0 is bad.
52
+ if (offset !== undefined && (!Number.isInteger(offset) || offset <= 0)) {
53
+ return { ok: false, status: 400, data: { error: { code: 'bad_offset' } } };
54
+ }
55
+ const start = offset || 0;
56
+ return { ok: true, status: 200, data: { tasks: rows.slice(start, start + limit) } };
57
+ };
58
+ return { get, calls };
59
+ }
60
+
61
+ const corpus = (n, from = 1) => Array.from({ length: n }, (_, i) => ({ id: from + i, status: 'ready' }));
62
+
63
+ // ---------------------------------------------------------------- the cap
64
+
65
+ await t('the shared cap is the number the route actually enforces', () => {
66
+ const routeSrc = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
67
+
68
+ // routes/tasks.js is a NEEDS_TRUST gate surface, so this fix does not edit it
69
+ // — the route was never wrong, the client was. That leaves the cap written in
70
+ // two files, which is what let them drift in the first place, so the drift is
71
+ // pinned HERE instead: change the cap in the route and this test reds until
72
+ // task-list-limits.js is changed to match.
73
+ //
74
+ // Both reads are guarded against going blind. A source scan fails by quietly
75
+ // matching nothing, and a green test over a pattern that no longer matches is
76
+ // a false claim of coverage, not coverage.
77
+ const capM = routeSrc.match(/posInt\(limitRaw, 'bad_limit', (\d+)\)/);
78
+ assert.ok(capM, 'BLIND SCAN: the GET /tasks limit cap no longer matches this pattern — fix the pattern, do not delete the test');
79
+ assert.equal(Number(capM[1]), TASK_LIST_MAX_LIMIT,
80
+ `route caps limit at ${capM && capM[1]} but TASK_LIST_MAX_LIMIT is ${TASK_LIST_MAX_LIMIT} — the pager will 400 or under-read`);
81
+
82
+ // The 400 message is what a human debugging this actually reads, so it has to
83
+ // carry the same number the validator enforces.
84
+ const msgM = routeSrc.match(/limit must be a positive integer up to (\d+)\./);
85
+ assert.ok(msgM, 'BLIND SCAN: the bad_limit message no longer matches this pattern');
86
+ assert.equal(Number(msgM[1]), TASK_LIST_MAX_LIMIT, 'the error message must quote the cap it enforces');
87
+
88
+ // And `offset` must still be the STRICTLY-positive validator the pager is
89
+ // built around — if the route ever starts accepting offset=0, the first-page
90
+ // special case below becomes dead weight rather than a requirement.
91
+ assert.match(routeSrc, /posInt\(offsetRaw, 'bad_offset'\)/,
92
+ 'offset is validated with no cap and must stay positive-only — buildTaskPageQuery depends on it');
93
+ });
94
+
95
+ await t('no page this pager requests can exceed the route cap', async () => {
96
+ // The literal regression: ask for 3000, the number that 400'd for months.
97
+ const q = buildTaskPageQuery({ limit: 3000 });
98
+ assert.ok(q.limit <= TASK_LIST_MAX_LIMIT, `asked ${q.limit}, cap ${TASK_LIST_MAX_LIMIT}`);
99
+ const { get, calls } = fakeRoute(corpus(10));
100
+ await fetchAllTasks(get, { pageSize: 3000 });
101
+ assert.ok(calls.length > 0, 'the fake must actually have been called');
102
+ for (const c of calls) {
103
+ assert.ok(c.limit <= TASK_LIST_MAX_LIMIT, `page asked for limit=${c.limit}`);
104
+ }
105
+ });
106
+
107
+ // ------------------------------------------------------- the offset landmine
108
+
109
+ await t('the FIRST page omits offset entirely (offset=0 is a 400)', () => {
110
+ const q = buildTaskPageQuery({ offset: 0 });
111
+ assert.ok(!('offset' in q), 'offset=0 must not be sent — the route rejects it');
112
+ assert.equal(buildTaskPageQuery({ offset: 1000 }).offset, 1000, 'a real offset is sent');
113
+ });
114
+
115
+ await t('a full-corpus read never trips bad_offset', async () => {
116
+ const { get, calls } = fakeRoute(corpus(2500));
117
+ await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
118
+ assert.ok(!('offset' in calls[0]), 'first page must omit offset');
119
+ assert.ok(calls.slice(1).every((c) => c.offset > 0), 'later pages send a positive offset');
120
+ });
121
+
122
+ // --------------------------------------------------------------- the paging
123
+
124
+ await t('reads EVERY row when the corpus is several pages long', async () => {
125
+ const rows = corpus(2500);
126
+ const { get, calls } = fakeRoute(rows);
127
+ const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
128
+ assert.equal(out.length, 2500, 'every row comes back');
129
+ assert.equal(new Set(out.map((r) => String(r.id))).size, 2500, 'no duplicates');
130
+ assert.equal(calls.length, 3, '1000 + 1000 + 500');
131
+ });
132
+
133
+ await t('an exact multiple of the page size still terminates', async () => {
134
+ const { get, calls } = fakeRoute(corpus(2000));
135
+ const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
136
+ assert.equal(out.length, 2000);
137
+ assert.equal(calls.length, 3, 'the third page comes back empty and ends the loop');
138
+ });
139
+
140
+ await t('a duplicated row across pages is collapsed, not double-counted', async () => {
141
+ // What an ordering tie does under LIMIT/OFFSET: one row served twice.
142
+ const rows = [...corpus(1000), ...corpus(1, 1000)];
143
+ const { get } = fakeRoute(rows);
144
+ const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
145
+ assert.equal(out.length, 1000, 'the repeat of id 1000 is deduped by id');
146
+ });
147
+
148
+ await t('the ORDER BY is a TOTAL order, so pages cannot drop a row', () => {
149
+ const dbSrc = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-tasks.js'), 'utf8');
150
+ assert.match(dbSrc, /ORDER BY priority NULLS LAST, created_at, t\.id/,
151
+ 'listTasks must tiebreak on id — priority+created_at tie inside one seeding transaction');
152
+ });
153
+
154
+ // ------------------------------------------------------------- the failures
155
+
156
+ await t('a failing page THROWS rather than returning a short corpus', async () => {
157
+ // The original defect dressed as success: a detector that reports "nothing
158
+ // found" because its read failed is worse than one that crashes.
159
+ const get = async () => ({ ok: false, status: 400, data: {} });
160
+ await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT }), /400/);
161
+ });
162
+
163
+ await t('a mid-corpus failure does not silently truncate', async () => {
164
+ const rows = corpus(2500);
165
+ const real = fakeRoute(rows);
166
+ let n = 0;
167
+ const get = async (q) => (++n === 2 ? { ok: false, status: 500, data: {} } : real.get(q));
168
+ await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT }), /500/);
169
+ });
170
+
171
+ await t('a route that never returns a short page cannot spin forever', async () => {
172
+ const get = async (q) => ({ ok: true, status: 200, data: { tasks: corpus(q.limit) } });
173
+ await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT, maxPages: 5 }),
174
+ /refusing to loop forever/);
175
+ });
176
+
177
+ console.log(`\ntask_list_paging: ${passed} passed, ${failed} failed`);
178
+ process.exit(failed ? 1 : 0);