@tekyzinc/gsd-t 5.0.14 → 5.1.10
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 +13 -0
- package/README.md +1 -1
- package/bin/gsd-t-doc-marker.cjs +65 -0
- package/bin/gsd-t-env-registry-check.cjs +318 -0
- package/bin/gsd-t-env-registry.cjs +1046 -0
- package/bin/gsd-t-logging-scaffolder.cjs +6 -18
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +10 -0
- package/commands/gsd-t-architect.md +10 -0
- package/commands/gsd-t-init.md +6 -0
- package/commands/gsd-t-populate.md +5 -0
- package/package.json +1 -1
- package/scripts/gsd-t-architect-oversight-guard.js +5 -1
- package/templates/CLAUDE-global.md +32 -0
- package/templates/infrastructure.md +40 -0
- package/templates/workflows/gsd-t-phase.workflow.js +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.1.10] - 2026-07-13
|
|
6
|
+
|
|
7
|
+
### Added — Simply Stated Doctrine (clarity as a defect gate, the verbose-virus cure)
|
|
8
|
+
|
|
9
|
+
The recurring "verbose virus" — GSD-T deliverables that are dense, jargon-heavy, and unreadable (one architecture review drew "plain English please" five times in a single message) — has resisted every prior fix (Reader Contract, retired brevity Stop hook, jargon lint) because those treat verbosity as OUTPUT polish and are reminders that lose under load. Simply Stated is a different mechanism: it treats verbosity as a SYMPTOM of unclear thinking, and the same unclear thinking ships the bugs. Before GSD-T presents an architecture/plan/finding/milestone, it must state it simply first — every word load-bearing, straight-line logic, no jargon or nested clauses. If it can't, the thinking isn't done: RE-THINK, don't re-word. HALT-as-defect, not a soft nudge. "Simply" ≠ dumbed down — the content stays as sophisticated as the problem; only the expression is clean ("too sophisticated to simplify" is a banned escape hatch).
|
|
10
|
+
|
|
11
|
+
- `templates/CLAUDE-global.md` (+ `~/.claude/CLAUDE.md`): the doctrine (definition) — the clarity=thinking equivalence, the HALT, the anti-dumbing-down clause.
|
|
12
|
+
- `scripts/gsd-t-architect-oversight-guard.js`: the Write/Edit trigger reminder now carries the Simply-Stated challenge (reused the architect hook — no new hook).
|
|
13
|
+
- `templates/workflows/gsd-t-phase.workflow.js`: Stage 7 (Simply-Stated clarity gate) in the Six-Stage Pass — the plan isn't done until it has a clean simply-stated lead.
|
|
14
|
+
- `commands/gsd-t-architect.md`: Stage 7 in the pass + "Simply Stated" is now the REQUIRED FIRST LINE of the architect's session summary.
|
|
15
|
+
- `.gsd-t/contracts/architects-oversight-contract.md`: Stage 7 row.
|
|
16
|
+
- `test/m101-architect-oversight-hook.test.js`: 5 wiring tests (both templates, hook, workflow, command).
|
|
17
|
+
|
|
5
18
|
## [5.0.14] - 2026-07-13
|
|
6
19
|
|
|
7
20
|
### Added — No-Fallback-Ever Doctrine (never branch around a failure without asking)
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.
|
|
3
|
+
**v5.1.10** - 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.
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
module.exports = { upsertMarkedDocBlock };
|
|
@@ -0,0 +1,318 @@
|
|
|
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
|
+
const GATE_IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
62
|
+
const GATE_BARE_HOST_LABEL = /^[a-z][a-z-]{0,15}$/;
|
|
63
|
+
const GATE_VAR_REF = /^["']?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?["']?$/;
|
|
64
|
+
const GATE_UPPER_SNAKE = /^[A-Z][A-Z0-9_]*$/;
|
|
65
|
+
// An ISO-8601 timestamp (the `recorded` column) is structural, not a secret.
|
|
66
|
+
const GATE_ISO_TS = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
67
|
+
|
|
68
|
+
const GATE_AUTH_METHODS = new Set([
|
|
69
|
+
"password", "iam", "oauth", "oauth2", "service-account", "ssh-key",
|
|
70
|
+
"api-key", "none", "scram-sha-256", "md5", "trust", "token", "key",
|
|
71
|
+
"cert", "mtls", "kerberos", "ldap",
|
|
72
|
+
]);
|
|
73
|
+
const GATE_VAULTS = new Set([
|
|
74
|
+
"local", "local (.env)", ".env", "env",
|
|
75
|
+
"vercel", "neon", "gcp-secret-manager", "google secret manager",
|
|
76
|
+
"aws-secrets-manager", "aws secrets manager", "doppler", "1password",
|
|
77
|
+
"hashicorp-vault", "vault", "azure-key-vault", "infisical",
|
|
78
|
+
]);
|
|
79
|
+
const GATE_GOTCHA_ENUM = new Set(["vpn", "ip-allowlist", "ssh-tunnel", "bastion", "none"]);
|
|
80
|
+
const GATE_CLI_WORDS = new Set([
|
|
81
|
+
"psql", "mysql", "mysqldump", "mongo", "mongosh", "redis-cli", "sqlite3",
|
|
82
|
+
"pg_dump", "pg_restore", "pg_dumpall", "cqlsh", "clickhouse-client",
|
|
83
|
+
"neonctl", "vercel", "gcloud", "aws", "az", "doppler", "supabase",
|
|
84
|
+
"flyctl", "fly", "heroku", "railway", "wrangler", "turso", "op", "infisical",
|
|
85
|
+
"kubectl", "helm", "terraform", "vault",
|
|
86
|
+
"ssh", "scp", "sftp", "curl", "wget", "ldapsearch", "nc", "openssl", "rsync",
|
|
87
|
+
"env", "pull", "push", "list", "get", "set", "secrets", "versions",
|
|
88
|
+
"access", "version", "exec", "run", "connect", "login", "logout",
|
|
89
|
+
"connection-string", "db-url", "database-url", "redis-url",
|
|
90
|
+
"admin", "default", "latest", "read", "write", "describe", "show",
|
|
91
|
+
"from", "cat", "source", "printenv", "dotenv",
|
|
92
|
+
]);
|
|
93
|
+
const GATE_DOTFILE_TOKEN = /^\.[a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*$/;
|
|
94
|
+
|
|
95
|
+
function gateIsDbNameShape(s) {
|
|
96
|
+
if (typeof s !== "string" || s.length === 0 || s.length > 16) return false;
|
|
97
|
+
if (!/^[a-z][a-z0-9_]*$/.test(s)) return false;
|
|
98
|
+
if (/[0-9][a-z]/.test(s)) return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
function gateIsHostShape(s) {
|
|
102
|
+
if (s === "localhost") return true;
|
|
103
|
+
if (GATE_IPV4.test(s)) return true;
|
|
104
|
+
if (GATE_DOTTED_HOSTNAME.test(s)) return true;
|
|
105
|
+
if (GATE_BARE_HOST_LABEL.test(s)) return true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
function gateIsVarRef(tok) {
|
|
109
|
+
const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
|
|
110
|
+
return GATE_VAR_REF.test(tok) || /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(bare);
|
|
111
|
+
}
|
|
112
|
+
// A single command token is on the positive allowlist?
|
|
113
|
+
function gateCommandTokenOk(tok) {
|
|
114
|
+
const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
|
|
115
|
+
if (bare === "") return true;
|
|
116
|
+
if (gateIsVarRef(tok)) return true;
|
|
117
|
+
if (/^--?[A-Za-z][A-Za-z0-9-]*$/.test(bare)) return true; // bare flag
|
|
118
|
+
if (/^--?[A-Za-z][A-Za-z0-9-]*=/.test(bare)) {
|
|
119
|
+
const value = bare.slice(bare.indexOf("=") + 1);
|
|
120
|
+
if (value === "") return true;
|
|
121
|
+
return gateCommandTokenOk(value);
|
|
122
|
+
}
|
|
123
|
+
if (gateIsHostShape(bare)) return true;
|
|
124
|
+
if (GATE_DOTFILE_TOKEN.test(bare)) return true;
|
|
125
|
+
if (GATE_CLI_WORDS.has(bare.toLowerCase())) return true;
|
|
126
|
+
if (gateIsDbNameShape(bare)) return true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
function gateTokenizeCommand(s) {
|
|
130
|
+
const tokens = [];
|
|
131
|
+
const re = /"[^"]*"|'[^']*'|\S+/g;
|
|
132
|
+
let m;
|
|
133
|
+
while ((m = re.exec(s)) !== null) tokens.push(m[0]);
|
|
134
|
+
return tokens;
|
|
135
|
+
}
|
|
136
|
+
function gateCommandOk(cell) {
|
|
137
|
+
for (const tok of gateTokenizeCommand(cell)) {
|
|
138
|
+
if (!gateCommandTokenOk(tok)) return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
function gateGotchasOk(cell) {
|
|
143
|
+
const tokens = cell.split(/[,\s]+/).filter(Boolean);
|
|
144
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
145
|
+
const t = tokens[i].toLowerCase();
|
|
146
|
+
if (GATE_GOTCHA_ENUM.has(t)) continue;
|
|
147
|
+
if (t === "via") {
|
|
148
|
+
const next = tokens[i + 1];
|
|
149
|
+
if (!next || !gateIsHostShape(next)) return false;
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (gateIsHostShape(tokens[i])) continue;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The BACKSTOP leak test — the known-prefix/JWT/base64/hex detector + the
|
|
160
|
+
// embedded-cred regex. Applied to EVERY cell as an extra layer.
|
|
161
|
+
function cellHitsBackstop(cell) {
|
|
162
|
+
if (typeof cell !== "string" || !cell) return false;
|
|
163
|
+
if (looksLikeSecretValue(cell)) return true;
|
|
164
|
+
if (GATE_EMBEDDED_CRED.test(cell)) return true;
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// PRIMARY GUARD: is this cell the POSITIVE shape its column is supposed to hold?
|
|
169
|
+
// Returns true if the cell is VALID for its column. An empty cell / placeholder
|
|
170
|
+
// (`—`) is always valid. `col` is the column NAME.
|
|
171
|
+
function cellMatchesColumnShape(col, cell) {
|
|
172
|
+
if (typeof cell !== "string") return true;
|
|
173
|
+
const s = cell.trim();
|
|
174
|
+
if (s === "" || s === "—") return true;
|
|
175
|
+
switch (col) {
|
|
176
|
+
case "id":
|
|
177
|
+
// <scope>-<kind> — lowercase identifier with a hyphen.
|
|
178
|
+
return /^[a-z0-9][a-z0-9-]*$/.test(s);
|
|
179
|
+
case "scope":
|
|
180
|
+
return s === "local" || s === "staging" || s === "prod";
|
|
181
|
+
case "kind":
|
|
182
|
+
return /^[a-z0-9][a-z0-9-]*$/.test(s);
|
|
183
|
+
case "host":
|
|
184
|
+
return gateIsHostShape(s.replace(/:\d+$/, ""));
|
|
185
|
+
case "port":
|
|
186
|
+
return /^\d+$/.test(s);
|
|
187
|
+
case "db/name":
|
|
188
|
+
return gateIsDbNameShape(s);
|
|
189
|
+
case "auth method":
|
|
190
|
+
return GATE_AUTH_METHODS.has(s.toLowerCase());
|
|
191
|
+
case "secret vault":
|
|
192
|
+
return GATE_VAULTS.has(s.toLowerCase());
|
|
193
|
+
case "secret env-var NAME":
|
|
194
|
+
return GATE_UPPER_SNAKE.test(s);
|
|
195
|
+
case "fetch command":
|
|
196
|
+
case "connect command":
|
|
197
|
+
return gateCommandOk(s);
|
|
198
|
+
case "access gotchas":
|
|
199
|
+
return gateGotchasOk(s);
|
|
200
|
+
case "read-only default":
|
|
201
|
+
return s === "YES" || s === "NO";
|
|
202
|
+
case "recorded":
|
|
203
|
+
return GATE_ISO_TS.test(s);
|
|
204
|
+
default:
|
|
205
|
+
// Unknown column — fall back to refusing anything the backstop flags.
|
|
206
|
+
return !cellHitsBackstop(s);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The gate's leak test for a cell in a KNOWN column: FAIL if it is NOT the
|
|
211
|
+
// column's positive shape OR (backstop) it hits the known-prefix/embedded-cred
|
|
212
|
+
// detector. The positive shape is the PRIMARY guard.
|
|
213
|
+
function cellLeaks(col, cell) {
|
|
214
|
+
if (typeof cell !== "string" || !cell) return false;
|
|
215
|
+
if (!cellMatchesColumnShape(col, cell)) return true; // primary: wrong shape
|
|
216
|
+
if (cellHitsBackstop(cell)) return true; // backstop: extra layer
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readSafe(p) {
|
|
221
|
+
try {
|
|
222
|
+
return fs.readFileSync(p, "utf8");
|
|
223
|
+
} catch (_) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function splitRow(line) {
|
|
229
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
230
|
+
const cells = [];
|
|
231
|
+
let cur = "";
|
|
232
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
233
|
+
const ch = trimmed[i];
|
|
234
|
+
if (ch === "\\" && trimmed[i + 1] === "|") {
|
|
235
|
+
cur += "|";
|
|
236
|
+
i++;
|
|
237
|
+
} else if (ch === "|") {
|
|
238
|
+
cells.push(cur.trim());
|
|
239
|
+
cur = "";
|
|
240
|
+
} else {
|
|
241
|
+
cur += ch;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
cells.push(cur.trim());
|
|
245
|
+
return cells;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function check(projectDir) {
|
|
249
|
+
const infraPath = path.join(projectDir, "docs", "infrastructure.md");
|
|
250
|
+
const claudePath = path.join(projectDir, "CLAUDE.md");
|
|
251
|
+
|
|
252
|
+
const infra = readSafe(infraPath) || "";
|
|
253
|
+
const claude = readSafe(claudePath) || "";
|
|
254
|
+
|
|
255
|
+
const hasMarkers = infra.includes(ENV_MARKER_START) && infra.includes(ENV_MARKER_END);
|
|
256
|
+
// The env-access rule is identified by its stable marker phrase.
|
|
257
|
+
const hasRule = /Environment Access — read-first, HALT-and-document/.test(claude);
|
|
258
|
+
|
|
259
|
+
const failures = [];
|
|
260
|
+
|
|
261
|
+
// (b) rule present but table markers absent.
|
|
262
|
+
if (hasRule && !hasMarkers) {
|
|
263
|
+
failures.push(
|
|
264
|
+
"env-access rule is present in CLAUDE.md but the `## Environments` table markers are absent from docs/infrastructure.md"
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// (a) secret-shaped value in any row cell.
|
|
269
|
+
if (hasMarkers) {
|
|
270
|
+
const start = infra.indexOf(ENV_MARKER_START);
|
|
271
|
+
const end = infra.indexOf(ENV_MARKER_END);
|
|
272
|
+
const block = infra.slice(start, end);
|
|
273
|
+
const lines = block.split("\n");
|
|
274
|
+
for (const line of lines) {
|
|
275
|
+
if (!line.trim().startsWith("|")) continue;
|
|
276
|
+
const cells = splitRow(line);
|
|
277
|
+
if (cells[0] === "id") continue; // header
|
|
278
|
+
if (cells.every((c) => /^-{1,}$/.test(c) || c === "")) continue; // separator
|
|
279
|
+
for (let i = 0; i < cells.length; i++) {
|
|
280
|
+
const col = ENV_COLUMNS[i] || `col${i}`;
|
|
281
|
+
if (cellLeaks(col, cells[i])) {
|
|
282
|
+
failures.push(
|
|
283
|
+
`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`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
ok: failures.length === 0,
|
|
292
|
+
check: "env-registry",
|
|
293
|
+
hasMarkers,
|
|
294
|
+
hasRule,
|
|
295
|
+
failures,
|
|
296
|
+
note:
|
|
297
|
+
!hasMarkers && !hasRule
|
|
298
|
+
? "no-op PASS: project has not adopted the M102 Environments registry (no table, no rule)"
|
|
299
|
+
: undefined,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parseArgs(argv) {
|
|
304
|
+
const out = { projectDir: "." };
|
|
305
|
+
for (let i = 0; i < argv.length; i++) {
|
|
306
|
+
if (argv[i] === "--project") out.projectDir = argv[++i] || ".";
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = { check };
|
|
312
|
+
|
|
313
|
+
if (require.main === module) {
|
|
314
|
+
const { projectDir } = parseArgs(process.argv.slice(2));
|
|
315
|
+
const result = check(projectDir);
|
|
316
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
317
|
+
process.exit(result.ok ? 0 : 1);
|
|
318
|
+
}
|