@tekyzinc/gsd-t 5.1.13 → 5.2.11

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/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.2.11] - 2026-07-22
6
+
7
+ ### Changed — architect-overview cleanup (dead tool, shared reader, archived contracts)
8
+
9
+ Applied the three safe findings from a `/gsd-t-architect` framework-overview pass, and recorded a correction to a wrong finding.
10
+
11
+ - Deleted `bin/model-selector.js` + `test/model-selector.test.js` — the M35 model picker with zero live callers, superseded by M85's `bin/gsd-t-model-tier-policy.cjs`. Scrubbed 3 stale `selectModel()` doc references (`gsd-t-quick.md`, `gsd-t-test-sync.md`, `templates/CLAUDE-project.md`, `README.md`).
12
+ - Extracted a shared marker-block *reader* (`extractMarkedDocBlock`) into `bin/gsd-t-doc-marker.cjs` and rewired `bin/gsd-t-env-registry.cjs::extractBlock` to it — finishing the M102 writer/reader extraction. The verify-gate's independent copy is left intact on purpose (defensive-independence invariant — a writer bug must not disable the gate).
13
+ - Archived 13 stale `mXX-integration-points.md` contracts to `.gsd-t/milestones/_integration-points-archive/`. `m94-integration-points.md` was RESTORED — 3 live M94 tests read it by path (a live fixture, not archaeology).
14
+
15
+ Recorded in `progress.md`: the overview's "graph subsystem is broken sprawl" finding was a **false negative** — it checked the pre-M99 path `.gsd-t/graph.db`; the real store moved to `.gsd-t/graphDB/graph.db` (M99 consolidation, already shipped, with a canonical resolver). The graph builds and works in every project. No code change — the correction is the deliverable.
16
+
17
+ Suite: 3020/3020 pass, 13 skipped.
18
+
19
+ ## [5.2.10] - 2026-07-17
20
+
21
+ ### Added — Environment Registry (M102): the committed, secret-free connection map
22
+
23
+ Every project now gets ONE `## Environments` table in `docs/infrastructure.md` recording each environment's connection MAP (host/port/db/auth/which-vault-holds-the-secret/env-var-NAME/connect-command) — so the AI stops re-discovering non-local DB/server URLs, credentials, and permissions from scratch. Two triggers on one mechanism: **record-at-create** (greenfield — record the map as GSD-T builds the env) and **capture-on-first-need** (brownfield — first access with no row HALTs → detect → ask → record → add broad-glob permission → proceed; never guesses a connection string, never greps transcripts).
24
+
25
+ The hard invariant — **no secret VALUE ever in a committed cell** — took NINE Red Team cycles to converge. Value denylists (cycles 1-4) and a secret-flag-name denylist (5-6) both failed as open categories; a "safe value" slot regressed to an entropy floor (7). The converged design: a CLOSED **safe-label allowlist** (`-U`/`-d`/`-h`/`--port`/`--secret`) with **typed value shapes** (port=digits, host=host-shape, user/db=tight-db-name), a **strict command-hostname** (lowercase labels ≤24, alpha TLD — kills dotted-token laundering), and the entropy detector demoted to a dot-stripping backstop. A strong/random credential fails every typed shape → must be `$VAR`; a weak dictionary-word username passes (not a *recognizable* secret). Cycle 9 = GRUDGING-PASS (20k-command fuzz, zero writer/gate divergence).
26
+
27
+ - `bin/gsd-t-env-registry.cjs`: writer — converged command grammar, upsert-by-(scope,kind), `detectEnvConfig`, `addPermissionEntry`, `ensureEnvGitignored`, per-project local-literal switch, `detectCommandLeak`/`proposeRemediation`.
28
+ - `bin/gsd-t-env-registry-check.cjs`: independent verify gate — re-implements the same grammar so a writer bug can't disable it; overflow-column hard-fail; opted-in local-row exemption.
29
+ - `bin/gsd-t-doc-marker.cjs`: shared marker-block doc writer (extracted from the M100 logging scaffolder — no third copy).
30
+ - `bin/gsd-t-verify-gate.cjs`: env-registry FAIL-closed gate wired into the substrate track.
31
+ - `bin/gsd-t.js`: env-registry bins added to `PROJECT_BIN_TOOLS`.
32
+ - `commands/gsd-t-init.md`: record-at-create trigger + `ensure-gitignore`.
33
+ - `commands/gsd-t-populate.md`: capture-on-first-need HALT + leak-remediation + local-literal switch.
34
+ - `templates/infrastructure.md`: marker-delimited `## Environments` table (map-only columns).
35
+ - `templates/CLAUDE-global.md`: "Environment Access — read-first, HALT-and-document" rule.
36
+ - `.gsd-t/contracts/env-registry-contract.md` v1.1.0; `.gsd-t/pseudocode/PseudoCode-EnvironmentRegistry.md` §E (converged design).
37
+ - `test/m102-env-registry.test.js`: 274 tests — the full 9-cycle Red Team leak corpus as regressions.
38
+
39
+ A project may opt in to literal secrets in `scope=local` rows via `.gsd-t/env-registry-config.json` `{"allowLocalLiteral": true}` (staging/prod always strict). Repo suite 3077/0.
40
+
5
41
  ## [5.1.13] - 2026-07-15
6
42
 
7
43
  ### Changed — explicit "No Preambles" rule with banned openers
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.1.13** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.2.11** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -18,7 +18,7 @@
18
18
  **Rigorous User-Journey Coverage + Anti-Drift Test Quality** — `bin/journey-coverage.cjs` regex listener detector + `gsd-t check-coverage` CLI + `scripts/hooks/pre-commit-journey-coverage` commit gate blocks viewer-source commits when uncovered listeners exist. Journey specs in `e2e/journeys/` use functional assertions (zero `toBeVisible`-only tests) per the E2E Test Quality Standard in CLAUDE.md.
19
19
  **Universal Playwright Bootstrap + Deterministic UI Enforcement (M50)** — three executable enforcement layers: (1) `bin/playwright-bootstrap.cjs` + `bin/ui-detection.cjs` - idempotent installer detects package manager, installs `@playwright/test` + chromium, scaffolds `e2e/`; (2) Workflow runtime runs `playwright-bootstrap.cjs::installPlaywright()` before any E2E stage when `hasUI && !hasPlaywright`; install failure halts with `blocked-needs-human`; (3) `scripts/hooks/pre-commit-playwright-gate` (opt-in via `gsd-t doctor --install-hooks`) blocks viewer-source commits when staged files are newer than `.gsd-t/.last-playwright-pass`. The `gsd-t setup-playwright [path]` subcommand handles manual install.
20
20
  **Visualizer (`/gsd-t-visualize`)** — launches a real-time browser dashboard with dual-pane view: top pane streams the main session, bottom pane streams whichever spawn the user clicks. Left rail shows Live Spawns and Completed (last 100 spawns, status-badged, collapsible). Right rail shows Spawn Plan / Parallelism / Tool Cost. Powered by `gsd-t-stream-feed-server.js` + `gsd-t-dashboard.html`.
21
- **Surgical model selection** — `bin/model-selector.js` assigns haiku/sonnet/opus/fable per phase via a declarative rules table; `/advisor` escalation path with convention-based fallback. **M85 single-source tier policy:** `bin/gsd-t-model-tier-policy.cjs` is the SINGLE source of truth for model-tier assignments; the 5 highest-leverage stages (solution-space probe, partition probe, competition judge, pre-mortem, Red Team) run on `fable` (Claude Fable 5, tier above Opus); competition producers stay `opus` (M82 blindness); debug escalates cycle-1→opus, cycle-2→fable. Drift is mechanically enforced by the M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`). **M86 model profiles:** `bin/gsd-t-model-profile.cjs` adds a per-project SECOND dimension — three named profiles (`standard` / `pro` / `premium`) that control which stages run on Fable vs. Opus/Sonnet (see [Model Profiles](#model-profiles) below).
21
+ **Surgical model selection** — models are assigned haiku/sonnet/opus/fable per phase. **M85 single-source tier policy:** `bin/gsd-t-model-tier-policy.cjs` is the SINGLE source of truth for model-tier assignments; the 5 highest-leverage stages (solution-space probe, partition probe, competition judge, pre-mortem, Red Team) run on `fable` (Claude Fable 5, tier above Opus); competition producers stay `opus` (M82 blindness); debug escalates cycle-1→opus, cycle-2→fable. Drift is mechanically enforced by the M71-family lint (`test/m85-workflow-tier-policy-lint.test.js`). **M86 model profiles:** `bin/gsd-t-model-profile.cjs` adds a per-project SECOND dimension — three named profiles (`standard` / `pro` / `premium`) that control which stages run on Fable vs. Opus/Sonnet (see [Model Profiles](#model-profiles) below).
22
22
  **Token Telemetry** — `gsd-t-calibration-hook.js` records token usage per spawn to `.gsd-t/token-metrics.jsonl` (18-field rows). `gsd-t-token-aggregator.js` aggregates across tasks for the `/gsd-t-metrics` view. Use the native Claude Code `/context` command for live in-session context percentage.
23
23
  **Quality North Star** — projects define a `## Quality North Star` section in CLAUDE.md (1–3 sentences, e.g., "This is a published npm library. Every public API must be intuitive and backward-compatible."). `gsd-t-init` auto-detects preset (library/web-app/cli) from package.json signals; `gsd-t-setup` configures it for existing projects. Subagents read it as a quality lens; absent = silent skip (backward compatible).
24
24
  **Design Brief Artifact** — during partition, UI/frontend projects (React, Vue, Svelte, Flutter, Tailwind) automatically get `.gsd-t/contracts/design-brief.md` with color palette, typography, spacing system, component patterns, and tone/voice. Non-UI projects skip silently. User-customized briefs are preserved. Referenced in plan phase for visual consistency.
@@ -466,7 +466,6 @@ get-stuff-done-teams/
466
466
  │ ├── graph-*.js # Code graph engine (CGC/Neo4j integration)
467
467
  │ ├── journey-coverage.cjs # Listener detector + coverage gap reporting
468
468
  │ ├── playwright-bootstrap.cjs # Idempotent Playwright installer
469
- │ ├── model-selector.js # Phase-to-model assignment (haiku/sonnet/opus/fable)
470
469
  │ ├── gsd-t-model-tier-policy.cjs # M85: single-source tier policy (haiku/sonnet/opus/fable), resolver CLI
471
470
  │ ├── rule-engine.js # Declarative failure-pattern rules
472
471
  │ ├── patch-lifecycle.js # 5-stage patch candidate→graduated lifecycle
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+
3
+ // bin/gsd-t-doc-marker.cjs
4
+ //
5
+ // M102 — the ONE shared marker-block idempotent doc-writer. Extracted from the
6
+ // M100 logging scaffolder's private `writeChoiceToProjectDocs` (so there is a
7
+ // single writer, not two copies): both bin/gsd-t-logging-scaffolder.cjs and
8
+ // bin/gsd-t-env-registry.cjs call this.
9
+ //
10
+ // upsertMarkedDocBlock(targetPath, startMarker, endMarker, block):
11
+ // - If targetPath contains BOTH markers, the region between them (inclusive)
12
+ // is REPLACED with `block` (idempotent re-write).
13
+ // - Otherwise `block` is appended to the end of the file (creating the file
14
+ // and any missing parent dirs).
15
+ // - `block` MUST already begin with startMarker and end with endMarker — the
16
+ // caller composes the full block (same contract the M100 writer used).
17
+ //
18
+ // Zero external npm runtime deps — fs/path only. Symlink-guarded: refuses to
19
+ // write through a symlinked target (defense-in-depth, matches the installer's
20
+ // settings-writer scaffold).
21
+
22
+ const fs = require("fs");
23
+ const path = require("path");
24
+
25
+ function isSymlink(filePath) {
26
+ try {
27
+ return fs.lstatSync(filePath).isSymbolicLink();
28
+ } catch (_) {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ // Replace the [startMarker … endMarker] region with `block`, or append it.
34
+ // Returns the written targetPath. Idempotent: a second call with the same
35
+ // block leaves the file byte-identical.
36
+ function upsertMarkedDocBlock(targetPath, startMarker, endMarker, block) {
37
+ if (!targetPath) throw new Error("upsertMarkedDocBlock: targetPath is required");
38
+ if (!startMarker || !endMarker) throw new Error("upsertMarkedDocBlock: start/end markers are required");
39
+ if (typeof block !== "string") throw new Error("upsertMarkedDocBlock: block must be a string");
40
+
41
+ if (isSymlink(targetPath)) {
42
+ throw new Error(`upsertMarkedDocBlock: refusing to write through symlinked target: ${targetPath}`);
43
+ }
44
+
45
+ let content = "";
46
+ if (fs.existsSync(targetPath)) {
47
+ content = fs.readFileSync(targetPath, "utf8");
48
+ } else {
49
+ const dir = path.dirname(targetPath);
50
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
51
+ }
52
+
53
+ if (content.includes(startMarker) && content.includes(endMarker)) {
54
+ const startIdx = content.indexOf(startMarker);
55
+ const endIdx = content.indexOf(endMarker) + endMarker.length;
56
+ content = content.slice(0, startIdx) + block + content.slice(endIdx);
57
+ } else {
58
+ content = content.replace(/\s*$/, "") + "\n\n" + block + "\n";
59
+ }
60
+
61
+ fs.writeFileSync(targetPath, content, "utf8");
62
+ return targetPath;
63
+ }
64
+
65
+ // Read the marked block back OUT of `content`. Returns the slice from the START
66
+ // marker up to (but NOT including) the END marker — i.e. the block body a parser
67
+ // walks line-by-line — or null if either marker is absent. The complement of
68
+ // upsertMarkedDocBlock's write. (Shared so a reader isn't re-implemented per
69
+ // caller — the env-registry writer/reader used to copy this inline.)
70
+ //
71
+ // NOTE: the verify GATE (gsd-t-env-registry-check.cjs) deliberately does NOT use
72
+ // this — it re-implements the extract independently so a bug here cannot disable
73
+ // the gate (M102 defensive-independence invariant). That duplication is
74
+ // load-bearing; do not "consolidate" it.
75
+ function extractMarkedDocBlock(content, startMarker, endMarker) {
76
+ if (typeof content !== "string" || !startMarker || !endMarker) return null;
77
+ if (!content.includes(startMarker) || !content.includes(endMarker)) return null;
78
+ const start = content.indexOf(startMarker);
79
+ const end = content.indexOf(endMarker);
80
+ return content.slice(start, end);
81
+ }
82
+
83
+ module.exports = { upsertMarkedDocBlock, extractMarkedDocBlock };
@@ -0,0 +1,449 @@
1
+ "use strict";
2
+
3
+ // bin/gsd-t-env-registry-check.cjs
4
+ //
5
+ // M102 D3 — the deterministic verify-gate lint for the Environment Registry.
6
+ // FAIL-CLOSED. Two failure conditions (spec D3):
7
+ // (a) any Environments row cell is NOT the POSITIVE shape its column is
8
+ // supposed to hold (a value that is not a real hostname / not the enum /
9
+ // not a $VAR / not a curated command token = a probable inline secret);
10
+ // (b) the env-access DOC RULE is present in the project's CLAUDE.md but the
11
+ // `## Environments` table MARKERS are absent from docs/infrastructure.md
12
+ // (rule promises a registry the doc doesn't provide).
13
+ //
14
+ // A project that has neither the table nor the rule is a NO-OP PASS (it simply
15
+ // hasn't adopted M102 yet) — distinguishable from a wired-but-broken FAIL.
16
+ //
17
+ // Zero external npm runtime deps — fs/path only.
18
+ //
19
+ // PRIMARY GUARD = POSITIVE PER-COLUMN SHAPE (re-derived here, NOT a call into
20
+ // the writer). The gate RE-IMPLEMENTS the positive shapes so a writer bug can
21
+ // never silently disable it. It maps each cell to its column and requires the
22
+ // cell to BE the shape that column holds:
23
+ // - host → localhost / IPv4 / dotted-DNS / short lowercase service label
24
+ // - db/name → short lowercase snake identifier (≤16, no digit→letter)
25
+ // - auth method → enumerated auth-method name
26
+ // - fetch/connect command → every token is $VAR / flag / hostname / curated word
27
+ // - access gotchas → enumerated (vpn|ip-allowlist|ssh-tunnel|bastion|none) + via host
28
+ // - secret vault → enumerated vault name
29
+ // - secret env-var NAME → UPPER_SNAKE
30
+ // A cell that is NOT its column's positive shape → FAIL.
31
+ //
32
+ // BACKSTOP (extra layer, applied to EVERY cell regardless of column): the
33
+ // imported known-prefix/JWT/base64/hex `looksLikeSecretValue` + the gate's OWN
34
+ // embedded-credential regex. These are NOT the primary guard — the positive
35
+ // per-column shape is. A secret that somehow matched a positive shape (it can't
36
+ // by construction) would still trip these.
37
+
38
+ const fs = require("fs");
39
+ const path = require("path");
40
+
41
+ const {
42
+ ENV_MARKER_START,
43
+ ENV_MARKER_END,
44
+ ENV_COLUMNS,
45
+ looksLikeSecretValue,
46
+ } = require("./gsd-t-env-registry.cjs");
47
+
48
+ // The gate ALSO carries its own inline embedded-credential detector so it is
49
+ // not solely dependent on the imported symbol — a proto://user:pw@ literal in
50
+ // ANY cell fails independently of the writer's classification.
51
+ const GATE_EMBEDDED_CRED = /[a-z][a-z0-9+.\-]*:\/\/[^\s:/@]+:(?!\$)[^\s:/@]+@/i;
52
+
53
+ // ─── Gate's OWN re-implemented POSITIVE shapes (independent of the writer) ────
54
+ //
55
+ // Deliberately re-declared here (not imported) so the gate is a genuinely
56
+ // independent implementation — a bug in the writer's shapes cannot disable the
57
+ // gate's.
58
+
59
+ const GATE_DOTTED_HOSTNAME =
60
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
61
+ // STRICT command hostname (cycle-8 mirror): lowercase labels ≤24, alpha TLD —
62
+ // so a dotted high-entropy token cannot launder through the command grammar.
63
+ const GATE_STRICT_CMD_HOSTNAME =
64
+ /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?\.)+[a-z]{2,}$/;
65
+ const GATE_IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
66
+ const GATE_BARE_HOST_LABEL = /^[a-z][a-z-]{0,15}$/;
67
+ const GATE_VAR_REF = /^["']?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?["']?$/;
68
+ const GATE_UPPER_SNAKE = /^[A-Z][A-Z0-9_]*$/;
69
+ // An ISO-8601 timestamp (the `recorded` column) is structural, not a secret.
70
+ const GATE_ISO_TS = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
71
+
72
+ const GATE_AUTH_METHODS = new Set([
73
+ "password", "iam", "oauth", "oauth2", "service-account", "ssh-key",
74
+ "api-key", "none", "scram-sha-256", "md5", "trust", "token", "key",
75
+ "cert", "mtls", "kerberos", "ldap",
76
+ ]);
77
+ const GATE_VAULTS = new Set([
78
+ "local", "local (.env)", ".env", "env",
79
+ "vercel", "neon", "gcp-secret-manager", "google secret manager",
80
+ "aws-secrets-manager", "aws secrets manager", "doppler", "1password",
81
+ "hashicorp-vault", "vault", "azure-key-vault", "infisical",
82
+ ]);
83
+ const GATE_GOTCHA_ENUM = new Set(["vpn", "ip-allowlist", "ssh-tunnel", "bastion", "none"]);
84
+ const GATE_CLI_WORDS = new Set([
85
+ "psql", "mysql", "mysqldump", "mongo", "mongosh", "redis-cli", "sqlite3",
86
+ "pg_dump", "pg_restore", "pg_dumpall", "cqlsh", "clickhouse-client",
87
+ "neonctl", "vercel", "gcloud", "aws", "az", "doppler", "supabase",
88
+ "flyctl", "fly", "heroku", "railway", "wrangler", "turso", "op", "infisical",
89
+ "kubectl", "helm", "terraform", "vault",
90
+ "ssh", "scp", "sftp", "curl", "wget", "ldapsearch", "nc", "openssl", "rsync",
91
+ "env", "pull", "push", "list", "get", "set", "secrets", "versions",
92
+ "access", "version", "exec", "run", "connect", "login", "logout",
93
+ "connection-string", "db-url", "database-url", "redis-url",
94
+ "admin", "default", "latest", "read", "write", "describe", "show",
95
+ "from", "cat", "source", "printenv", "dotenv",
96
+ ]);
97
+ const GATE_DOTFILE_TOKEN = /^\.[a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*$/;
98
+
99
+ function gateIsDbNameShape(s) {
100
+ if (typeof s !== "string" || s.length === 0 || s.length > 16) return false;
101
+ if (!/^[a-z][a-z0-9_]*$/.test(s)) return false;
102
+ if (/[0-9][a-z]/.test(s)) return false;
103
+ return true;
104
+ }
105
+ function gateIsHostShape(s) {
106
+ if (s === "localhost") return true;
107
+ if (GATE_IPV4.test(s)) return true;
108
+ if (GATE_DOTTED_HOSTNAME.test(s)) return true;
109
+ if (GATE_BARE_HOST_LABEL.test(s)) return true;
110
+ return false;
111
+ }
112
+ function gateIsVarRef(tok) {
113
+ const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
114
+ return GATE_VAR_REF.test(tok) || /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(bare);
115
+ }
116
+ function gateIsBareVarRef(value) {
117
+ return GATE_VAR_REF.test(value) || /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(value);
118
+ }
119
+
120
+ // ─── CONVERGED command grammar — SAFE-LABEL ALLOWLIST (mirror of the writer) ──
121
+ //
122
+ // Independently re-declared here (a writer bug must not disable the gate). The
123
+ // primitive: a bare identifier value is safe ONLY right after an allowlisted
124
+ // value-flag (--user/--dbname/--host/--port/--secret). After ANY other flag —
125
+ // and for any standalone bare arg — the value must be a $VAR or a provably-safe
126
+ // shape (dotted host / IPv4 / localhost / .env dotfile / curated CLI word). This
127
+ // closes the whole `--password swordfish` / `-pS3cret` / `--pw=x` leak family:
128
+ // those flags are NOT on the safe list, so their value must be a $VAR.
129
+
130
+ // Each value-flag maps to a TYPED shape (mirror of the writer). A bare value is
131
+ // allowed ONLY if it matches that flag's tight shape — NOT a wide identifier
132
+ // (cycle-7 leak). A strong/random credential fails and must be a $VAR.
133
+ const GATE_VALUE_FLAG_SHAPE = {
134
+ u: "dbname", user: "dbname", username: "dbname",
135
+ d: "dbname", dbname: "dbname", db: "dbname", database: "dbname",
136
+ h: "host", host: "host", hostname: "host",
137
+ port: "port",
138
+ secret: "dbname",
139
+ };
140
+ function gateFlagBareName(tok) {
141
+ return tok.replace(/^--?/, "").toLowerCase();
142
+ }
143
+ function gateFlagValueMatchesTypedShape(name, bareVal) {
144
+ const shape = GATE_VALUE_FLAG_SHAPE[name];
145
+ if (shape === "port") return /^\d+$/.test(bareVal);
146
+ if (shape === "host") {
147
+ return bareVal === "localhost" || GATE_IPV4.test(bareVal) ||
148
+ GATE_STRICT_CMD_HOSTNAME.test(bareVal) || GATE_BARE_HOST_LABEL.test(bareVal);
149
+ }
150
+ if (shape === "dbname") return gateIsDbNameShape(bareVal);
151
+ return false;
152
+ }
153
+ // A bare flag NAME with no attached value: --long, or a single-letter -X. A
154
+ // multi-char single-dash token (`-pMyPass`) is a GLUED flag+value, NOT a bare
155
+ // flag — it must be classified as flag+value, never as a value-less flag name.
156
+ function gateIsBareFlag(bare) {
157
+ if (/^--[A-Za-z][A-Za-z0-9-]*$/.test(bare)) return true;
158
+ if (/^-[A-Za-z]$/.test(bare)) return true;
159
+ return false;
160
+ }
161
+ // SAFE non-$VAR shapes ANY token may take: dotted-DNS / IPv4 / localhost / .env
162
+ // dotfile / curated CLI word. NO bare host-label, NO db-name-shape.
163
+ const GATE_SAFE_URL =
164
+ /^[a-z][a-z0-9+.\-]*:\/\/(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|(?:[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?\.)+[a-z]{2,})(?::\d+)?(?:[/?#]\S*)?$/;
165
+ function gateIsSafeNonSecretToken(bare) {
166
+ if (bare === "localhost") return true;
167
+ if (GATE_IPV4.test(bare)) return true;
168
+ if (GATE_STRICT_CMD_HOSTNAME.test(bare)) return true; // strict, not loose
169
+ if (GATE_SAFE_URL.test(bare)) return true;
170
+ if (GATE_DOTFILE_TOKEN.test(bare)) return true;
171
+ if (GATE_CLI_WORDS.has(bare.toLowerCase())) return true;
172
+ return false;
173
+ }
174
+ // A flag's VALUE (attached, glued, or next-token). An allowlisted value-flag may
175
+ // carry a bare identifier; every other flag's value must be $VAR or a safe shape.
176
+ function gateFlagValueOk(name, value) {
177
+ const bareVal = value.replace(/^["']/, "").replace(/["']$/, "");
178
+ if (gateIsBareVarRef(value)) return true;
179
+ if (gateIsSafeNonSecretToken(bareVal)) return true;
180
+ if (Object.prototype.hasOwnProperty.call(GATE_VALUE_FLAG_SHAPE, name) &&
181
+ gateFlagValueMatchesTypedShape(name, bareVal)) return true;
182
+ return false;
183
+ }
184
+ // A single command token WITHOUT adjacency (attached flag values + standalone).
185
+ function gateCommandTokenOk(tok) {
186
+ const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
187
+ if (bare === "") return true;
188
+ if (gateIsVarRef(tok)) return true;
189
+ // --flag=value
190
+ const eqMatch = bare.match(/^(--?[A-Za-z][A-Za-z0-9-]*)=(.*)$/);
191
+ if (eqMatch) {
192
+ const name = gateFlagBareName(eqMatch[1]);
193
+ const value = eqMatch[2];
194
+ if (value === "") return true;
195
+ return gateFlagValueOk(name, value);
196
+ }
197
+ // glued short flag -pVALUE
198
+ const gluedMatch = bare.match(/^(-[A-Za-z])(.+)$/);
199
+ if (gluedMatch) {
200
+ return gateFlagValueOk(gateFlagBareName(gluedMatch[1]), gluedMatch[2]);
201
+ }
202
+ if (gateIsBareFlag(bare)) return true;
203
+ if (gateIsSafeNonSecretToken(bare)) return true;
204
+ return false;
205
+ }
206
+ function gateTokenizeCommand(s) {
207
+ const tokens = [];
208
+ const re = /"[^"]*"|'[^']*'|\S+/g;
209
+ let m;
210
+ while ((m = re.exec(s)) !== null) tokens.push(m[0]);
211
+ return tokens;
212
+ }
213
+ // Adjacency-aware: a bare flag NAME followed by a non-flag value forces that
214
+ // value through gateFlagValueOk with the flag's name (so `-U binvoice` passes,
215
+ // `--password swordfish` fails).
216
+ function gateCommandOk(cell) {
217
+ const tokens = gateTokenizeCommand(cell);
218
+ for (let i = 0; i < tokens.length; i++) {
219
+ const tok = tokens[i];
220
+ const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
221
+ if (gateIsBareFlag(bare) && !gateIsVarRef(tok)) {
222
+ const next = tokens[i + 1];
223
+ if (next !== undefined) {
224
+ const nextBare = next.replace(/^["']/, "").replace(/["']$/, "");
225
+ if (!/^-/.test(nextBare)) {
226
+ if (!gateFlagValueOk(gateFlagBareName(bare), next)) return false;
227
+ i++;
228
+ continue;
229
+ }
230
+ }
231
+ continue;
232
+ }
233
+ if (!gateCommandTokenOk(tok)) return false;
234
+ }
235
+ return true;
236
+ }
237
+ function gateGotchasOk(cell) {
238
+ const tokens = cell.split(/[,\s]+/).filter(Boolean);
239
+ for (let i = 0; i < tokens.length; i++) {
240
+ const t = tokens[i].toLowerCase();
241
+ if (GATE_GOTCHA_ENUM.has(t)) continue;
242
+ if (t === "via") {
243
+ const next = tokens[i + 1];
244
+ if (!next || !gateIsHostShape(next)) return false;
245
+ i++;
246
+ continue;
247
+ }
248
+ if (gateIsHostShape(tokens[i])) continue;
249
+ return false;
250
+ }
251
+ return true;
252
+ }
253
+
254
+ // The BACKSTOP leak test — the known-prefix/JWT/base64/hex detector + the
255
+ // embedded-cred regex. Applied to EVERY cell as an extra layer.
256
+ function cellHitsBackstop(cell) {
257
+ if (typeof cell !== "string" || !cell) return false;
258
+ if (looksLikeSecretValue(cell)) return true;
259
+ if (GATE_EMBEDDED_CRED.test(cell)) return true;
260
+ return false;
261
+ }
262
+
263
+ // PRIMARY GUARD: is this cell the POSITIVE shape its column is supposed to hold?
264
+ // Returns true if the cell is VALID for its column. An empty cell / placeholder
265
+ // (`—`) is always valid. `col` is the column NAME.
266
+ function cellMatchesColumnShape(col, cell) {
267
+ if (typeof cell !== "string") return true;
268
+ const s = cell.trim();
269
+ if (s === "" || s === "—") return true;
270
+ switch (col) {
271
+ case "id":
272
+ // <scope>-<kind> — lowercase identifier with a hyphen.
273
+ return /^[a-z0-9][a-z0-9-]*$/.test(s);
274
+ case "scope":
275
+ return s === "local" || s === "staging" || s === "prod";
276
+ case "kind":
277
+ return /^[a-z0-9][a-z0-9-]*$/.test(s);
278
+ case "host":
279
+ return gateIsHostShape(s.replace(/:\d+$/, ""));
280
+ case "port":
281
+ return /^\d+$/.test(s);
282
+ case "db/name":
283
+ return gateIsDbNameShape(s);
284
+ case "auth method":
285
+ return GATE_AUTH_METHODS.has(s.toLowerCase());
286
+ case "secret vault":
287
+ return GATE_VAULTS.has(s.toLowerCase());
288
+ case "secret env-var NAME":
289
+ return GATE_UPPER_SNAKE.test(s);
290
+ case "fetch command":
291
+ case "connect command":
292
+ return gateCommandOk(s);
293
+ case "access gotchas":
294
+ return gateGotchasOk(s);
295
+ case "read-only default":
296
+ return s === "YES" || s === "NO";
297
+ case "recorded":
298
+ return GATE_ISO_TS.test(s);
299
+ default:
300
+ // Unknown column — fall back to refusing anything the backstop flags.
301
+ return !cellHitsBackstop(s);
302
+ }
303
+ }
304
+
305
+ // The gate's leak test for a cell in a KNOWN column: FAIL if it is NOT the
306
+ // column's positive shape OR (backstop) it hits the known-prefix/embedded-cred
307
+ // detector. The positive shape is the PRIMARY guard.
308
+ function cellLeaks(col, cell) {
309
+ if (typeof cell !== "string" || !cell) return false;
310
+ if (!cellMatchesColumnShape(col, cell)) return true; // primary: wrong shape
311
+ if (cellHitsBackstop(cell)) return true; // backstop: extra layer
312
+ return false;
313
+ }
314
+
315
+ function readSafe(p) {
316
+ try {
317
+ return fs.readFileSync(p, "utf8");
318
+ } catch (_) {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ function splitRow(line) {
324
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
325
+ const cells = [];
326
+ let cur = "";
327
+ for (let i = 0; i < trimmed.length; i++) {
328
+ const ch = trimmed[i];
329
+ if (ch === "\\" && trimmed[i + 1] === "|") {
330
+ cur += "|";
331
+ i++;
332
+ } else if (ch === "|") {
333
+ cells.push(cur.trim());
334
+ cur = "";
335
+ } else {
336
+ cur += ch;
337
+ }
338
+ }
339
+ cells.push(cur.trim());
340
+ return cells;
341
+ }
342
+
343
+ // Per-project local-literal switch (mirror of the writer). When a project opts
344
+ // in, a `scope=local` row is EXEMPT from the secret-leak check (the user's
345
+ // testing convenience). staging/prod are ALWAYS checked. The OVERFLOW-column
346
+ // (corrupt-schema) check is NEVER exempted — a malformed table fails regardless.
347
+ function readAllowLocalLiteral(projectDir) {
348
+ const p = path.join(projectDir, ".gsd-t", "env-registry-config.json");
349
+ try {
350
+ const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
351
+ return !!(cfg && cfg.allowLocalLiteral === true);
352
+ } catch (_) {
353
+ return false; // absent / invalid → strict (no silent relax)
354
+ }
355
+ }
356
+
357
+ function check(projectDir) {
358
+ const infraPath = path.join(projectDir, "docs", "infrastructure.md");
359
+ const claudePath = path.join(projectDir, "CLAUDE.md");
360
+
361
+ const infra = readSafe(infraPath) || "";
362
+ const claude = readSafe(claudePath) || "";
363
+ const allowLocalLiteral = readAllowLocalLiteral(projectDir);
364
+
365
+ const hasMarkers = infra.includes(ENV_MARKER_START) && infra.includes(ENV_MARKER_END);
366
+ // The env-access rule is identified by its stable marker phrase.
367
+ const hasRule = /Environment Access — read-first, HALT-and-document/.test(claude);
368
+
369
+ const failures = [];
370
+
371
+ // (b) rule present but table markers absent.
372
+ if (hasRule && !hasMarkers) {
373
+ failures.push(
374
+ "env-access rule is present in CLAUDE.md but the `## Environments` table markers are absent from docs/infrastructure.md"
375
+ );
376
+ }
377
+
378
+ // (a) secret-shaped value in any row cell.
379
+ if (hasMarkers) {
380
+ const start = infra.indexOf(ENV_MARKER_START);
381
+ const end = infra.indexOf(ENV_MARKER_END);
382
+ const block = infra.slice(start, end);
383
+ const lines = block.split("\n");
384
+ for (const line of lines) {
385
+ if (!line.trim().startsWith("|")) continue;
386
+ const cells = splitRow(line);
387
+ if (cells[0] === "id") continue; // header
388
+ if (cells.every((c) => /^-{1,}$/.test(c) || c === "")) continue; // separator
389
+ // Local-literal exemption: a scope=local row (cells[1]) in an opted-in
390
+ // project skips the SECRET-leak check (but NOT the overflow/corruption
391
+ // check below — a malformed schema always fails).
392
+ const rowScope = (cells[1] || "").trim();
393
+ const exemptSecrets = allowLocalLiteral && rowScope === "local";
394
+ // An OVERFLOW cell (index ≥ the fixed 14-column schema) is itself a
395
+ // corruption signal — a hand-edit/merge/tool that appended a 15th column
396
+ // could hide a plaintext secret in a column the shape-map doesn't cover.
397
+ // The old `col${i}` default branch fell back to the WEAK backstop only
398
+ // (leak #3). A cell beyond the schema is now a HARD FAIL, no exceptions.
399
+ for (let i = 0; i < cells.length; i++) {
400
+ if (i >= ENV_COLUMNS.length) {
401
+ if (cells[i] !== "" && cells[i] !== "—") {
402
+ failures.push(
403
+ `Environments row has an extra cell beyond the fixed ${ENV_COLUMNS.length}-column schema ` +
404
+ `(index ${i}): "${cells[i]}" — the table shape is corrupt; a secret could hide in an ` +
405
+ `unmapped column. Restore the exact ${ENV_COLUMNS.length}-column schema.`
406
+ );
407
+ }
408
+ continue;
409
+ }
410
+ if (exemptSecrets) continue; // opted-in local row — skip secret-leak check
411
+ const col = ENV_COLUMNS[i];
412
+ if (cellLeaks(col, cells[i])) {
413
+ failures.push(
414
+ `Environments row cell (${col}) contains a secret-shaped literal value: "${cells[i]}" — record the env-var NAME and a $VAR reference, never a literal secret`
415
+ );
416
+ }
417
+ }
418
+ }
419
+ }
420
+
421
+ return {
422
+ ok: failures.length === 0,
423
+ check: "env-registry",
424
+ hasMarkers,
425
+ hasRule,
426
+ failures,
427
+ note:
428
+ !hasMarkers && !hasRule
429
+ ? "no-op PASS: project has not adopted the M102 Environments registry (no table, no rule)"
430
+ : undefined,
431
+ };
432
+ }
433
+
434
+ function parseArgs(argv) {
435
+ const out = { projectDir: "." };
436
+ for (let i = 0; i < argv.length; i++) {
437
+ if (argv[i] === "--project") out.projectDir = argv[++i] || ".";
438
+ }
439
+ return out;
440
+ }
441
+
442
+ module.exports = { check };
443
+
444
+ if (require.main === module) {
445
+ const { projectDir } = parseArgs(process.argv.slice(2));
446
+ const result = check(projectDir);
447
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
448
+ process.exit(result.ok ? 0 : 1);
449
+ }