@event4u/agent-config 9.23.0 → 9.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +2 -2
  4. package/dist/agent-src/commands/agent-handoff.md +10 -3
  5. package/dist/agent-src/commands/analyze/conformance.md +182 -0
  6. package/dist/agent-src/commands/analyze/inbox.md +221 -0
  7. package/dist/agent-src/commands/analyze.md +4 -3
  8. package/dist/agent-src/commands/pr/create.md +6 -6
  9. package/dist/agent-src/rules/context-hygiene.md +3 -0
  10. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  11. package/dist/agent-src/rules/evaluator-independence.md +105 -0
  12. package/dist/agent-src/rules/session-canary.md +13 -1
  13. package/dist/agent-src/rules/user-interaction.md +22 -0
  14. package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
  15. package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
  16. package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
  17. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  18. package/dist/cli/registry.js +1 -0
  19. package/dist/cli/registry.js.map +1 -1
  20. package/dist/discovery/deprecation-report.md +1 -1
  21. package/dist/discovery/discovery-manifest.json +96 -22
  22. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  23. package/dist/discovery/discovery-manifest.summary.md +4 -4
  24. package/dist/discovery/orphan-report.md +1 -1
  25. package/dist/discovery/packs.json +11 -8
  26. package/dist/discovery/trust-report.md +3 -3
  27. package/dist/discovery/workspaces.json +9 -6
  28. package/dist/hooks/dispatch.js +1026 -328
  29. package/dist/mcp/registry-manifest.json +2 -2
  30. package/dist/mcp/server.json +2 -2
  31. package/dist/router.json +1 -1
  32. package/package.json +1 -1
  33. package/src/config/evaluator-budgets.json +68 -68
  34. package/src/config/gate-coverage.yml +103 -5
  35. package/src/config/gate-violation-baselines.json +11 -6
  36. package/src/scripts/_dispatch.bash +12 -0
  37. package/src/scripts/before_complete_hook.ts +127 -5
  38. package/src/scripts/check_depth_budget.ts +295 -0
  39. package/src/scripts/conformance_scan.ts +392 -0
  40. package/src/scripts/git_authorization_hook.ts +263 -0
  41. package/src/scripts/hook_manifest.yaml +50 -9
  42. package/src/scripts/hooks/block_unauthorized_git.ts +254 -0
  43. package/src/scripts/hooks/concern_registry.ts +8 -0
  44. package/src/scripts/hooks/evidence_independence.ts +334 -0
  45. package/src/scripts/language_mirror_hook.ts +267 -0
  46. package/src/scripts/lint_decision_impact.ts +246 -0
  47. package/src/scripts/lint_example_fences.ts +609 -0
  48. package/src/scripts/lint_rule_interactions.ts +90 -0
  49. package/src/scripts/lint_rule_migration_ledger.ts +480 -0
  50. package/src/scripts/lint_skill_descriptions.ts +86 -2
  51. package/src/scripts/measure_rule_absoluta.ts +231 -0
  52. package/src/scripts/schemas/rule.schema.json +10 -0
@@ -0,0 +1,609 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * lint_example_fences — hold authored code examples to the rules they sit beside.
4
+ *
5
+ * The gap this closes: every gate in this suite reads the *prose* an artifact
6
+ * teaches. None reads the code it *shows*. A fenced block demonstrating
7
+ * `dangerouslySetInnerHTML` on user input, or `git commit --no-verify`, passes
8
+ * `check_references`, `lint_framework_leakage`, `skill_linter` and every
9
+ * description check — while sitting three paragraphs under a rule that forbids
10
+ * exactly that. The example is the part a reader copies.
11
+ *
12
+ * ## What it scans, and what it deliberately does not
13
+ *
14
+ * Scope is fenced code blocks in the three authored surfaces the roadmap names:
15
+ * `src/rules/*.md`, `src/skills/ * /SKILL.md`, `docs/guidelines/ ** /*.md`. Prose
16
+ * outside a fence is never read — a rule that *names* `--no-verify` in order to
17
+ * forbid it is the common case, and reading its prose would make this gate fire
18
+ * on every artifact that does its job.
19
+ *
20
+ * Patterns are **scoped by the fence's language tag**. `eval(` in a `js` fence
21
+ * is the render-security anti-pattern; the same characters in a `text` fence are
22
+ * usually a grammar. Scoping by tag is what keeps the registry narrow enough to
23
+ * be believed. An untagged fence is scanned only by the language-agnostic rules.
24
+ *
25
+ * ## Registry provenance — and one deliberate omission
26
+ *
27
+ * Seeded, as the roadmap prescribes, from four rules:
28
+ *
29
+ * - `frontend-render-security` / `senior-engineering-discipline` § user-controlled
30
+ * render → the four sink patterns.
31
+ * - `tool-safety` § no hidden credentials → a literal credential in an example.
32
+ * - `git-history-discipline` → hook-bypass and unleased force-push.
33
+ * - `output-discipline` → **intentionally not re-implemented.** Its six
34
+ * placeholder patterns are already owned by `lint_output_slop`, which scans
35
+ * the same fences. Detecting them twice would produce two findings per defect
36
+ * and two suppressions per exception. This is the "do not report what a
37
+ * deterministic gate already owns" rubric line applied to a gate rather than
38
+ * to a reviewer.
39
+ *
40
+ * `git reset --hard` is also deliberately absent. It is Hard-Floor-listed, but
41
+ * this suite documents genuine divergent-state recovery procedures that must
42
+ * show it, so a pattern for it would fire mostly on correct instruction. A gate
43
+ * whose hits are mostly legitimate teaches maintainers to suppress by reflex.
44
+ *
45
+ * ## The escape token is mandatory, and there is no allowlist file
46
+ *
47
+ * A deliberate negative example — "never write this" followed by the thing —
48
+ * declares itself inline:
49
+ *
50
+ * <!-- example-fence-allow: render-innerhtml -- negative example for the XSS section -->
51
+ *
52
+ * on the fence's opening line, the line above it, or at the top of the file
53
+ * (file scope). The reason is required and must be more than a word: an entry a
54
+ * reviewer cannot classify from the comment alone is how a justified exception
55
+ * becomes boilerplate. There is deliberately **no allowlist JSON**: a
56
+ * side-channel file is the shape that grows past 20 entries and silently becomes
57
+ * the budget bypass `autonomous-execution` names.
58
+ *
59
+ * ## Advisory by default
60
+ *
61
+ * `--strict` makes findings exit non-zero. Without it the gate reports and exits
62
+ * 0. This is the sibling gate-integrity roadmap's lifecycle: a new pattern gate
63
+ * over a large corpus full of intentional negatives ships advisory until every
64
+ * current hit is classified, because a gate that lands as an unfixable blocker
65
+ * is a recorded failure in this repository, not a hypothetical one.
66
+ *
67
+ * Exit codes: 0 = clean (or advisory), 1 = findings under `--strict`, 2 = usage.
68
+ */
69
+ import * as fs from 'node:fs';
70
+ import * as os from 'node:os';
71
+ import * as path from 'node:path';
72
+ import { fileURLToPath, pathToFileURL } from 'node:url';
73
+
74
+ import { GateLedger } from './_lib/gate_ledger.js';
75
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
76
+ import { reportScanned } from './_lib/scan_scope.js';
77
+
78
+ const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
79
+ const REAL_REPO_ROOT = path.dirname(path.dirname(SCRIPTS_DIR));
80
+
81
+ let REPO_ROOT = REAL_REPO_ROOT;
82
+
83
+ export function _setRepoRootForTest(root: string): void {
84
+ REPO_ROOT = root;
85
+ }
86
+ export function _resetRepoRootForTest(): void {
87
+ REPO_ROOT = REAL_REPO_ROOT;
88
+ }
89
+
90
+ /** Language-tag families a pattern may be scoped to. `*` = any fence. */
91
+ type LangFamily = 'web' | 'shell' | 'any';
92
+
93
+ export interface FencePattern {
94
+ /** Stable id — the token an escape comment names. */
95
+ readonly id: string;
96
+ /** Which rule this pattern is the code-side of. */
97
+ readonly source: string;
98
+ /** Fence languages the pattern applies to. */
99
+ readonly langs: LangFamily;
100
+ readonly label: string;
101
+ readonly pattern: RegExp;
102
+ /**
103
+ * Optional second gate: the line must NOT match this to count. Used where a
104
+ * safe form of the same call exists (`--force-with-lease`), so the safe form
105
+ * is never a finding a reader has to reason about.
106
+ */
107
+ readonly unless?: RegExp;
108
+ }
109
+
110
+ const WEB_LANGS = new Set([
111
+ 'js', 'javascript', 'jsx', 'ts', 'typescript', 'tsx', 'vue', 'svelte',
112
+ 'html', 'php', 'blade', 'twig', 'erb',
113
+ ]);
114
+ const SHELL_LANGS = new Set(['sh', 'bash', 'zsh', 'shell', 'console', 'terminal']);
115
+
116
+ export const PATTERNS: readonly FencePattern[] = [
117
+ // --- frontend-render-security / senior-engineering-discipline -----------
118
+ {
119
+ id: 'render-innerhtml',
120
+ source: 'frontend-render-security',
121
+ langs: 'web',
122
+ label: 'Unescaped HTML sink (innerHTML / outerHTML assignment)',
123
+ pattern: /\.(inner|outer)HTML\s*=/,
124
+ },
125
+ {
126
+ id: 'render-dangerously-set',
127
+ source: 'frontend-render-security',
128
+ langs: 'web',
129
+ label: 'React dangerouslySetInnerHTML',
130
+ pattern: /dangerouslySetInnerHTML/,
131
+ },
132
+ {
133
+ id: 'render-v-html',
134
+ source: 'frontend-render-security',
135
+ langs: 'web',
136
+ label: 'Vue v-html directive',
137
+ pattern: /\bv-html\b/,
138
+ },
139
+ {
140
+ id: 'render-document-write',
141
+ source: 'frontend-render-security',
142
+ langs: 'web',
143
+ label: 'document.write() sink',
144
+ pattern: /\bdocument\.write\s*\(/,
145
+ },
146
+ {
147
+ id: 'render-eval',
148
+ source: 'frontend-render-security',
149
+ langs: 'web',
150
+ label: 'eval() on a non-constant',
151
+ // A string or numeric literal argument is a constant and cannot carry
152
+ // user input; only a variable / expression argument is the sink.
153
+ pattern: /\beval\s*\(\s*[A-Za-z_$][\w$.[\]]*\s*[),]/,
154
+ },
155
+
156
+ // --- tool-safety § no hidden credentials --------------------------------
157
+ {
158
+ id: 'secret-literal',
159
+ source: 'tool-safety',
160
+ langs: 'any',
161
+ label: 'Credential assigned a literal value in an example',
162
+ pattern:
163
+ /\b(api[_-]?key|apikey|password|passwd|secret|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*["'][^"']{8,}["']/i,
164
+ // Placeholders are the correct way to show the shape and must not fire.
165
+ // `secret-vcs-guard` owns real credentials; this pattern owns the
166
+ // *teaching* of an inline literal, so anything self-evidently fake is out.
167
+ unless:
168
+ /(your[_-]?|xxx|changeme|placeholder|example|dummy|fake|redacted|<[^>]+>|\$\{|process\.env|getenv|os\.environ|\.\.\.)/i,
169
+ },
170
+
171
+ // --- git-history-discipline ---------------------------------------------
172
+ {
173
+ id: 'git-no-verify',
174
+ source: 'git-history-discipline',
175
+ langs: 'shell',
176
+ label: 'Hook bypass (--no-verify / core.hooksPath override)',
177
+ pattern: /--no-verify\b|\bcore\.hooksPath\b/,
178
+ },
179
+ {
180
+ id: 'git-force-push',
181
+ source: 'git-history-discipline',
182
+ langs: 'shell',
183
+ label: 'Force-push without --force-with-lease',
184
+ pattern: /\bgit\s+push\b[^\n]*(--force\b|(?<![\w-])-f(?![\w-]))/,
185
+ unless: /--force-with-lease/,
186
+ },
187
+ ];
188
+
189
+ const PATTERN_IDS = new Set(PATTERNS.map((p) => p.id));
190
+
191
+ export interface Finding {
192
+ readonly file: string;
193
+ readonly line: number;
194
+ readonly rule: string;
195
+ readonly source: string;
196
+ readonly label: string;
197
+ readonly lang: string;
198
+ readonly snippet: string;
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Escape tokens
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * `example-fence-allow: <id>[,<id>] -- <reason>`
207
+ *
208
+ * The reason is captured so the reason-quality check below can reject a word.
209
+ */
210
+ const ALLOW_RE = /example-fence-allow:\s*([\w-]+(?:\s*,\s*[\w-]+)*)\s*--\s*(.+?)\s*(?:-->)?\s*$/i;
211
+ const ALLOW_FILE_RE =
212
+ /example-fence-allow-file:\s*([\w-]+(?:\s*,\s*[\w-]+)*)\s*--\s*(.+?)\s*(?:-->)?\s*$/i;
213
+
214
+ /** A reason must carry real content — three words minimum, and not just the id. */
215
+ export function reasonIsSubstantive(reason: string): boolean {
216
+ const words = reason.trim().split(/\s+/).filter((w) => w.length > 1);
217
+ return words.length >= 3;
218
+ }
219
+
220
+ export interface ParsedAllow {
221
+ readonly ids: string[];
222
+ readonly reason: string;
223
+ readonly substantive: boolean;
224
+ }
225
+
226
+ export function parseAllow(line: string, fileScope = false): ParsedAllow | null {
227
+ const m = (fileScope ? ALLOW_FILE_RE : ALLOW_RE).exec(line);
228
+ if (!m || m[1] === undefined || m[2] === undefined) {
229
+ return null;
230
+ }
231
+ const ids = m[1].split(',').map((s) => s.trim()).filter((s) => s !== '');
232
+ const reason = m[2].trim();
233
+ return { ids, reason, substantive: reasonIsSubstantive(reason) };
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Scanning
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function langMatches(scope: LangFamily, lang: string): boolean {
241
+ if (scope === 'any') {
242
+ return true;
243
+ }
244
+ const l = lang.toLowerCase();
245
+ if (scope === 'web') {
246
+ return WEB_LANGS.has(l);
247
+ }
248
+ return SHELL_LANGS.has(l);
249
+ }
250
+
251
+ /**
252
+ * Scan one authored markdown file's fenced blocks.
253
+ *
254
+ * Fence tracking handles both ``` and ~~~ openers and refuses to treat a longer
255
+ * inner fence as a close — the house convention (`markdown-safe-codeblocks`)
256
+ * wraps ```-bearing content in ~~~, and a naive toggle would read the inner
257
+ * ``` as a close and then scan the following prose as if it were code.
258
+ */
259
+ export function scanFile(rel: string, text: string): Finding[] {
260
+ const lines = text.split(/\r\n|\r|\n/);
261
+ const findings: Finding[] = [];
262
+
263
+ const fileAllows = new Map<string, ParsedAllow>();
264
+ for (const line of lines.slice(0, 40)) {
265
+ const parsed = parseAllow(line, true);
266
+ if (parsed) {
267
+ for (const id of parsed.ids) {
268
+ fileAllows.set(id, parsed);
269
+ }
270
+ }
271
+ }
272
+
273
+ let fenceChar: '`' | '~' | null = null;
274
+ let fenceLen = 0;
275
+ let lang = '';
276
+ /** Allows declared on the opener line or the line directly above it. */
277
+ let blockAllows = new Map<string, ParsedAllow>();
278
+
279
+ for (let i = 0; i < lines.length; i++) {
280
+ const line = lines[i] as string;
281
+ const open = /^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/.exec(line);
282
+
283
+ if (fenceChar === null) {
284
+ if (open && open[1] !== undefined) {
285
+ fenceChar = open[1][0] === '`' ? '`' : '~';
286
+ fenceLen = open[1].length;
287
+ lang = open[2] ?? '';
288
+ blockAllows = new Map();
289
+ for (const candidate of [line, lines[i - 1] ?? '']) {
290
+ const parsed = parseAllow(candidate);
291
+ if (parsed) {
292
+ for (const id of parsed.ids) {
293
+ blockAllows.set(id, parsed);
294
+ }
295
+ }
296
+ }
297
+ }
298
+ continue;
299
+ }
300
+
301
+ // Inside a fence: only a run of the SAME char, at least as long, closes it.
302
+ const close = new RegExp(`^\\s*${fenceChar === '`' ? '`' : '~'}{${String(fenceLen)},}\\s*$`);
303
+ if (close.test(line)) {
304
+ fenceChar = null;
305
+ lang = '';
306
+ continue;
307
+ }
308
+
309
+ for (const p of PATTERNS) {
310
+ if (!langMatches(p.langs, lang)) {
311
+ continue;
312
+ }
313
+ if (!p.pattern.test(line)) {
314
+ continue;
315
+ }
316
+ if (p.unless && p.unless.test(line)) {
317
+ continue;
318
+ }
319
+ const allow = blockAllows.get(p.id) ?? fileAllows.get(p.id);
320
+ if (allow) {
321
+ // An allow with a one-word reason is NOT an allow. Reporting it
322
+ // as a finding is the point: the alternative is a token that
323
+ // silences the gate while recording nothing a reviewer can use.
324
+ if (allow.substantive) {
325
+ continue;
326
+ }
327
+ findings.push({
328
+ file: rel,
329
+ line: i + 1,
330
+ rule: `${p.id}/unsubstantive-reason`,
331
+ source: p.source,
332
+ label: `escape token present but its reason is too thin to review: "${allow.reason}"`,
333
+ lang: lang || '(untagged)',
334
+ snippet: line.trim().slice(0, 140),
335
+ });
336
+ continue;
337
+ }
338
+ findings.push({
339
+ file: rel,
340
+ line: i + 1,
341
+ rule: p.id,
342
+ source: p.source,
343
+ label: p.label,
344
+ lang: lang || '(untagged)',
345
+ snippet: line.trim().slice(0, 140),
346
+ });
347
+ }
348
+ }
349
+ return findings;
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // File discovery
354
+ // ---------------------------------------------------------------------------
355
+
356
+ function* walk(dir: string, match: RegExp): Generator<string> {
357
+ let entries: fs.Dirent[];
358
+ try {
359
+ entries = fs.readdirSync(dir, { withFileTypes: true });
360
+ } catch {
361
+ return;
362
+ }
363
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
364
+ const full = path.join(dir, e.name);
365
+ if (e.isDirectory()) {
366
+ yield* walk(full, match);
367
+ } else if (e.isFile() && match.test(e.name)) {
368
+ yield full;
369
+ }
370
+ }
371
+ }
372
+
373
+ export function collectFiles(root = REPO_ROOT): string[] {
374
+ const out: string[] = [];
375
+ for (const f of walk(path.join(root, 'src', 'rules'), /\.md$/)) out.push(f);
376
+ for (const f of walk(path.join(root, 'src', 'skills'), /^SKILL\.md$/)) out.push(f);
377
+ for (const f of walk(path.join(root, 'docs', 'guidelines'), /\.md$/)) out.push(f);
378
+ return out;
379
+ }
380
+
381
+ function relToRepo(p: string): string {
382
+ const rel = path.relative(REPO_ROOT, p);
383
+ // An explicit `--paths` target outside the repo (a probe in a temp dir)
384
+ // relativises to a wall of `../`, which is unreadable and useless as a
385
+ // copy-paste path. Show it absolute instead.
386
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
387
+ return p;
388
+ }
389
+ return rel.split(path.sep).join('/');
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // CLI
394
+ // ---------------------------------------------------------------------------
395
+
396
+ interface Args {
397
+ readonly json: boolean;
398
+ readonly quiet: boolean;
399
+ readonly strict: boolean;
400
+ readonly paths: string[];
401
+ }
402
+
403
+ function parseArgs(argv: readonly string[]): Args {
404
+ let json = false;
405
+ let quiet = false;
406
+ let strict = false;
407
+ const paths: string[] = [];
408
+ let collecting = false;
409
+ for (const arg of argv) {
410
+ if (arg === '--json') { json = true; collecting = false; }
411
+ else if (arg === '--quiet') { quiet = true; collecting = false; }
412
+ else if (arg === '--strict') { strict = true; collecting = false; }
413
+ else if (arg === '--paths') { collecting = true; }
414
+ else if (arg === '-h' || arg === '--help') {
415
+ process.stdout.write(
416
+ 'usage: lint_example_fences [--json] [--quiet] [--strict] [--paths P ...]\n',
417
+ );
418
+ process.exit(0);
419
+ } else if (collecting) { paths.push(arg); }
420
+ else {
421
+ process.stderr.write(`lint_example_fences: unrecognized argument: ${arg}\n`);
422
+ process.exit(2);
423
+ }
424
+ }
425
+ return { json, quiet, strict, paths };
426
+ }
427
+
428
+ /** The escape comment that would silence one finding, ready to paste. */
429
+ export function suppressionKey(f: Finding): string {
430
+ return `<!-- example-fence-allow: ${f.rule} -- <why this example must show the forbidden form> -->`;
431
+ }
432
+
433
+ export function selfTest(): number {
434
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lef-selftest-'));
435
+ const write = (rel: string, body: string): string => {
436
+ const p = path.join(tmp, rel);
437
+ fs.mkdirSync(path.dirname(p), { recursive: true });
438
+ fs.writeFileSync(p, body, 'utf-8');
439
+ return p;
440
+ };
441
+ const run = (target: string): number =>
442
+ runGateCli(
443
+ REAL_REPO_ROOT,
444
+ 'src/scripts/lint_example_fences.ts',
445
+ ['--paths', target, '--quiet', '--strict'],
446
+ REAL_REPO_ROOT,
447
+ );
448
+
449
+ try {
450
+ const bad = write('bad.md', '# Demo\n\n```js\nel.innerHTML = userInput;\n```\n');
451
+ const clean = write('clean.md', '# Demo\n\n```js\nel.textContent = userInput;\n```\n');
452
+ const prose = write(
453
+ 'prose.md',
454
+ '# Demo\n\nNever assign to `.innerHTML =` on user input.\n',
455
+ );
456
+ const allowed = write(
457
+ 'allowed.md',
458
+ '# Demo\n\n<!-- example-fence-allow: render-innerhtml -- negative example for the XSS section -->\n```js\nel.innerHTML = userInput;\n```\n',
459
+ );
460
+ const thin = write(
461
+ 'thin.md',
462
+ '# Demo\n\n<!-- example-fence-allow: render-innerhtml -- example -->\n```js\nel.innerHTML = userInput;\n```\n',
463
+ );
464
+ return runSelfTest({
465
+ gate: 'lint_example_fences',
466
+ minCases: 3,
467
+ minRejectCases: 2,
468
+ cases: [
469
+ { name: 'an XSS sink inside a fence is rejected', expect: 'reject', run: () => run(bad) },
470
+ { name: 'the safe form passes', expect: 'accept', run: () => run(clean) },
471
+ {
472
+ name: 'the same token in PROSE is not a finding — the gate reads code, not text about code',
473
+ expect: 'accept',
474
+ run: () => run(prose),
475
+ },
476
+ {
477
+ name: 'a declared negative example with a real reason passes',
478
+ expect: 'accept',
479
+ run: () => run(allowed),
480
+ },
481
+ {
482
+ name: 'an escape token whose reason is one word is still rejected',
483
+ expect: 'reject',
484
+ run: () => run(thin),
485
+ },
486
+ ],
487
+ });
488
+ } finally {
489
+ fs.rmSync(tmp, { recursive: true, force: true });
490
+ }
491
+ }
492
+
493
+ export function main(argv?: readonly string[]): number {
494
+ const raw = argv ?? process.argv.slice(2);
495
+ if (raw.includes('--self-test')) {
496
+ return selfTest();
497
+ }
498
+ const args = parseArgs(raw);
499
+
500
+ let files: string[];
501
+ if (args.paths.length > 0) {
502
+ files = [];
503
+ for (const p of args.paths) {
504
+ const target = path.isAbsolute(p) ? p : path.join(REPO_ROOT, p);
505
+ if (!fs.existsSync(target)) {
506
+ process.stderr.write(`error: path does not exist: ${p}\n`);
507
+ return 2;
508
+ }
509
+ if (fs.statSync(target).isDirectory()) {
510
+ files.push(...walk(target, /\.md$/));
511
+ } else {
512
+ files.push(target);
513
+ }
514
+ }
515
+ } else {
516
+ files = collectFiles();
517
+ }
518
+
519
+ const ledger = new GateLedger('lint_example_fences');
520
+ ledger.plan(files.map(relToRepo));
521
+
522
+ const findings: Finding[] = [];
523
+ for (const f of files) {
524
+ const rel = relToRepo(f);
525
+ let text: string;
526
+ try {
527
+ text = fs.readFileSync(f, 'utf-8');
528
+ } catch {
529
+ ledger.fail(rel, 'unreadable');
530
+ continue;
531
+ }
532
+ // This file enumerates the forbidden tokens as its own subject matter;
533
+ // it can never be clean by construction, the same self-exemption
534
+ // `lint_framework_leakage` carries for the neutrality rule.
535
+ if (path.basename(f) === 'lint_example_fences.md') {
536
+ ledger.outOfScope(rel, 'declared_exemption');
537
+ continue;
538
+ }
539
+ const hits = scanFile(rel, text);
540
+ if (hits.length > 0) {
541
+ findings.push(...hits);
542
+ ledger.fail(rel, `${String(hits.length)} example-fence finding(s)`);
543
+ } else {
544
+ ledger.complete(rel);
545
+ }
546
+ }
547
+
548
+ const tally = ledger.finalize();
549
+
550
+ if (args.json) {
551
+ process.stdout.write(
552
+ `${JSON.stringify({ version: 1, strict: args.strict, findings, ledger: tally }, null, 2)}\n`,
553
+ );
554
+ return args.strict && findings.length > 0 ? 1 : 0;
555
+ }
556
+
557
+ if (!args.quiet) {
558
+ let current = '';
559
+ for (const f of findings) {
560
+ if (f.file !== current) {
561
+ process.stdout.write(`\n${f.file}\n`);
562
+ current = f.file;
563
+ }
564
+ process.stdout.write(
565
+ ` L${String(f.line).padStart(4, ' ')} ${f.rule.padEnd(24, ' ')} [${f.lang}] ${f.snippet}\n`,
566
+ );
567
+ process.stdout.write(` allow: ${suppressionKey(f)}\n`);
568
+ }
569
+ }
570
+
571
+ const mode = args.strict ? 'strict' : 'ADVISORY (exit 0; pass --strict to gate)';
572
+ process.stdout.write(
573
+ `\nlint_example_fences: ${String(findings.length)} finding(s) across ` +
574
+ `${String(new Set(findings.map((f) => f.file)).size)} file(s) — ${mode}\n`,
575
+ );
576
+ ledger.report();
577
+ reportScanned({
578
+ gate: 'lint_example_fences',
579
+ scanned: tally.completed + tally.failed,
580
+ units: 'file(s)',
581
+ roots: ['src/rules', 'src/skills', 'docs/guidelines'],
582
+ });
583
+ return args.strict && findings.length > 0 ? 1 : 0;
584
+ }
585
+
586
+ export { PATTERN_IDS };
587
+
588
+ const _HERE = fileURLToPath(import.meta.url);
589
+ function _isCliEntry(): boolean {
590
+ if (process.argv[1] === undefined) {
591
+ return false;
592
+ }
593
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
594
+ if (import.meta.url === argvUrl) {
595
+ return true;
596
+ }
597
+ try {
598
+ return (
599
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
600
+ fs.realpathSync(path.resolve(process.argv[1]))
601
+ );
602
+ } catch {
603
+ return false;
604
+ }
605
+ }
606
+
607
+ if (_isCliEntry() || process.argv[1] === _HERE) {
608
+ process.exit(main());
609
+ }