instar 1.3.767 → 1.3.769

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.767",
3
+ "version": "1.3.769",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-machine-local-justification.js — the DETERMINISTIC marker floor for
4
+ * Standard A ("An Instar Agent Is Always a Multi-Machine Entity",
5
+ * docs/STANDARDS-REGISTRY.md), built per docs/specs/three-standards-enforcement.md
6
+ * §178-202 ("The marker's location + parse contract").
7
+ *
8
+ * ── What it is (Signal vs. Authority — the BODY, not the MIND) ─────────────
9
+ * A no-LLM static parser that grades the PRESENCE + well-formedness of the
10
+ * `machine-local-justification: <taxonomy-key>` marker a spec must carry for each
11
+ * machine-local state surface it introduces. It is the cheap deterministic SIGNAL
12
+ * that front-runs the /spec-converge integration reviewer; the reviewer holds the
13
+ * semantic AUTHORITY (is the justification actually TRUE?) that a parser cannot
14
+ * make (§194-202). Neither alone is the enforcement — together they are.
15
+ *
16
+ * ── The contract it checks (bidirectional, §170) ──────────────────────────
17
+ * A1 — UNDEFENDED machine-local: the spec's `## Multi-machine posture` section
18
+ * ASSERTS a machine-local posture (contains the token `machine-local`) but
19
+ * carries NO well-formed `machine-local-justification:` marker. §163: "a
20
+ * bare 'machine-local BY DESIGN' with no taxonomy-jailed justification is a
21
+ * MATERIAL FINDING."
22
+ * A2 — SPURIOUS / MALFORMED marker (the reverse direction, §170): a
23
+ * `machine-local-justification:` marker whose key is NOT in the closed
24
+ * taxonomy {physical-credential-locality, hardware-bound-resource,
25
+ * operator-ratified-exception}; OR an `operator-ratified-exception` marker
26
+ * that cites no machine-verifiable, existence-checkable ref (a commit SHA,
27
+ * a dotted registry key, or a URL — §155-162: a bare topic+date fails
28
+ * DETERMINISTICALLY); OR a well-formed marker that sits OUTSIDE the
29
+ * `## Multi-machine posture` section (the location contract, §178-181).
30
+ *
31
+ * ── Honest deterministic scope (§194-202, §242-245) ───────────────────────
32
+ * PRESENCE + well-formedness only. It does NOT judge whether a declared posture
33
+ * is the CORRECT one for the surface (that is the reviewer's semantic audit), and
34
+ * it does NOT flag a spec that simply omits a posture section — §168's
35
+ * "absence defaults to unified-required" is a semantic call the reviewer owns, not
36
+ * a deterministic one. A `<placeholder>` marker value (template prose like
37
+ * `machine-local-justification: <taxonomy-key>`) is IGNORED — it is documentation,
38
+ * not a real marker.
39
+ *
40
+ * ── Rollout MODE — REPORT-FIRST (graduated rollout) ───────────────────────
41
+ * Per the spec's honesty / hard-sequencing clause (§197-202, §563-573) and the
42
+ * dark-first Maturation convention: the deterministic marker lint's grade is
43
+ * "inert until the registry ship" — this floor lands as a SIGNAL first, not a new
44
+ * brittle blocking gate. Default run REPORTS findings and exits 0 (never blocks;
45
+ * existing specs predate the marker convention and are swept separately, §242-245).
46
+ * `--strict` makes findings exit non-zero — the FAIL capability the spec describes
47
+ * (§197-199), used by this lint's tests and available for a later CI graduation.
48
+ *
49
+ * Exit codes:
50
+ * 0 — clean, OR findings in report mode (default)
51
+ * 1 — findings in --strict mode, OR a usage error
52
+ *
53
+ * Usage:
54
+ * node scripts/lint-machine-local-justification.js # report, scans docs/specs
55
+ * node scripts/lint-machine-local-justification.js FILE... # report specific files
56
+ * node scripts/lint-machine-local-justification.js --strict FILE # FAIL on any finding
57
+ * node scripts/lint-machine-local-justification.js --json FILE # machine-readable findings
58
+ */
59
+
60
+ import fs from 'node:fs';
61
+ import path from 'node:path';
62
+ import { fileURLToPath } from 'node:url';
63
+
64
+ const __filename = fileURLToPath(import.meta.url);
65
+ const ROOT = path.resolve(path.dirname(__filename), '..');
66
+
67
+ // ── The closed taxonomy (§148-162). Widening it is a constitution-bound
68
+ // operator decision, never an author's convenience (§205-219). ──────────
69
+ export const TAXONOMY_KEYS = new Set([
70
+ 'physical-credential-locality',
71
+ 'hardware-bound-resource',
72
+ 'operator-ratified-exception',
73
+ ]);
74
+
75
+ const POSTURE_SECTION_RE = /^#{1,6}\s+Multi-machine posture\b.*$/im;
76
+
77
+ // A standalone marker line: start-of-line (optional bullet / backtick), the
78
+ // label, a colon, then `key rest`. Anchored so a mid-sentence backticked prose
79
+ // mention (e.g. "declares it with a `machine-local-justification: <key>` marker")
80
+ // does NOT match — only a real declaration line does.
81
+ const MARKER_LINE_RE =
82
+ /^[ \t]*(?:[-*+][ \t]+)?`?machine-local-justification`?[ \t]*:[ \t]*`?([^\s`]+)`?[ \t]*(.*?)`?[ \t]*$/gim;
83
+
84
+ // A machine-verifiable, existence-checkable ref for operator-ratified-exception
85
+ // (§155-162): a commit SHA (7-40 hex), a URL, or a dotted registry key.
86
+ const REF_SHA_RE = /\b[0-9a-f]{7,40}\b/i;
87
+ const REF_URL_RE = /https?:\/\/\S+/i;
88
+ const REF_REGISTRY_KEY_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+){1,}\b/;
89
+
90
+ /**
91
+ * Locate the bounds of the `## Multi-machine posture` section in a spec's text.
92
+ * Returns { start, end } character offsets, or null when the section is absent.
93
+ * The section runs from its heading to the next heading of equal-or-higher level
94
+ * (or EOF).
95
+ */
96
+ export function findPostureSection(text) {
97
+ const m = POSTURE_SECTION_RE.exec(text);
98
+ if (!m) return null;
99
+ const headingLevel = (m[0].match(/^#+/) || ['#'])[0].length;
100
+ const start = m.index;
101
+ const afterHeading = start + m[0].length;
102
+ // Find the next heading at <= headingLevel after the section starts.
103
+ const rest = text.slice(afterHeading);
104
+ const nextHeadingRe = new RegExp(`^#{1,${headingLevel}}\\s+\\S`, 'im');
105
+ const nm = nextHeadingRe.exec(rest);
106
+ const end = nm ? afterHeading + nm.index : text.length;
107
+ return { start, end };
108
+ }
109
+
110
+ /**
111
+ * Parse every standalone `machine-local-justification:` marker line in the text.
112
+ * `<placeholder>` values (template prose) are skipped. Returns
113
+ * [{ key, rest, index }].
114
+ */
115
+ export function parseMarkers(text) {
116
+ const out = [];
117
+ MARKER_LINE_RE.lastIndex = 0;
118
+ let m;
119
+ while ((m = MARKER_LINE_RE.exec(text)) !== null) {
120
+ const key = m[1];
121
+ const rest = (m[2] || '').trim();
122
+ // Skip template placeholders like `<taxonomy-key>` / `<key>` — documentation,
123
+ // not a real declaration.
124
+ if (/^<.*>$/.test(key)) continue;
125
+ out.push({ key, rest, index: m.index });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** True if `rest` carries a machine-verifiable, existence-checkable ref. */
131
+ export function hasResolvableRef(rest) {
132
+ if (!rest) return false;
133
+ return REF_SHA_RE.test(rest) || REF_URL_RE.test(rest) || REF_REGISTRY_KEY_RE.test(rest);
134
+ }
135
+
136
+ /**
137
+ * Grade one spec's text against Standard A's marker contract. Pure — no I/O.
138
+ * Returns { findings: [{ rule, message }] }.
139
+ */
140
+ export function gradeMachineLocalMarkers(text) {
141
+ const findings = [];
142
+ const markers = parseMarkers(text);
143
+ const posture = findPostureSection(text);
144
+
145
+ // ── A2 — spurious / malformed markers (both directions, §170) ──
146
+ for (const marker of markers) {
147
+ if (!TAXONOMY_KEYS.has(marker.key)) {
148
+ findings.push({
149
+ rule: 'A2-invalid-taxonomy-key',
150
+ message:
151
+ `machine-local-justification key "${marker.key}" is not in the closed taxonomy ` +
152
+ `{${[...TAXONOMY_KEYS].join(', ')}}. A key outside the set is a MATERIAL FINDING ` +
153
+ `(a fourth reason is an operator-ratified constitutional decision, §205-219).`,
154
+ });
155
+ continue;
156
+ }
157
+ if (marker.key === 'operator-ratified-exception' && !hasResolvableRef(marker.rest)) {
158
+ findings.push({
159
+ rule: 'A2-unresolvable-ratification-ref',
160
+ message:
161
+ `operator-ratified-exception must cite a machine-verifiable, existence-checkable ref ` +
162
+ `(a commit SHA, a URL, or a dotted registry key) — a bare topic+date fails ` +
163
+ `deterministically (§155-162). Marker value: "${marker.rest || '(empty)'}".`,
164
+ });
165
+ }
166
+ // Location contract (§178-181): a real marker belongs inside `## Multi-machine posture`.
167
+ if (posture && (marker.index < posture.start || marker.index >= posture.end)) {
168
+ findings.push({
169
+ rule: 'A2-marker-outside-posture-section',
170
+ message:
171
+ `machine-local-justification marker ("${marker.key}") sits OUTSIDE the ` +
172
+ `## Multi-machine posture section — the marker's fixed location is what makes ` +
173
+ `PRESENCE machine-checkable (§178-181).`,
174
+ });
175
+ }
176
+ }
177
+
178
+ // ── A1 — undefended machine-local assertion (§163) ──
179
+ // Scope the trigger to the posture section so a mere prose mention of
180
+ // "machine-local" elsewhere in the spec is not a false positive.
181
+ if (posture) {
182
+ const sectionText = text.slice(posture.start, posture.end);
183
+ const assertsMachineLocal = /machine-local/i.test(sectionText);
184
+ const hasValidMarker = markers.some(
185
+ (mk) =>
186
+ TAXONOMY_KEYS.has(mk.key) &&
187
+ mk.index >= posture.start &&
188
+ mk.index < posture.end &&
189
+ (mk.key !== 'operator-ratified-exception' || hasResolvableRef(mk.rest)),
190
+ );
191
+ if (assertsMachineLocal && !hasValidMarker) {
192
+ findings.push({
193
+ rule: 'A1-undefended-machine-local',
194
+ message:
195
+ `the ## Multi-machine posture section asserts a machine-local posture but carries no ` +
196
+ `well-formed machine-local-justification: <taxonomy-key> marker. A bare ` +
197
+ `"machine-local BY DESIGN" with no taxonomy-jailed justification is a MATERIAL FINDING ` +
198
+ `(§163). Default posture is unified (§148).`,
199
+ });
200
+ }
201
+ }
202
+
203
+ return { findings };
204
+ }
205
+
206
+ // ── CLI ────────────────────────────────────────────────────────────────────
207
+
208
+ function listSpecFiles() {
209
+ const dir = path.join(ROOT, 'docs', 'specs');
210
+ const out = [];
211
+ const walk = (d) => {
212
+ let entries;
213
+ try {
214
+ entries = fs.readdirSync(d, { withFileTypes: true });
215
+ } catch {
216
+ return;
217
+ }
218
+ for (const e of entries) {
219
+ const p = path.join(d, e.name);
220
+ if (e.isDirectory()) walk(p);
221
+ else if (e.isFile() && e.name.endsWith('.md')) out.push(p);
222
+ }
223
+ };
224
+ walk(dir);
225
+ return out;
226
+ }
227
+
228
+ function main() {
229
+ const argv = process.argv.slice(2);
230
+ const strict = argv.includes('--strict');
231
+ const json = argv.includes('--json');
232
+ const files = argv.filter((a) => !a.startsWith('--'));
233
+ const targets = files.length ? files : listSpecFiles();
234
+
235
+ const allFindings = [];
236
+ for (const file of targets) {
237
+ let text;
238
+ try {
239
+ text = fs.readFileSync(file, 'utf8');
240
+ } catch {
241
+ continue;
242
+ }
243
+ const { findings } = gradeMachineLocalMarkers(text);
244
+ for (const f of findings) allFindings.push({ file: path.relative(ROOT, path.resolve(file)), ...f });
245
+ }
246
+
247
+ if (json) {
248
+ process.stdout.write(JSON.stringify({ findings: allFindings, strict }, null, 2) + '\n');
249
+ } else if (allFindings.length === 0) {
250
+ console.log('lint-machine-local-justification: clean — no undefended or malformed markers.');
251
+ } else {
252
+ const header = strict
253
+ ? 'lint-machine-local-justification: FINDINGS (strict — blocking):'
254
+ : 'lint-machine-local-justification: findings (report-first — non-blocking signal):';
255
+ console.error(header);
256
+ for (const f of allFindings) {
257
+ console.error(` • [${f.rule}] ${f.file}`);
258
+ console.error(` ${f.message}`);
259
+ }
260
+ console.error(
261
+ `\n ${allFindings.length} finding(s). Standard A: docs/STANDARDS-REGISTRY.md ` +
262
+ `("An Instar Agent Is Always a Multi-Machine Entity").`,
263
+ );
264
+ }
265
+
266
+ if (strict && allFindings.length > 0) process.exit(1);
267
+ process.exit(0);
268
+ }
269
+
270
+ // Only run the CLI when invoked directly (not when imported by a test).
271
+ const invokedDirectly =
272
+ process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(__filename);
273
+ if (invokedDirectly) main();
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-self-heal-fields.js — the DETERMINISTIC field-schema floor for Standard B
4
+ * ("Self-Heal Before Notify", docs/STANDARDS-REGISTRY.md), built per
5
+ * docs/specs/three-standards-enforcement.md §256-289, §343-361.
6
+ *
7
+ * ── What it is (Signal vs. Authority — the BODY, not the MIND) ─────────────
8
+ * A no-LLM static parser over a spec's self-heal DECLARATION. When a spec adds a
9
+ * watcher/monitor that can escalate to the operator, it must declare a bounded
10
+ * self-heal step with the P19 brake fields (§265-283). `remediation-actions` is
11
+ * the deterministic anti-no-op SIGNAL (§270): listing the concrete operations the
12
+ * heal invokes makes "did this heal actually DO something?" machine-inspectable,
13
+ * so the /spec-converge reviewer's substance judgment becomes a semantic AUDIT
14
+ * over a deterministic floor rather than the sole guard. This lint IS that floor;
15
+ * the reviewer holds the authority (is the heal SUBSTANTIVE? is the severity class
16
+ * CORRECT?) a parser cannot judge.
17
+ *
18
+ * ── The contract it checks ────────────────────────────────────────────────
19
+ * B0 — IN SCOPE: a spec declares a self-heal block iff it carries a
20
+ * `remediation-actions:` labeled line (the anchor field, §270). No anchor →
21
+ * not in scope (a spec with no watcher pays no self-heal cost, §353-355).
22
+ * B1 — REQUIRED FIELDS present (§265-283, §592): max-attempts, max-wall-clock,
23
+ * backoff, dedupe-key, breaker, max-notification-latency, audit-location,
24
+ * remediation-actions, class. A missing field is a MATERIAL FINDING.
25
+ * B2 — NO-OP heal: remediation-actions must be non-empty (inline value OR ≥1
26
+ * following bullet). An empty list is the no-op that merely flips a flag to
27
+ * unlock the escalation path (§286-291) — a MATERIAL FINDING.
28
+ * B3 — LATENCY UNITS: max-notification-latency must be a duration WITH units
29
+ * (e.g. `120s`, `5m`) — never a bare number or an adjective (§318-323).
30
+ * B4 — SEVERITY CLASS value: class must be a recognized class
31
+ * {recoverable, critical, irreversible, data-loss, security}. (The lint
32
+ * grades the value's WELL-FORMEDNESS; the reviewer CONTESTS its
33
+ * correctness, §306-310.)
34
+ *
35
+ * ── Honest deterministic scope (§357-361, §194-202) ───────────────────────
36
+ * PRESENCE + well-formedness of the declared fields only. It does NOT judge
37
+ * whether a heal is genuinely SUBSTANTIVE, whether a `recoverable` label is honest,
38
+ * or whether exhaustion is truly reachable — those are the reviewer's semantic
39
+ * audit and (for the runtime gate) the downstream SelfHealGate fixture (§374-389).
40
+ *
41
+ * ── Rollout MODE — REPORT-FIRST (graduated rollout) ───────────────────────
42
+ * Per the spec's honesty / hard-sequencing clause (§357-361, §563-573) and the
43
+ * dark-first Maturation convention: this floor lands as a SIGNAL first, not a new
44
+ * brittle blocking gate. Default run REPORTS findings and exits 0 (never blocks).
45
+ * `--strict` makes findings exit non-zero — the FAIL capability, used by this
46
+ * lint's tests and available for a later CI graduation.
47
+ *
48
+ * Exit codes:
49
+ * 0 — clean, OR findings in report mode (default)
50
+ * 1 — findings in --strict mode, OR a usage error
51
+ *
52
+ * Usage:
53
+ * node scripts/lint-self-heal-fields.js # report, scans docs/specs
54
+ * node scripts/lint-self-heal-fields.js FILE... # report specific files
55
+ * node scripts/lint-self-heal-fields.js --strict FILE # FAIL on any finding
56
+ * node scripts/lint-self-heal-fields.js --json FILE # machine-readable findings
57
+ */
58
+
59
+ import fs from 'node:fs';
60
+ import path from 'node:path';
61
+ import { fileURLToPath } from 'node:url';
62
+
63
+ const __filename = fileURLToPath(import.meta.url);
64
+ const ROOT = path.resolve(path.dirname(__filename), '..');
65
+
66
+ // The full P19-brake + declaration field set a self-heal watcher must declare
67
+ // (§265-283, §592). `remediation-actions` is the in-scope anchor (§270).
68
+ export const REQUIRED_FIELDS = [
69
+ 'max-attempts',
70
+ 'max-wall-clock',
71
+ 'backoff',
72
+ 'dedupe-key',
73
+ 'breaker',
74
+ 'max-notification-latency',
75
+ 'audit-location',
76
+ 'remediation-actions',
77
+ 'class',
78
+ ];
79
+
80
+ const ANCHOR_FIELD = 'remediation-actions';
81
+
82
+ // The severity classes an author may declare (§306). The reviewer CONTESTS
83
+ // correctness; the lint only grades that the declared value is well-formed.
84
+ export const SEVERITY_CLASSES = new Set([
85
+ 'recoverable',
86
+ 'critical',
87
+ 'irreversible',
88
+ 'data-loss',
89
+ 'security',
90
+ ]);
91
+
92
+ // A duration WITH units: an integer/decimal followed by ms | s | m | h (§318-323).
93
+ const DURATION_WITH_UNITS_RE = /^\d+(?:\.\d+)?\s*(?:ms|s|m|h)$/i;
94
+
95
+ function fieldLineRe(name) {
96
+ const escaped = name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
97
+ // start-of-line, optional bullet, optional backticks, label, colon, value.
98
+ return new RegExp(`^[ \\t]*(?:[-*+][ \\t]+)?\`?${escaped}\`?[ \\t]*:[ \\t]*(.*)$`, 'im');
99
+ }
100
+
101
+ /** Return the inline value string for a labeled field, or null if absent. */
102
+ export function findField(text, name) {
103
+ const m = fieldLineRe(name).exec(text);
104
+ if (!m) return null;
105
+ // Strip a trailing backtick and surrounding whitespace/backticks from the value.
106
+ return m[1].replace(/`/g, '').trim();
107
+ }
108
+
109
+ /**
110
+ * True if the field's list value is non-empty: either an inline value after the
111
+ * colon, OR at least one bullet line following the field's declaration line.
112
+ */
113
+ export function listFieldIsNonEmpty(text, name) {
114
+ const re = fieldLineRe(name);
115
+ const m = re.exec(text);
116
+ if (!m) return false;
117
+ const inline = m[1].replace(/[`[\]]/g, '').trim();
118
+ if (inline.length > 0) return true;
119
+ // Scan the lines immediately after the field line for bullets, stopping at a
120
+ // blank line or the next labeled field / heading. Strip the single newline
121
+ // that terminates the field line itself so it doesn't read as an empty line.
122
+ const after = text.slice(m.index + m[0].length).replace(/^\r?\n/, '');
123
+ const lines = after.split('\n');
124
+ for (const raw of lines) {
125
+ const line = raw.replace(/\r$/, '');
126
+ if (/^\s*$/.test(line)) break; // blank line ends the block
127
+ if (/^#{1,6}\s/.test(line)) break; // next heading
128
+ if (/^[ \t]*[-*+][ \t]+\S/.test(line)) return true; // a real bullet with content
129
+ if (/^[ \t]*\S+[ \t]*:/.test(line)) break; // next labeled field
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * Grade one spec's text against Standard B's self-heal field schema. Pure — no
136
+ * I/O. Returns { inScope, findings: [{ rule, message }] }.
137
+ */
138
+ export function gradeSelfHealFields(text) {
139
+ const findings = [];
140
+ const inScope = findField(text, ANCHOR_FIELD) !== null;
141
+ if (!inScope) return { inScope: false, findings };
142
+
143
+ // B1 — required fields present.
144
+ for (const field of REQUIRED_FIELDS) {
145
+ if (findField(text, field) === null) {
146
+ findings.push({
147
+ rule: 'B1-missing-field',
148
+ message:
149
+ `self-heal declaration is missing required field "${field}". A watcher's bounded ` +
150
+ `self-heal must declare its full P19 brake set (§265-283) — an unbounded or ` +
151
+ `undeclared brake is the compounding-loop failure the standard forbids.`,
152
+ });
153
+ }
154
+ }
155
+
156
+ // B2 — remediation-actions must not be a no-op (non-empty).
157
+ if (!listFieldIsNonEmpty(text, ANCHOR_FIELD)) {
158
+ findings.push({
159
+ rule: 'B2-noop-remediation',
160
+ message:
161
+ `remediation-actions is empty. A heal that names no concrete operation is a no-op that ` +
162
+ `merely unlocks the escalation path (§286-291) — the anti-no-op floor requires ≥1 ` +
163
+ `substantive action (e.g. re-register-flag, restart-tracker, re-deliver-report).`,
164
+ });
165
+ }
166
+
167
+ // B3 — max-notification-latency must carry units.
168
+ const latency = findField(text, 'max-notification-latency');
169
+ if (latency !== null && latency.length > 0 && !DURATION_WITH_UNITS_RE.test(latency)) {
170
+ findings.push({
171
+ rule: 'B3-latency-unitless',
172
+ message:
173
+ `max-notification-latency "${latency}" must be an explicit duration WITH units ` +
174
+ `(e.g. 120s, 5m) — never a bare number or an adjective (§318-323).`,
175
+ });
176
+ }
177
+
178
+ // B4 — severity class value well-formed.
179
+ const cls = findField(text, 'class');
180
+ if (cls !== null && cls.length > 0 && !SEVERITY_CLASSES.has(cls.toLowerCase())) {
181
+ findings.push({
182
+ rule: 'B4-unknown-severity-class',
183
+ message:
184
+ `severity class "${cls}" is not a recognized class ` +
185
+ `{${[...SEVERITY_CLASSES].join(', ')}}. (The reviewer CONTESTS whether the class is ` +
186
+ `CORRECT for the degradation, §306-310; the lint checks it is well-formed.)`,
187
+ });
188
+ }
189
+
190
+ return { inScope: true, findings };
191
+ }
192
+
193
+ // ── CLI ────────────────────────────────────────────────────────────────────
194
+
195
+ function listSpecFiles() {
196
+ const dir = path.join(ROOT, 'docs', 'specs');
197
+ const out = [];
198
+ const walk = (d) => {
199
+ let entries;
200
+ try {
201
+ entries = fs.readdirSync(d, { withFileTypes: true });
202
+ } catch {
203
+ return;
204
+ }
205
+ for (const e of entries) {
206
+ const p = path.join(d, e.name);
207
+ if (e.isDirectory()) walk(p);
208
+ else if (e.isFile() && e.name.endsWith('.md')) out.push(p);
209
+ }
210
+ };
211
+ walk(dir);
212
+ return out;
213
+ }
214
+
215
+ function main() {
216
+ const argv = process.argv.slice(2);
217
+ const strict = argv.includes('--strict');
218
+ const json = argv.includes('--json');
219
+ const files = argv.filter((a) => !a.startsWith('--'));
220
+ const targets = files.length ? files : listSpecFiles();
221
+
222
+ const allFindings = [];
223
+ for (const file of targets) {
224
+ let text;
225
+ try {
226
+ text = fs.readFileSync(file, 'utf8');
227
+ } catch {
228
+ continue;
229
+ }
230
+ const { findings } = gradeSelfHealFields(text);
231
+ for (const f of findings) allFindings.push({ file: path.relative(ROOT, path.resolve(file)), ...f });
232
+ }
233
+
234
+ if (json) {
235
+ process.stdout.write(JSON.stringify({ findings: allFindings, strict }, null, 2) + '\n');
236
+ } else if (allFindings.length === 0) {
237
+ console.log('lint-self-heal-fields: clean — every self-heal declaration carries its field set.');
238
+ } else {
239
+ const header = strict
240
+ ? 'lint-self-heal-fields: FINDINGS (strict — blocking):'
241
+ : 'lint-self-heal-fields: findings (report-first — non-blocking signal):';
242
+ console.error(header);
243
+ for (const f of allFindings) {
244
+ console.error(` • [${f.rule}] ${f.file}`);
245
+ console.error(` ${f.message}`);
246
+ }
247
+ console.error(
248
+ `\n ${allFindings.length} finding(s). Standard B: docs/STANDARDS-REGISTRY.md ` +
249
+ `("Self-Heal Before Notify").`,
250
+ );
251
+ }
252
+
253
+ if (strict && allFindings.length > 0) process.exit(1);
254
+ process.exit(0);
255
+ }
256
+
257
+ const invokedDirectly =
258
+ process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(__filename);
259
+ if (invokedDirectly) main();