@bongos/core 1.19.963 → 1.19.964

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.
Files changed (34) hide show
  1. package/.bongos-core.json +64 -34
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md +5 -0
  8. package/docs/adr/README.md +1 -1
  9. package/docs/api/openapi.json +47 -2
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +134 -128
  12. package/docs/copy-registry.json +212 -158
  13. package/docs/module-api-changelog.md +2 -0
  14. package/modules/hall-ui/public/goal-forest-lib.js +229 -0
  15. package/modules/hall-ui/public/goal-forest-render.js +151 -0
  16. package/modules/hall-ui/public/goals-page.js +6 -0
  17. package/modules/hall-ui/public/goals-render.js +9 -1
  18. package/modules/hall-ui/public/goals.css +26 -0
  19. package/modules/hall-ui/public/goals.html +19 -0
  20. package/modules/lifecycle/goal-forest.js +135 -0
  21. package/modules/lifecycle/routes/goals.js +8 -8
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/scripts/gds/claude-materialize.js +29 -6
  25. package/scripts/hall-preview/server.js +1 -0
  26. package/src/module-api.js +1 -1
  27. package/tests/claude_materialize.mjs +48 -4
  28. package/tests/goal_category_ui.mjs +2 -0
  29. package/tests/goal_criterion_confirm_ui.mjs +2 -0
  30. package/tests/goal_forest.mjs +160 -0
  31. package/tests/goal_forest_lib.mjs +153 -0
  32. package/tests/goal_forest_page.mjs +196 -0
  33. package/tests/goal_invite_consent.mjs +1 -1
  34. package/tests/helpers.mjs +7 -3
@@ -31,6 +31,7 @@ const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edi
31
31
  const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
32
32
  const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
33
33
  const goalGraph = require('../goal-graph.js'); // task 1003479 — the goal graph: nodes + cross-goal edges, aggregated in SQL
34
+ const goalForest = require('../goal-forest.js'); // task 1003480 — per-goal dependency forest
34
35
  const { suggestCriteria } = require('../criterion-suggest');
35
36
  const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, TASK_FIELD_BOUNDS } = require('../task-classifier');
36
37
  const { validateOrRespond, parseId, LIMITS } = api;
@@ -908,14 +909,13 @@ module.exports = function buildGoalsRouter() {
908
909
  return res.fail('transfer_failed', { status: 500, message: 'internal error' });
909
910
  }
910
911
  });
911
-
912
- // GET /goals/:id/conflicts — the server-side task-conflict view (task 1736).
913
- // Replaces the hall Goals page's client-side exact-string approximation (the
914
- // page feature-detects on 404): glob-aware touches[] intersection via the
915
- // canonical path-match machinery, claim-aware severity, and cross-goal
916
- // overlaps against other OPEN goals sharing scope_modules. Detection only —
917
- // resolution is the POST below.
918
- // rank: any-builder — a read; the same audience as GET /goals/:id.
912
+ // rank: any-builder — GET /goals/:id/dependency-forest, the per-goal task dependency drill-down (task 1003480, ADR 0227 §6.4).
913
+ router.get('/goals/:id/dependency-forest', auth.requireBuilder, goalForest.goalForestHandler({ getGoal: db.getGoal, parseId, log }));
914
+ // GET /goals/:id/conflicts — the server-side task-conflict view (task 1736). Replaces the hall Goals
915
+ // page's client-side exact-string approximation (the page feature-detects on 404): glob-aware touches[]
916
+ // intersection via the canonical path-match machinery, claim-aware severity, and cross-goal overlaps
917
+ // against other OPEN goals sharing scope_modules. Detection only — resolution is the POST below.
918
+ // rank: any-builder — a read, GET /goals/:id's audience.
919
919
  router.get('/goals/:id/conflicts', auth.requireBuilder, async (req, res) => {
920
920
  const id = parseId(req, res, { code: 'bad_goal_id' });
921
921
  if (id === null) return;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.963",
3
+ "version": "1.19.964",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.963",
9
+ "version": "1.19.964",
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.963",
3
+ "version": "1.19.964",
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",
@@ -404,15 +404,36 @@ function rebrandSettings(text, rebrand) {
404
404
 
405
405
  // Credential files are derived from where the CLI actually reads them, not rebranded from the
406
406
  // core's literals: the rebrand would also move the host-keyed store, which sits at a fixed anchor
407
- // whatever the brand (cli-lib sessionStoreDir, task 1003741). The shelf rule is the only layer an
408
- // instance gets against the repo-global stash stack, since the git-guard hook does not ship.
409
- function starterDeny(branding = {}) {
407
+ // whatever the brand (cli-lib sessionStoreDir, task 1003741).
408
+ //
409
+ // credentialDeny is the half that may be added to a settings.json the instance already owns.
410
+ // Every rule here stops the agent READING A SECRET, and nothing an instance legitimately does
411
+ // requires reading its own key or session token from inside an agent session — which is what
412
+ // makes appending one provably safe (ADR 0326: a deny rule is monotonic in the safe direction).
413
+ function credentialDeny(branding = {}) {
410
414
  const dirs = [...new Set([effConfigDir(branding), ...ic.LEGACY_DIRS])];
411
415
  const credentialFiles = ['env', ...ic.SESSION_FILENAMES];
412
416
  return [
413
417
  ...STARTER_DENY_SECRETS,
414
418
  ...dirs.flatMap((dir) => credentialFiles.map((file) => `Read(~/.config/${dir}/${file})`)),
415
419
  SESSION_STORE_DENY,
420
+ ];
421
+ }
422
+
423
+ // starterDeny — what a FRESH instance gets: the credential half plus the shelf rules, which are
424
+ // the only layer an instance has against the repo-global stash stack since the git-guard hook is
425
+ // topology and does not ship.
426
+ //
427
+ // The shelf rules are starter-only, and deliberately NOT in the top-up (task 1004158, amending
428
+ // ADR 0326, which named this exclusion as the remedy if the rule ever bit). They guard a WORKFLOW
429
+ // rather than a secret, so the monotonicity argument that licenses the top-up does not reach them:
430
+ // with no provenance by design, and deny beating allow in Claude Code's permission order, an
431
+ // instance whose builders used the shelf as a set-aside could not re-enable it from its own
432
+ // settings.json afterwards. A fresh instance is different in kind — there the rule is a visible
433
+ // default present from the first commit, editable before any workflow has grown around it.
434
+ function starterDeny(branding = {}) {
435
+ return [
436
+ ...credentialDeny(branding),
416
437
  ...STARTER_SHELL_TOOLS.map((tool) => `${tool}(git stash:*)`),
417
438
  ];
418
439
  }
@@ -453,7 +474,7 @@ function topUpStarterDeny(existingText, wanted) {
453
474
  // package-only install. Permissions ONLY: no hooks (they are topology), no env.
454
475
  function renderStarterSettings(branding = {}) {
455
476
  return `${JSON.stringify({
456
- _doc: 'Starter written by scripts/gds/claude-materialize.js (tasks 1003962, 1003966). Permissions only — the blessed Bongos commands, mirroring the core repo\'s own allow-list (ADR 0104), and the deny rules that keep the local agent from reading keys and session tokens. An allow-rule lets the local agent RUN a command; it grants no authority, which the server decides per request from your live rank (ADR 0016). Yours to edit: a later materialize never removes or rewrites a rule you wrote. It will ADD a deny rule this starter has gained since (task 1004156) and name it in the run output — additive only, so it can never widen what the local agent may do.',
477
+ _doc: 'Starter written by scripts/gds/claude-materialize.js (tasks 1003962, 1003966). Permissions only — the blessed Bongos commands, mirroring the core repo\'s own allow-list (ADR 0104), and the deny rules that keep the local agent from reading keys and session tokens. An allow-rule lets the local agent RUN a command; it grants no authority, which the server decides per request from your live rank (ADR 0016). Yours to edit: a later materialize never removes or rewrites a rule you wrote. It will ADD a CREDENTIAL deny rule this starter has gained since (task 1004156) and name it in the run output — additive only, so it can never widen what the local agent may do. Only that half is topped up: the shell rules below guard a workflow rather than a secret, so once this file exists they are yours alone and an upgrade will neither add nor remove one (task 1004158).',
457
478
  permissions: { allow: STARTER_ALLOW, deny: starterDeny(branding) },
458
479
  }, null, 2)}\n`;
459
480
  }
@@ -749,11 +770,13 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
749
770
  if (fs.existsSync(settingsDest)) {
750
771
  // The instance owns this file — but a deny rule the starter has gained since is ADDED to
751
772
  // it rather than withheld forever (task 1004156). Nothing of theirs is removed or changed.
773
+ // Only the CREDENTIAL half is offered: the starter's shelf rules guard a workflow, not a
774
+ // secret, and are starter-only (task 1004158).
752
775
  // The read is guarded: existsSync passing does not mean readable (a directory at that path,
753
776
  // a locked or root-owned file), and this runs AFTER the pin moved and the migrations ran, so
754
777
  // a throw here would leave a half-applied upgrade. It fails soft, like its sibling above.
755
778
  let topUp;
756
- try { topUp = topUpStarterDeny(fs.readFileSync(settingsDest, 'utf8'), starterDeny(toBranding)); }
779
+ try { topUp = topUpStarterDeny(fs.readFileSync(settingsDest, 'utf8'), credentialDeny(toBranding)); }
757
780
  catch (err) { topUp = { text: null, added: [], reason: `it could not be read (${err.code || err.message})` }; }
758
781
  if (topUp.text) {
759
782
  if (!dryRun) fs.writeFileSync(settingsDest, topUp.text);
@@ -779,7 +802,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
779
802
  log(` ! .claude/settings.json could NOT be checked for missing deny rules — ${topUp.reason}. Left untouched; its deny half may still be missing.`);
780
803
  } else {
781
804
  summary.skippedFiles.push('settings.json');
782
- log(' • .claude/settings.json already present — left untouched (the instance owns it; it already carries every starter deny rule).');
805
+ log(' • .claude/settings.json already present — left untouched (the instance owns it; it already carries every credential deny rule. The starter\'s shelf rules are not offered here — they guard a workflow, not a secret, and are starter-only: task 1004158).');
783
806
  }
784
807
  } else {
785
808
  if (!dryRun) {
@@ -101,6 +101,7 @@ let liveWhy = null;
101
101
  // paths fall back to the generic names below so one fixture serves any id.
102
102
  const FIXTURE_FALLBACKS = [
103
103
  [/^goals\/\d+\/conflicts$/, 'goals__conflicts'],
104
+ [/^goals\/\d+\/dependency-forest$/, 'goals__dependency-forest'],
104
105
  [/^goals\/\d+\/requests$/, 'goals__requests'],
105
106
  [/^goals\/\d+$/, 'goals__detail'],
106
107
  [/^tasks\/\d+$/, 'tasks__detail'],
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.963'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.964'; // 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');
@@ -895,6 +895,12 @@ t('a checkout-materialized instance keeps the session-store rule at its fixed an
895
895
  // safety argument — a deny rule can only narrow what the agent may do, so appending one cannot
896
896
  // widen this instance's permissions. ADR 0121 §2 already licenses "merge OR leave-and-report"
897
897
  // for a pre-existing file; this implements the merge half for the one shape that is provably safe.
898
+ // task 1004158 — the top-up asks for the CREDENTIAL half only. Derived here by excluding the
899
+ // shell-tool rules rather than read back from the implementation's own list, so this states the
900
+ // contract independently instead of agreeing with whatever the code happens to do.
901
+ const topUpDenyFor = (branding) => starterDenyFor(branding).filter((r) => !/^(?:Bash|PowerShell)\(/.test(r));
902
+ const shelfRules = (branding) => starterDenyFor(branding).filter((r) => /^(?:Bash|PowerShell)\(/.test(r));
903
+
898
904
  const topUpFixture = (settings) => {
899
905
  const core = mkdtempSync(join(tmpdir(), 'w3-topup-core-'));
900
906
  const inst = mkdtempSync(join(tmpdir(), 'w3-topup-inst-'));
@@ -912,8 +918,8 @@ t('an instance still on the allow-only starter GAINS the deny rules on its next
912
918
  const f = topUpFixture({ _doc: 'the task-1003962 starter', permissions: { allow: m.STARTER_ALLOW } });
913
919
  const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
914
920
  const after = JSON.parse(f.read());
915
- assert.deepEqual(after.permissions.deny, starterDenyFor({ configDir: 'acme' }),
916
- "every starter deny rule, pointed at THIS instance's config dir");
921
+ assert.deepEqual(after.permissions.deny, topUpDenyFor({ configDir: 'acme' }),
922
+ "every credential deny rule, pointed at THIS instance's config dir — and nothing else");
917
923
  assert.deepEqual(after.permissions.allow, m.STARTER_ALLOW, 'its allow half is untouched');
918
924
  assert.equal(after._doc, 'the task-1003962 starter', 'and every other key it carried survives');
919
925
  assert.deepEqual(res.settingsDenyAdded, after.permissions.deny, 'the run names each rule it added');
@@ -939,12 +945,50 @@ t("an owner-edited settings.json keeps every rule of its own and only gains what
939
945
  assert.equal(after.permissions.deny.filter((r) => r === 'Read(**/*.pem)').length, 1,
940
946
  'a rule they already had is not added a second time');
941
947
  assert.ok(!res.settingsDenyAdded.includes('Read(**/*.pem)'), 'nor reported as added');
942
- for (const rule of starterDenyFor({ configDir: 'acme' })) {
948
+ for (const rule of topUpDenyFor({ configDir: 'acme' })) {
943
949
  assert.ok(after.permissions.deny.includes(rule), `missing ${rule}`);
944
950
  }
945
951
  f.cleanup();
946
952
  });
947
953
 
954
+ t('the shelf rules are starter-only — the TOP-UP never adds them to a file the instance owns', () => {
955
+ // Scoped to the top-up (the package-only branch, which is the one `bongos upgrade` always takes:
956
+ // the published core excludes .claude/settings.json). The CHECKOUT branch still overwrites an
957
+ // existing instance settings.json wholesale, shelf rules included — that predates this change
958
+ // and is not reachable from an upgrade, so it is out of scope here rather than disproved.
959
+ // task 1004158, amending ADR 0326. The top-up's licence is that a deny rule is monotonic in the
960
+ // safe direction, and that argument covers a rule about reading a SECRET. The shelf rules guard
961
+ // a WORKFLOW: an instance whose builders used the repo-global shelf as a set-aside would find it
962
+ // blocked after an upgrade and — no provenance by design, deny beating allow — could not re-enable
963
+ // it from its own settings.json. So the core stops handing it out to instances that already exist.
964
+ const brand = { configDir: 'acme' };
965
+ assert.deepEqual(shelfRules(brand), ['Bash(git stash:*)', 'PowerShell(git stash:*)'],
966
+ 'the starter still carries both shelf rules — this change withholds them from the TOP-UP, not the starter');
967
+ const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW } });
968
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
969
+ const deny = JSON.parse(f.read()).permissions.deny;
970
+ for (const rule of shelfRules(brand)) {
971
+ assert.ok(!res.settingsDenyAdded.includes(rule), `${rule} must not be reported as added`);
972
+ assert.ok(!deny.includes(rule), `${rule} must not be written into a settings.json the instance owns`);
973
+ }
974
+ assert.ok(deny.includes('Read(~/.config/acme/bongos-session.json)'),
975
+ 'while the credential half still lands — withholding the shelf rules must not withhold the secrets half');
976
+ f.cleanup();
977
+ });
978
+
979
+ t('an instance that already has a shelf rule keeps it — this withholds, it never removes', () => {
980
+ // The top-up only ever appends, so an instance that took the shelf rules (from the starter, from
981
+ // an upgrade before task 1004158, or by hand) is unaffected. Live cloudbongos.com is in exactly
982
+ // this position after its 0-to-20 run: lifting them there is a hand-edit, not something an
983
+ // upgrade will undo.
984
+ const f = topUpFixture({ permissions: { allow: [], deny: ['Bash(git stash:*)'] } });
985
+ m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
986
+ const deny = JSON.parse(f.read()).permissions.deny;
987
+ assert.equal(deny[0], 'Bash(git stash:*)', 'the rule they already had keeps its place at the front');
988
+ assert.ok(!deny.includes('PowerShell(git stash:*)'), 'and its twin is still not volunteered');
989
+ f.cleanup();
990
+ });
991
+
948
992
  t('the top-up is idempotent — a second materialize changes nothing', () => {
949
993
  const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW } });
950
994
  m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
@@ -961,7 +1005,7 @@ t('a dry run names the missing deny rules and writes nothing', () => {
961
1005
  const before = f.read();
962
1006
  const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: true });
963
1007
  assert.equal(f.read(), before, 'a dry run writes nothing');
964
- assert.deepEqual(res.settingsDenyAdded, starterDenyFor({ configDir: 'acme' }),
1008
+ assert.deepEqual(res.settingsDenyAdded, topUpDenyFor({ configDir: 'acme' }),
965
1009
  'but it still names what a real run would add');
966
1010
  f.cleanup();
967
1011
  });
@@ -23,6 +23,7 @@ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
23
23
  const SRC = read('modules/hall-ui/public/goals-page.js');
24
24
  const TASK_LIB_SRC = read('modules/hall-ui/public/task-lib.js');
25
25
  const GOALS_LIB_SRC = read('modules/hall-ui/public/goals-lib.js');
26
+ const GOAL_FOREST_RENDER_SRC = read('modules/hall-ui/public/goal-forest-render.js');
26
27
  const GOALS_RENDER_SRC = read('modules/hall-ui/public/goals-render.js');
27
28
  const DOM_UTILS_SRC = read('modules/hall-ui/public/dom-utils.js');
28
29
  const WORK_SRC = read('modules/hall-ui/public/work.js');
@@ -124,6 +125,7 @@ async function renderList() {
124
125
  windowObj.OTB.toast = () => {};
125
126
  vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
126
127
  vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
128
+ vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
127
129
  vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
128
130
  vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
129
131
  for (let i = 0; i < 30; i++) await new Promise((r) => setTimeout(r, 0));
@@ -44,6 +44,7 @@ const GOALS_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/go
44
44
  // (window.OTBGoalsRender) — the second half of the same carve. Run the REAL file
45
45
  // too, for the same reason: this is the only test that executes either board, so
46
46
  // stubbing the renderers would let them rot unwatched.
47
+ const GOAL_FOREST_RENDER_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goal-forest-render.js'), 'utf8');
47
48
  const GOALS_RENDER_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-render.js'), 'utf8');
48
49
 
49
50
  const { test, summary } = makeRunner();
@@ -154,6 +155,7 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
154
155
  vm.createContext(sandbox);
155
156
  vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
156
157
  vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
158
+ vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
157
159
  vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
158
160
  vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
159
161
 
@@ -0,0 +1,160 @@
1
+ // tests/goal_forest.mjs — GET /goals/:id/dependency-forest, the per-goal task
2
+ // drill-down (task 1003480, goal 1000089, ADR 0227 §6 item 4 + §10.5).
3
+ //
4
+ // NOT A ZOOM OF THE GOAL MAP. This pins the filter that keeps the two views
5
+ // from overlapping or double-drawing anything: an edge counts here ONLY when
6
+ // BOTH its tasks sit in the goal being opened — the exact opposite of
7
+ // goal-graph.js's cross-goal filter — and a goal with no such edge returns an
8
+ // empty forest rather than an error, so the page can say so plainly.
9
+ //
10
+ // DB-free: the pure shaper runs directly, the SQL against a recording fake
11
+ // pool, the handler against stub collaborators.
12
+ //
13
+ // Run: node tests/goal_forest.mjs
14
+ process.env.NODE_ENV = 'test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { createRequire } from 'node:module';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const { shapeForest, goalForestRows, goalForest, goalForestHandler } = require(path.join(ROOT, 'modules/lifecycle/goal-forest.js'));
25
+ const GOALS_ROUTE = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/goals.js'), 'utf8');
26
+
27
+ const { test, summary } = makeRunner();
28
+
29
+ const t = (id, extra = {}) => ({ id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
30
+ const e = (from, to) => ({ from_task_id: String(from), to_task_id: String(to) });
31
+
32
+ // ---- shapeForest ----------------------------------------------------------
33
+
34
+ await test('an edge means "from waits on to"; both endpoints become nodes with the right degree', () => {
35
+ const out = shapeForest({ tasks: [t(1), t(2), t(3)], edges: [e(1, 2), e(1, 3)] });
36
+ const by = Object.fromEntries(out.nodes.map((n) => [n.task_id, n]));
37
+ assert.deepEqual(out.edges, [{ from: '1', to: '2' }, { from: '1', to: '3' }]);
38
+ assert.equal(by['1'].depends_on, 2);
39
+ assert.equal(by['2'].depended_on_by, 1);
40
+ assert.equal(by['2'].depends_on, 0);
41
+ });
42
+
43
+ await test('a task with NO edge is never a node — the omission is the finding, not a gap (ADR 0227 §3)', () => {
44
+ const out = shapeForest({ tasks: [t(1), t(2), t(3)], edges: [e(1, 2)] });
45
+ assert.deepEqual(out.nodes.map((n) => n.task_id).sort(), ['1', '2']);
46
+ assert.equal(out.counts.tasks, 2);
47
+ });
48
+
49
+ await test('a self edge and a duplicate are dropped; an edge to a task not in the fetched set is dropped, not drawn to nowhere', () => {
50
+ const out = shapeForest({ tasks: [t(1), t(2)], edges: [e(2, 2), e(1, 2), e(1, 2), e(1, 99)] });
51
+ assert.deepEqual(out.edges, [{ from: '1', to: '2' }]);
52
+ });
53
+
54
+ await test('a task whose only edge was dropped (a stale endpoint) does not appear as a lone edge-less node', () => {
55
+ const out = shapeForest({ tasks: [t(1), t(2), t(3)], edges: [e(1, 99), e(2, 3)] });
56
+ assert.deepEqual(out.nodes.map((n) => n.task_id).sort(), ['2', '3']);
57
+ });
58
+
59
+ await test('priority is coerced to a number or null; status/title pass through verbatim', () => {
60
+ const out = shapeForest({ tasks: [t(1, { priority: '2' }), t(2, { priority: null, status: 'blocked', title: 'Blocked one' })], edges: [e(1, 2)] });
61
+ const by = Object.fromEntries(out.nodes.map((n) => [n.task_id, n]));
62
+ assert.strictEqual(by['1'].priority, 2);
63
+ assert.strictEqual(by['2'].priority, null);
64
+ assert.equal(by['2'].status, 'blocked');
65
+ assert.equal(by['2'].title, 'Blocked one');
66
+ });
67
+
68
+ await test('an empty corpus is a valid, empty forest', () => {
69
+ assert.deepEqual(shapeForest({}), { nodes: [], edges: [], counts: { tasks: 0, edges: 0 } });
70
+ });
71
+
72
+ // ---- the SQL, against a recording pool ----------------------------------------
73
+
74
+ function fakePool(edgeRows = [], taskRows = []) {
75
+ const calls = [];
76
+ return {
77
+ calls,
78
+ async query(sql, params) {
79
+ calls.push({ sql, params });
80
+ return { rows: /FROM tasks\s*$|WHERE id = ANY/.test(sql) ? taskRows : edgeRows };
81
+ },
82
+ };
83
+ }
84
+
85
+ await test('the edge query is bound to ONE goal id, on BOTH endpoints — the opposite of the goal map\'s cross-goal filter', async () => {
86
+ const pool = fakePool();
87
+ await goalForestRows(42, { pool });
88
+ const [q] = pool.calls;
89
+ assert.deepEqual(q.params, [42]);
90
+ assert.match(q.sql, /ft\.goal_id = \$1 AND tt\.goal_id = \$1/);
91
+ assert.match(q.sql, /d\.from_kind = 'task'/);
92
+ assert.match(q.sql, /d\.to_kind\s+= 'task'/);
93
+ });
94
+
95
+ await test('no edges at all short-circuits — the task-facts query never runs', async () => {
96
+ const pool = fakePool([]);
97
+ const out = await goalForestRows(1, { pool });
98
+ assert.equal(pool.calls.length, 1, 'only the edge query ran');
99
+ assert.deepEqual(out, { edges: [], tasks: [] });
100
+ });
101
+
102
+ await test('the task-facts query is scoped to exactly the ids the edges named — never the goal\'s whole task list', async () => {
103
+ const pool = fakePool([e(1, 2), e(2, 3)], [t(1), t(2), t(3)]);
104
+ await goalForestRows(1, { pool });
105
+ const factsQ = pool.calls[1];
106
+ assert.deepEqual(factsQ.params[0].sort((a, b) => a - b), [1, 2, 3]);
107
+ assert.match(factsQ.sql, /WHERE id = ANY/);
108
+ });
109
+
110
+ await test('an end-to-end fake read produces the shaped forest', async () => {
111
+ const pool = fakePool([e(1, 2)], [t(1), t(2)]);
112
+ const out = await goalForest(1, { pool });
113
+ assert.equal(out.counts.tasks, 2);
114
+ assert.deepEqual(out.edges, [{ from: '1', to: '2' }]);
115
+ });
116
+
117
+ // ---- the handler ---------------------------------------------------------------
118
+
119
+ function run(handler, req) {
120
+ const res = { body: null, failed: null, statusCode: null, json(b) { this.body = b; return this; }, fail(code, o) { this.failed = { code, o }; return this; } };
121
+ return handler(req, res).then(() => res);
122
+ }
123
+ const parseId = (req) => { const n = Number(req.params.id); return Number.isInteger(n) && n > 0 ? n : null; };
124
+
125
+ await test('a missing goal is a 404, and the forest is never read', async () => {
126
+ let readCalled = false;
127
+ const h = goalForestHandler({ getGoal: async () => null, read: async () => { readCalled = true; return {}; }, parseId, log: { error() {} } });
128
+ const res = await run(h, { params: { id: '5' } });
129
+ assert.equal(res.failed.code, 'goal_not_found');
130
+ assert.equal(res.failed.o, 404);
131
+ assert.equal(readCalled, false);
132
+ });
133
+
134
+ await test('a real goal returns the forest with the goal id attached', async () => {
135
+ const h = goalForestHandler({ getGoal: async () => ({ id: 5 }), read: async () => ({ nodes: [], edges: [], counts: { tasks: 0, edges: 0 } }), parseId, log: { error() {} } });
136
+ const res = await run(h, { params: { id: '5' } });
137
+ assert.equal(res.body.ok, true);
138
+ assert.equal(res.body.goal_id, 5);
139
+ assert.deepEqual(res.body.nodes, []);
140
+ });
141
+
142
+ await test('a failed read is a named 500, never a stack trace', async () => {
143
+ const errors = [];
144
+ const h = goalForestHandler({ getGoal: async () => ({ id: 1 }), read: async () => { throw new Error('db down'); }, parseId, log: { error: (...a) => errors.push(a) } });
145
+ const res = await run(h, { params: { id: '1' } });
146
+ assert.equal(res.failed.code, 'forest_failed');
147
+ assert.equal(res.failed.o.status, 500);
148
+ assert.equal(errors.length, 1);
149
+ });
150
+
151
+ // ---- mounting --------------------------------------------------------------------
152
+
153
+ await test('/goals/:id/dependency-forest is mounted for any signed-in builder', () => {
154
+ const at = GOALS_ROUTE.indexOf("router.get('/goals/:id/dependency-forest'");
155
+ assert.ok(at > -1, 'the route is registered');
156
+ assert.match(GOALS_ROUTE.slice(at, at + 200), /auth\.requireBuilder/);
157
+ assert.match(GOALS_ROUTE.slice(Math.max(0, at - 260), at), /rank: any-builder/, 'the route-rank scanner needs its declaration');
158
+ });
159
+
160
+ summary();
@@ -0,0 +1,153 @@
1
+ // tests/goal_forest_lib.mjs — the per-goal dependency forest's model and
2
+ // layout, EXECUTED (task 1003480, goal 1000089, ADR 0227 §6 item 4 + §10.5).
3
+ //
4
+ // goal-forest-lib.js is pure, so this runs the real code over synthetic
5
+ // corpora, including one at the measured worst case (§10.5: the biggest single
6
+ // goal's forest is 127 tasks). What is pinned: no task is drawn twice or on
7
+ // top of another, a task always sits right of what it depends on, two
8
+ // independent chains in one goal share the picture without overlapping, and
9
+ // every fact in the picture is also a table row.
10
+ //
11
+ // Run: node tests/goal_forest_lib.mjs
12
+ import assert from 'node:assert/strict';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { createRequire } from 'node:module';
16
+ import { makeRunner } from './helpers.mjs';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const L = require(path.join(ROOT, 'modules/hall-ui/public/goal-forest-lib.js'));
21
+
22
+ const { test, summary } = makeRunner();
23
+
24
+ const node = (id, extra = {}) => ({ task_id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
25
+ const edge = (from, to) => ({ from: String(from), to: String(to) });
26
+
27
+ function overlaps(a, b) {
28
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
29
+ }
30
+ function assertSound(a, lay, label) {
31
+ const ids = lay.nodes.map((n) => n.id);
32
+ assert.equal(new Set(ids).size, ids.length, `${label}: no task drawn twice`);
33
+ assert.equal(ids.length, a.order.length, `${label}: every analysed node is drawn — none are edge-less by construction`);
34
+ for (let i = 0; i < lay.nodes.length; i++) {
35
+ for (let j = i + 1; j < lay.nodes.length; j++) {
36
+ assert.equal(overlaps(lay.nodes[i], lay.nodes[j]), false, `${label}: ${lay.nodes[i].id} overlaps ${lay.nodes[j].id}`);
37
+ }
38
+ }
39
+ for (const n of lay.nodes) {
40
+ assert.ok(n.x >= 0 && n.y >= 0, `${label}: ${n.id} is on the canvas`);
41
+ assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${label}: ${n.id} fits inside the svg's own size`);
42
+ }
43
+ }
44
+
45
+ // ---- structure -----------------------------------------------------------------
46
+
47
+ await test('an edge means "from waits on to", and each node counts its own degree', () => {
48
+ const a = L.analyse({ nodes: [node(1), node(2), node(3)], edges: [edge(1, 2), edge(1, 3)] });
49
+ assert.equal(a.waits['1'].length, 2);
50
+ assert.equal(a.needed['2'].length, 1);
51
+ });
52
+
53
+ await test('a self edge, a duplicate, and an edge to an unknown task are all ignored', () => {
54
+ const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(1, 99), edge(2, 2), edge(1, 2), edge(1, 2)] });
55
+ assert.equal(a.edges.length, 1);
56
+ });
57
+
58
+ await test('chains are found, largest first — two disjoint pairs are two chains of size 2, not one', () => {
59
+ const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6, 7].map((i) => node(i)), edges: [edge(1, 2), edge(3, 4), edge(5, 6), edge(6, 7)] });
60
+ assert.deepEqual(a.chains.map((c) => c.length), [3, 2, 2]);
61
+ });
62
+
63
+ // ---- layout --------------------------------------------------------------------
64
+
65
+ await test('a task sits to the RIGHT of every task it depends on', () => {
66
+ const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(4, 2)] });
67
+ const lay = L.layout(a);
68
+ const x = Object.fromEntries(lay.nodes.map((n) => [n.id, n.x]));
69
+ for (const e of a.edges) assert.ok(x[e.to] < x[e.from], `${e.from} waits on ${e.to}, must be right of it`);
70
+ });
71
+
72
+ await test('two independent chains in one goal share the picture with no overlap', () => {
73
+ const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(1, 2), edge(3, 4)] });
74
+ const lay = L.layout(a);
75
+ assertSound(a, lay, 'two chains');
76
+ });
77
+
78
+ await test('a would-be cycle terminates and still places every task', () => {
79
+ const a = L.analyse({ nodes: [1, 2, 3].map((i) => node(i)), edges: [edge(1, 2), edge(2, 1), edge(3, 1)] });
80
+ const lay = L.layout(a);
81
+ assert.equal(lay.nodes.length, 3);
82
+ assertSound(a, lay, 'cycle');
83
+ });
84
+
85
+ await test('the same data always gives the same picture', () => {
86
+ const p = { nodes: [1, 2, 3, 4, 5].map((i) => node(i)), edges: [edge(2, 1), edge(3, 1), edge(4, 2), edge(5, 2)] };
87
+ assert.deepEqual(L.layout(L.analyse(p)), L.layout(L.analyse(p)));
88
+ });
89
+
90
+ await test('an edge path starts at the dependency\'s right edge and ends at the dependent\'s left edge', () => {
91
+ const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(2, 1)] });
92
+ const lay = L.layout(a);
93
+ const by = Object.fromEntries(lay.nodes.map((n) => [n.id, n]));
94
+ const [e] = lay.edges;
95
+ assert.ok(e.d.startsWith(`M${by['1'].x + lay.nodeW} ${by['1'].y + lay.nodeH / 2}`));
96
+ assert.ok(e.d.endsWith(`${by['2'].x} ${by['2'].y + lay.nodeH / 2}`));
97
+ });
98
+
99
+ await test('THE MEASURED WORST CASE: a 127-task single goal forest lays out soundly and fast (ADR 0227 §10.5)', () => {
100
+ let seed = 20260921;
101
+ const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };
102
+ const ids = Array.from({ length: 127 }, (_, i) => i + 1);
103
+ const nodes = ids.map((i) => node(i));
104
+ const edges = [];
105
+ for (let i = 1; i < ids.length; i++) edges.push(edge(ids[i], ids[Math.floor(rnd() * i)]));
106
+ const a = L.analyse({ nodes, edges });
107
+ const t0 = Date.now();
108
+ const lay = L.layout(a);
109
+ const ms = Date.now() - t0;
110
+ assertSound(a, lay, 'worst case');
111
+ assert.ok(ms < 500, `laid out in ${ms}ms`);
112
+ });
113
+
114
+ // ---- table + description ---------------------------------------------------------
115
+
116
+ await test('the table has one row per task and carries both directions by name', () => {
117
+ const a = L.analyse({ nodes: [node(1, { title: 'Base' }), node(2, { title: 'Top' })], edges: [edge(2, 1)] });
118
+ const rows = L.tableRows(a);
119
+ const base = rows.find((r) => r.id === '1');
120
+ const top = rows.find((r) => r.id === '2');
121
+ assert.deepEqual(top.waits, [{ id: '1', title: 'Base' }]);
122
+ assert.deepEqual(base.needed, [{ id: '2', title: 'Top' }]);
123
+ });
124
+
125
+ await test('priority renders as P1..P5, or an em dash for null', () => {
126
+ const a = L.analyse({ nodes: [node(1, { priority: 1 }), node(2, { priority: null })], edges: [edge(1, 2)] });
127
+ const rows = L.tableRows(a);
128
+ assert.equal(rows.find((r) => r.id === '1').priority, 'P1');
129
+ assert.equal(rows.find((r) => r.id === '2').priority, '—');
130
+ });
131
+
132
+ await test('a lone pair is labelled plainly, a real chain is labelled and counted', () => {
133
+ const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(1, 2), edge(2, 3), edge(2, 4)] });
134
+ const rows = L.tableRows(a);
135
+ assert.match(rows[0].chainLabel, /^Chain 1 · 4 tasks$/);
136
+ });
137
+
138
+ await test('describe states the numbers plainly, and an empty forest says so', () => {
139
+ const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(1, 2)] });
140
+ assert.match(L.describe(a), /^2 tasks in this goal have a dependency.*in 1 chain\.$/);
141
+ assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
142
+ });
143
+
144
+ // ---- purity -----------------------------------------------------------------------
145
+
146
+ await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
147
+ const fs = require('fs');
148
+ const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goal-forest-lib.js'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
149
+ assert.equal(/\bdocument\b|\bfetch\b|\bwindow\b|XMLHttpRequest/.test(src.replace(/root\.OTBGoalForestLib/, '')), false);
150
+ assert.equal(/goal_id|category/i.test(src), false, 'this file knows nothing about goals or categories — that is the map, not the forest');
151
+ });
152
+
153
+ summary();