instar 1.3.734 → 1.3.735

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.
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * class-closure-declare — the AUTHOR's declaration helper for the Class-Closure
4
+ * Gate (docs/specs/class-closure-gate.md → Piece 1 host). It merges a
5
+ * `classClosure` block into the MOST-RECENT committed decision-audit entry
6
+ * (`.instar/instar-dev-decisions/<ts>-<slug>.json`) — the machine-readable host
7
+ * the gate lint validates.
8
+ *
9
+ * Self-contained ESM (no build step), idempotent (re-running with the same block
10
+ * is a no-op). The block is supplied EITHER as a whole JSON string via
11
+ * `--json '{"defectClass":"...","closure":"guard",...}'`, OR assembled from flags:
12
+ *
13
+ * node scripts/class-closure-declare.mjs \
14
+ * --class injection-credulity --closure guard \
15
+ * --citation src/core/promptClauses.ts#authorityClause \
16
+ * --enforcement gate --how-caught "the authority clause separates ..." \
17
+ * --pr 1290 --component CoherenceReviewer
18
+ *
19
+ * For a gap: `--closure gap --gap-item ACT-1234`.
20
+ * For a novel class: `--class novel --novel-json '{"nearestExistingClass":"...","includes":[...],"excludes":[...],"severity":"normal"}'`.
21
+ *
22
+ * It only WRITES the review host; it does not touch the registry or grade the
23
+ * guard (the lint does the grading). Prints the merged block to stdout.
24
+ */
25
+
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+
29
+ const DECISIONS_REL = path.join('.instar', 'instar-dev-decisions');
30
+
31
+ /** Parse `--key value` / `--flag` pairs into a plain object. */
32
+ export function parseArgs(argv) {
33
+ const out = {};
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (!a.startsWith('--')) continue;
37
+ const key = a.slice(2);
38
+ const next = argv[i + 1];
39
+ if (next === undefined || next.startsWith('--')) {
40
+ out[key] = true;
41
+ } else {
42
+ out[key] = next;
43
+ i++;
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Build a classClosure block from parsed args. Throws on a structurally-invalid
51
+ * request (so a bad declaration is caught at author time, not just at lint time).
52
+ * @returns {object} the classClosure block.
53
+ */
54
+ export function buildClassClosureBlock(args) {
55
+ if (args.json) {
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(args.json);
59
+ } catch (err) {
60
+ throw new Error(`--json is not valid JSON: ${err && err.message ? err.message : String(err)}`);
61
+ }
62
+ if (!parsed || typeof parsed !== 'object') throw new Error('--json must be an object');
63
+ return normalizeBlock(parsed);
64
+ }
65
+
66
+ const block = {};
67
+ if (!args.class || typeof args.class !== 'string') throw new Error('--class <id|novel> is required');
68
+ block.defectClass = args.class;
69
+ const closure = args.closure;
70
+ if (closure !== 'guard' && closure !== 'gap') throw new Error("--closure must be 'guard' or 'gap'");
71
+ block.closure = closure;
72
+
73
+ if (closure === 'guard') {
74
+ if (!args.citation || typeof args.citation !== 'string') throw new Error("closure 'guard' requires --citation <path|route|symbol>");
75
+ block.guardEvidence = {
76
+ enforcementType: typeof args.enforcement === 'string' ? args.enforcement : undefined,
77
+ citation: args.citation,
78
+ howCaught: typeof args['how-caught'] === 'string' ? args['how-caught'] : undefined,
79
+ };
80
+ } else {
81
+ if (!args['gap-item'] || typeof args['gap-item'] !== 'string') throw new Error("closure 'gap' requires --gap-item <evolution-action-id>");
82
+ block.gapItem = args['gap-item'];
83
+ }
84
+
85
+ if (args.pr !== undefined) {
86
+ const n = Number(args.pr);
87
+ if (!Number.isFinite(n)) throw new Error('--pr must be a number');
88
+ block.prNumber = n;
89
+ }
90
+ if (typeof args.component === 'string') block.component = args.component;
91
+
92
+ if (args.class === 'novel') {
93
+ if (!args['novel-json']) throw new Error("--class novel requires --novel-json '{...}' with full class semantics");
94
+ let nc;
95
+ try {
96
+ nc = JSON.parse(args['novel-json']);
97
+ } catch (err) {
98
+ throw new Error(`--novel-json is not valid JSON: ${err && err.message ? err.message : String(err)}`);
99
+ }
100
+ if (!nc || typeof nc !== 'object' || !nc.nearestExistingClass) {
101
+ throw new Error('--novel-json must carry nearestExistingClass (+ includes/excludes/severity)');
102
+ }
103
+ block.novelClass = nc;
104
+ }
105
+
106
+ return normalizeBlock(block);
107
+ }
108
+
109
+ /** Strip undefined leaves so the written block is minimal + idempotent-comparable. */
110
+ function normalizeBlock(block) {
111
+ const out = {};
112
+ for (const [k, v] of Object.entries(block)) {
113
+ if (v === undefined) continue;
114
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
115
+ out[k] = normalizeBlock(v);
116
+ } else {
117
+ out[k] = v;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** Find the most-recent decision-audit entry file (by ts field, else filename). */
124
+ export function findMostRecentDecisionEntry(repoRoot) {
125
+ const dir = path.join(repoRoot, DECISIONS_REL);
126
+ let files;
127
+ try {
128
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
129
+ } catch {
130
+ return null;
131
+ }
132
+ if (files.length === 0) return null;
133
+ let best = null;
134
+ for (const f of files) {
135
+ let entry;
136
+ try {
137
+ entry = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
138
+ } catch {
139
+ continue;
140
+ }
141
+ const tsKey = typeof entry.ts === 'string' ? entry.ts : f;
142
+ if (best === null || tsKey > best.tsKey || (tsKey === best.tsKey && f > best.file)) {
143
+ best = { file: f, entry, tsKey };
144
+ }
145
+ }
146
+ return best ? { file: path.join(dir, best.file), entry: best.entry } : null;
147
+ }
148
+
149
+ /**
150
+ * Merge the block into the most-recent entry + write it back. Idempotent — a
151
+ * re-run with an identical block returns { changed: false }.
152
+ * @returns {{ changed: boolean, file: string, block: object }}
153
+ */
154
+ export function applyDeclaration(repoRoot, block) {
155
+ const target = findMostRecentDecisionEntry(repoRoot);
156
+ if (!target) {
157
+ throw new Error(`no decision-audit entry found under ${DECISIONS_REL} to attach the declaration to`);
158
+ }
159
+ const before = JSON.stringify(target.entry.classClosure ?? null);
160
+ const after = JSON.stringify(block);
161
+ if (before === after) {
162
+ return { changed: false, file: target.file, block };
163
+ }
164
+ target.entry.classClosure = block;
165
+ fs.writeFileSync(target.file, `${JSON.stringify(target.entry, null, 2)}\n`, 'utf-8');
166
+ return { changed: true, file: target.file, block };
167
+ }
168
+
169
+ // ── CLI entrypoint ──────────────────────────────────────────────────────────
170
+ const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());
171
+ if (invokedDirectly) {
172
+ const repoRoot = process.env.CLASS_CLOSURE_REPO_ROOT || process.cwd();
173
+ const args = parseArgs(process.argv.slice(2));
174
+ let block;
175
+ try {
176
+ block = buildClassClosureBlock(args);
177
+ } catch (err) {
178
+ console.error(`class-closure-declare: ${err instanceof Error ? err.message : String(err)}`);
179
+ process.exit(2);
180
+ }
181
+ let result;
182
+ try {
183
+ result = applyDeclaration(repoRoot, block);
184
+ } catch (err) {
185
+ console.error(`class-closure-declare: ${err instanceof Error ? err.message : String(err)}`);
186
+ process.exit(2);
187
+ }
188
+ console.log(`class-closure-declare: ${result.changed ? 'wrote' : 'no change (idempotent)'} classClosure into ${path.relative(repoRoot, result.file)}`);
189
+ console.log(JSON.stringify(result.block, null, 2));
190
+ process.exit(0);
191
+ }
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ // safe-git-allow: CI-only gate script — single read-only `git diff --name-status`
3
+ // against the Actions checkout; runs on ubuntu runners where the TS
4
+ // SafeGitExecutor is not importable from a standalone .mjs.
5
+ /**
6
+ * class-closure-lint — the REPORT-ONLY CI lint for the Class-Closure Gate
7
+ * (docs/specs/class-closure-gate.md → Rollout step 1). Increment 1.
8
+ *
9
+ * A fix for a defect found in an AGENT-AUTHORED artifact (prompts, hooks,
10
+ * configs, skills, standards text) is supposed to declare, in its committed
11
+ * instar-dev decision-audit entry, WHAT CLASS of defect it is an instance of
12
+ * and HOW the class is closed (a live guard, or a tracked gap). This lint
13
+ * VALIDATES those declarations at the PR boundary — it does NOT mutate source
14
+ * (the periodic escalator pass owns mutation) and, in report-only mode (the
15
+ * increment-1 default), it prints findings and ALWAYS exits 0.
16
+ *
17
+ * What it does (all report-only unless enabled && !dryRun):
18
+ * - repo-gate: no docs/defect-classes.json ⇒ "not an instar class-closure
19
+ * repo", exit 0.
20
+ * - load + validate the class registry (a malformed registry is a HARD
21
+ * structural violation).
22
+ * - read every classClosure declaration from committed decision-audit entries
23
+ * (the SINGLE machine-readable counting host — C1: the side-effects mirror
24
+ * is display-only and NOT summed here).
25
+ * - grade each `closure:'guard'` citation via the self-contained grader
26
+ * (evaluateGuardClosure) — a citation that does not resolve to a live
27
+ * enforcing guard DOWNGRADES the declaration to `gap` (G3), logged.
28
+ * - validate `defectClass:'novel'`: it must carry a full new class entry with
29
+ * nearestExistingClass + includes/excludes/severity (a novel class with no
30
+ * semantics is a HARD structural violation); a novel class enters
31
+ * `unconfirmed` and CANNOT satisfy `closure:'guard'` (logged downgrade).
32
+ * - derive per-class counts (deduped by PR) + compute deterministic escalation
33
+ * crossings — LOGGED only (proposals/attention items are increment 3).
34
+ * - assert mirror-consistency is trivially satisfiable (the side-effects mirror
35
+ * is display-only; hosts are never summed).
36
+ * - for an in-scope PR with NO declaration, LOG "missing class declaration
37
+ * (report-only)" (the flip criterion measures population rate during dryRun).
38
+ *
39
+ * Exit codes: 0 in report-only ALWAYS. Nonzero ONLY when
40
+ * `prGate.classClosure.enabled && !dryRun` AND a hard structural violation exists
41
+ * (malformed registry, or a novel class with no semantics).
42
+ */
43
+
44
+ import { execFileSync } from 'node:child_process';
45
+ import fs from 'node:fs';
46
+ import path from 'node:path';
47
+
48
+ import {
49
+ loadRegistry,
50
+ validateRegistry,
51
+ readDecisionDeclarations,
52
+ deriveClassData,
53
+ computeEscalation,
54
+ REGISTRY_REL_PATH,
55
+ } from './lib/defect-class-registry.mjs';
56
+ import { evaluateGuardClosure } from './lib/class-closure-grader.mjs';
57
+
58
+ const DEFAULT_CONFIG = { enabled: false, dryRun: true, escalatorDrafting: false };
59
+
60
+ /**
61
+ * The agent-authored-artifact predicate — a fix touching one of these is
62
+ * IN SCOPE for the class declaration (docs/specs/class-closure-gate.md →
63
+ * Frontloaded Decision 1: prompts, hooks, configs, skills, standards text).
64
+ */
65
+ export function isAgentAuthoredArtifact(file) {
66
+ const f = String(file ?? '');
67
+ if (/^research\/llm-pathway-bench\/.*\/tasks\//.test(f)) return true;
68
+ if (f === 'src/core/promptClauses.ts') return true;
69
+ if (f.startsWith('src/core/promptParsers')) return true;
70
+ if (f === 'src/data/llmBenchCoverage.ts') return true;
71
+ if (f === 'docs/STANDARDS-REGISTRY.md') return true;
72
+ if (f.startsWith('.instar/hooks/')) return true;
73
+ if (f.startsWith('.claude/hooks/')) return true;
74
+ if (f.startsWith('skills/')) return true;
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * The gate's OWN source files — for the bounded, gate-source-ONLY self-wedge
80
+ * exemption (spec: a broken gate can never block its own repair; the exemption
81
+ * applies only when the diff touches EXCLUSIVELY these files).
82
+ */
83
+ export function isGateSourceFile(file) {
84
+ const f = String(file ?? '');
85
+ return (
86
+ f === 'scripts/class-closure-lint.mjs' ||
87
+ f === 'scripts/class-closure-declare.mjs' ||
88
+ f === 'scripts/lib/class-closure-grader.mjs' ||
89
+ f === 'scripts/lib/defect-class-registry.mjs' ||
90
+ f === 'src/core/DefectClassRegistry.ts' ||
91
+ f === REGISTRY_REL_PATH ||
92
+ f === 'docs/defect-classes.json' ||
93
+ f === '.github/workflows/class-closure-gate.yml' ||
94
+ f === 'docs/specs/class-closure-gate.md' ||
95
+ /^tests\/(unit|integration)\/class-closure-/.test(f)
96
+ );
97
+ }
98
+
99
+ /** Load prGate.classClosure config from a repo checkout, or the report-only default. */
100
+ export function loadClassClosureConfig(repoRoot) {
101
+ try {
102
+ const raw = fs.readFileSync(path.join(repoRoot, '.instar', 'config.json'), 'utf-8');
103
+ const cfg = JSON.parse(raw);
104
+ const cc = cfg && cfg.prGate && cfg.prGate.classClosure;
105
+ if (cc && typeof cc === 'object') {
106
+ return {
107
+ enabled: cc.enabled === true,
108
+ dryRun: cc.dryRun !== false, // default true (report-only) unless explicitly false
109
+ escalatorDrafting: cc.escalatorDrafting === true,
110
+ };
111
+ }
112
+ } catch {
113
+ // no config / unreadable → report-only default
114
+ }
115
+ return { ...DEFAULT_CONFIG };
116
+ }
117
+
118
+ /** Validate one classClosure declaration's shape. Returns { findings, hardViolations }. */
119
+ function validateDeclaration(repoRoot, decl, knownClassIds) {
120
+ const findings = [];
121
+ const hardViolations = [];
122
+ const where = `declaration in ${decl.source ?? '<unknown>'} (class="${decl.defectClass ?? '?'}")`;
123
+
124
+ if (!decl.defectClass || typeof decl.defectClass !== 'string') {
125
+ findings.push(`${where}: missing defectClass`);
126
+ return { findings, hardViolations };
127
+ }
128
+
129
+ const isNovel = decl.defectClass === 'novel';
130
+ if (!isNovel && !knownClassIds.has(decl.defectClass)) {
131
+ findings.push(`${where}: defectClass "${decl.defectClass}" is not a registered class id and is not 'novel'`);
132
+ }
133
+
134
+ if (decl.closure !== 'guard' && decl.closure !== 'gap') {
135
+ findings.push(`${where}: closure must be 'guard' or 'gap' (got "${decl.closure}")`);
136
+ }
137
+
138
+ // Novel class: REQUIRES full semantics (a hard violation if absent).
139
+ if (isNovel) {
140
+ const nc = decl.novelClass;
141
+ const missing = [];
142
+ if (!nc || typeof nc !== 'object') {
143
+ missing.push('novelClass block');
144
+ } else {
145
+ if (!nc.nearestExistingClass || typeof nc.nearestExistingClass !== 'string') missing.push('nearestExistingClass');
146
+ if (!Array.isArray(nc.includes) || nc.includes.length < 1) missing.push('≥1 includes');
147
+ if (!Array.isArray(nc.excludes) || nc.excludes.length < 1) missing.push('≥1 excludes');
148
+ if (nc.severity !== 'critical' && nc.severity !== 'normal') missing.push('severity (critical|normal)');
149
+ }
150
+ if (missing.length > 0) {
151
+ hardViolations.push(`${where}: novel class with no semantics — missing ${missing.join(', ')}`);
152
+ }
153
+ // A novel class enters `unconfirmed` and CANNOT satisfy closure:'guard'.
154
+ if (decl.closure === 'guard') {
155
+ findings.push(`${where}: a novel (unconfirmed) class cannot satisfy closure:'guard' — its fix carries closure:'gap' until operator-confirmed`);
156
+ }
157
+ }
158
+
159
+ // Guard closure: grade the citation (downgrade to gap if it does not resolve).
160
+ if (decl.closure === 'guard') {
161
+ const citation = decl.guardEvidence && decl.guardEvidence.citation;
162
+ if (!citation || typeof citation !== 'string') {
163
+ findings.push(`${where}: closure:'guard' requires guardEvidence.citation`);
164
+ } else {
165
+ const verdict = evaluateGuardClosure(repoRoot, citation);
166
+ if (verdict.effectiveClosure === 'gap') {
167
+ findings.push(`${where}: DOWNGRADE guard→gap — ${verdict.downgradeReason}`);
168
+ } else {
169
+ findings.push(`${where}: guard citation resolves (${verdict.gradedKind}) — closure:'guard' upheld`);
170
+ }
171
+ }
172
+ } else if (decl.closure === 'gap') {
173
+ if (!decl.gapItem || typeof decl.gapItem !== 'string') {
174
+ findings.push(`${where}: closure:'gap' should cite a tracked gap item (gapItem) — none provided`);
175
+ }
176
+ }
177
+
178
+ return { findings, hardViolations };
179
+ }
180
+
181
+ /**
182
+ * Pure evaluation over a prepared repo checkout — exported for tests.
183
+ * @param {{ repoRoot: string, changedFiles?: string[]|null, config: {enabled:boolean,dryRun:boolean,escalatorDrafting:boolean}, thresholds?: object }} input
184
+ * @returns {{ exitCode: number, repoGated?: boolean, findings: string[], hardViolations: string[], escalations: object[], inScope: boolean, exempt?: string, declarationCount: number }}
185
+ */
186
+ export function runClassClosureLint(input) {
187
+ const { repoRoot, config } = input;
188
+ const thresholds = input.thresholds ?? {};
189
+ const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : null;
190
+ const findings = [];
191
+ const hardViolations = [];
192
+ const escalations = [];
193
+
194
+ // Repo-gate: no registry ⇒ not an instar class-closure repo.
195
+ if (!fs.existsSync(path.join(repoRoot, REGISTRY_REL_PATH))) {
196
+ return { exitCode: 0, repoGated: true, findings, hardViolations, escalations, inScope: false, declarationCount: 0 };
197
+ }
198
+
199
+ // Load + validate the registry (malformed = hard structural violation).
200
+ let registry = null;
201
+ const rawParse = (() => {
202
+ try {
203
+ return JSON.parse(fs.readFileSync(path.join(repoRoot, REGISTRY_REL_PATH), 'utf-8'));
204
+ } catch (err) {
205
+ return { __parseError: err && err.message ? err.message : String(err) };
206
+ }
207
+ })();
208
+ if (rawParse && rawParse.__parseError) {
209
+ hardViolations.push(`registry unparseable: ${rawParse.__parseError}`);
210
+ } else {
211
+ const v = validateRegistry(rawParse);
212
+ if (!v.ok) {
213
+ hardViolations.push(`malformed registry: ${v.errors.join('; ')}`);
214
+ } else {
215
+ registry = rawParse;
216
+ }
217
+ }
218
+
219
+ // Read + grade declarations from the decision-audit host.
220
+ const declarations = readDecisionDeclarations(repoRoot);
221
+ const knownClassIds = new Set(registry ? registry.classes.map((c) => c.id) : []);
222
+ for (const decl of declarations) {
223
+ const r = validateDeclaration(repoRoot, decl, knownClassIds);
224
+ findings.push(...r.findings);
225
+ hardViolations.push(...r.hardViolations);
226
+ }
227
+
228
+ // Derive per-class counts + compute deterministic escalation crossings (LOG only).
229
+ if (registry) {
230
+ const derived = deriveClassData(declarations);
231
+ for (const cls of registry.classes) {
232
+ const d = derived.get(cls.id);
233
+ if (!d) continue;
234
+ const verdict = computeEscalation(
235
+ { severity: cls.severity, evidenceCountAtLastAck: cls.evidenceCountAtLastAck },
236
+ d,
237
+ thresholds,
238
+ );
239
+ if (verdict.shouldEscalate) {
240
+ escalations.push({ classId: cls.id, arm: verdict.arm, reason: verdict.reason, derivedCount: d.dedupedCount });
241
+ findings.push(`ESCALATION (report-only): class "${cls.id}" crossed threshold [${verdict.arm}] — ${verdict.reason}`);
242
+ }
243
+ }
244
+ // Mirror-consistency: the side-effects artifact mirror is DISPLAY-ONLY; the
245
+ // lint never sums the two hosts (C1). This invariant is satisfied by
246
+ // construction here — we counted the decision-audit host only.
247
+ findings.push('mirror-consistency: counted the decision-audit host only (side-effects mirror is display-only) — trivially satisfied');
248
+ }
249
+
250
+ // Diff-scope: does this PR touch an agent-authored artifact needing a declaration?
251
+ let inScope = false;
252
+ let exempt;
253
+ if (changedFiles && changedFiles.length > 0) {
254
+ const agentFiles = changedFiles.filter(isAgentAuthoredArtifact);
255
+ inScope = agentFiles.length > 0;
256
+ if (inScope) {
257
+ // Gate-source-ONLY self-wedge exemption: applies ONLY when the diff
258
+ // touches EXCLUSIVELY the gate's own source (a mixed PR cannot ride it).
259
+ const allGateSource = changedFiles.every(isGateSourceFile);
260
+ if (allGateSource) {
261
+ exempt = 'gate-source-only';
262
+ findings.push('exempt: diff touches EXCLUSIVELY the gate\'s own source (gate-source-only self-wedge exemption, logged)');
263
+ } else if (declarations.length === 0) {
264
+ findings.push('missing class declaration (report-only) — this PR touches agent-authored artifacts but carries no classClosure declaration');
265
+ }
266
+ }
267
+ }
268
+
269
+ // Exit code: report-only ALWAYS exits 0. Nonzero ONLY when enforcing AND a
270
+ // hard structural violation exists.
271
+ const enforcing = config.enabled && !config.dryRun;
272
+ const exitCode = enforcing && hardViolations.length > 0 ? 1 : 0;
273
+
274
+ return {
275
+ exitCode,
276
+ findings,
277
+ hardViolations,
278
+ escalations,
279
+ inScope,
280
+ exempt,
281
+ declarationCount: declarations.length,
282
+ };
283
+ }
284
+
285
+ /** Parse `git diff --name-status base...head` output into changed file paths. */
286
+ export function parseNameStatus(text) {
287
+ const files = [];
288
+ for (const line of String(text).split('\n')) {
289
+ const t = line.trim();
290
+ if (!t) continue;
291
+ const parts = t.split('\t');
292
+ if (parts.length < 2) continue;
293
+ files.push(parts[parts.length - 1]); // renames: take the new path
294
+ }
295
+ return files;
296
+ }
297
+
298
+ // ── CLI entrypoint (CI) ────────────────────────────────────────────────────
299
+ const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());
300
+ if (invokedDirectly) {
301
+ const repoRoot = process.env.CLASS_CLOSURE_REPO_ROOT || process.cwd();
302
+
303
+ // Repo-gate first (before anything else).
304
+ if (!fs.existsSync(path.join(repoRoot, REGISTRY_REL_PATH))) {
305
+ console.log('class-closure gate: not an instar class-closure repo (no docs/defect-classes.json) — skipping.');
306
+ process.exit(0);
307
+ }
308
+
309
+ const config = loadClassClosureConfig(repoRoot);
310
+
311
+ // Compute changed files from git when the PR SHAs are present; otherwise run
312
+ // scope-blind (still grades all committed declarations — the count host).
313
+ let changedFiles = null;
314
+ const baseSha = process.env.BASE_SHA;
315
+ const headSha = process.env.HEAD_SHA;
316
+ if (baseSha && headSha) {
317
+ try {
318
+ const diffOut = execFileSync('git', ['diff', '--name-status', `${baseSha}...${headSha}`], {
319
+ cwd: repoRoot,
320
+ encoding: 'utf8',
321
+ });
322
+ changedFiles = parseNameStatus(diffOut);
323
+ } catch (err) {
324
+ console.warn(`class-closure gate: git diff failed (${err instanceof Error ? err.message : String(err)}) — running scope-blind.`);
325
+ changedFiles = null;
326
+ }
327
+ }
328
+
329
+ const res = runClassClosureLint({ repoRoot, changedFiles, config });
330
+
331
+ console.log('class-closure gate: report-only lint');
332
+ console.log(` mode: ${config.enabled ? (config.dryRun ? 'enabled+dryRun (report-only)' : 'ENFORCING') : 'disabled (report-only)'}`);
333
+ console.log(` declarations scanned: ${res.declarationCount}`);
334
+ if (changedFiles) console.log(` changed files: ${changedFiles.length} (in-scope: ${res.inScope}${res.exempt ? `, exempt: ${res.exempt}` : ''})`);
335
+ for (const f of res.findings) console.log(` - ${f}`);
336
+ if (res.hardViolations.length > 0) {
337
+ console.log(' HARD STRUCTURAL VIOLATIONS:');
338
+ for (const h of res.hardViolations) console.log(` ! ${h}`);
339
+ }
340
+ if (res.exitCode !== 0) {
341
+ console.error('class-closure gate: FAIL (enforcing mode + hard structural violation)');
342
+ } else {
343
+ console.log('class-closure gate: OK (report-only — no build failure)');
344
+ }
345
+ process.exit(res.exitCode);
346
+ }
@@ -0,0 +1,139 @@
1
+ // class-closure-grader.mjs — the SELF-CONTAINED deterministic guard grader used
2
+ // by the class-closure gate's CI lint (scripts/class-closure-lint.mjs).
3
+ //
4
+ // WHY self-contained: PR-gate lints run on a fresh checkout with NO build step
5
+ // (decision-audit-gate.yml precedent), so the lint cannot import the TS grader
6
+ // from dist/. This mirrors the EXACT classification rules of
7
+ // src/core/StandardsEnforcementAuditor.ts::classifyFileGuard/gradeGuardCitation;
8
+ // tests/unit/class-closure-grader-parity.test.ts pins the two implementations
9
+ // equivalent so they cannot drift (Structure > Willpower).
10
+ //
11
+ // The spec's rule (docs/specs/class-closure-gate.md → Piece 1 guardEvidence):
12
+ // a citation that does not RESOLVE to a live enforcing guard — resolved:false,
13
+ // or a resolved kind of `spec-only` — downgrades the closure declaration to
14
+ // `gap` (G3: a dark/spec-only artifact guards nothing). Only ratchet/gate/lint
15
+ // count as a live enforcing guard. Grader erroring ⇒ downgrade to gap
16
+ // (fail-closed).
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ /** Classify a VERIFIED file ref into its guard weight (mirror of classifyFileGuard). */
22
+ export function classifyFileGuard(ref) {
23
+ const base = ref.split('/').pop() ?? ref;
24
+ if (/\.test\.(ts|js|mjs)$/.test(base) || base.startsWith('no-') || /-coverage\.(mjs|js)$/.test(base)) {
25
+ return 'ratchet';
26
+ }
27
+ if (ref.startsWith('scripts/') && base.startsWith('lint-')) return 'lint';
28
+ if (ref.startsWith('.husky/') || /precommit/i.test(base)) return 'gate';
29
+ if (ref.startsWith('scripts/')) return 'lint';
30
+ if (ref.startsWith('docs/specs/')) return 'spec-only';
31
+ if (ref.startsWith('docs/')) return 'spec-only';
32
+ if (ref.startsWith('src/')) return 'gate';
33
+ return 'spec-only';
34
+ }
35
+
36
+ /** Scan src/server/*.ts for router.<verb>('...') tokens (mirror of loadRouteTable). */
37
+ function loadRouteTable(projectDir) {
38
+ const serverDir = path.join(projectDir, 'src', 'server');
39
+ const out = new Set();
40
+ let files;
41
+ try {
42
+ files = fs.readdirSync(serverDir).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
43
+ } catch {
44
+ return out;
45
+ }
46
+ const re = /router\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g;
47
+ for (const f of files) {
48
+ let content;
49
+ try { content = fs.readFileSync(path.join(serverDir, f), 'utf-8'); } catch { continue; }
50
+ for (const m of content.matchAll(re)) out.add(`${m[1].toUpperCase()} ${m[2]}`);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Bounded grep for a single symbol across src/** (mirror of buildSymbolIndex, size 1). */
56
+ function symbolExists(projectDir, symbol) {
57
+ const srcDir = path.join(projectDir, 'src');
58
+ try { if (!fs.statSync(srcDir).isDirectory()) return false; } catch { return false; }
59
+ const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+ const re = new RegExp(`\\b${escaped}\\b`);
61
+ const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
62
+ let readBytes = 0;
63
+ let found = false;
64
+ const walk = (dir) => {
65
+ if (found) return;
66
+ let entries;
67
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
68
+ for (const e of entries) {
69
+ if (found) return;
70
+ const full = path.join(dir, e.name);
71
+ if (e.isDirectory()) {
72
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
73
+ walk(full);
74
+ } else if (/\.(ts|js|mjs|cjs)$/.test(e.name)) {
75
+ if (readBytes > MAX_TOTAL_BYTES) return;
76
+ let content;
77
+ try { content = fs.readFileSync(full, 'utf-8'); } catch { continue; }
78
+ readBytes += content.length;
79
+ if (re.test(content)) { found = true; return; }
80
+ }
81
+ }
82
+ };
83
+ walk(srcDir);
84
+ return found;
85
+ }
86
+
87
+ /**
88
+ * Grade a single guard citation against a repo checkout. Mirror of
89
+ * StandardsEnforcementAuditor.gradeGuardCitation.
90
+ * @returns {{ resolved: boolean, kind: string|null, citation: string }}
91
+ */
92
+ export function gradeGuardCitation(projectDir, citation) {
93
+ const raw = (citation ?? '').trim();
94
+ if (!raw) return { resolved: false, kind: null, citation: raw };
95
+
96
+ const routeMatch = /^(GET|POST|PUT|DELETE|PATCH)\s+(\/\S+)$/i.exec(raw);
97
+ if (routeMatch) {
98
+ const token = `${routeMatch[1].toUpperCase()} ${routeMatch[2]}`;
99
+ const resolved = loadRouteTable(projectDir).has(token);
100
+ return { resolved, kind: resolved ? 'gate' : null, citation: raw };
101
+ }
102
+
103
+ if (raw.includes('/')) {
104
+ const filePart = raw.split('#')[0].split(':')[0];
105
+ let resolved = false;
106
+ try { resolved = fs.existsSync(path.join(projectDir, filePart)); } catch { resolved = false; }
107
+ return { resolved, kind: resolved ? classifyFileGuard(filePart) : null, citation: raw };
108
+ }
109
+
110
+ const resolved = symbolExists(projectDir, raw);
111
+ return { resolved, kind: resolved ? 'gate' : null, citation: raw };
112
+ }
113
+
114
+ /**
115
+ * The closure verdict for a `guard` declaration: does the cited guard actually
116
+ * enforce (ratchet/gate/lint + resolved)? Otherwise the declaration downgrades
117
+ * to `gap`. Any thrown error ⇒ downgrade to gap (fail-closed).
118
+ * @returns {{ effectiveClosure: 'guard'|'gap', gradedKind: string|null, resolved: boolean, downgradeReason: string|null }}
119
+ */
120
+ export function evaluateGuardClosure(projectDir, citation) {
121
+ try {
122
+ const g = gradeGuardCitation(projectDir, citation);
123
+ const enforcing = g.resolved && (g.kind === 'ratchet' || g.kind === 'gate' || g.kind === 'lint');
124
+ if (enforcing) {
125
+ return { effectiveClosure: 'guard', gradedKind: g.kind, resolved: true, downgradeReason: null };
126
+ }
127
+ const reason = !g.resolved
128
+ ? `guard citation "${citation}" does not resolve to a live guard on disk`
129
+ : `guard citation "${citation}" graded ${g.kind} — not a live enforcing guard (ratchet/gate/lint required)`;
130
+ return { effectiveClosure: 'gap', gradedKind: g.kind, resolved: g.resolved, downgradeReason: reason };
131
+ } catch (err) {
132
+ return {
133
+ effectiveClosure: 'gap',
134
+ gradedKind: null,
135
+ resolved: false,
136
+ downgradeReason: `grader error (fail-closed): ${err && err.message ? err.message : String(err)}`,
137
+ };
138
+ }
139
+ }