@bongos/core 1.19.975 → 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.
@@ -58,7 +58,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
58
58
  // 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
59
59
  // 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
60
60
  // deploy door. The web tier only QUEUES it; the control-plane runner performs it.
61
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight'];
61
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private'];
62
62
  // Every state a teardown can be asked for — i.e. everything except the one
63
63
  // state where there is provably nothing left to release. DERIVED from
64
64
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
@@ -1158,7 +1158,7 @@ async function enqueueIntent(db, instanceId, action, requestedBy, targetVersion
1158
1158
  // (the state CHECK has no 'superseded'; last_error says why) so the runner never
1159
1159
  // stands it up. A PENDING settings push is superseded the same way (task 1003140):
1160
1160
  // a push into a project about to be torn down is dead work, and letting it hold the
1161
- // delete hostage would repeat 1002691. A pending 'restart' joins them for the same
1161
+ // delete hostage would repeat 1002691. A pending 'restart' or 'repo-private' (task 1004193) joins them for the same
1162
1162
  // reason (task 1003238) — and more sharply, since the owner reaches for Restart
1163
1163
  // precisely when a project looks broken, which is also when they give up and delete
1164
1164
  // it. A RUNNING intent is mid-flight and never yanked —
@@ -1171,7 +1171,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
1171
1171
  `UPDATE provisioning_intents
1172
1172
  SET state = 'error', last_error = 'superseded: teardown requested',
1173
1173
  updated_at = now(), resolved_at = now()
1174
- WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart') AND state = 'pending'
1174
+ WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private') AND state = 'pending'
1175
1175
  RETURNING id`,
1176
1176
  [instanceId]
1177
1177
  );
@@ -0,0 +1,29 @@
1
+ // modules/provisioning/repo-private.js — the queue half of "make this project's repository
2
+ // private" (task 1004193, split (b) of task 1003078; ADR 0176).
3
+ //
4
+ // A project stood up with a public repository reads its code with the platform's shared
5
+ // key, which cannot read a private one — so the flip must wait until the project has its
6
+ // own read-only deploy key. That wiring needs the box and the owner's stored credential, so
7
+ // the web tier only enqueues; scripts/gds/provision-repo-private.js is the runner leg that
8
+ // wires the key and THEN flips.
9
+ //
10
+ // Its own file because provisioning.js sits at the size ratchet. It reaches the queue
11
+ // through the provisioning module OBJECT, so a test can stub enqueueIntent.
12
+ 'use strict';
13
+
14
+ const provisioning = require('./provisioning');
15
+
16
+ // Queue a flip-to-private. Arbitration is the restart shape: an open repo-private in
17
+ // either state makes a repeat idempotent (the flip has no payload, so there is nothing a
18
+ // second request could change); any OTHER open intent refuses it with
19
+ // { conflict: 'open_intent' } rather than racing a standup, teardown or settings push over
20
+ // the same checkout. The partial unique index ux_provisioning_intents_open stays the
21
+ // arbiter if two requests interleave.
22
+ async function enqueueRepoPrivate(db, instanceId, requestedBy) {
23
+ const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'repo-private', requestedBy);
24
+ if (created) return { intent, created: true };
25
+ if (intent.action === 'repo-private') return { intent, created: false };
26
+ return { intent, created: false, conflict: 'open_intent' };
27
+ }
28
+
29
+ module.exports = { enqueueRepoPrivate };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.975",
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.975",
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.975",
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. The detector reuses
15
- // the ONE canonical overlap matcher (src/bongos/path-match.touchesOverlap) — the
16
- // same one tests/touches_overlap.mjs covers — so it can't drift from the claim
17
- // gate's notion of "overlap".
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
- // Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
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
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ // provision-repo-private.js — "make this project's repository private": the runner leg
3
+ // (task 1004193, split (b) of task 1003078; ADR 0176).
4
+ //
5
+ // WHY. A project stood up while its repository was public reads its code with the
6
+ // platform's shared deploy key, which cannot read a private repository. Flip such a
7
+ // repository private and the project's next deploy pull fails (walk finding N4). Standups
8
+ // since ADR 0176 give a private repository the project's own read-only deploy key; this
9
+ // leg gives an EXISTING project the same wiring, and only then flips.
10
+ //
11
+ // ORDER IS THE SAFETY PROPERTY. Wire the key FIRST, flip SECOND. A deploy key authenticates
12
+ // against a still-public repository, so wiring first leaves no window in which the repo is
13
+ // private and the checkout still reads with the shared key. ensurePrivateRepoAccess would
14
+ // see the repo as public and do nothing, so it is told where the repo is going
15
+ // (`intendedPrivate`, task 1004167). If the flip then fails, the repository is public with
16
+ // a working key — harmless, and the retry re-runs both steps idempotently.
17
+ //
18
+ // Every expected failure returns { ok:false } rather than throwing: a throw makes the
19
+ // runner mark the whole project 'error', and a project that could not be made private is
20
+ // still running fine. The error text is the owner's sentence — it rides error_note onto
21
+ // the project card once the retries are spent.
22
+ //
23
+ // Its own file because provision.js sits at the size ratchet (the provision-disconnect.js
24
+ // precedent). The web tier only enqueues (modules/provisioning/repo-private.js).
25
+
26
+ const path = require('node:path');
27
+ const { REPO_ROOT } = require('./provision-config.js');
28
+ const { parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo-auth.js');
29
+ const { ensurePrivateRepoAccess } = require('./provision-repo.js');
30
+
31
+ async function repoPrivateInstance(inst, deps) {
32
+ const { db, provisioning, log, apply } = deps;
33
+ log(`repo-private ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
34
+
35
+ // Only a standalone project has its own checkout to wire; any other shape reads from a
36
+ // checkout this project does not own, so a key minted here would change nothing.
37
+ if (inst.hosting_shape !== 'standalone') {
38
+ return { ok: false, error: 'This project shares its code checkout with others, so its repository cannot be made private from here.' };
39
+ }
40
+ if (inst.status !== 'active') {
41
+ return { ok: false, error: `This project is ${inst.status}, not running, so its repository was not changed. Try again once it is live.` };
42
+ }
43
+ const repo = parseTargetRef(inst.target_ref);
44
+ if (!repo) return { ok: false, error: 'This project has no GitHub repository on record, so there is nothing to make private.' };
45
+
46
+ let wired;
47
+ try {
48
+ wired = await ensurePrivateRepoAccess(inst, { ...deps, intendedPrivate: true });
49
+ } catch (e) {
50
+ return { ok: false, error: (e && e.message) || String(e) };
51
+ }
52
+ if (!apply) {
53
+ log(` [repo-private] would then make ${repo.owner}/${repo.name} private with the owner's credential — only after the deploy key above is wired (dry-run — nothing ran)`);
54
+ return { ok: true, planned: true };
55
+ }
56
+ if (!wired || wired.private !== true) {
57
+ // Defensive: with intendedPrivate set, a standalone row with a repo either wires or
58
+ // throws. Anything else means the checkout is unwired, and flipping would break it.
59
+ return { ok: false, error: `The deploy key for ${repo.owner}/${repo.name} could not be set up, so the repository was left public.` };
60
+ }
61
+
62
+ const token = await resolveOwnerGithubToken(inst, deps);
63
+ if (!token) {
64
+ return { ok: false, error: `${repo.owner}/${repo.name} was not made private: the project's GitHub credential could not be read. Its deploy key is already in place, so reconnect GitHub and try again.` };
65
+ }
66
+ const ghRepos = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos'));
67
+ try {
68
+ log(` [repo-private] making ${repo.owner}/${repo.name} private`);
69
+ await ghRepos.makeRepoPrivate(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
70
+ } catch (e) {
71
+ const why = e && e.code === 'not_repo_admin' ? 'GitHub refused — the connected account cannot change this repository’s settings'
72
+ : e && e.code === 'repo_not_found' ? 'GitHub could not find the repository with the connected account'
73
+ : `GitHub could not make the change (${(e && e.message) || e})`;
74
+ return { ok: false, error: `${repo.owner}/${repo.name} is still public: ${why}. Its deploy key is already in place, so trying again is safe.` };
75
+ }
76
+
77
+ await provisioning.recordEvent(db, {
78
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'repo-private',
79
+ detail: `${repo.owner}/${repo.name} made private — the project now reads it with its own read-only deploy key${wired.minted ? ' (newly registered)' : ''}`,
80
+ actor: 'runner',
81
+ }).catch(() => {});
82
+ log(` ✓ ${repo.owner}/${repo.name} is private; ${inst.slug} reads it with its own deploy key`);
83
+ return { ok: true, minted: !!wired.minted };
84
+ }
85
+
86
+ module.exports = { repoPrivateInstance };
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- //
4
3
  // scripts/gds/provision.js — the INSTANCE-provisioning control-plane runner
5
4
  // (ADR 0111 §2/§3, BONGOS-V1 goal 32, task #1944). A sibling of box.js.
6
5
  //
@@ -1294,6 +1293,7 @@ async function cmdRunIntents(deps) {
1294
1293
  else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
1295
1294
  else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
1296
1295
  else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
1296
+ else if (intent.action === 'repo-private') result = await require('./provision-repo-private.js').repoPrivateInstance(inst, deps); // wire the deploy key, THEN flip (task 1004193); required here — this file is at the size ratchet
1297
1297
  else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
1298
1298
  if (result && result.ok === false) {
1299
1299
  if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
@@ -129,17 +129,14 @@ async function getUserRepo(token, { owner, name, fetchImpl = fetch } = {}) {
129
129
  };
130
130
  }
131
131
 
132
- // Flip one repo PUBLIC — and only public. Like createUserRepo's private:false,
133
- // the direction is deliberately not a parameter: the deploy pull still runs as
134
- // the shared deploy key that cannot read a private repo (walk finding N4), so
135
- // nothing in this codebase may flip a repo private until that fix lands — the
136
- // route refuses the request; this helper makes the refusal structural
137
- // (task 1003045). Returns the same shape as getUserRepo.
138
- async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
132
+ // The visibility PATCH both directions share. Deliberately NOT exported: callers get
133
+ // one named function per direction, so the name stays the contract and no caller can
134
+ // pass a direction it did not mean (the createUserRepo private:false precedent).
135
+ async function setRepoPrivate(token, { owner, name, fetchImpl = fetch }, isPrivate) {
139
136
  const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
140
137
  method: 'PATCH',
141
138
  headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
142
- body: JSON.stringify({ private: false }),
139
+ body: JSON.stringify({ private: isPrivate }),
143
140
  });
144
141
  if (res.status === 401) {
145
142
  throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
@@ -162,6 +159,22 @@ async function makeRepoPublic(token, { owner, name, fetchImpl = fetch } = {}) {
162
159
  };
163
160
  }
164
161
 
162
+ // Flip one repo PUBLIC (task 1003045). Needs no deploy-key wiring — the shared key
163
+ // reads a public repo — so the core route may call it directly. Returns the same
164
+ // shape as getUserRepo.
165
+ async function makeRepoPublic(token, ref = {}) {
166
+ return setRepoPrivate(token, ref, false);
167
+ }
168
+
169
+ // Flip one repo PRIVATE. Only the control-plane runner may call this, and only AFTER
170
+ // ensurePrivateRepoAccess has wired the project's own read-only deploy key: the
171
+ // shared key cannot read a private repo, so flipping first breaks the next deploy
172
+ // pull (walk finding N4, ADR 0176). scripts/gds/provision-repo-private.js is that
173
+ // caller (task 1004193); no web route calls it. Returns the same shape as getUserRepo.
174
+ async function makeRepoPrivate(token, ref = {}) {
175
+ return setRepoPrivate(token, ref, true);
176
+ }
177
+
165
178
  // ---- deploy keys (task 1002735 / ADR 0176) ----
166
179
  // The per-repo credential a PRIVATE repo's recurring deploy pull authenticates
167
180
  // with: provisioning mints an ed25519 keypair, registers its public half here with
@@ -237,7 +250,7 @@ async function deleteDeployKey(token, { owner, name, keyId, fetchImpl = fetch }
237
250
  }
238
251
 
239
252
  module.exports = {
240
- listUserRepos, createUserRepo, getUserRepo, makeRepoPublic,
253
+ listUserRepos, createUserRepo, getUserRepo, makeRepoPublic, makeRepoPrivate,
241
254
  listDeployKeys, addDeployKey, deleteDeployKey,
242
255
  GithubApiError, MAX_PAGES,
243
256
  };
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.975'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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();