@bongos/core 1.19.962 → 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.
- package/.bongos-core.json +69 -34
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md +51 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +47 -2
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +134 -128
- package/docs/copy-registry.json +212 -158
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goal-forest-lib.js +229 -0
- package/modules/hall-ui/public/goal-forest-render.js +151 -0
- package/modules/hall-ui/public/goals-page.js +6 -0
- package/modules/hall-ui/public/goals-render.js +9 -1
- package/modules/hall-ui/public/goals.css +26 -0
- package/modules/hall-ui/public/goals.html +19 -0
- package/modules/lifecycle/goal-forest.js +135 -0
- package/modules/lifecycle/routes/goals.js +8 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +98 -8
- package/scripts/hall-preview/server.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +180 -4
- package/tests/goal_category_ui.mjs +2 -0
- package/tests/goal_criterion_confirm_ui.mjs +2 -0
- package/tests/goal_forest.mjs +160 -0
- package/tests/goal_forest_lib.mjs +153 -0
- package/tests/goal_forest_page.mjs +196 -0
- package/tests/goal_invite_consent.mjs +1 -1
- 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
|
-
|
|
913
|
-
//
|
|
914
|
-
// page feature-detects on 404): glob-aware touches[]
|
|
915
|
-
// canonical path-match machinery, claim-aware severity, and cross-goal
|
|
916
|
-
//
|
|
917
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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,24 +404,77 @@ 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).
|
|
408
|
-
//
|
|
409
|
-
|
|
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
|
}
|
|
419
440
|
|
|
441
|
+
// topUpStarterDeny — the deny rules an instance's OWN settings.json is missing, added to it
|
|
442
|
+
// (task 1004156). The starter is only ever written when the file is ABSENT, so every instance
|
|
443
|
+
// that took the allow-only starter from task 1003962 keeps it forever and its local agent can
|
|
444
|
+
// still read .env files, keys and session tokens. ADDING is the whole safety argument: a deny
|
|
445
|
+
// rule can only ever narrow what the agent may do, so appending one cannot widen this instance's
|
|
446
|
+
// permissions — which is why the owner's call (task 1004156) was "add them, and say which".
|
|
447
|
+
// "Never clobber" is unchanged in the sense that matters: nothing the instance wrote is removed
|
|
448
|
+
// or rewritten. Returns { text, added, reason }; `text` is null when nothing should be written.
|
|
449
|
+
// `reason` is the STRUCTURED signal that the file could not be CHECKED — null means "healthy",
|
|
450
|
+
// so callers branch on its presence rather than on the wording of a message. An unparseable or
|
|
451
|
+
// unexpectedly-shaped file is LEFT ALONE and reported, the posture task 1004023 took for a
|
|
452
|
+
// settings.json that will not parse: a half-understood rewrite of a permissions file is worse
|
|
453
|
+
// than the gap it closes. Note the write re-emits the whole document at 2-space JSON, so an
|
|
454
|
+
// instance indented some other way sees a whole-file diff — the RULES are still append-only,
|
|
455
|
+
// which is what the safety argument rests on, and the run output says so.
|
|
456
|
+
function topUpStarterDeny(existingText, wanted) {
|
|
457
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
458
|
+
const cannotCheck = (reason) => ({ text: null, added: [], reason });
|
|
459
|
+
let doc;
|
|
460
|
+
try { doc = JSON.parse(existingText); } catch (err) { return cannotCheck(`not valid JSON (${err.message})`); }
|
|
461
|
+
if (!isObject(doc)) return cannotCheck('its top level is not a JSON object');
|
|
462
|
+
const perms = doc.permissions === undefined ? {} : doc.permissions;
|
|
463
|
+
if (!isObject(perms)) return cannotCheck('its "permissions" is not an object');
|
|
464
|
+
const deny = perms.deny === undefined ? [] : perms.deny;
|
|
465
|
+
if (!Array.isArray(deny)) return cannotCheck('its "permissions.deny" is not an array');
|
|
466
|
+
const have = new Set(deny);
|
|
467
|
+
const added = wanted.filter((rule) => !have.has(rule));
|
|
468
|
+
if (!added.length) return { text: null, added: [], reason: null }; // healthy: every rule already there
|
|
469
|
+
const next = { ...doc, permissions: { ...perms, deny: [...deny, ...added] } };
|
|
470
|
+
return { text: `${JSON.stringify(next, null, 2)}\n`, added, reason: null };
|
|
471
|
+
}
|
|
472
|
+
|
|
420
473
|
// The starter an instance gets when the core root carries no settings.json — a
|
|
421
474
|
// package-only install. Permissions ONLY: no hooks (they are topology), no env.
|
|
422
475
|
function renderStarterSettings(branding = {}) {
|
|
423
476
|
return `${JSON.stringify({
|
|
424
|
-
_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
|
|
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).',
|
|
425
478
|
permissions: { allow: STARTER_ALLOW, deny: starterDeny(branding) },
|
|
426
479
|
}, null, 2)}\n`;
|
|
427
480
|
}
|
|
@@ -493,11 +546,14 @@ function transformSettings(srcPath) {
|
|
|
493
546
|
// Materialize the .claude/ surface from `coreRoot` into `instanceDir`. Returns a summary.
|
|
494
547
|
// `overwrite` defaults true (greenfield init / upgrade — unchanged). `overwrite: false`
|
|
495
548
|
// is the `bongos init --adopt` layering path (ADR 0121): every pre-existing .claude/
|
|
496
|
-
// file is LEFT untouched and recorded in `summary.skippedFiles` (never clobbered).
|
|
549
|
+
// file is LEFT untouched and recorded in `summary.skippedFiles` (never clobbered). The ONE
|
|
550
|
+
// exception is settings.json in a package-only install, which GAINS any starter deny rule it is
|
|
551
|
+
// missing and reports them in `summary.settingsDenyAdded` (task 1004156) — additive only, so
|
|
552
|
+
// nothing the instance wrote is removed or rewritten.
|
|
497
553
|
const HOOK_TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.sh', '.md', '.txt']); // hooks are .js — rebrand them too
|
|
498
554
|
|
|
499
555
|
function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite = true, log = () => {} }) {
|
|
500
|
-
const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, settingsUnparsed: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, writtenPaths: [], notes: [] };
|
|
556
|
+
const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, settingsUnparsed: false, settingsDenyAdded: [], settingsDenyUncheckable: null, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, writtenPaths: [], notes: [] };
|
|
501
557
|
// Every instance-relative path this run WROTE or REMOVED (task 1003957). `bongos upgrade`
|
|
502
558
|
// hands these to persistPin as artifactPaths, exactly as task 1003926 already does for
|
|
503
559
|
// regenerateApiArtifacts' output: committing only the pin left the materialized files dirty,
|
|
@@ -712,8 +768,42 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
712
768
|
// permissions owns them, and a later materialize must never clobber them.
|
|
713
769
|
const settingsDest = path.join(destClaude, 'settings.json');
|
|
714
770
|
if (fs.existsSync(settingsDest)) {
|
|
715
|
-
|
|
716
|
-
|
|
771
|
+
// The instance owns this file — but a deny rule the starter has gained since is ADDED to
|
|
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).
|
|
775
|
+
// The read is guarded: existsSync passing does not mean readable (a directory at that path,
|
|
776
|
+
// a locked or root-owned file), and this runs AFTER the pin moved and the migrations ran, so
|
|
777
|
+
// a throw here would leave a half-applied upgrade. It fails soft, like its sibling above.
|
|
778
|
+
let topUp;
|
|
779
|
+
try { topUp = topUpStarterDeny(fs.readFileSync(settingsDest, 'utf8'), credentialDeny(toBranding)); }
|
|
780
|
+
catch (err) { topUp = { text: null, added: [], reason: `it could not be read (${err.code || err.message})` }; }
|
|
781
|
+
if (topUp.text) {
|
|
782
|
+
if (!dryRun) fs.writeFileSync(settingsDest, topUp.text);
|
|
783
|
+
// Reported as written only on the REAL run, deliberately. writtenPaths doubles as the set
|
|
784
|
+
// the upgrade pre-flight IGNORES when it checks for a dirty tree (task 1004122), and that
|
|
785
|
+
// waiver is justified by "materialize overwrites these unconditionally, so whatever they
|
|
786
|
+
// hold now is discarded either way" — which is precisely NOT true of a top-up, the one
|
|
787
|
+
// path here that preserves existing content. Leaving it out of the dry-run preview keeps
|
|
788
|
+
// an owner's uncommitted edit to settings.json halting the bump, as it always did; putting
|
|
789
|
+
// it in the real run's summary still hands it to persistPin, so the pin commit carries the
|
|
790
|
+
// added rules and the next upgrade does not read them as a human's stray work.
|
|
791
|
+
if (!dryRun) addWritten('.claude/settings.json');
|
|
792
|
+
summary.settingsDenyAdded = topUp.added;
|
|
793
|
+
summary.notes.push(`.claude/settings.json kept every rule it already had and GAINED ${topUp.added.length} missing deny rule(s) — an instance that took the starter before task 1003966 carried none of them, so its local agent could still read keys and session tokens: ${topUp.added.join(', ')}`);
|
|
794
|
+
log(` ${dryRun ? '[dry-run] would add' : '✓ added'} ${topUp.added.length} missing deny rule(s) to .claude/settings.json — every rule you wrote is kept, the file is re-indented to 2 spaces: ${topUp.added.join(', ')}`);
|
|
795
|
+
} else if (topUp.reason) {
|
|
796
|
+
// The security gap PERSISTS here, so it does not get the same quiet bullet as the
|
|
797
|
+
// all-clear. upgrade.js never iterates summary.notes, so this log line is the only
|
|
798
|
+
// channel that reaches the operator's terminal.
|
|
799
|
+
summary.skippedFiles.push('settings.json');
|
|
800
|
+
summary.settingsDenyUncheckable = topUp.reason;
|
|
801
|
+
summary.notes.push(`.claude/settings.json could not be checked for missing deny rules — ${topUp.reason}. It was left exactly as it is, so if it predates task 1003966 its local agent can still read keys and session tokens; compare its permissions.deny against the starter by hand, or fix the file and upgrade again.`);
|
|
802
|
+
log(` ! .claude/settings.json could NOT be checked for missing deny rules — ${topUp.reason}. Left untouched; its deny half may still be missing.`);
|
|
803
|
+
} else {
|
|
804
|
+
summary.skippedFiles.push('settings.json');
|
|
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).');
|
|
806
|
+
}
|
|
717
807
|
} else {
|
|
718
808
|
if (!dryRun) {
|
|
719
809
|
fs.mkdirSync(destClaude, { recursive: true });
|
|
@@ -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.
|
|
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');
|
|
@@ -885,6 +885,173 @@ t('a checkout-materialized instance keeps the session-store rule at its fixed an
|
|
|
885
885
|
rmSync(inst, { recursive: true, force: true });
|
|
886
886
|
});
|
|
887
887
|
|
|
888
|
+
// ---- task 1004156: the deny rules reach an instance that ALREADY has a settings.json ----
|
|
889
|
+
// THE DEFECT. The starter is written only when the file is ABSENT, so every instance that took
|
|
890
|
+
// the allow-only starter (task 1003962) kept it forever and never received task 1003966's deny
|
|
891
|
+
// half — its local agent could still read .env files, keys and session tokens. `bongos upgrade`
|
|
892
|
+
// always takes this branch: it materializes from the published package, which excludes
|
|
893
|
+
// .claude/settings.json on purpose, so the starter branch always meets a file already there.
|
|
894
|
+
// THE FIX, and the owner's call: ADD the missing deny rules and name them. Additive is the whole
|
|
895
|
+
// safety argument — a deny rule can only narrow what the agent may do, so appending one cannot
|
|
896
|
+
// widen this instance's permissions. ADR 0121 §2 already licenses "merge OR leave-and-report"
|
|
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
|
+
|
|
904
|
+
const topUpFixture = (settings) => {
|
|
905
|
+
const core = mkdtempSync(join(tmpdir(), 'w3-topup-core-'));
|
|
906
|
+
const inst = mkdtempSync(join(tmpdir(), 'w3-topup-inst-'));
|
|
907
|
+
mkdirSync(join(core, '.claude', 'skills'), { recursive: true }); // skills ship; settings.json does not
|
|
908
|
+
mkdirSync(join(inst, '.claude'), { recursive: true });
|
|
909
|
+
mkdirSync(join(inst, 'config'), { recursive: true });
|
|
910
|
+
writeFileSync(join(inst, 'config', 'branding.json'), JSON.stringify({ configDir: 'acme', envPrefix: 'ACME' }));
|
|
911
|
+
const settingsPath = join(inst, '.claude', 'settings.json');
|
|
912
|
+
writeFileSync(settingsPath, typeof settings === 'string' ? settings : `${JSON.stringify(settings, null, 2)}\n`);
|
|
913
|
+
return { core, inst, settingsPath, read: () => readFileSync(settingsPath, 'utf8'),
|
|
914
|
+
cleanup: () => { rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true }); } };
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
t('an instance still on the allow-only starter GAINS the deny rules on its next materialize', () => {
|
|
918
|
+
const f = topUpFixture({ _doc: 'the task-1003962 starter', permissions: { allow: m.STARTER_ALLOW } });
|
|
919
|
+
const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
|
|
920
|
+
const after = JSON.parse(f.read());
|
|
921
|
+
assert.deepEqual(after.permissions.deny, topUpDenyFor({ configDir: 'acme' }),
|
|
922
|
+
"every credential deny rule, pointed at THIS instance's config dir — and nothing else");
|
|
923
|
+
assert.deepEqual(after.permissions.allow, m.STARTER_ALLOW, 'its allow half is untouched');
|
|
924
|
+
assert.equal(after._doc, 'the task-1003962 starter', 'and every other key it carried survives');
|
|
925
|
+
assert.deepEqual(res.settingsDenyAdded, after.permissions.deny, 'the run names each rule it added');
|
|
926
|
+
assert.ok(res.writtenPaths.some((p) => p.replace(/\\/g, '/') === '.claude/settings.json'),
|
|
927
|
+
'the path is reported as written, or upgrade\'s clean-tree pre-flight halts on it as human work');
|
|
928
|
+
f.cleanup();
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
t("an owner-edited settings.json keeps every rule of its own and only gains what's missing", () => {
|
|
932
|
+
const f = topUpFixture({
|
|
933
|
+
permissions: { allow: ['Bash(npm test:*)'], deny: ['Read(secrets/**)', 'Read(**/*.pem)'], ask: ['Bash(rm:*)'] },
|
|
934
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo mine' }] }] },
|
|
935
|
+
env: { MY_VAR: '1' },
|
|
936
|
+
});
|
|
937
|
+
const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
|
|
938
|
+
const after = JSON.parse(f.read());
|
|
939
|
+
assert.deepEqual(after.permissions.allow, ['Bash(npm test:*)'], "the owner's allow list is theirs alone");
|
|
940
|
+
assert.deepEqual(after.permissions.ask, ['Bash(rm:*)'], 'a permissions key we do not manage is untouched');
|
|
941
|
+
assert.deepEqual(after.env, { MY_VAR: '1' }, 'and so is every key outside permissions');
|
|
942
|
+
assert.deepEqual(after.hooks.Stop[0].hooks[0], { type: 'command', command: 'echo mine' }, 'their hooks survive');
|
|
943
|
+
assert.deepEqual(after.permissions.deny.slice(0, 2), ['Read(secrets/**)', 'Read(**/*.pem)'],
|
|
944
|
+
"the owner's own deny rules keep their place at the front — appended, never reordered");
|
|
945
|
+
assert.equal(after.permissions.deny.filter((r) => r === 'Read(**/*.pem)').length, 1,
|
|
946
|
+
'a rule they already had is not added a second time');
|
|
947
|
+
assert.ok(!res.settingsDenyAdded.includes('Read(**/*.pem)'), 'nor reported as added');
|
|
948
|
+
for (const rule of topUpDenyFor({ configDir: 'acme' })) {
|
|
949
|
+
assert.ok(after.permissions.deny.includes(rule), `missing ${rule}`);
|
|
950
|
+
}
|
|
951
|
+
f.cleanup();
|
|
952
|
+
});
|
|
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
|
+
|
|
992
|
+
t('the top-up is idempotent — a second materialize changes nothing', () => {
|
|
993
|
+
const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW } });
|
|
994
|
+
m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
|
|
995
|
+
const settled = f.read();
|
|
996
|
+
const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
|
|
997
|
+
assert.deepEqual(res.settingsDenyAdded, [], 'nothing left to add');
|
|
998
|
+
assert.equal(f.read(), settled, 'and the file comes back byte-identical');
|
|
999
|
+
assert.ok(res.skippedFiles.includes('settings.json'), 'reported as left alone, as it was before');
|
|
1000
|
+
f.cleanup();
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
t('a dry run names the missing deny rules and writes nothing', () => {
|
|
1004
|
+
const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW } });
|
|
1005
|
+
const before = f.read();
|
|
1006
|
+
const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: true });
|
|
1007
|
+
assert.equal(f.read(), before, 'a dry run writes nothing');
|
|
1008
|
+
assert.deepEqual(res.settingsDenyAdded, topUpDenyFor({ configDir: 'acme' }),
|
|
1009
|
+
'but it still names what a real run would add');
|
|
1010
|
+
f.cleanup();
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
t("the DRY RUN does not waive settings.json from upgrade's dirty-tree pre-flight", () => {
|
|
1014
|
+
// writtenPaths doubles as the set the pre-flight IGNORES when it looks for uncommitted work
|
|
1015
|
+
// (task 1004122). That waiver is justified by "materialize overwrites these unconditionally,
|
|
1016
|
+
// so whatever they hold now is discarded either way" — which is exactly NOT true of the
|
|
1017
|
+
// top-up, the one path that PRESERVES what is there. If the preview listed settings.json, an
|
|
1018
|
+
// owner's half-finished edit to it would stop halting the bump and would be committed and
|
|
1019
|
+
// pushed by --commit-pin unreviewed. The real run still reports it, so persistPin commits the
|
|
1020
|
+
// rules it added and the next upgrade does not read them as a human's stray work.
|
|
1021
|
+
const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW } });
|
|
1022
|
+
const preview = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: true });
|
|
1023
|
+
const settings = (paths) => paths.some((p) => p.replace(/\\/g, '/') === '.claude/settings.json');
|
|
1024
|
+
assert.equal(settings(preview.writtenPaths), false,
|
|
1025
|
+
"a dirty settings.json must still halt the bump — the preview may not vouch for a file we don't overwrite");
|
|
1026
|
+
const real = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
|
|
1027
|
+
assert.equal(settings(real.writtenPaths), true,
|
|
1028
|
+
'the real run reports it, so the pin commit carries the rules it added');
|
|
1029
|
+
f.cleanup();
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
t('a settings.json we cannot parse is left byte-for-byte alone, and said so', () => {
|
|
1033
|
+
// Same posture task 1004023 took for the core's own file: a rewrite we cannot reason about is
|
|
1034
|
+
// worse than the gap. The operator is told, rather than the file being quietly half-fixed.
|
|
1035
|
+
const raw = '{ "permissions": { "allow": [] } // a comment JSON cannot hold\n}';
|
|
1036
|
+
const f = topUpFixture(raw);
|
|
1037
|
+
const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
|
|
1038
|
+
assert.equal(f.read(), raw, 'we never rewrite a file we could not parse');
|
|
1039
|
+
assert.deepEqual(res.settingsDenyAdded, [], 'and nothing is claimed to have been added');
|
|
1040
|
+
assert.match(res.settingsDenyUncheckable, /not valid JSON/);
|
|
1041
|
+
assert.ok(res.notes.some((n) => n.includes('could not be checked for missing deny rules')),
|
|
1042
|
+
'the gap is reported rather than silently left');
|
|
1043
|
+
f.cleanup();
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
t('a deny half that is not a list is left alone rather than coerced', () => {
|
|
1047
|
+
const f = topUpFixture({ permissions: { allow: [], deny: 'Read(**/*.pem)' } });
|
|
1048
|
+
const before = f.read();
|
|
1049
|
+
const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
|
|
1050
|
+
assert.equal(f.read(), before, 'an unexpected shape is never rewritten into our own');
|
|
1051
|
+
assert.match(res.settingsDenyUncheckable, /permissions\.deny/);
|
|
1052
|
+
f.cleanup();
|
|
1053
|
+
});
|
|
1054
|
+
|
|
888
1055
|
// task 1004012 — the other source: a CHECKOUT core root does carry a settings.json, and the
|
|
889
1056
|
// instance used to receive its allow-list verbatim, naming scripts its skills no longer run.
|
|
890
1057
|
t('a checkout-materialized instance gets allow-rules in the bongos form, end to end', () => {
|
|
@@ -903,16 +1070,25 @@ t('a checkout-materialized instance gets allow-rules in the bongos form, end to
|
|
|
903
1070
|
rmSync(inst, { recursive: true, force: true });
|
|
904
1071
|
});
|
|
905
1072
|
|
|
906
|
-
|
|
1073
|
+
// SUPERSEDED BY task 1004156, deliberately. This test used to assert the file came back
|
|
1074
|
+
// BYTE-IDENTICAL. That spelling of "never clobbered" also froze out the deny rules — an instance
|
|
1075
|
+
// on the allow-only starter could never receive them (task 1003966's fix reached only instances
|
|
1076
|
+
// materialized after it). The contract the project actually wants is the strictly stronger one
|
|
1077
|
+
// asserted here: nothing the instance wrote is removed, reordered or rewritten — a deny rule may
|
|
1078
|
+
// be APPENDED, and that can only ever narrow what the local agent may do.
|
|
1079
|
+
t("an instance's OWN settings.json keeps every rule it has — only deny rules are added", () => {
|
|
907
1080
|
const core = mkdtempSync(join(tmpdir(), 'w3-keep-core-'));
|
|
908
1081
|
const inst = mkdtempSync(join(tmpdir(), 'w3-keep-inst-'));
|
|
909
1082
|
mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
|
|
910
1083
|
mkdirSync(join(inst, '.claude'), { recursive: true });
|
|
911
|
-
const mine =
|
|
912
|
-
writeFileSync(join(inst, '.claude', 'settings.json'), mine);
|
|
1084
|
+
const mine = { permissions: { allow: ['Bash(echo hello:*)'] } };
|
|
1085
|
+
writeFileSync(join(inst, '.claude', 'settings.json'), JSON.stringify(mine, null, 2));
|
|
913
1086
|
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
914
|
-
|
|
1087
|
+
const after = JSON.parse(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8'));
|
|
1088
|
+
assert.deepEqual(after.permissions.allow, mine.permissions.allow,
|
|
915
1089
|
'an upgrade must never overwrite permissions the instance has grown');
|
|
1090
|
+
assert.deepEqual(Object.keys(after), Object.keys(mine), 'and it grows no key of its own');
|
|
1091
|
+
assert.ok(after.permissions.deny.length > 0, 'the deny half it never had does arrive');
|
|
916
1092
|
rmSync(core, { recursive: true, force: true });
|
|
917
1093
|
rmSync(inst, { recursive: true, force: true });
|
|
918
1094
|
});
|
|
@@ -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();
|