@bongos/core 1.19.331 → 1.19.333

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 (61) hide show
  1. package/.bongos-core.json +163 -43
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/public-landing/assets/cosmos.css +4 -0
  26. package/public-landing/projects.html +103 -23
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/module-api.js +5 -1
  46. package/tests/auth_admission_gate.mjs +3 -1
  47. package/tests/est_advisory.mjs +3 -1
  48. package/tests/goal_advisory.mjs +3 -2
  49. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  50. package/tests/goal_subtitle.mjs +5 -2
  51. package/tests/goals_card_view.mjs +10 -3
  52. package/tests/grader_generated_provenance.mjs +3 -1
  53. package/tests/hall_palette.mjs +3 -1
  54. package/tests/helpers.mjs +58 -25
  55. package/tests/lib_sh_resolution.mjs +3 -1
  56. package/tests/membership_kind_lockstep.mjs +3 -1
  57. package/tests/migration_allocation.mjs +5 -2
  58. package/tests/patch_gate_ordering.mjs +3 -1
  59. package/tests/projects_hub_pre_uat.mjs +53 -0
  60. package/tests/rank_tier_single_source.mjs +16 -3
  61. package/tests/reward_gate.mjs +3 -2
@@ -29,62 +29,14 @@ const { parseRoutineFrontmatter } = require('./run-routine'); // System 4 (ADR 0
29
29
  const { filenameViolation, stemCollisions, CORE_NS, INSTANCE_NS } = require('./migration-namespace'); // ADR 0108 §5: cross-repo migration namespacing
30
30
  const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-config'); // ADR 0108 §1: configurable-root
31
31
 
32
- // ADR 0108 §Consequences #4 (configurable-root W6 / task 1886) — the guardrail walks TWO trees.
33
- // CORE code + core-owned docs (src/, server.js, scripts/, the src/bongos + scripts/gds nested docs)
34
- // live under the core PACKAGE; INSTANCE content (the root CLAUDE.md, the .claude/ methodology
35
- // surface, instance modules + migrations) lives under the host REPO. Modules ship from BOTH.
36
- // Today both resolve to the same checkout, so every walk below is byte-identical; once the core
37
- // installs from node_modules they diverge and each check inspects the CORRECT tree. A NEW
38
- // hardcoded `path.resolve(__dirname,'..','..')` here reintroduces the bug this check exists to stop.
39
- const CORE_ROOT = resolveCoreRoot();
40
- const INSTANCE_ROOT = resolveInstanceRoot();
41
- // REPO_ROOT retained as an alias to the core root for the many purely-core walks below (src/bongos,
42
- // KERNEL_FILES, scope map, …) — those keep their exact behavior. Instance-owned reads route
43
- // through INSTANCE_ROOT explicitly, and module reads through MODULE_ROOTS.
44
- const REPO_ROOT = CORE_ROOT;
45
- // Deduped module search roots — a module can ship from the core package OR the instance repo (the
46
- // loader errors on a cross-root key collision, so a key resolves to exactly one root). Deduped so
47
- // today's single checkout scans each module ONCE (no double-counting).
48
- const MODULE_ROOTS = [...new Set([CORE_ROOT, INSTANCE_ROOT])];
49
-
50
- // ---- shared fs helpers -----------------------------------------------------
51
-
52
- function walkJs(absRoot, out = []) {
53
- let st;
54
- try { st = fs.statSync(absRoot); } catch { return out; }
55
- if (st.isFile()) { if (absRoot.endsWith('.js')) out.push(absRoot); return out; }
56
- for (const ent of fs.readdirSync(absRoot, { withFileTypes: true })) {
57
- if (ent.name === 'node_modules' || ent.name === '.git') continue;
58
- walkJs(path.join(absRoot, ent.name), out);
59
- }
60
- return out;
61
- }
62
- // repo-relative POSIX path for an abs file under EITHER root (core or instance). The classifier
63
- // regexes throughout this file key on these relative strings, which are identical whichever tree
64
- // a file lives in — so only the WALK roots change under the split, not the classification.
65
- const rel = (abs) => {
66
- for (const base of [CORE_ROOT, INSTANCE_ROOT]) {
67
- const r = path.relative(base, abs);
68
- if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r.split(path.sep).join('/');
69
- }
70
- return path.relative(CORE_ROOT, abs).split(path.sep).join('/');
71
- };
72
- // Walk .js under `subpath` (e.g. 'modules') across ALL module roots, deduped by repo-relative
73
- // path so the same file is never scanned twice when the roots coincide (today).
74
- function walkJsAllRoots(subpath, roots = MODULE_ROOTS) {
75
- const seen = new Set();
76
- const out = [];
77
- for (const root of roots) {
78
- for (const abs of walkJs(path.join(root, subpath))) {
79
- const r = rel(abs);
80
- if (seen.has(r)) continue;
81
- seen.add(r);
82
- out.push(abs);
83
- }
84
- }
85
- return out;
86
- }
87
-
32
+ // task 1003206 (goal 1000079): fitness.js was 2,004 lines. Four cohesive pieces now
33
+ // live beside it — the shared roots/walkers and three check clusters. They are
34
+ // IMPORTED (not registered by path) so the CHECKS array and module.exports below
35
+ // stay byte-identical to the single-file version every test was written against.
36
+ const { CORE_ROOT, INSTANCE_ROOT, MODULE_ROOTS, REPO_ROOT, rel, walkJs, walkJsAllRoots } = require('./fitness-lib.js');
37
+ const { IDENTITY_SCAN_ALLOWED, IDENTITY_SCAN_EXCLUDE, IDENTITY_SCAN_EXTRA_FILES, IDENTITY_SCAN_EXTS, IDENTITY_SCAN_ROOTS, IDENTITY_SCAN_SKIP_DIRS, PROMPT_IDENTITY_LITERALS, checkDesignContract, checkKernelPromptsNoHardcodedIdentity, checkNeutralConfigsNoHostIdentity, collectIdentityFiles, scanPromptIdentity, stripCommentsForIdentity } = require('./fitness-checks-identity.js');
38
+ const { WRITE_VALIDATION_BASELINE, WRITE_VALIDATION_EXEMPT, checkWriteRoutesValidated, scanUnvalidatedWriteRoutes } = require('./fitness-checks-write-validation.js');
39
+ const { GOAL_VOCAB_CITATION, GOAL_VOCAB_LINE_SCOPED, GOAL_VOCAB_SURFACE, LIFECYCLE_DB_FILES, VOCAB_ALLOW, VOCAB_BANNED, VOCAB_EXTS, VOCAB_HISTORICAL_REF, VOCAB_SURFACE, checkGoalMembershipVocabulary, checkVocabularyWall, vocabFilesToScan } = require('./fitness-checks-vocabulary.js');
88
40
  // ============================================================================
89
41
  // Check 1 — core ↔ host/game import boundary (ADR 0062 §1 cut-line)
90
42
  // ============================================================================
@@ -521,6 +473,16 @@ const KERNEL_FILES = [
521
473
  'src/bongos/module-scope-map.js', // the module→path registry (it NAMES every module — exempt below)
522
474
  // trust primitives (ADR 0091 §1) — the authz boundary + data substrate
523
475
  'src/bongos/auth.js', // authz gates (requireBuilder/requireRank, ADR 0016)
476
+ // task 1003206 (goal 1000079) split auth.js's 1,867 lines into these five sign-in
477
+ // flow modules. They are the SAME kernel machinery, not new domain — auth.js is a
478
+ // kernel file, so if they were left off this roster the boundary check would read
479
+ // its own carve as five kernel→non-kernel leaks. Being on the roster also means
480
+ // each of them is itself held to the kernel import rule, which is the point.
481
+ 'src/bongos/auth-config.js', // provider config + OAuth scope selection
482
+ 'src/bongos/auth-admission.js', // membership kind, admission gate, first-admin seating
483
+ 'src/bongos/auth-github.js', // GitHub device + web OAuth flows
484
+ 'src/bongos/auth-idp.js', // hub federation, assertion + logout-token verification
485
+ 'src/bongos/auth-discord.js', // Discord OAuth link + guild membership
524
486
  'src/bongos/route-rank-check.js', // deterministic route→rank classification
525
487
  'src/bongos/permission-path-check.js', // protected-path matcher (PROTECTED_GLOBS, ADR 0043)
526
488
  'src/bongos/goal-scope-check.js', // goal create-gate scope wall (composes the 3 matchers above; ADR 0086 §3, BV1.R60)
@@ -556,7 +518,19 @@ const KERNEL_FILES = [
556
518
  // instance-config, build-info) are NOT exempt and must stay module-agnostic.
557
519
  const KERNEL_NAMES_MODULES_OK = new Set([
558
520
  'src/bongos/module-scope-map.js', // the module→path registry
521
+ // auth.js and the auth-*.js flow modules task 1003206 split out of it: the
522
+ // sign-in path legitimately names modules — it resolves the 'onboarding' and
523
+ // 'government' PORTS by key, and carries the Discord OAuth account-link flow
524
+ // (core by design — see modules/discord/CLAUDE.md "NOT in this module"). The
525
+ // exemption follows the code, exactly as the KERNEL_FILES roster above does;
526
+ // Check 12 still guards these files' dependency DIRECTION, which is the
527
+ // stronger guarantee.
559
528
  'src/bongos/auth.js', // Discord OAuth account-link flow (core by design)
529
+ 'src/bongos/auth-config.js',
530
+ 'src/bongos/auth-admission.js', // resolves the 'government' rank-role port by key
531
+ 'src/bongos/auth-github.js', // ticks the 'onboarding' port on first sign-in
532
+ 'src/bongos/auth-idp.js', // same, on the federated path
533
+ 'src/bongos/auth-discord.js', // the Discord account-link flow itself
560
534
  'src/bongos/permission-path-check.js', // names module path subtrees to PROTECT them
561
535
  'src/bongos/route-rank-check.js', // names module route dirs to rank-gate them
562
536
  ]);
@@ -937,292 +911,6 @@ function checkMigrationNamespacing() {
937
911
  };
938
912
  }
939
913
 
940
- // ============================================================================
941
- // Check 13 — portable code carries no hardcoded host-brand identity (BV1.R76 + task 1705)
942
- // ============================================================================
943
- //
944
- // The portable core (src/bongos/**) and every feature MODULE (modules/**) must read
945
- // the instance's product/world name + domains from branding() / window.__BRANDING__,
946
- // never a baked-in "Off the Boats" / "Amazonprimea" / apex domain (ADR 0062 §3/§5;
947
- // the branding contract). R76 shipped this as an 8-FILE allowlist over the kernel
948
- // LLM-prompt headers; task 1705 WIDENS it to a directory-wide scan, so ANY new brand
949
- // leak in portable code reds the build — not just the nine files someone remembered
950
- // to list. (The 2026-07-02 separation audit found five leaks the old allowlist missed:
951
- // the rank/graduation broadcasts, the auth error-page title, the apex-origin default,
952
- // and ~124 served-UI strings.) Comments are stripped first (block /* */, HTML
953
- // <!-- -->, and JS // line comments via a `://`-safe strip so a URL is never
954
- // truncated) because the de-brand rationale legitimately names the old literals in
955
- // prose. Instance-injected placeholders ({{worldName}} etc.) carry no literal, so
956
- // they pass untouched.
957
- //
958
- // EXCLUDED — the small instance-1 / ops allowlist (task 1705):
959
- // - modules/game/** — the INSTANCE'S OWN PRODUCT. A product's identity is
960
- // instance-specific by definition (a vanilla instance ships a different product,
961
- // or none); it is not portable-platform code the branding pack can neutralize.
962
- // A documented carve-out, not an oversight.
963
- // - generated / build-output dirs (no authored source).
964
- const PROMPT_IDENTITY_LITERALS = [
965
- /off the boats/i,
966
- /amazonprimea/i,
967
- ];
968
- // Scan roots (recursive) + kernel-prompt CLIs that live outside them.
969
- // scripts/gds joined at task 1002427: the builder CLI was UNSCANNED, which is how
970
- // paste-token.js kept a hardcoded 'https://amazonprimea.com' long after the task
971
- // 1002201 sweep replaced that literal everywhere else. The straggler surfaced as a
972
- // 401 that locked a builder out of re-authenticating — a valid token verified
973
- // against the wrong host — and nothing in CI could see it (idea 1000564).
974
- // Widened scripts/gds → ALL of scripts/ at task 1002756: migrate.sh's hardcoded
975
- // founder DB default (task 1002755) survived every gate because the detector never
976
- // saw the file — a gate that cannot see the file class the bug lives in is worse
977
- // than no gate, because it is trusted.
978
- const IDENTITY_SCAN_ROOTS = ['src/bongos', 'modules', 'scripts'];
979
- const IDENTITY_SCAN_EXTRA_FILES = [];
980
- // PERMANENT, COUNTED carve-outs for the scripts/gds root — {file: allowed hits}.
981
- //
982
- // Widening the root (task 1002427) surfaced 23 files. Task 1002428 swept them:
983
- // every one whose literal was a real runtime bug is FIXED (a wrong instance URL
984
- // printed to a builder, an `amazonprimea-*` User-Agent that should come from
985
- // branding().userAgent(), a hardcoded product name in user-facing copy). What is
986
- // left below is NOT a backlog — each entry is a literal that is CORRECT as
987
- // written, so this list is permanent rather than a ratchet-to-zero.
988
- //
989
- // The COUNT is still enforced, which is the point: an entry may not grow. A file
990
- // here that gains a NEW hardcoded host fails the build exactly like an unlisted
991
- // one, and any file NOT listed must have zero hits. Getting better never fails.
992
- //
993
- // Why each is legitimate:
994
- // * box.js — REAL Cloudflare/DNS zone names for the operator plane
995
- // (`dev.<host>`, the terminal zone). These are infrastructure identifiers,
996
- // not an instance URL; routing them through branding would be wrong. (Its
997
- // two genuine API-base defaults WERE fixed in 1002428.)
998
- // * publish-manifest.js — the literal FILENAMES of two real committed docs
999
- // (docs/recipes/amazonprimea-decouple-*.md). Renaming the strings without
1000
- // renaming the files would break the publish manifest.
1001
- // * backfill-sessions.js / version-close.js — one-time operator runbook text
1002
- // naming the actual prod DB + a historical local directory. Describing what
1003
- // an operator must literally type is the correct content.
1004
- // * seed-*.js — the hits are PROSE INSIDE seeded task descriptions: historical
1005
- // records of what those tasks said when they were filed. Rewriting them would
1006
- // falsify the record; deleting the files would destroy it. They are inert
1007
- // (idempotent on source_ref) and referenced only by generated manifests.
1008
- // * the four *.sh entries — surfaced when `.sh` joined the extension set (task
1009
- // 1002567). Each is a HOST-PINNED OPS script, not portable code: they name the
1010
- // live box's actual Postgres database/role and its actual /etc secrets dir, and
1011
- // every one of those literals is already only the DEFAULT behind an env override
1012
- // (GDS_BACKUP_DB, PROD_DB/STAGING_DB, PG_SOCKET). Same rationale as box.js's real
1013
- // DNS zones — an infrastructure identifier, not an instance URL, and the systemd
1014
- // units on that box invoke these by path. Re-pointing an instance's backups is
1015
- // deployment work, not a branding read. Their COUNT is still frozen here, so a
1016
- // NEW literal in any of them fails exactly like one in an unlisted file.
1017
- // * fitness.js / package-core.js — THE DETECTORS THEMSELVES: Check 13's regexes
1018
- // and package-core's PRIOR_INSTANCE_FLOOR name the brand because hunting it is
1019
- // their job. * seed-v3-tasks.js — the seeded-task-prose rationale above. All
1020
- // three passed pre-1002756 only by the old stripper's accidents (phantom
1021
- // blocks + regex-blindness); now they are seen and counted.
1022
- const IDENTITY_SCAN_ALLOWED = {
1023
- 'scripts/gds/fitness.js': 2,
1024
- 'scripts/gds/package-core.js': 1,
1025
- 'scripts/gds/seed-v3-tasks.js': 14,
1026
- // audit-replay.sh + db-backup-{nightly,offsite}.sh were carved out here as
1027
- // "host-pinned ops"; task 1002757 RETIRED that justification for them — each
1028
- // now resolves its database from the branding pack (the migrate.sh pattern) and
1029
- // fails loudly rather than defaulting, so their counts ratcheted to zero and the
1030
- // entries are gone. seed-staging.sh keeps its carve-out on the OPPOSITE
1031
- // rationale: its literal is a fail-CLOSED safety interlock (the script wipes
1032
- // STAGING_DB and refuses unless the name matches exactly), so on any other
1033
- // instance it declines to run instead of destroying the wrong database.
1034
- 'scripts/gds/seed-staging.sh': 4,
1035
- 'scripts/gds/backfill-sessions.js': 2,
1036
- 'scripts/gds/box.js': 3,
1037
- 'scripts/gds/publish-manifest.js': 2,
1038
- 'scripts/gds/seed-bongos-coreB-tranche1-tasks.js': 1,
1039
- 'scripts/gds/seed-bongos-coreB-tranche2-tasks.js': 1,
1040
- 'scripts/gds/seed-bongos-goal-hierarchy-tasks.js': 1,
1041
- 'scripts/gds/seed-bongos-tasks.js': 1,
1042
- 'scripts/gds/seed-gds-v4-tasks.js': 1,
1043
- 'scripts/gds/seed-medusa-tasks.js': 7,
1044
- 'scripts/gds/seed-module-upstreaming.js': 1,
1045
- 'scripts/gds/seed-productization-tasks.js': 6,
1046
- 'scripts/gds/seed-provisioning-tasks.js': 2,
1047
- 'scripts/gds/version-close.js': 1,
1048
- };
1049
- // `.sh` joined at task 1002567: the extension set was `.js/.html/.css`, so NO shell
1050
- // script had ever been scanned — which is how scripts/gds/lib.sh kept a hardcoded
1051
- // founder host as its API-base default (and a hardcoded ~/.config/otb token path)
1052
- // through the entire task 1002201 sweep, leaving all five bash smokes dead on any
1053
- // instance with a different configDir. The guard could not see the file class the
1054
- // bug lived in.
1055
- const IDENTITY_SCAN_EXTS = new Set(['.js', '.html', '.css', '.sh']);
1056
- // Path-prefix carve-outs (see the header note). Kept SMALL and documented.
1057
- const IDENTITY_SCAN_EXCLUDE = ['modules/game/'];
1058
- // Directories with no authored source to gate.
1059
- const IDENTITY_SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'generated', 'iterations', 'references']);
1060
-
1061
- // Comment stripping lives in identity-strip.js (task 1002756: extracted + replaced
1062
- // the two-regex JS strip, which a `/*` inside a `//` comment could OPEN — it
1063
- // silently hid ~330 provision.js lines from this gate). Re-exported for the tests.
1064
- const { stripCommentsForIdentity } = require('./identity-strip');
1065
-
1066
- // Pure: scan one file's comment-stripped source for a hardcoded host-brand literal.
1067
- // Returns violation strings. Exported for the "actually catches a re-hardcode" test.
1068
- function scanPromptIdentity(relFile, text, ext = path.extname(relFile)) {
1069
- const violations = [];
1070
- stripCommentsForIdentity(text, ext).split('\n').forEach((raw, i) => {
1071
- if (!raw.trim()) return;
1072
- for (const re of PROMPT_IDENTITY_LITERALS) {
1073
- const m = raw.match(re);
1074
- if (m) {
1075
- violations.push(`${relFile}:${i + 1} hardcodes the host-brand identity "${m[0]}" — read it from branding()/window.__BRANDING__ (identity/domains/currency), or use a {{worldName}}-style token, so a non-OTB instance renders ITS OWN brand (ADR 0062 §3/§5; BV1.R76 / task 1705).`);
1076
- break; // one hit per line is enough
1077
- }
1078
- }
1079
- });
1080
- return violations;
1081
- }
1082
-
1083
- // Recursively collect scannable files under one root, honoring the skip-dir + carve-out lists.
1084
- function collectIdentityFiles(absRoot, relRoot) {
1085
- const out = [];
1086
- let entries;
1087
- try { entries = fs.readdirSync(absRoot, { withFileTypes: true }); }
1088
- catch { return out; }
1089
- for (const e of entries) {
1090
- const rel = relRoot ? `${relRoot}/${e.name}` : e.name;
1091
- if (e.isDirectory()) {
1092
- if (IDENTITY_SCAN_SKIP_DIRS.has(e.name)) continue;
1093
- if (IDENTITY_SCAN_EXCLUDE.some((p) => `${rel}/`.startsWith(p))) continue;
1094
- out.push(...collectIdentityFiles(path.join(absRoot, e.name), rel));
1095
- } else if (IDENTITY_SCAN_EXTS.has(path.extname(e.name))
1096
- && !IDENTITY_SCAN_EXCLUDE.some((p) => rel.startsWith(p))) {
1097
- out.push(rel);
1098
- }
1099
- }
1100
- return out;
1101
- }
1102
-
1103
- function checkKernelPromptsNoHardcodedIdentity() {
1104
- const violations = [];
1105
- let scanned = 0;
1106
- // Collect {abs, rel}: src/bongos is CORE; modules ship from BOTH roots (ADR 0108 §Consequences #4).
1107
- // Dedup by repo-relative path so a shared checkout never scans a file twice, and read each file
1108
- // from the root it was actually found under (a module may live in the instance repo post-split).
1109
- const seen = new Set();
1110
- const entries = [];
1111
- const addFrom = (rootAbs, sub) => {
1112
- for (const relFromRoot of collectIdentityFiles(path.join(rootAbs, sub), sub)) {
1113
- if (seen.has(relFromRoot)) continue;
1114
- seen.add(relFromRoot);
1115
- entries.push({ abs: path.join(rootAbs, relFromRoot), rel: relFromRoot });
1116
- }
1117
- };
1118
- for (const root of IDENTITY_SCAN_ROOTS) {
1119
- for (const rootAbs of (root === 'modules' ? MODULE_ROOTS : [CORE_ROOT])) addFrom(rootAbs, root);
1120
- }
1121
- for (const extra of IDENTITY_SCAN_EXTRA_FILES) {
1122
- if (!seen.has(extra)) { seen.add(extra); entries.push({ abs: path.join(CORE_ROOT, extra), rel: extra }); }
1123
- }
1124
- let ratcheted = 0;
1125
- for (const { abs, rel: relFile } of entries) {
1126
- let text;
1127
- try { text = fs.readFileSync(abs, 'utf8'); }
1128
- catch { continue; } // a file moved/carved away is not a failure of this gate
1129
- scanned += 1;
1130
- const found = scanPromptIdentity(relFile, text);
1131
- // Permanently allowed at a COUNT (task 1002428): the listed literals are
1132
- // correct as written, but an entry may not GROW — a new hardcode in one of
1133
- // these files fails exactly like one in an unlisted file.
1134
- const allowed = IDENTITY_SCAN_ALLOWED[relFile];
1135
- if (allowed !== undefined) {
1136
- if (found.length > allowed) {
1137
- violations.push(
1138
- `${relFile} now hardcodes the host-brand identity ${found.length} time(s), above its allowed ${allowed} — ` +
1139
- `the carve-out covers the EXISTING literals (see IDENTITY_SCAN_ALLOWED), not new ones. ` +
1140
- `Read the host from branding()/window.__BRANDING__ instead (task 1002428).`
1141
- );
1142
- } else {
1143
- ratcheted += 1;
1144
- }
1145
- continue;
1146
- }
1147
- violations.push(...found);
1148
- }
1149
- return {
1150
- name: 'portable code carries no hardcoded host identity (BV1.R76 / task 1705)',
1151
- ok: violations.length === 0,
1152
- hardFail: violations.length > 0,
1153
- violations,
1154
- warnings: [],
1155
- note: `${scanned} portable source file(s) scanned (.js/.html/.css/.sh under src/bongos/** + modules/** + scripts/** (whole root, task 1002756), minus the instance-1 product + generated dirs) — none may hardcode the host brand; identity comes from branding()/window.__BRANDING__ (ADR 0062 §3/§5). ${ratcheted} scripts/gds file(s) hold documented permanent carve-outs (real DNS zones, real doc filenames, operator runbook text, historical seeded prose) capped at a count they may not exceed — task 1002428.`,
1156
- };
1157
- }
1158
-
1159
- // ============================================================================
1160
- // Check 15b — core-shipped NEUTRAL config packs carry no host identity (BV1.R102)
1161
- // ============================================================================
1162
- //
1163
- // Sibling of Check 15 for the CONFIG class. IDENTITY_SCAN_EXTS is js/html/css, so
1164
- // the scan above never opens a .json — yet config/*.neutral.json is precisely what
1165
- // a vanilla instance boots from (branding R76, governance R96). A host name leaking
1166
- // into a neutral pack ships one instance's brand to EVERY fresh instance, and until
1167
- // this check nothing in CI could see it.
1168
- //
1169
- // Only the NEUTRAL packs are scanned, deliberately: an instance pack (config/
1170
- // branding.json, config/government.json) is host content that legitimately names
1171
- // its host, and is boundary-excluded from the published core anyway.
1172
- function checkNeutralConfigsNoHostIdentity() {
1173
- const violations = [];
1174
- const dir = path.join(CORE_ROOT, 'config');
1175
- let names = [];
1176
- try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.neutral.json')).sort(); }
1177
- catch { /* no config/ dir in this root — nothing to gate */ }
1178
- for (const name of names) {
1179
- const relFile = `config/${name}`;
1180
- let text;
1181
- try { text = fs.readFileSync(path.join(dir, name), 'utf8'); }
1182
- catch { continue; } // a pack that moved away is not a failure of this gate
1183
- violations.push(...scanPromptIdentity(relFile, text, '.json'));
1184
- }
1185
- return {
1186
- name: 'neutral config packs carry no host identity (BV1.R102)',
1187
- ok: violations.length === 0,
1188
- hardFail: violations.length > 0,
1189
- violations,
1190
- warnings: [],
1191
- note: `${names.length} core-shipped config/*.neutral.json pack(s) scanned — a vanilla instance boots from these alone, so none may name a host brand. Instance packs (config/<name>.json) are host content and are deliberately NOT scanned.`,
1192
- };
1193
- }
1194
-
1195
- // ---- design contract (ADR 0081 / task 1377) --------------------------------
1196
-
1197
- function checkDesignContract() {
1198
- const { spawnSync } = require('node:child_process');
1199
- const validatorPath = path.join(REPO_ROOT, 'scripts', 'gds', 'validate-design.js');
1200
- const adaptersDir = path.join(REPO_ROOT, 'src', 'ui', 'adapters');
1201
- const hasAdapters = (() => {
1202
- try { return fs.readdirSync(adaptersDir).some((e) => fs.statSync(path.join(adaptersDir, e)).isDirectory()); }
1203
- catch { return false; }
1204
- })();
1205
-
1206
- const result = spawnSync(process.execPath, [validatorPath, '--json'], { encoding: 'utf8' });
1207
- let report = { ok: true, errors: [], warnings: [] };
1208
- try { report = JSON.parse(result.stdout || '{}'); } catch { /* fall through with defaults */ }
1209
- if (result.status !== 0 && !report.errors.length) {
1210
- report.errors.push(result.stderr ? result.stderr.trim() : 'validate-design.js exited non-zero');
1211
- report.ok = false;
1212
- }
1213
-
1214
- return {
1215
- name: 'design contract valid (docs/design-contract.md / ADR 0081)',
1216
- ok: report.ok,
1217
- hardFail: !report.ok && hasAdapters,
1218
- violations: report.errors || [],
1219
- warnings: report.warnings || [],
1220
- note: hasAdapters
1221
- ? 'Hard-fail: adapters present — contract must be valid. Run: node scripts/gds/validate-design.js'
1222
- : 'Warn-only until first adapter lands (task 1378 / task 1379). Run: node scripts/gds/validate-design.js',
1223
- };
1224
- }
1225
-
1226
914
  // ============================================================================
1227
915
  // Check — hall sections are registry-owned, not hardcoded (BV1.R75 / ADR 0062 §4)
1228
916
  // ============================================================================
@@ -1253,7 +941,19 @@ function checkDesignContract() {
1253
941
  // that would either fail today or assert nothing.
1254
942
  const HALL_INDEX = path.join(moduleDir('hall-ui'), 'public', 'index.html'); // hall-ui is a module — resolve its root (ADR 0108)
1255
943
  const HALL_PUBLIC = path.join(moduleDir('hall-ui'), 'public');
944
+ // task 1003206 split the hall page script: builders.js keeps state/loading/wiring
945
+ // and hall-render.js holds the view painters — ZONE_BUDGETS travelled with the
946
+ // widget mounting. Read whichever of the pair actually declares it, so this
947
+ // check follows the code instead of a filename (it went vacuous on the move).
1256
948
  const HALL_BUILDERS_JS = path.join(HALL_PUBLIC, 'builders.js');
949
+ const HALL_RENDER_JS = path.join(HALL_PUBLIC, 'hall-render.js');
950
+ function hallWidgetSource() {
951
+ const parts = [];
952
+ for (const f of [HALL_BUILDERS_JS, HALL_RENDER_JS]) {
953
+ try { parts.push(fs.readFileSync(f, 'utf8')); } catch { /* absent → skip */ }
954
+ }
955
+ return parts.join('\n');
956
+ }
1257
957
 
1258
958
  // Parse builders.js's ZONE_BUDGETS object literal — the single source of truth
1259
959
  // for the caps (this check must never carry its own copy of the numbers).
@@ -1290,10 +990,14 @@ function scanHallZoneCounts(dir = HALL_PUBLIC, fsImpl = fs) {
1290
990
 
1291
991
  function checkHallZoneBudgets(fsImpl = fs) {
1292
992
  let js = '';
1293
- try { js = fsImpl.readFileSync(HALL_BUILDERS_JS, 'utf8'); } catch { return []; }
993
+ try {
994
+ js = [HALL_BUILDERS_JS, HALL_RENDER_JS]
995
+ .map((f) => { try { return fsImpl.readFileSync(f, 'utf8'); } catch { return ''; } })
996
+ .join('\n');
997
+ } catch { return []; }
1294
998
  const budgets = parseZoneBudgets(js);
1295
999
  if (!budgets) {
1296
- return ['modules/hall-ui/public/builders.js no longer declares a parseable ZONE_BUDGETS literal — the Home zone budgets are unenforced (task 2177).'];
1000
+ return ['modules/hall-ui/public/{builders,hall-render}.js no longer declares a parseable ZONE_BUDGETS literal — the Home zone budgets are unenforced (task 2177).'];
1297
1001
  }
1298
1002
  const counts = scanHallZoneCounts(HALL_PUBLIC, fsImpl);
1299
1003
  const violations = [];
@@ -1561,329 +1265,6 @@ function checkErrorEnvelope() {
1561
1265
  };
1562
1266
  }
1563
1267
 
1564
- // ============================================================================
1565
- // Check 18 — every write route validates its body (BV1 R12 / task 1999 / ADR 0118)
1566
- // ============================================================================
1567
- //
1568
- // Criterion api-validation: "the build fails on an unvalidated write route." R11
1569
- // (#1998) gave every body-reading write route a strict validateOrRespond schema
1570
- // (code-first, spec-driven — ADR 0118); this LOCKS that in. Reusing the SAME
1571
- // route model that generates the OpenAPI spec (gen-api-docs.extractModel —
1572
- // readsBody + validated), a POST/PUT/PATCH/DELETE that reads req.body must carry
1573
- // a validateOrRespond()/validate(req.body) schema. Hard-zero (no ratchet — the
1574
- // R11 sweep already reached 100%): a NEW write route that reads a body but
1575
- // declares no schema fails the build immediately, so unknown/typo'd fields can
1576
- // never again be silently dropped.
1577
- //
1578
- // EXEMPT — the builder-settings /me/*-prefs routes. Their bodies are DYNAMIC-KEY
1579
- // maps ({ "<skill|sound|event-id>": <value> }, or a single-key toggle) that a
1580
- // FIXED field schema cannot express; they validate via their module lib's
1581
- // validatePatch()/validate() (which already rejects unknown keys with a 422). A
1582
- // fixed strict schema would wrongly reject the valid dynamic keys — so these are
1583
- // validated-by-lib, not by validateOrRespond. Documented per-route below; see
1584
- // learning #133 / ADR 0118. Adding a route here is a deliberate, reviewable act.
1585
- const WRITE_VALIDATION_EXEMPT = new Set([
1586
- 'PATCH /me/skill-prefs', // { "<skill>": "<model>"|null } — dynamic keys; skillPrefs.validatePatch
1587
- 'PATCH /me/sound-prefs', // { "<sound>": <bool>|null } — dynamic keys; soundPrefs.validatePatch
1588
- 'PATCH /me/event-sound-prefs', // { "<event>": "<variant>"|null } — dynamic keys; eventSoundPrefs.validatePatch
1589
- 'PATCH /me/wandering', // { wandering: 0..3|null } — rank-capped; wanderingPrefs.validatePatch
1590
- 'PATCH /me/interaction', // interaction profile — interactionPrefs.validatePatch
1591
- 'PATCH /me/model-allocation', // per-slot tiers — modelAllocPrefs.validatePatch
1592
- 'PATCH /me/render', // { widgets: <bool>|null } — renderPrefs.validatePatch
1593
- 'PATCH /me/display-name', // { display_name } — displayName.validate
1594
- ]);
1595
-
1596
- // KNOWN GAPS, NOT EXEMPTIONS (task 1002551 / BV1.R109b → tracked by task 1002552).
1597
- //
1598
- // These three are NOT validated-by-lib and do NOT belong in WRITE_VALIDATION_EXEMPT
1599
- // above — they are genuine ADR 0118 gaps that must grow a validateOrRespond schema.
1600
- // They surface here only because R109b taught route discovery to see MULTI-LINE
1601
- // `router.<method>(` declarations: this check reuses gen-api-docs.extractModel(), so
1602
- // the same regex hole that hid them from the rank auditor hid them from Check 18 too.
1603
- // They are pre-existing (never validated), not a regression.
1604
- //
1605
- // They are recorded rather than fixed because modules/*/routes/*.js was being migrated
1606
- // concurrently by BV1.R105, so editing those files would have collided. This is the
1607
- // Check-17 ratchet posture: a NAMED, non-growing baseline. The list may only ever
1608
- // SHRINK — a new unvalidated write route is not on it and still hard-fails the build,
1609
- // and a stale entry (one that HAS been fixed) also fails, so the list cannot rot.
1610
- // Delete this constant when task 1002552 lands and Check 18 returns to hard-zero.
1611
- // EMPTY as of BV1.R109c (task 1002552) — all three original entries now validate.
1612
- // Kept (rather than deleted) as the ratchet itself: an empty named baseline states
1613
- // the invariant is hard-zero, and any future entry has to be added deliberately.
1614
- const WRITE_VALIDATION_BASELINE = new Set([]);
1615
-
1616
- function scanUnvalidatedWriteRoutes(model = genApiDocs.extractModel()) {
1617
- const unvalidated = [];
1618
- const baselineSeen = new Set();
1619
- for (const r of model) {
1620
- if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(r.method)) continue;
1621
- // Only routes that actually read a body have an input surface to validate;
1622
- // a write that ignores req.body has nothing to declare.
1623
- if (!r.body || !r.body.readsBody || r.body.validated) continue;
1624
- const key = `${r.method} ${r.route}`;
1625
- if (WRITE_VALIDATION_EXEMPT.has(key)) continue;
1626
- if (WRITE_VALIDATION_BASELINE.has(key)) { baselineSeen.add(key); continue; }
1627
- unvalidated.push(`${key} [${r.file}]`);
1628
- }
1629
- // A baseline entry that no longer reports as unvalidated has been FIXED — it must be
1630
- // removed, or the list would quietly license a future regression on that same route.
1631
- for (const key of WRITE_VALIDATION_BASELINE) {
1632
- if (!baselineSeen.has(key)) {
1633
- unvalidated.push(`${key} [STALE BASELINE — this route now validates; remove it from WRITE_VALIDATION_BASELINE in scripts/gds/fitness.js]`);
1634
- }
1635
- }
1636
- return unvalidated;
1637
- }
1638
-
1639
- function checkWriteRoutesValidated() {
1640
- const unvalidated = scanUnvalidatedWriteRoutes();
1641
- const violations = [];
1642
- if (unvalidated.length) {
1643
- violations.push(
1644
- `${unvalidated.length} write route(s) read req.body but declare NO validateOrRespond/validate schema (ADR 0118 / R11 requires one so unknown fields are rejected via the ADR-0116 envelope): ${unvalidated.join('; ')}. Add a strict validateOrRespond(req, res, { … }) listing every field the handler reads (see learning #133). A dynamic-key body validated by a module lib belongs in WRITE_VALIDATION_EXEMPT with a one-line reason.`
1645
- );
1646
- }
1647
- return {
1648
- name: 'every write route validates its body (ADR 0118 / R12)',
1649
- ok: violations.length === 0,
1650
- hardFail: violations.length > 0,
1651
- violations,
1652
- warnings: [],
1653
- note: `body-reading POST/PUT/PATCH/DELETE routes must call validateOrRespond/validate (${WRITE_VALIDATION_EXEMPT.size} dynamic-key prefs routes validate by lib; ${WRITE_VALIDATION_BASELINE.size} known gaps tracked by task 1002552, list may only shrink); ${unvalidated.length} unvalidated.`,
1654
- };
1655
- }
1656
-
1657
- // ── Check 19 — the ADR 0174 vocabulary wall (goal 1000068 R07, task 1003008) ──
1658
- //
1659
- // "Role" means ONE thing in this codebase: a craft — Artist, Ideator, or Builder.
1660
- // The permission system is the GOVERNMENT and the positions in it are RANKS.
1661
- // Before ADR 0174 the same word named both, and goal 1000066 was about to claim
1662
- // it for the crafts while modules/governance/ still used it for the ladder. This
1663
- // check is what stops that collision reforming the first time someone copies an
1664
- // old file.
1665
- //
1666
- // TWO RULES, both scoped to the GOVERNMENT SURFACE — not the whole repo. A repo-
1667
- // wide ban would be wrong: `builders.system_role` is a core column for machine
1668
- // identities, Discord's API owns its own "role" noun, and the crafts are supposed
1669
- // to use the word.
1670
- //
1671
- // 1. no "role" under modules/government/ or on the Government hall page
1672
- // 2. no "governance" there either — the old name of the same surface
1673
- //
1674
- // EXCLUDED BY CONSTRUCTION (not by allowlist):
1675
- // • modules/government/migrations/governance_*.sql — the already-applied
1676
- // migrations keep their filenames forever (schema_migrations is stem-keyed;
1677
- // renaming one re-runs it, and governance_002 is a seed/backfill). Their
1678
- // bodies necessarily name the pre-rename tables they created.
1679
- // • docs/adr/, docs/session-logs/, docs/governance/ — the historical record.
1680
- // An ADR describes a decision at a point in time; rewriting it to match
1681
- // today's vocabulary would erase the evidence that the words changed.
1682
- //
1683
- // The ALLOWLIST below is deliberately tiny and each entry carries its reason. A
1684
- // rule that passes because everything is suppressed reads as evidence while
1685
- // proving nothing — if this list grows, that is the signal to finish the rename,
1686
- // not to add a line.
1687
- const VOCAB_ALLOW = [
1688
- {
1689
- file: 'modules/government/config.js',
1690
- match: 'roles',
1691
- why: 'the legacy INSTANCE-config key fallback. The core ships `ranks`, but an '
1692
- + 'external self-hosted instance may still have a config/government.json '
1693
- + 'authored as `roles`; reading it is what stops their templates vanishing '
1694
- + 'on upgrade. Removing this line would break them silently.',
1695
- },
1696
- {
1697
- file: 'modules/government/config.js',
1698
- match: 'governance_role_labels',
1699
- why: 'the legacy ENV-VAR fallback. <PREFIX>_GOVERNMENT_RANK_LABELS is the current '
1700
- + 'name, but an operator sets this in their own shell or env file, outside this '
1701
- + 'repo, so the old spelling is still read. Dropping it would silently stop '
1702
- + 'honouring a label override someone already configured.',
1703
- },
1704
- {
1705
- file: 'modules/government/board.js',
1706
- match: 'governancepasscredit',
1707
- why: 'the R02 pass-event contract (docs/specs/board-room-pass-event-contract.md, agreed '
1708
- + 'cross-goal 2026-08-17) names the economy-side award awardIdeaGovernancePassCredit, '
1709
- + 'matching the credit_log reason family idea.credit.governance_pass:<id> that streams '
1710
- + '1-3 share. The board calls the CONSUMER\'s agreed method name — renaming it here '
1711
- + 'would break the contract, and the reason strings are durable ledger rows. Scoped to '
1712
- + 'the method name, so a stray "governance" elsewhere in board.js still reds the build.',
1713
- },
1714
- {
1715
- file: 'modules/government/module.json',
1716
- match: 'role',
1717
- why: 'the manifest states the rule itself — "The word `role` is deliberately '
1718
- + 'absent here". Meta-commentary about the ban has to be able to name it.',
1719
- },
1720
- ];
1721
-
1722
- // A line CITING a historical artifact by its real name: an ADR filename, the
1723
- // docs/governance/ evidence folder, or one of the already-applied
1724
- // governance_NNN migrations. These names are permanent (see the header), so a
1725
- // reference to one is a citation, not drift.
1726
- const VOCAB_HISTORICAL_REF = /docs\/adr\/|docs\/governance\/|docs\/session-logs\/|governance_0\d\d/;
1727
-
1728
- const VOCAB_SURFACE = [
1729
- 'modules/government',
1730
- 'modules/hall-ui/public/government.js',
1731
- 'modules/hall-ui/public/government.html',
1732
- 'modules/hall-ui/public/government.css',
1733
- ];
1734
- const VOCAB_EXTS = new Set(['.js', '.json', '.html', '.css']);
1735
- const VOCAB_BANNED = [
1736
- { re: /\brole/i, label: 'role', hint: '"role" is reserved for the crafts (Artist / Ideator / Builder). On this surface the noun is RANK.' },
1737
- { re: /governance/i, label: 'governance', hint: 'the pre-ADR-0174 name of this surface. It is the GOVERNMENT now.' },
1738
- ];
1739
-
1740
- function vocabFilesToScan() {
1741
- const out = [];
1742
- const walk = (abs, rel) => {
1743
- let st;
1744
- try { st = fs.statSync(abs); } catch { return; }
1745
- if (st.isDirectory()) {
1746
- // the historical migrations keep their names + bodies (see header)
1747
- if (rel.endsWith('/migrations')) return;
1748
- for (const e of fs.readdirSync(abs)) walk(path.join(abs, e), `${rel}/${e}`);
1749
- return;
1750
- }
1751
- if (VOCAB_EXTS.has(path.extname(abs))) out.push({ abs, rel });
1752
- };
1753
- for (const s of VOCAB_SURFACE) walk(path.join(CORE_ROOT, s), s);
1754
- return out;
1755
- }
1756
-
1757
- // ── Check 19b — the FOURTH and FIFTH "role": a membership is not a craft either ─
1758
- //
1759
- // task 1003013 / ADR 0177. ADR 0174 reserved `role` for the three crafts and R07
1760
- // (checkVocabularyWall above) enforces it on the GOVERNMENT surface — deliberately
1761
- // only there, because `builders.system_role` is a core machine-identity column and
1762
- // Discord's API owns its own "role" noun. Goal membership was a fourth,
1763
- // independent use of the word (`goal_members.role` = 'lead' | 'member'), which is
1764
- // exactly why it survived the rename: the rule was silent on it, as designed.
1765
- //
1766
- // So this is a SECOND narrow wall rather than a widened first one — same reason the
1767
- // first was scoped: the honest unit is a SURFACE, not the repo. Two surfaces carry
1768
- // a membership, and both now say `membership_kind`:
1769
- // • GOAL membership (`goal_members`, task 1003013) — the routes and pure gate that
1770
- // decide it, the hall page that renders it, and (line-scoped) the membership SQL
1771
- // in modules/lifecycle/db.js;
1772
- // • PROJECT membership on the hub (`platform_identity_project_memberships`, task
1773
- // 1003090) — whose old VALUE 'builder' was itself a craft name. Its check-in
1774
- // route keeps reading a legacy `role` field for the cross-version federation
1775
- // window (other instances upgrade on their own schedule), so the lines that
1776
- // handle that legacy field cite the task and are exempt like any other citation.
1777
- //
1778
- // Naming the rule stays legal — a line that cites ADR 0174 / ADR 0177 / task
1779
- // 1003013 is meta-commentary about the ban, the same principle as
1780
- // VOCAB_HISTORICAL_REF above. `role="…"` is stripped first: that is HTML's ARIA
1781
- // attribute, a different word spelled the same.
1782
- const GOAL_VOCAB_SURFACE = [
1783
- 'modules/lifecycle/routes/goals.js',
1784
- 'modules/lifecycle/goal-authz.js',
1785
- 'modules/lifecycle/routes/done-when.js',
1786
- 'modules/hall-ui/public/goals-page.js',
1787
- 'modules/hall-ui/public/goals.html',
1788
- 'modules/hall-ui/public/goals.css',
1789
- // The FIFTH use (task 1003090): the hub's coarse project membership.
1790
- 'modules/platform-identity/platform-identity.js',
1791
- 'modules/platform-identity/routes/my-projects.js',
1792
- 'modules/hall-ui/public/my-projects.js',
1793
- ];
1794
- // modules/lifecycle/db.js is 5k+ lines and legitimately says "role" about ranks
1795
- // (the rank-sync port, builders.system_role). Scanning it whole would need an
1796
- // allowlist longer than the rule; scanning the LINES THAT NAME THE TABLE catches
1797
- // the only thing that can regress here — a membership query reaching for the old
1798
- // column — with no false positives to excuse.
1799
- // task 1003204 (goal 1000079) split that 5k-line file into db.js (a re-export
1800
- // facade) + twelve db-*.js units, so the membership SQL is no longer in db.js at
1801
- // all. Enumerate the family from disk rather than naming one file: a line-scoped
1802
- // scan costs nothing on the units that hold no membership SQL, and a future
1803
- // re-carve cannot silently disarm the rule by moving the queries again.
1804
- const LIFECYCLE_DB_FILES = (() => {
1805
- const dir = path.join(CORE_ROOT, 'modules', 'lifecycle');
1806
- try {
1807
- return fs.readdirSync(dir)
1808
- .filter((f) => f === 'db.js' || (f.startsWith('db-') && f.endsWith('.js')))
1809
- .sort()
1810
- .map((f) => `modules/lifecycle/${f}`);
1811
- } catch { return ['modules/lifecycle/db.js']; }
1812
- })();
1813
- const GOAL_VOCAB_LINE_SCOPED = [
1814
- ...LIFECYCLE_DB_FILES.map((file) => ({ file, mustMention: /goal_members/ })),
1815
- // sso.js is the federation surface: only the lines that name the membership table
1816
- // or its upsert are in scope, so the file's other server-to-server prose stays out.
1817
- { file: 'modules/platform-identity/routes/sso.js', mustMention: /project_memberships|upsertMembership/ },
1818
- ];
1819
- const GOAL_VOCAB_CITATION = /ADR 017[47]|task 1003013|task 1003090/;
1820
-
1821
- function checkGoalMembershipVocabulary() {
1822
- const violations = [];
1823
- let scanned = 0;
1824
- const scanFile = (rel, lineFilter = null) => {
1825
- const abs = path.join(CORE_ROOT, rel);
1826
- let src;
1827
- try { src = fs.readFileSync(abs, 'utf8'); } catch { return; }
1828
- scanned += 1;
1829
- src.split('\n').forEach((line, i) => {
1830
- if (lineFilter && !lineFilter.test(line)) return;
1831
- const scanLine = line.replace(/\srole\s*=\s*("[^"]*"|'[^']*')/g, ' ');
1832
- if (!/\brole/i.test(scanLine)) return;
1833
- if (GOAL_VOCAB_CITATION.test(scanLine)) return;
1834
- violations.push(`${rel}:${i + 1}: "role" — a membership is a 'membership_kind' (ADR 0174 reserves "role" for the crafts; ADR 0177). → ${line.trim().slice(0, 90)}`);
1835
- });
1836
- };
1837
- for (const rel of GOAL_VOCAB_SURFACE) scanFile(rel);
1838
- for (const { file, mustMention } of GOAL_VOCAB_LINE_SCOPED) scanFile(file, mustMention);
1839
- return {
1840
- name: 'a membership is a "membership_kind", never a role (ADR 0177)',
1841
- ok: violations.length === 0,
1842
- hardFail: violations.length > 0,
1843
- violations,
1844
- warnings: [],
1845
- note: `${scanned} membership-surface file(s) scanned (${GOAL_VOCAB_SURFACE.length} whole — goal + project membership — plus `
1846
- + `${GOAL_VOCAB_LINE_SCOPED.length} line-scoped to the lines naming the membership tables); no allowlist — `
1847
- + 'a line that CITES the rule is the only exemption.',
1848
- };
1849
- }
1850
-
1851
- function checkVocabularyWall() {
1852
- const violations = [];
1853
- let scanned = 0;
1854
- for (const { abs, rel } of vocabFilesToScan()) {
1855
- scanned += 1;
1856
- const lines = fs.readFileSync(abs, 'utf8').split('\n');
1857
- lines.forEach((line, i) => {
1858
- // `role="…"` is HTML's own ARIA attribute — a different word that happens to
1859
- // be spelled the same. Strip attributes before matching rather than
1860
- // allowlisting each occurrence: this is a property of HTML, not of any one file.
1861
- const scanLine = line.replace(/\srole\s*=\s*("[^"]*"|'[^']*')/g, ' ');
1862
- for (const b of VOCAB_BANNED) {
1863
- if (!b.re.test(scanLine)) continue;
1864
- // A REFERENCE to a historical artifact by its real name is not a
1865
- // vocabulary violation — it is a citation. The artifacts themselves are
1866
- // excluded by construction (see the header); naming one has to be legal
1867
- // too, or the code could not point at the migration that created its own
1868
- // tables. Same principle, applied to the line rather than the file.
1869
- if (VOCAB_HISTORICAL_REF.test(scanLine)) continue;
1870
- const excused = VOCAB_ALLOW.some((a) => a.file === rel && scanLine.toLowerCase().includes(a.match));
1871
- if (excused) continue;
1872
- violations.push(`${rel}:${i + 1}: "${b.label}" — ${b.hint} → ${line.trim().slice(0, 90)}`);
1873
- }
1874
- });
1875
- }
1876
- return {
1877
- name: 'the word "role" means a craft, never a rank (ADR 0174)',
1878
- ok: violations.length === 0,
1879
- hardFail: violations.length > 0,
1880
- violations,
1881
- warnings: [],
1882
- note: `${scanned} government-surface file(s) scanned for "role"/"governance"; `
1883
- + `${VOCAB_ALLOW.length} documented allowlist entr(y|ies). Historical migrations + docs/adr are excluded by construction, not suppressed.`,
1884
- };
1885
- }
1886
-
1887
1268
  const CHECKS = [
1888
1269
  checkCoreHostBoundary,
1889
1270
  checkClaudeMdBudget,
@@ -1910,7 +1291,13 @@ const CHECKS = [
1910
1291
  checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
1911
1292
  require('./fitness-ratchets.js').checkQualityRatchets, // Check 20 — task 1003129: quality budgets only tighten (mechanics + knip CI step live in fitness-ratchets.js)
1912
1293
  require('./doc-cli-guard.js').checkDocNamesRealCli, // Check 22 — task 1003165 / G01: a doc that names a CLI invocation must match the real CLI
1913
- require('./skill-preflight.js').checkOpsSkillsDeclareMachine, require('./version-literal-guard.js').checkNoVersionLiterals, // Checks 23 (task 1003167/G03: a skill declares its machine) + 24 (task 1003166/G02: no script hardcodes a version id) — one line because fitness.js is 500 over the excess-lines ratchet and a new check must not cost a line (idea 1000810)
1294
+ require('./skill-preflight.js').checkOpsSkillsDeclareMachine, // Check 23 — task 1003167 / G03: an ops skill declares the machine it needs
1295
+ // Check 24 — task 1003166 / G02: no ops script hardcodes a version id. It landed
1296
+ // sharing Check 23's line, deliberately, because fitness.js was ~500 lines over
1297
+ // the excess-lines ratchet and a new check could not afford one (idea 1000810).
1298
+ // Task 1003206 carved this file from 2,004 lines to ~1,350, so that toll is paid
1299
+ // and the check gets its own line back.
1300
+ require('./version-literal-guard.js').checkNoVersionLiterals,
1914
1301
  require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
1915
1302
  ];
1916
1303