@event4u/agent-config 9.24.0 → 9.26.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +136 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +182 -0
- package/dist/agent-src/commands/analyze/inbox.md +33 -2
- package/dist/agent-src/commands/review/changes.md +6 -5
- package/dist/agent-src/commands/roadmap/next.md +194 -0
- package/dist/agent-src/commands/roadmap.md +12 -4
- package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +3 -2
- package/dist/agent-src/rules/design-fidelity.md +17 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
- package/dist/agent-src/rules/evaluator-independence.md +111 -0
- package/dist/agent-src/rules/session-canary.md +15 -1
- package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
- package/dist/agent-src/rules/token-budget-discipline.md +6 -3
- package/dist/agent-src/rules/ui-audit-gate.md +3 -0
- package/dist/agent-src/rules/user-interaction.md +23 -0
- package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +4 -1
- package/dist/cli-delegate/cmd_route_explain.js +4 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +146 -28
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +5 -5
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +16 -11
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +12 -7
- package/dist/hooks/dispatch.js +1501 -511
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/nicknameResolver.js +116 -0
- package/dist/server/nicknameResolver.js.map +1 -0
- package/dist/server/routes/ping.js +29 -11
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/shared/interactiveContext.js +73 -0
- package/dist/shared/interactiveContext.js.map +1 -0
- package/dist/shared/nicknamePrefill.js +59 -0
- package/dist/shared/nicknamePrefill.js.map +1 -0
- package/dist/shared/settingsAsks.js +128 -0
- package/dist/shared/settingsAsks.js.map +1 -0
- package/dist/shared/settingsConsent.js +93 -0
- package/dist/shared/settingsConsent.js.map +1 -0
- package/package.json +1 -1
- package/src/config/agents-paths.yml +15 -0
- package/src/config/evaluator-budgets.json +68 -68
- package/src/config/gate-coverage.yml +131 -5
- package/src/config/gate-violation-baselines.json +11 -0
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/_dispatch.bash +12 -0
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +146 -5
- package/src/scripts/bench_ab_v2_run.ts +272 -16
- package/src/scripts/bench_ab_v2_stats.ts +156 -3
- package/src/scripts/check_completion_review.ts +80 -3
- package/src/scripts/check_corpus_staleness.ts +692 -0
- package/src/scripts/check_depth_budget.ts +295 -0
- package/src/scripts/check_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/conformance_scan.ts +422 -0
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/git_authorization_hook.ts +328 -0
- package/src/scripts/hook_manifest.yaml +69 -9
- package/src/scripts/hooks/block_no_verify.ts +3 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +371 -0
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/design_slop_hook.ts +13 -1
- package/src/scripts/hooks/dispatch_hook.ts +97 -2
- package/src/scripts/hooks/evidence_independence.ts +368 -0
- package/src/scripts/language_mirror_hook.ts +456 -0
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_decision_impact.ts +246 -0
- package/src/scripts/lint_example_fences.ts +609 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- package/src/scripts/lint_rule_interactions.ts +90 -0
- package/src/scripts/lint_rule_migration_ledger.ts +480 -0
- package/src/scripts/lint_skill_descriptions.ts +86 -2
- package/src/scripts/memory_learn_hook.ts +109 -6
- package/src/scripts/pr_url_reminder_hook.ts +17 -2
- package/src/scripts/release.ts +38 -1
- package/src/scripts/schemas/review-findings.schema.json +99 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/shared/interactiveContext.ts +98 -0
- package/src/shared/nicknamePrefill.ts +96 -0
- package/src/shared/settingsAsks.ts +176 -0
- package/src/shared/settingsConsent.ts +136 -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
|
+
}
|
|
@@ -148,6 +148,35 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
|
|
|
148
148
|
if (!_isFile(path.join(REPO_ROOT, script))) {
|
|
149
149
|
errors.push(`concerns.${name}: script not found at '${script}'`);
|
|
150
150
|
}
|
|
151
|
+
// `tools:` — the optional per-concern tool filter the dispatcher applies
|
|
152
|
+
// in-process (`_concern_matches_tool`). Validated because the failure mode
|
|
153
|
+
// is silent: the dispatcher fails toward RUNNING the concern on anything
|
|
154
|
+
// malformed, so a typo (`tool:`, a bare string, `[]`) would look like a
|
|
155
|
+
// working filter while filtering nothing. An unvalidated key is worse than
|
|
156
|
+
// no key.
|
|
157
|
+
if ("tools" in spec) {
|
|
158
|
+
const tools = spec["tools"];
|
|
159
|
+
if (!Array.isArray(tools)) {
|
|
160
|
+
errors.push(
|
|
161
|
+
`concerns.${name}: 'tools' must be a list of tool names ` +
|
|
162
|
+
`(got ${pyTypeName(tools)}) — omit the key for "every event"`,
|
|
163
|
+
);
|
|
164
|
+
} else if (tools.length === 0) {
|
|
165
|
+
errors.push(
|
|
166
|
+
`concerns.${name}: 'tools' is an empty list — omit the key, or use ` +
|
|
167
|
+
`["*"], rather than a filter that reads as "no tools"`,
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
for (const t of tools) {
|
|
171
|
+
if (typeof t !== "string" || t.trim() === "") {
|
|
172
|
+
errors.push(
|
|
173
|
+
`concerns.${name}: 'tools' entries must be non-empty strings, ` +
|
|
174
|
+
`got ${pyTypeName(t)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
151
180
|
names.add(name);
|
|
152
181
|
}
|
|
153
182
|
return names;
|