@bongos/core 1.19.976 → 1.19.977
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/.claude/skills/goal-create/SKILL.md +8 -0
- package/.claude/skills/goal-review/SKILL.md +8 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/lifecycle/db-tasks.js +43 -0
- package/modules/lifecycle/dependency-advisory.js +99 -0
- package/modules/lifecycle/missing-dep-classify.js +95 -0
- package/modules/lifecycle/routes/task-write-routes.js +10 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-deps.js +13 -61
- package/src/module-api.js +1 -1
- package/tests/dependency_advisory.mjs +231 -0
- package/tests/missing_dep_classify.mjs +108 -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.977",
|
|
6
|
+
"core_contract": "1.19.977",
|
|
7
|
+
"source_commit": "97ba854401629212275dafc294e6632004516781",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-23T15:
|
|
9
|
+
"built_at": "2026-09-23T15:21:57.063Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 516,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2386,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2928,
|
|
20
|
+
"tree_sha256": "bc6990ad901c42ffd73df27e947932152a51800591640250f55acda99925c20f",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -142,12 +142,12 @@
|
|
|
142
142
|
{
|
|
143
143
|
"path": ".claude/skills/goal-create/SKILL.md",
|
|
144
144
|
"mode": "0000644",
|
|
145
|
-
"sha256": "
|
|
145
|
+
"sha256": "fef368de4b2a0b42aad43f505b053d7b84aac34bf8f1da7525053970e8e27f42"
|
|
146
146
|
},
|
|
147
147
|
{
|
|
148
148
|
"path": ".claude/skills/goal-review/SKILL.md",
|
|
149
149
|
"mode": "0000644",
|
|
150
|
-
"sha256": "
|
|
150
|
+
"sha256": "39016b0c0bf7c42e0b0ee1dc9bff23b90401a1ad29c34f7a475406660855be79"
|
|
151
151
|
},
|
|
152
152
|
{
|
|
153
153
|
"path": ".claude/skills/grade-audit/SKILL.md",
|
|
@@ -2702,7 +2702,7 @@
|
|
|
2702
2702
|
{
|
|
2703
2703
|
"path": "docs/module-api-changelog.md",
|
|
2704
2704
|
"mode": "0000644",
|
|
2705
|
-
"sha256": "
|
|
2705
|
+
"sha256": "5df4d92085d7624d9c5acbf8f99af5a02d10731359071102d4cbe8b8eb73820c"
|
|
2706
2706
|
},
|
|
2707
2707
|
{
|
|
2708
2708
|
"path": "docs/modules-contract.md",
|
|
@@ -5797,7 +5797,7 @@
|
|
|
5797
5797
|
{
|
|
5798
5798
|
"path": "modules/lifecycle/db-tasks.js",
|
|
5799
5799
|
"mode": "0000644",
|
|
5800
|
-
"sha256": "
|
|
5800
|
+
"sha256": "d0844d8f53c003e6026918ad2c132262a358b8c64951aa7927eace1e37fcd41f"
|
|
5801
5801
|
},
|
|
5802
5802
|
{
|
|
5803
5803
|
"path": "modules/lifecycle/db-versions.js",
|
|
@@ -5814,6 +5814,11 @@
|
|
|
5814
5814
|
"mode": "0000644",
|
|
5815
5815
|
"sha256": "4738131a48e85dd7bf0644ddd3686c85d0d1d0da55ee623b1834d048c614b91c"
|
|
5816
5816
|
},
|
|
5817
|
+
{
|
|
5818
|
+
"path": "modules/lifecycle/dependency-advisory.js",
|
|
5819
|
+
"mode": "0000644",
|
|
5820
|
+
"sha256": "b2d93f29010a27f50d5da5712e485b7fac0ef6582f3e531052699b321c693d5e"
|
|
5821
|
+
},
|
|
5817
5822
|
{
|
|
5818
5823
|
"path": "modules/lifecycle/done-when.js",
|
|
5819
5824
|
"mode": "0000644",
|
|
@@ -5964,6 +5969,11 @@
|
|
|
5964
5969
|
"mode": "0000644",
|
|
5965
5970
|
"sha256": "85f7c058dcd6bc3f3d7e4d46ee1c58047347c9bff833148ec715f2a29fe7d7a7"
|
|
5966
5971
|
},
|
|
5972
|
+
{
|
|
5973
|
+
"path": "modules/lifecycle/missing-dep-classify.js",
|
|
5974
|
+
"mode": "0000644",
|
|
5975
|
+
"sha256": "d89f4659debd15dfac18258ba1e900f58c757e02937444d42391acec727afef0"
|
|
5976
|
+
},
|
|
5967
5977
|
{
|
|
5968
5978
|
"path": "modules/lifecycle/module.json",
|
|
5969
5979
|
"mode": "0000644",
|
|
@@ -6107,7 +6117,7 @@
|
|
|
6107
6117
|
{
|
|
6108
6118
|
"path": "modules/lifecycle/routes/task-write-routes.js",
|
|
6109
6119
|
"mode": "0000644",
|
|
6110
|
-
"sha256": "
|
|
6120
|
+
"sha256": "7f9419719070ec7048a2c81149427cbdaf3bf0e4a33993c1c79453161bdc06cc"
|
|
6111
6121
|
},
|
|
6112
6122
|
{
|
|
6113
6123
|
"path": "modules/lifecycle/routes/tasks.js",
|
|
@@ -8132,12 +8142,12 @@
|
|
|
8132
8142
|
{
|
|
8133
8143
|
"path": "package-lock.json",
|
|
8134
8144
|
"mode": "0000644",
|
|
8135
|
-
"sha256": "
|
|
8145
|
+
"sha256": "94174c5cd82588020aca94cba69a1e64e311efc037ab4768df616028056268d3"
|
|
8136
8146
|
},
|
|
8137
8147
|
{
|
|
8138
8148
|
"path": "package.json",
|
|
8139
8149
|
"mode": "0000644",
|
|
8140
|
-
"sha256": "
|
|
8150
|
+
"sha256": "97a39697bb451c4ace97d3d753dc66b8e6d77aa0c832fdc91f8d9dd60c10fea9"
|
|
8141
8151
|
},
|
|
8142
8152
|
{
|
|
8143
8153
|
"path": "public-docs/index.html",
|
|
@@ -8272,7 +8282,7 @@
|
|
|
8272
8282
|
{
|
|
8273
8283
|
"path": "scripts/gds/audit-deps.js",
|
|
8274
8284
|
"mode": "0000644",
|
|
8275
|
-
"sha256": "
|
|
8285
|
+
"sha256": "529829a6ba6de16a0166ac3bf4108366c2831d6ac76eac1b448b9fe3b397d775"
|
|
8276
8286
|
},
|
|
8277
8287
|
{
|
|
8278
8288
|
"path": "scripts/gds/audit-exports.js",
|
|
@@ -10162,7 +10172,7 @@
|
|
|
10162
10172
|
{
|
|
10163
10173
|
"path": "src/module-api.js",
|
|
10164
10174
|
"mode": "0000644",
|
|
10165
|
-
"sha256": "
|
|
10175
|
+
"sha256": "a666eed999520506fa357a4b21c5f304f7237bad0b4ca25953505485a8eb14bc"
|
|
10166
10176
|
},
|
|
10167
10177
|
{
|
|
10168
10178
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11209,6 +11219,11 @@
|
|
|
11209
11219
|
"mode": "0000644",
|
|
11210
11220
|
"sha256": "9d16748bfadb1b13969613bd7a43bed42ea3e1bdcc7254ab26fba14ea8b4c036"
|
|
11211
11221
|
},
|
|
11222
|
+
{
|
|
11223
|
+
"path": "tests/dependency_advisory.mjs",
|
|
11224
|
+
"mode": "0000644",
|
|
11225
|
+
"sha256": "9141b1e9544eceeaf94d5156a4c150236db7011f769dafec9a70f5fd5f51bc25"
|
|
11226
|
+
},
|
|
11212
11227
|
{
|
|
11213
11228
|
"path": "tests/deploy_audit_gate.mjs",
|
|
11214
11229
|
"mode": "0000644",
|
|
@@ -12774,6 +12789,11 @@
|
|
|
12774
12789
|
"mode": "0000644",
|
|
12775
12790
|
"sha256": "1da3144c843ebcacc914f8ccc6e4e0578b2efc40bd3d2feb99d3665989923512"
|
|
12776
12791
|
},
|
|
12792
|
+
{
|
|
12793
|
+
"path": "tests/missing_dep_classify.mjs",
|
|
12794
|
+
"mode": "0000644",
|
|
12795
|
+
"sha256": "0bd80e5c2a08bf90509563a878cceea2485d7755022f7818f036c37fe60a32e6"
|
|
12796
|
+
},
|
|
12777
12797
|
{
|
|
12778
12798
|
"path": "tests/model_alloc_prefs.mjs",
|
|
12779
12799
|
"mode": "0000644",
|
|
@@ -153,6 +153,14 @@ Run the phases above **with the orphan task as the seed** — its title, descrip
|
|
|
153
153
|
|
|
154
154
|
**A goal-less task is not an error and was never blocked.** `goal_advisory` is advice on a request that already succeeded. Never fail a build, refuse work, or badger the builder over it — if they say "later", file the idea and move on.
|
|
155
155
|
|
|
156
|
+
## The sibling advisory: `dependency_advisory` (task 1002827)
|
|
157
|
+
|
|
158
|
+
The same `POST /tasks` response can also carry a non-null **`dependency_advisory`** — a *different* check, unrelated to which goal the task landed in: it names OPEN tasks in the same version whose `touches[]` overlap this one's, with no declared dependency between them (`scripts/gds/audit-deps.js`'s detector, run live for the first time).
|
|
159
|
+
|
|
160
|
+
- `dependency_advisory.candidates` is sorted by confidence (`high`/`medium`/`low`) — read each candidate's title before acting; most overlap is parallel work on a shared file, not a real build-order dependency.
|
|
161
|
+
- Only backfill a **high**-confidence candidate, and only after reading both task descriptions. Add the edge with `POST /tasks/<id>/dependencies --body '{"depends_on_task_id":<dep_id>}'`.
|
|
162
|
+
- **Same non-blocking contract as `goal_advisory`** — it is advice on a request that already succeeded, never a reason to redo the create, and a wrong edge GATES the dependent (`POST /claims` → 409 `DEPS_NOT_SHIPPED`) until the named blocker ships, so silence (skip it) beats a guessed edge.
|
|
163
|
+
|
|
156
164
|
## Why we run this
|
|
157
165
|
|
|
158
166
|
Nothing in the system creates a goal on its own, and nothing errors when a task has none. `db.createTask` falls back to the version's `"<version> — general"` catch-all, and those rows were created **only** by migration 160, for the versions that existed at that moment — `db.createVersion` makes no such goal and there is no trigger. So on any newer version, every task quietly gets `goal_id = NULL`: no scope wall, no criteria gating it, no workspace where anyone reviews it. This skill is how a real goal comes to exist before that happens.
|
|
@@ -96,6 +96,14 @@ Report to the user:
|
|
|
96
96
|
- Deferred (count + ids — these surface again next session)
|
|
97
97
|
- New queue `count`
|
|
98
98
|
|
|
99
|
+
**Step 6 — dependency check (task 1002827, idea 1000276).** A cheap housekeeping pass over the same corpus this session already has open: run the likely-missing-dependency detector and surface any `high`-confidence candidate for a human to triage.
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
node scripts/gds/audit-deps.js --min high
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each line names an open task and an unshipped task whose files it overlaps with no declared dependency between them. **Read both task descriptions before backfilling an edge** — most overlap is parallel work on a shared file, not a build order; a wrong edge gates the dependent (`POST /claims` → 409 `DEPS_NOT_SHIPPED`) until the named blocker ships. Report the count in the same summary as Step 5; zero is the common case and needs no further comment. This is advisory only — it never blocks the review, and skipping it on a rushed pass is fine (it resurfaces next session, same as a deferred criterion).
|
|
106
|
+
|
|
99
107
|
## Why we run this
|
|
100
108
|
|
|
101
109
|
Without a closing cadence, criteria pile up "done but unconfirmed" and goals never flip to achieved — the version looks perpetually in-progress even when the work has shipped. A fast pass keeps the rollup honest. As with `/idea-triage`, the cadence is the discipline; a quick walk with mostly defers beats skipping it.
|
|
@@ -2426,5 +2426,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2426
2426
|
landed since 1.19.974 with no explicit bump. run 35871083707. (task 1002620)
|
|
2427
2427
|
1.19.976 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2428
2428
|
landed since 1.19.975 with no explicit bump. run 35878415178. (task 1002620)
|
|
2429
|
+
1.19.977 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2430
|
+
landed since 1.19.976 with no explicit bump. run 35880930697. (task 1002620)
|
|
2429
2431
|
---------------------------------------------------------------------------
|
|
2430
2432
|
```
|
|
@@ -765,6 +765,48 @@ async function listTasks({ versionId, status, kind, discipline, goalId, createdB
|
|
|
765
765
|
return rows;
|
|
766
766
|
}
|
|
767
767
|
|
|
768
|
+
// task 1002827 — the candidate pool for the dependency advisory (POST /tasks'
|
|
769
|
+
// `dependency_advisory` + the periodic sweep): OPEN tasks in this version that
|
|
770
|
+
// carry touches[], the same OPEN set scripts/gds/audit-deps.js already treats
|
|
771
|
+
// as "auditable" (ready, backlog, active, blocked). Bounded and cheap —
|
|
772
|
+
// measured 2026-09-22, BONGOS-V2 carries ~51 such rows, since most tasks
|
|
773
|
+
// declare no touches[] at all (ADR 0049 demoted the field to advisory, so
|
|
774
|
+
// most authors skip it). `dependencies` is not selected: it lives in a join
|
|
775
|
+
// table, not this one (see addTaskDependency), and this advisory only needs
|
|
776
|
+
// the NEW task's own declared deps, supplied by its caller.
|
|
777
|
+
//
|
|
778
|
+
// VISIBILITY (ADR 0112's leak rule — the goal-graph.js precedent). A task's
|
|
779
|
+
// title and touched paths are exactly the "prose" that rule protects: a task
|
|
780
|
+
// under a PRIVATE goal the caller neither manages nor is a member of is
|
|
781
|
+
// EXCLUDED here, not merely redacted — unlike the graph, this advisory has
|
|
782
|
+
// no structural need to keep an invisible node in the picture (omitting a
|
|
783
|
+
// candidate says nothing false; it just offers no advice about it). A
|
|
784
|
+
// goal-less task carries no visibility concern (`t.goal_id IS NULL`).
|
|
785
|
+
// `LIMIT` bounds the candidate count so a growing corpus cannot turn
|
|
786
|
+
// classify()'s O(|a|×|b|) pairwise scan (dependency-advisory.js) into an
|
|
787
|
+
// unbounded synchronous cost on this write path.
|
|
788
|
+
const DEPENDENCY_ADVISORY_CANDIDATE_LIMIT = 200;
|
|
789
|
+
async function listOpenTasksWithTouches(versionId, excludeId, { viewerId = null, viewerIsArchon = false } = {}) {
|
|
790
|
+
const { rows } = await pool.query(
|
|
791
|
+
`SELECT t.id, t.title, t.status, t.touches
|
|
792
|
+
FROM tasks t
|
|
793
|
+
LEFT JOIN goals g ON g.id = t.goal_id
|
|
794
|
+
LEFT JOIN goal_members gm ON gm.goal_id = g.id AND gm.builder_id = $4::bigint
|
|
795
|
+
WHERE t.version_id = $1
|
|
796
|
+
AND t.status = ANY($2::text[])
|
|
797
|
+
AND t.touches <> '{}'
|
|
798
|
+
AND ($3::bigint IS NULL OR t.id <> $3)
|
|
799
|
+
AND (t.goal_id IS NULL
|
|
800
|
+
OR g.visibility IS DISTINCT FROM 'private'
|
|
801
|
+
OR $5::boolean
|
|
802
|
+
OR gm.builder_id IS NOT NULL)
|
|
803
|
+
ORDER BY t.id
|
|
804
|
+
LIMIT ${DEPENDENCY_ADVISORY_CANDIDATE_LIMIT}`,
|
|
805
|
+
[versionId, ['ready', 'backlog', 'active', 'blocked'], excludeId, viewerId, viewerIsArchon]
|
|
806
|
+
);
|
|
807
|
+
return rows;
|
|
808
|
+
}
|
|
809
|
+
|
|
768
810
|
// Tasks that are ready, unblocked, and have no touches[] overlap
|
|
769
811
|
// with currently active claims. Excludes tasks already claimed by this
|
|
770
812
|
// builder (so the same builder doesn't see their own active claim listed
|
|
@@ -1269,6 +1311,7 @@ module.exports = {
|
|
|
1269
1311
|
listActiveClaims,
|
|
1270
1312
|
listClaimableTasks,
|
|
1271
1313
|
listOpenArtistReviews,
|
|
1314
|
+
listOpenTasksWithTouches,
|
|
1272
1315
|
listTasks,
|
|
1273
1316
|
promoteUnblockedBacklogTasks,
|
|
1274
1317
|
rankCrossDisciplineRecommendations,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/dependency-advisory.js — the MISSING-DEPENDENCY advisory
|
|
4
|
+
// (task 1002827, goal 1000089).
|
|
5
|
+
//
|
|
6
|
+
// THE HOLE. scripts/gds/audit-deps.js has shipped a working
|
|
7
|
+
// likely-missing-dependency detector since task 1000349 (unit-tested,
|
|
8
|
+
// tests/audit_deps.mjs, tests/missing_dep_classify.mjs) — and nothing ever
|
|
9
|
+
// called it. A task can be created touching the exact same files an
|
|
10
|
+
// unshipped task already claims, with no declared dependency between them,
|
|
11
|
+
// and nothing says so until a human happens to run the CLI by hand.
|
|
12
|
+
//
|
|
13
|
+
// This module wires that SAME detector's predicate —
|
|
14
|
+
// modules/lifecycle/missing-dep-classify.js's `candidateEdge`/`classify`,
|
|
15
|
+
// not a second copy of either — into two surfaces, mirroring
|
|
16
|
+
// goal-advisory.js's own two-surface shape (task 1002539):
|
|
17
|
+
// 1. POST /tasks attaches `dependency_advisory` to its 201 body (this
|
|
18
|
+
// file's `buildDependencyAdvisory`), read by the /goal-create skill's
|
|
19
|
+
// "sibling advisory" section (.claude/skills/goal-create/SKILL.md).
|
|
20
|
+
// 2. The periodic sweep: `/goal-review`'s daily walk runs
|
|
21
|
+
// `node scripts/gds/audit-deps.js --min high` as one more housekeeping
|
|
22
|
+
// step (idea 1000276's half — see .claude/skills/goal-review/SKILL.md).
|
|
23
|
+
//
|
|
24
|
+
// HARD CONSTRAINT, same as goal-advisory.js and ADR 0162: advisory only. This
|
|
25
|
+
// must never refuse a POST, and `buildDependencyAdvisory` swallows its own
|
|
26
|
+
// failures for the same reason goal-advisory.js's does — the task row is
|
|
27
|
+
// ALREADY COMMITTED by the time this runs, so a throw here would turn a
|
|
28
|
+
// successful create into a 500 the caller then retries.
|
|
29
|
+
//
|
|
30
|
+
// VISIBILITY (ADR 0112's leak rule, the goal-graph.js precedent) — enforced
|
|
31
|
+
// in the query, not here: `db.listOpenTasksWithTouches` excludes a candidate
|
|
32
|
+
// under a PRIVATE goal the caller neither manages nor belongs to, so this
|
|
33
|
+
// file never even sees a task it should not name in a 201 body.
|
|
34
|
+
//
|
|
35
|
+
// PURE core (`adviseDependencies`) + a DB-read wrapper, the criterion-suggest /
|
|
36
|
+
// goal-advisory precedent, so every branch is pinned by a DB-free unit test.
|
|
37
|
+
|
|
38
|
+
const { candidateEdge } = require('./missing-dep-classify.js');
|
|
39
|
+
|
|
40
|
+
// How many candidates the advisory offers. Small on purpose (the
|
|
41
|
+
// goal-advisory.js CANDIDATE_LIMIT precedent) — this is a hint for the author
|
|
42
|
+
// to triage, not a browsable report; the full corpus scan already exists as
|
|
43
|
+
// `node scripts/gds/audit-deps.js` for that. Separate from (and much smaller
|
|
44
|
+
// than) db.listOpenTasksWithTouches's own query-side LIMIT, which bounds cost
|
|
45
|
+
// rather than display.
|
|
46
|
+
const CANDIDATE_LIMIT = 5;
|
|
47
|
+
|
|
48
|
+
// adviseDependencies — PURE. `task` is the just-created (or just-fetched) row,
|
|
49
|
+
// `candidates` is the OPEN-with-touches, visibility-filtered pool a caller
|
|
50
|
+
// already fetched. Returns null when there is nothing worth naming (fail-open
|
|
51
|
+
// on ambiguity, same posture as adviseGoal): no touches[] on the task at all,
|
|
52
|
+
// or no candidate clears the shared classify() confidence bar.
|
|
53
|
+
function adviseDependencies({ task, candidates = [] } = {}) {
|
|
54
|
+
if (!task || !Array.isArray(task.touches) || !task.touches.length) return null;
|
|
55
|
+
const declared = new Set((task.dependencies || []).map(String));
|
|
56
|
+
const edges = [];
|
|
57
|
+
for (const o of candidates) {
|
|
58
|
+
const e = candidateEdge(task, o, { declared });
|
|
59
|
+
if (e) edges.push({ dep_id: e.dep_id, dep_title: (o && o.title) || null, dep_status: e.dep_status, confidence: e.confidence, paths: e.paths });
|
|
60
|
+
}
|
|
61
|
+
if (!edges.length) return null;
|
|
62
|
+
const order = { high: 3, medium: 2, low: 1 };
|
|
63
|
+
edges.sort((a, b) => order[b.confidence] - order[a.confidence]);
|
|
64
|
+
return {
|
|
65
|
+
reason: 'possible_missing_dependency',
|
|
66
|
+
message: `${edges.length} open task(s) touch the same files as this one and are not declared `
|
|
67
|
+
+ 'dependencies — most touches[] overlap is parallel work on a shared surface, not a build-order '
|
|
68
|
+
+ 'dependency, so read each candidate before adding an edge (a wrong one gates the claim).',
|
|
69
|
+
candidates: edges.slice(0, CANDIDATE_LIMIT),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// buildDependencyAdvisory — the whole advisory, DB read included, so POST
|
|
74
|
+
// /tasks calls one line (the buildGoalAdvisory precedent). Fails open: any
|
|
75
|
+
// throw degrades to null rather than failing an otherwise-successful create.
|
|
76
|
+
// `viewerId`/`viewerIsArchon` ride straight into the query's own visibility
|
|
77
|
+
// filter — this file never post-filters what the query already excluded.
|
|
78
|
+
async function buildDependencyAdvisory({ db, versionId, task, viewerId = null, viewerIsArchon = false }) {
|
|
79
|
+
try {
|
|
80
|
+
if (!Array.isArray(task.touches) || !task.touches.length) return null;
|
|
81
|
+
const candidates = await db.listOpenTasksWithTouches(versionId, task.id, { viewerId, viewerIsArchon });
|
|
82
|
+
const verdict = adviseDependencies({ task, candidates });
|
|
83
|
+
if (!verdict) return null;
|
|
84
|
+
return {
|
|
85
|
+
...verdict,
|
|
86
|
+
next_step: 'Review each candidate; if it is a real build-order dependency, add it via '
|
|
87
|
+
+ `POST /tasks/${task.id}/dependencies.`,
|
|
88
|
+
};
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// Through the published logger seam (api.logger), not a raw console call
|
|
91
|
+
// — the est-advisory.js precedent. Lazy-required so the DB-free unit test
|
|
92
|
+
// that exercises this branch stays hermetic.
|
|
93
|
+
require('../../src/module-api').logger('lifecycle')
|
|
94
|
+
.error('dependency-advisory (non-blocking)', e && e.message);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { adviseDependencies, buildDependencyAdvisory, CANDIDATE_LIMIT };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/missing-dep-classify.js — the PURE core of the
|
|
4
|
+
// likely-missing-dependency detector (task 349, moved here from
|
|
5
|
+
// scripts/gds/audit-deps.js by task 1002827 so a live route can reuse it
|
|
6
|
+
// without pulling in that script's CLI-only machinery — cliClient(),
|
|
7
|
+
// resolveDefaultVersion() — into the server process. The `dead-deps.js`
|
|
8
|
+
// precedent beside this file: a pure lifecycle module, consumed both by the
|
|
9
|
+
// audit-deps.js CLI (its --stranded arm) and now by dependency-advisory.js's
|
|
10
|
+
// live POST /tasks path.
|
|
11
|
+
//
|
|
12
|
+
// THIS IS ADVISORY. Most touches[] overlaps are PARALLEL work on a shared
|
|
13
|
+
// surface (src/bongos/db.js, migrations/, CLAUDE.md, …), NOT a build-order
|
|
14
|
+
// dependency. Backfilling a wrong edge GATES the dependent (POST /claims →
|
|
15
|
+
// 409 DEPS_NOT_SHIPPED) until the named blocker ships. So: act only on
|
|
16
|
+
// `high` confidence, by hand, after reading both task descriptions. Reuses
|
|
17
|
+
// the ONE canonical overlap matcher (src/bongos/path-match.touchesOverlap,
|
|
18
|
+
// reached via the module-api doorway) so it can't drift from the claim
|
|
19
|
+
// gate's notion of "overlap".
|
|
20
|
+
|
|
21
|
+
const api = require('../../src/module-api');
|
|
22
|
+
const { touchesOverlap } = api.pathMatch;
|
|
23
|
+
|
|
24
|
+
// Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
|
|
25
|
+
const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
|
|
26
|
+
const isUnshippedBlocker = (s) => s !== 'shipped' && s !== 'abandoned';
|
|
27
|
+
|
|
28
|
+
// Shared collaboration surfaces: an overlap here is a parallel-safety (touches[])
|
|
29
|
+
// signal, not a dependency. A candidate whose ONLY overlap is one of these (or a
|
|
30
|
+
// bare directory) is demoted to `low`. Deliberately broad; tune as the repo moves.
|
|
31
|
+
const HOTSPOTS = [
|
|
32
|
+
'src/bongos/db.js', 'src/bongos/routes.js', 'server.js', 'migrations/', 'CLAUDE.md',
|
|
33
|
+
'modules/hall-ui/', 'tests/', 'scripts/gds/', 'src/bongos/', 'src/bongos/routes/',
|
|
34
|
+
'scripts/gds/ship.js', 'scripts/gds/api.js', 'scripts/gds/cli-lib.js',
|
|
35
|
+
'modules/grading/grader.js', 'docs/', 'docs/adr/', 'docs/adr/README.md', 'docs/recipes/',
|
|
36
|
+
'docs/architecture.md', 'docs/file-map.md', '.claude/skills/', '.claude/hooks/',
|
|
37
|
+
'infra/', 'public/assets/tiles/',
|
|
38
|
+
];
|
|
39
|
+
const isHotspot = (p) => HOTSPOTS.includes(p);
|
|
40
|
+
|
|
41
|
+
// Confidence heuristic (advisory; a human decides):
|
|
42
|
+
// high — an exact SAME non-hotspot, non-bare-dir FILE both tasks name.
|
|
43
|
+
// medium — a narrow (non-hotspot, non-bare-dir) overlap exists.
|
|
44
|
+
// low — overlap only on a shared hotspot / bare directory.
|
|
45
|
+
function classify(tTouches, oTouches) {
|
|
46
|
+
const pairs = [];
|
|
47
|
+
for (const a of tTouches) for (const b of oTouches) {
|
|
48
|
+
if (touchesOverlap([a], [b])) pairs.push({ a, b, exact: a === b });
|
|
49
|
+
}
|
|
50
|
+
if (!pairs.length) return null;
|
|
51
|
+
const narrow = pairs.filter((p) => !isHotspot(p.a) && !isHotspot(p.b) && !p.a.endsWith('/') && !p.b.endsWith('/'));
|
|
52
|
+
const confidence = narrow.some((p) => p.exact) ? 'high' : narrow.length ? 'medium' : 'low';
|
|
53
|
+
const paths = [...new Set(pairs.map((p) => (p.exact ? p.a : `${p.a} ~ ${p.b}`)))];
|
|
54
|
+
return { confidence, paths };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// candidateEdge — THE ONE predicate for "does `o` qualify as a missing-dependency
|
|
58
|
+
// candidate for `t`", pulled out (task 1002827 grade round 1) so findMissingDeps
|
|
59
|
+
// and dependency-advisory.js's live adviseDependencies call the SAME check
|
|
60
|
+
// instead of two hand-typed copies that can drift on the next rule change.
|
|
61
|
+
// `declared` is precomputed and passed in by findMissingDeps (one Set per `t`,
|
|
62
|
+
// not per pair); a caller checking a single `t` against many candidates may
|
|
63
|
+
// omit it and pay one Set build instead. Runs `classify` exactly ONCE — it
|
|
64
|
+
// already returns null on no overlap, so a separate touchesOverlap pre-check
|
|
65
|
+
// was dead work computing the same answer twice.
|
|
66
|
+
function candidateEdge(t, o, { isBlocker = isUnshippedBlocker, declared } = {}) {
|
|
67
|
+
if (!o || String(o.id) === String(t.id)) return null;
|
|
68
|
+
if (!isBlocker(o.status)) return null;
|
|
69
|
+
if (!Array.isArray(o.touches) || !o.touches.length) return null;
|
|
70
|
+
const declaredSet = declared || new Set(((t && t.dependencies) || []).map(String));
|
|
71
|
+
if (declaredSet.has(String(o.id))) return null;
|
|
72
|
+
if (!Array.isArray(t.touches) || !t.touches.length) return null;
|
|
73
|
+
const c = classify(t.touches, o.touches);
|
|
74
|
+
if (!c) return null;
|
|
75
|
+
return { task_id: t.id, dep_id: o.id, dep_status: o.status, ...c };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Pure core — unit-tested with fixtures (no API). `tasks` is an array of
|
|
79
|
+
// { id, status, touches:[], dependencies:[ids] }. Returns candidate edges
|
|
80
|
+
// { task_id, dep_id, dep_status, confidence, paths }.
|
|
81
|
+
function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker } = {}) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const t of tasks) {
|
|
84
|
+
if (!open.has(t.status)) continue;
|
|
85
|
+
if (!Array.isArray(t.touches) || !t.touches.length) continue;
|
|
86
|
+
const declared = new Set((t.dependencies || []).map(String));
|
|
87
|
+
for (const o of tasks) {
|
|
88
|
+
const e = candidateEdge(t, o, { isBlocker, declared });
|
|
89
|
+
if (e) out.push(e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { OPEN, isUnshippedBlocker, HOTSPOTS, isHotspot, classify, candidateEdge, findMissingDeps };
|
|
@@ -22,6 +22,7 @@ const db = require('../db');
|
|
|
22
22
|
const hierarchy = require('../hierarchy');
|
|
23
23
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
24
24
|
const { buildGoalAdvisory } = require('../goal-advisory');
|
|
25
|
+
const { buildDependencyAdvisory } = require('../dependency-advisory');
|
|
25
26
|
const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
26
27
|
const { isModuleKey, moduleKeys } = api.moduleScopeMap;
|
|
27
28
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
@@ -282,13 +283,21 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
282
283
|
const goal_advisory = await buildGoalAdvisory({
|
|
283
284
|
db, versionId: body.version_id, task, hasCriterionLink: criteria.length > 0,
|
|
284
285
|
});
|
|
286
|
+
// task 1002827 — the missing-dependency advisory (scripts/gds/audit-deps.js's
|
|
287
|
+
// detector, wired live for the first time): OPEN tasks in this version that
|
|
288
|
+
// touch the same files as this one and are not declared dependencies.
|
|
289
|
+
// Non-blocking by construction — see dependency-advisory.js.
|
|
290
|
+
const dependency_advisory = await buildDependencyAdvisory({
|
|
291
|
+
db, versionId: body.version_id, task,
|
|
292
|
+
viewerId: req.builder.id, viewerIsArchon: req.builder.rank === 'archon',
|
|
293
|
+
});
|
|
285
294
|
res.status(201).json({
|
|
286
295
|
task,
|
|
287
296
|
credits_reward: task.credits_reward,
|
|
288
297
|
needs_migration: needsMigration,
|
|
289
298
|
criteria,
|
|
290
299
|
suggested_criteria,
|
|
291
|
-
goal_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
|
|
300
|
+
goal_advisory, dependency_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
|
|
292
301
|
});
|
|
293
302
|
} catch (err) {
|
|
294
303
|
log.error('[gds] POST /tasks', err);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.977",
|
|
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.977",
|
|
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.977",
|
|
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",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// scripts/gds/audit-deps.js — likely-missing-dependency detector (task 349).
|
|
2
|
+
// scripts/gds/audit-deps.js — likely-missing-dependency detector CLI (task 349).
|
|
3
3
|
//
|
|
4
4
|
// WHY: an under-specified dependencies[] lets work start before the task it
|
|
5
5
|
// implicitly builds on has shipped, racing already-built work (the original
|
|
@@ -11,16 +11,21 @@
|
|
|
11
11
|
// (src/bongos/db.js, migrations/, CLAUDE.md, …), NOT a build-order dependency.
|
|
12
12
|
// Backfilling a wrong edge GATES the dependent (POST /claims → 409
|
|
13
13
|
// DEPS_NOT_SHIPPED) until the named blocker ships. So: act only on `high`
|
|
14
|
-
// confidence, by hand, after reading both task descriptions.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// confidence, by hand, after reading both task descriptions.
|
|
15
|
+
//
|
|
16
|
+
// PURE CORE MOVED to modules/lifecycle/missing-dep-classify.js (task 1002827),
|
|
17
|
+
// so dependency-advisory.js's live POST /tasks path can reuse the ONE canonical
|
|
18
|
+
// `classify`/`findMissingDeps` — the same one tests/audit_deps.mjs covers —
|
|
19
|
+
// without pulling this file's CLI-only machinery (cliClient's session-token
|
|
20
|
+
// I/O, resolveDefaultVersion) into the running server. This file is now the
|
|
21
|
+
// CLI wrapper only: argv parsing, the corpus fetch, and --stranded's dead-edge
|
|
22
|
+
// report. Every name below is RE-EXPORTED unchanged so no caller of this file
|
|
23
|
+
// (including tests/audit_deps.mjs) needs to know it moved.
|
|
18
24
|
//
|
|
19
25
|
// node scripts/gds/audit-deps.js [--version GDS-V3] [--min high|medium|low] [--json]
|
|
20
26
|
// node scripts/gds/audit-deps.js --stranded [--json] # the INVERSE: dead edges (task 1003153),
|
|
21
27
|
// # plus prose-declared gates naming a dead task (task 1003857, advisory)
|
|
22
28
|
|
|
23
|
-
const { touchesOverlap } = require('../../src/bongos/path-match.js');
|
|
24
29
|
const { cliClient } = require('./cli-lib');
|
|
25
30
|
const { resolveDefaultVersion } = require('./version-select.js');
|
|
26
31
|
// task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
|
|
@@ -28,61 +33,8 @@ const { resolveDefaultVersion } = require('./version-select.js');
|
|
|
28
33
|
const { findStrandedTasks, findProseGatedTasks } = require('../../modules/lifecycle/dead-deps.js');
|
|
29
34
|
// task 1003752 — the route's page bound + the first-page offset rule, declared once.
|
|
30
35
|
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
|
|
34
|
-
const isUnshippedBlocker = (s) => s !== 'shipped' && s !== 'abandoned';
|
|
35
|
-
|
|
36
|
-
// Shared collaboration surfaces: an overlap here is a parallel-safety (touches[])
|
|
37
|
-
// signal, not a dependency. A candidate whose ONLY overlap is one of these (or a
|
|
38
|
-
// bare directory) is demoted to `low`. Deliberately broad; tune as the repo moves.
|
|
39
|
-
const HOTSPOTS = [
|
|
40
|
-
'src/bongos/db.js', 'src/bongos/routes.js', 'server.js', 'migrations/', 'CLAUDE.md',
|
|
41
|
-
'modules/hall-ui/', 'tests/', 'scripts/gds/', 'src/bongos/', 'src/bongos/routes/',
|
|
42
|
-
'scripts/gds/ship.js', 'scripts/gds/api.js', 'scripts/gds/cli-lib.js',
|
|
43
|
-
'modules/grading/grader.js', 'docs/', 'docs/adr/', 'docs/adr/README.md', 'docs/recipes/',
|
|
44
|
-
'docs/architecture.md', 'docs/file-map.md', '.claude/skills/', '.claude/hooks/',
|
|
45
|
-
'infra/', 'public/assets/tiles/',
|
|
46
|
-
];
|
|
47
|
-
const isHotspot = (p) => HOTSPOTS.includes(p);
|
|
48
|
-
|
|
49
|
-
// Confidence heuristic (advisory; a human decides):
|
|
50
|
-
// high — an exact SAME non-hotspot, non-bare-dir FILE both tasks name.
|
|
51
|
-
// medium — a narrow (non-hotspot, non-bare-dir) overlap exists.
|
|
52
|
-
// low — overlap only on a shared hotspot / bare directory.
|
|
53
|
-
function classify(tTouches, oTouches) {
|
|
54
|
-
const pairs = [];
|
|
55
|
-
for (const a of tTouches) for (const b of oTouches) {
|
|
56
|
-
if (touchesOverlap([a], [b])) pairs.push({ a, b, exact: a === b });
|
|
57
|
-
}
|
|
58
|
-
if (!pairs.length) return null;
|
|
59
|
-
const narrow = pairs.filter((p) => !isHotspot(p.a) && !isHotspot(p.b) && !p.a.endsWith('/') && !p.b.endsWith('/'));
|
|
60
|
-
const confidence = narrow.some((p) => p.exact) ? 'high' : narrow.length ? 'medium' : 'low';
|
|
61
|
-
const paths = [...new Set(pairs.map((p) => (p.exact ? p.a : `${p.a} ~ ${p.b}`)))];
|
|
62
|
-
return { confidence, paths };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Pure core — unit-tested with fixtures (no API). `tasks` is an array of
|
|
66
|
-
// { id, status, touches:[], dependencies:[ids] }. Returns candidate edges
|
|
67
|
-
// { task_id, dep_id, dep_status, confidence, paths }.
|
|
68
|
-
function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker } = {}) {
|
|
69
|
-
const out = [];
|
|
70
|
-
for (const t of tasks) {
|
|
71
|
-
if (!open.has(t.status)) continue;
|
|
72
|
-
if (!Array.isArray(t.touches) || !t.touches.length) continue;
|
|
73
|
-
const declared = new Set((t.dependencies || []).map(String));
|
|
74
|
-
for (const o of tasks) {
|
|
75
|
-
if (String(o.id) === String(t.id)) continue;
|
|
76
|
-
if (!isBlocker(o.status)) continue;
|
|
77
|
-
if (!Array.isArray(o.touches) || !o.touches.length) continue;
|
|
78
|
-
if (declared.has(String(o.id))) continue;
|
|
79
|
-
if (!touchesOverlap(t.touches, o.touches)) continue;
|
|
80
|
-
const c = classify(t.touches, o.touches);
|
|
81
|
-
if (c) out.push({ task_id: t.id, dep_id: o.id, dep_status: o.status, ...c });
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return out;
|
|
85
|
-
}
|
|
36
|
+
const { OPEN, isUnshippedBlocker, HOTSPOTS, classify, findMissingDeps } =
|
|
37
|
+
require('../../modules/lifecycle/missing-dep-classify.js');
|
|
86
38
|
|
|
87
39
|
// Read the WHOLE task corpus, one allowed page at a time (task 1003752). This
|
|
88
40
|
// was a single `limit: 3000` read, which GET /tasks caps at TASK_LIST_MAX_LIMIT
|
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.977'; // 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');
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// tests/dependency_advisory.mjs — the missing-dependency advisory on POST
|
|
2
|
+
// /tasks (task 1002827, goal 1000089).
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/audit-deps.js has shipped a working likely-missing-dependency
|
|
5
|
+
// detector since task 1000349 (tests/audit_deps.mjs, tests/missing_dep_classify.mjs)
|
|
6
|
+
// and nothing called it. This wires the SAME detector's pure core
|
|
7
|
+
// (modules/lifecycle/missing-dep-classify.js) live, mirroring goal-advisory.js's
|
|
8
|
+
// two properties:
|
|
9
|
+
// 1. DETECTION reuses the one canonical overlap matcher — no second copy.
|
|
10
|
+
// 2. The advisory can NEVER fail the create — asserted here by driving it
|
|
11
|
+
// with a db that throws.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/dependency_advisory.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { makeRunner, taskRoutesSource } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
process.env.NODE_ENV = 'test';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { adviseDependencies, buildDependencyAdvisory, CANDIDATE_LIMIT } = require('../modules/lifecycle/dependency-advisory.js');
|
|
25
|
+
|
|
26
|
+
const tasksSrc = taskRoutesSource();
|
|
27
|
+
const { test, summary } = makeRunner();
|
|
28
|
+
|
|
29
|
+
// ---- adviseDependencies: the pure core ----
|
|
30
|
+
|
|
31
|
+
await test('no touches[] on the new task — silent, no candidates read', () => {
|
|
32
|
+
assert.equal(adviseDependencies({ task: { id: 1, touches: [] }, candidates: [{ id: 2, status: 'ready', touches: ['a.js'] }] }), null);
|
|
33
|
+
assert.equal(adviseDependencies({ task: { id: 1 }, candidates: [] }), null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await test('an exact narrow-file overlap with an open candidate is flagged high', () => {
|
|
37
|
+
const a = adviseDependencies({
|
|
38
|
+
task: { id: 1, touches: ['modules/foo/bar.js'] },
|
|
39
|
+
candidates: [{ id: 2, title: 'Sibling work', status: 'ready', touches: ['modules/foo/bar.js'] }],
|
|
40
|
+
});
|
|
41
|
+
assert.equal(a.reason, 'possible_missing_dependency');
|
|
42
|
+
assert.equal(a.candidates.length, 1);
|
|
43
|
+
assert.equal(a.candidates[0].dep_id, 2);
|
|
44
|
+
assert.equal(a.candidates[0].confidence, 'high');
|
|
45
|
+
assert.equal(a.candidates[0].dep_title, 'Sibling work');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await test('an already-declared dependency is never flagged', () => {
|
|
49
|
+
const a = adviseDependencies({
|
|
50
|
+
task: { id: 1, touches: ['modules/foo/bar.js'], dependencies: ['2'] },
|
|
51
|
+
candidates: [{ id: 2, status: 'ready', touches: ['modules/foo/bar.js'] }],
|
|
52
|
+
});
|
|
53
|
+
assert.equal(a, null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('a SHIPPED candidate never gates — the dependency system already treats it as satisfied', () => {
|
|
57
|
+
const a = adviseDependencies({
|
|
58
|
+
task: { id: 1, touches: ['modules/foo/bar.js'] },
|
|
59
|
+
candidates: [{ id: 2, status: 'shipped', touches: ['modules/foo/bar.js'] }],
|
|
60
|
+
});
|
|
61
|
+
assert.equal(a, null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await test('an ABANDONED candidate never gates', () => {
|
|
65
|
+
const a = adviseDependencies({
|
|
66
|
+
task: { id: 1, touches: ['modules/foo/bar.js'] },
|
|
67
|
+
candidates: [{ id: 2, status: 'abandoned', touches: ['modules/foo/bar.js'] }],
|
|
68
|
+
});
|
|
69
|
+
assert.equal(a, null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await test('a candidate never flags itself', () => {
|
|
73
|
+
const a = adviseDependencies({
|
|
74
|
+
task: { id: 1, touches: ['modules/foo/bar.js'] },
|
|
75
|
+
candidates: [{ id: 1, status: 'ready', touches: ['modules/foo/bar.js'] }],
|
|
76
|
+
});
|
|
77
|
+
assert.equal(a, null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('a hotspot-only overlap (e.g. CLAUDE.md) is flagged low, sorted after a high candidate', () => {
|
|
81
|
+
const a = adviseDependencies({
|
|
82
|
+
task: { id: 1, touches: ['CLAUDE.md', 'modules/foo/bar.js'] },
|
|
83
|
+
candidates: [
|
|
84
|
+
{ id: 2, status: 'ready', touches: ['CLAUDE.md'] },
|
|
85
|
+
{ id: 3, status: 'ready', touches: ['modules/foo/bar.js'] },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
assert.equal(a.candidates.length, 2);
|
|
89
|
+
assert.equal(a.candidates[0].dep_id, 3, 'high confidence sorts first');
|
|
90
|
+
assert.equal(a.candidates[0].confidence, 'high');
|
|
91
|
+
assert.equal(a.candidates[1].confidence, 'low');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('candidates are capped at CANDIDATE_LIMIT', () => {
|
|
95
|
+
const candidates = Array.from({ length: CANDIDATE_LIMIT + 3 }, (_, i) => ({ id: i + 2, status: 'ready', touches: ['modules/foo/bar.js'] }));
|
|
96
|
+
const a = adviseDependencies({ task: { id: 1, touches: ['modules/foo/bar.js'] }, candidates });
|
|
97
|
+
assert.equal(a.candidates.length, CANDIDATE_LIMIT);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---- buildDependencyAdvisory: DB-read wrapper, fail-open ----
|
|
101
|
+
|
|
102
|
+
await test('buildDependencyAdvisory: no touches[] skips the DB read entirely', async () => {
|
|
103
|
+
let read = false;
|
|
104
|
+
const db = { async listOpenTasksWithTouches() { read = true; return []; } };
|
|
105
|
+
const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: [] } });
|
|
106
|
+
assert.equal(a, null);
|
|
107
|
+
assert.equal(read, false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await test('buildDependencyAdvisory: a real candidate yields a next_step naming the actual task id', async () => {
|
|
111
|
+
const db = { async listOpenTasksWithTouches() { return [{ id: 2, title: 'Sibling', status: 'ready', touches: ['modules/foo/bar.js'] }]; } };
|
|
112
|
+
const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 42, touches: ['modules/foo/bar.js'] } });
|
|
113
|
+
assert.equal(a.reason, 'possible_missing_dependency');
|
|
114
|
+
assert.match(a.next_step, /POST \/tasks\/42\/dependencies/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('NON-BLOCKING: buildDependencyAdvisory returns null when db.listOpenTasksWithTouches throws', async () => {
|
|
118
|
+
const db = { async listOpenTasksWithTouches() { throw new Error('db down'); } };
|
|
119
|
+
const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
|
|
120
|
+
assert.equal(a, null, 'degrades to silence — never propagates into the already-committed create');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('NON-BLOCKING: a db missing the method entirely still yields null, not a crash', async () => {
|
|
124
|
+
const a = await buildDependencyAdvisory({ db: {}, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
|
|
125
|
+
assert.equal(a, null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('buildDependencyAdvisory forwards viewerId/viewerIsArchon to the query (ADR 0112 visibility)', async () => {
|
|
129
|
+
let seen = null;
|
|
130
|
+
const db = {
|
|
131
|
+
async listOpenTasksWithTouches(versionId, excludeId, opts) { seen = { versionId, excludeId, opts }; return []; },
|
|
132
|
+
};
|
|
133
|
+
await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 42, touches: ['a.js'] }, viewerId: 7, viewerIsArchon: true });
|
|
134
|
+
assert.deepEqual(seen, { versionId: 'V', excludeId: 42, opts: { viewerId: 7, viewerIsArchon: true } });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('buildDependencyAdvisory defaults viewerId/viewerIsArchon when the caller omits them', async () => {
|
|
138
|
+
let seen = null;
|
|
139
|
+
const db = { async listOpenTasksWithTouches(v, e, opts) { seen = opts; return []; } };
|
|
140
|
+
await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
|
|
141
|
+
assert.deepEqual(seen, { viewerId: null, viewerIsArchon: false });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ---- ADR 0112: the query itself must carry the visibility predicate ----
|
|
145
|
+
// (SQL correctness for this file is integration-tested elsewhere in this
|
|
146
|
+
// codebase's own convention — no local Postgres here — so this is the same
|
|
147
|
+
// source-scan safety net the goal-advisory/goal-graph precedents use to
|
|
148
|
+
// catch an accidental removal of the join/predicate.)
|
|
149
|
+
|
|
150
|
+
await test('listOpenTasksWithTouches SQL joins goals + goal_members and excludes an unseen private goal', () => {
|
|
151
|
+
const src = readFileSync(new URL('../modules/lifecycle/db-tasks.js', import.meta.url), 'utf8');
|
|
152
|
+
const fn = src.slice(src.indexOf('async function listOpenTasksWithTouches'), src.indexOf('async function listOpenTasksWithTouches') + 1200);
|
|
153
|
+
assert.match(fn, /LEFT JOIN goals g ON g\.id = t\.goal_id/);
|
|
154
|
+
assert.match(fn, /LEFT JOIN goal_members gm ON gm\.goal_id = g\.id/);
|
|
155
|
+
assert.match(fn, /g\.visibility IS DISTINCT FROM 'private'/);
|
|
156
|
+
assert.match(fn, /gm\.builder_id IS NOT NULL/, 'a viewer\'s own membership admits a private goal\'s task');
|
|
157
|
+
assert.match(fn, /LIMIT \$\{DEPENDENCY_ADVISORY_CANDIDATE_LIMIT\}/, 'the candidate pool is bounded');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---- the route wiring, asserted over source (the goal_advisory.mjs precedent) ----
|
|
161
|
+
|
|
162
|
+
await test('the route delegates to buildDependencyAdvisory — no inline logic to drift — and forwards the viewer identity for ADR 0112', () => {
|
|
163
|
+
assert.match(tasksSrc, /await buildDependencyAdvisory\(\{[\s\S]{0,80}?db, versionId: body\.version_id, task,[\s\S]{0,200}?viewerId: req\.builder\.id, viewerIsArchon: req\.builder\.rank === 'archon',[\s\S]{0,20}?\}\);/);
|
|
164
|
+
const body201 = (tasksSrc.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
|
|
165
|
+
assert.ok(body201, 'found the 201 response body');
|
|
166
|
+
assert.match(body201, /dependency_advisory,/, 'included in the 201 body');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---- ROUTER-LEVEL: the non-blocking invariant asserted over real HTTP ----
|
|
170
|
+
|
|
171
|
+
function listen(app) {
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function post(port, path, body) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const payload = JSON.stringify(body);
|
|
180
|
+
const req = http.request(
|
|
181
|
+
{ host: '127.0.0.1', port, path, method: 'POST',
|
|
182
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
|
|
183
|
+
(res) => {
|
|
184
|
+
let data = '';
|
|
185
|
+
res.on('data', (c) => { data += c; });
|
|
186
|
+
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data || '{}') }));
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
req.on('error', reject);
|
|
190
|
+
req.end(payload);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function makeApp(db) {
|
|
195
|
+
const express = require('express');
|
|
196
|
+
const app = express();
|
|
197
|
+
app.use(express.json());
|
|
198
|
+
app.post('/tasks', async (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const task = { id: 99, touches: req.body.touches || [], credits_reward: 60 }; // already committed
|
|
201
|
+
const dependency_advisory = await buildDependencyAdvisory({ db, versionId: req.body.version_id, task });
|
|
202
|
+
res.status(201).json({ task, criteria: [], dependency_advisory });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
res.status(500).json({ error: { code: 'create_failed' } });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
return app;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await test('HTTP: a healthy db yields 201 with a populated dependency_advisory', async () => {
|
|
211
|
+
const db = { async listOpenTasksWithTouches() { return [{ id: 2, title: 'Sibling', status: 'ready', touches: ['modules/foo/bar.js'] }]; } };
|
|
212
|
+
const server = await listen(makeApp(db));
|
|
213
|
+
try {
|
|
214
|
+
const r = await post(server.address().port, '/tasks', { version_id: 'V', touches: ['modules/foo/bar.js'] });
|
|
215
|
+
assert.equal(r.status, 201);
|
|
216
|
+
assert.equal(r.body.dependency_advisory.reason, 'possible_missing_dependency');
|
|
217
|
+
} finally { server.close(); }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('HTTP: db.listOpenTasksWithTouches THROWS → still 201, task returned, dependency_advisory null (never a 500)', async () => {
|
|
221
|
+
const db = { async listOpenTasksWithTouches() { throw new Error('db down'); } };
|
|
222
|
+
const server = await listen(makeApp(db));
|
|
223
|
+
try {
|
|
224
|
+
const r = await post(server.address().port, '/tasks', { version_id: 'V', touches: ['modules/foo/bar.js'] });
|
|
225
|
+
assert.equal(r.status, 201, 'the create must NOT become a 500 the caller retries into a duplicate');
|
|
226
|
+
assert.equal(r.body.task.id, 99, 'the created task is still returned');
|
|
227
|
+
assert.equal(r.body.dependency_advisory, null, 'the advisory degrades to silence');
|
|
228
|
+
} finally { server.close(); }
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
summary();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// tests/missing_dep_classify.mjs — the PURE missing-dependency detector core
|
|
2
|
+
// (task 1002827), moved out of scripts/gds/audit-deps.js so a live route can
|
|
3
|
+
// reuse it without pulling that script's CLI-only machinery into the server
|
|
4
|
+
// process.
|
|
5
|
+
//
|
|
6
|
+
// tests/audit_deps.mjs already pins findMissingDeps/classify's BEHAVIOR
|
|
7
|
+
// (unchanged by the move — it still imports them from audit-deps.js, which
|
|
8
|
+
// now re-exports this module's names). This file instead pins the three
|
|
9
|
+
// things that changed or were added: the module boundary (a module may reach
|
|
10
|
+
// touchesOverlap only through the src/module-api.js doorway, never a deep
|
|
11
|
+
// core require), the pure surface being intact post-move, and `candidateEdge`
|
|
12
|
+
// — the ONE per-pair predicate findMissingDeps and dependency-advisory.js's
|
|
13
|
+
// live adviseDependencies both call now, so neither re-types it.
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/missing_dep_classify.mjs
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { makeRunner } from './helpers.mjs';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { OPEN, isUnshippedBlocker, HOTSPOTS, classify, candidateEdge, findMissingDeps } =
|
|
24
|
+
require('../modules/lifecycle/missing-dep-classify.js');
|
|
25
|
+
|
|
26
|
+
const { test, summary } = makeRunner();
|
|
27
|
+
|
|
28
|
+
await test('OPEN names exactly the four auditable statuses', () => {
|
|
29
|
+
assert.deepEqual([...OPEN].sort(), ['active', 'backlog', 'blocked', 'ready']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await test('isUnshippedBlocker: only shipped/abandoned are terminal', () => {
|
|
33
|
+
assert.equal(isUnshippedBlocker('shipped'), false);
|
|
34
|
+
assert.equal(isUnshippedBlocker('abandoned'), false);
|
|
35
|
+
assert.equal(isUnshippedBlocker('ready'), true);
|
|
36
|
+
assert.equal(isUnshippedBlocker('backlog'), true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await test('classify: an exact non-hotspot file match is high confidence', () => {
|
|
40
|
+
const c = classify(['modules/foo/bar.js'], ['modules/foo/bar.js']);
|
|
41
|
+
assert.equal(c.confidence, 'high');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await test('classify: a hotspot-only overlap is low confidence', () => {
|
|
45
|
+
const c = classify(['CLAUDE.md'], ['CLAUDE.md']);
|
|
46
|
+
assert.equal(c.confidence, 'low');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await test('classify: no overlap at all returns null', () => {
|
|
50
|
+
assert.equal(classify(['a.js'], ['b.js']), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('findMissingDeps re-exported unchanged: a live example still flags', () => {
|
|
54
|
+
const tasks = [
|
|
55
|
+
{ id: 1, status: 'ready', touches: ['modules/x/y.js'], dependencies: [] },
|
|
56
|
+
{ id: 2, status: 'backlog', touches: ['modules/x/y.js'], dependencies: [] },
|
|
57
|
+
];
|
|
58
|
+
assert.equal(findMissingDeps(tasks).length, 2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---- candidateEdge: the ONE per-pair predicate, shared by findMissingDeps
|
|
62
|
+
// and dependency-advisory.js's adviseDependencies (grade round 1 finding) ----
|
|
63
|
+
|
|
64
|
+
await test('candidateEdge: an overlapping open non-dep is a real edge', () => {
|
|
65
|
+
const e = candidateEdge(
|
|
66
|
+
{ id: 1, touches: ['modules/x/y.js'] },
|
|
67
|
+
{ id: 2, status: 'ready', touches: ['modules/x/y.js'] },
|
|
68
|
+
);
|
|
69
|
+
assert.equal(e.task_id, 1);
|
|
70
|
+
assert.equal(e.dep_id, 2);
|
|
71
|
+
assert.equal(e.confidence, 'high');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await test('candidateEdge: null on self, terminal status, no touches, or an already-declared dep', () => {
|
|
75
|
+
const t = { id: 1, touches: ['a.js'], dependencies: ['2'] };
|
|
76
|
+
assert.equal(candidateEdge(t, { id: 1, status: 'ready', touches: ['a.js'] }), null, 'self');
|
|
77
|
+
assert.equal(candidateEdge(t, { id: 3, status: 'shipped', touches: ['a.js'] }), null, 'terminal');
|
|
78
|
+
assert.equal(candidateEdge(t, { id: 3, status: 'ready', touches: [] }), null, 'no touches');
|
|
79
|
+
assert.equal(candidateEdge(t, { id: 2, status: 'ready', touches: ['a.js'] }), null, 'already declared');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await test('candidateEdge: an explicit `declared` set is used instead of re-deriving from t.dependencies', () => {
|
|
83
|
+
const t = { id: 1, touches: ['a.js'], dependencies: [] };
|
|
84
|
+
const e = candidateEdge(t, { id: 2, status: 'ready', touches: ['a.js'] }, { declared: new Set(['2']) });
|
|
85
|
+
assert.equal(e, null, 'the passed-in declared set wins over the empty t.dependencies');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await test('candidateEdge runs classify exactly once — no separate touchesOverlap pre-check to duplicate', () => {
|
|
89
|
+
const src = readFileSync(new URL('../modules/lifecycle/missing-dep-classify.js', import.meta.url), 'utf8');
|
|
90
|
+
const body = src.slice(src.indexOf('function candidateEdge'), src.indexOf('function findMissingDeps'));
|
|
91
|
+
assert.equal((body.match(/classify\(/g) || []).length, 1, 'exactly one classify() call in candidateEdge');
|
|
92
|
+
assert.equal(/touchesOverlap\(/.test(body), false, 'no redundant overlap pre-check — classify already answers that');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('the module reaches touchesOverlap through the module-api doorway, never a deep core require', () => {
|
|
96
|
+
const src = readFileSync(new URL('../modules/lifecycle/missing-dep-classify.js', import.meta.url), 'utf8');
|
|
97
|
+
assert.match(src, /require\('\.\.\/\.\.\/src\/module-api'\)/, 'reaches core via the doorway');
|
|
98
|
+
assert.equal(/require\(['"]\.\.\/\.\.\/src\/bongos\//.test(src), false, 'never a direct src/bongos/* require (ADR 0083)');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('scripts/gds/audit-deps.js delegates to this module rather than redefining the pure core', () => {
|
|
102
|
+
const src = readFileSync(new URL('../scripts/gds/audit-deps.js', import.meta.url), 'utf8');
|
|
103
|
+
assert.match(src, /require\('\.\.\/\.\.\/modules\/lifecycle\/missing-dep-classify\.js'\)/);
|
|
104
|
+
assert.equal(/^function classify/m.test(src), false, 'classify is no longer redefined here');
|
|
105
|
+
assert.equal(/^function findMissingDeps/m.test(src), false, 'findMissingDeps is no longer redefined here');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
summary();
|