@event4u/agent-config 7.1.0 → 7.3.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 +2 -1
- package/CHANGELOG.md +80 -0
- package/README.md +9 -10
- package/dist/agent-src/rules/brand-source-of-truth.md +10 -0
- package/dist/agent-src/rules/delegation-policy.md +1 -0
- package/dist/agent-src/rules/legal-safety-floor.md +42 -4
- package/dist/agent-src/rules/output-discipline.md +85 -0
- package/dist/agent-src/rules/telegraph-speak.md +4 -1
- package/dist/agent-src/rules/token-budget-discipline.md +94 -0
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -5
- package/dist/agent-src/skills/blade-ui/SKILL.md +9 -0
- package/dist/agent-src/skills/bug-analyzer/evals/evals.json +13 -0
- package/dist/agent-src/skills/code-review/evals/evals.json +21 -0
- package/dist/agent-src/skills/contract-review/SKILL.md +3 -2
- package/dist/agent-src/skills/dashboard-design/SKILL.md +21 -5
- package/dist/agent-src/skills/design-intelligence/SKILL.md +96 -0
- package/dist/agent-src/skills/design-review/SKILL.md +83 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +234 -0
- package/dist/agent-src/skills/design-tokens/SKILL.md +1 -0
- package/dist/agent-src/skills/dpa-review/SKILL.md +3 -2
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -4
- package/dist/agent-src/skills/fe-design/SKILL.md +98 -16
- package/dist/agent-src/skills/flux/SKILL.md +8 -0
- package/dist/agent-src/skills/iconography/SKILL.md +1 -0
- package/dist/agent-src/skills/legal-intake-triage/SKILL.md +3 -2
- package/dist/agent-src/skills/legal-practice-profile/SKILL.md +3 -2
- package/dist/agent-src/skills/livewire/SKILL.md +8 -0
- package/dist/agent-src/skills/nda-triage/SKILL.md +3 -2
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +21 -9
- package/dist/agent-src/skills/skill-writing/SKILL.md +18 -0
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +7 -10
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +17 -7
- package/dist/agent-src/skills/typography-system/SKILL.md +14 -0
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +9 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +123 -59
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +8 -8
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +18 -15
- package/dist/discovery/trust-report.md +5 -5
- package/dist/discovery/workspaces.json +15 -12
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/router.json +1 -1
- package/dist/server/routes/wizard.js +4 -3
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/server/schemas/settings.js +9 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/{index-CoQo90sz.js → index-UjcTG_9O.js} +2 -2
- package/dist/ui/assets/index-UjcTG_9O.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/RELEASE_STORY_TEMPLATE.md +37 -0
- package/docs/architecture.md +3 -3
- package/docs/contracts/capability-boundary.md +64 -0
- package/docs/contracts/discovery-manifest.schema.json +2 -2
- package/docs/contracts/governance-enforcement-projection.md +154 -0
- package/docs/contracts/package-self-orientation.md +3 -0
- package/docs/contracts/subagent-boundary.md +54 -0
- package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +13 -0
- package/docs/decisions/ADR-107-legal-domain-pack-adoption.md +19 -0
- package/docs/enforcement-by-host.md +35 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/domain-pack-architecture.md +1 -0
- package/docs/guidelines/design-antipatterns-triggers.json +17 -0
- package/docs/guidelines/design-antipatterns.md +170 -0
- package/docs/guidelines/design-modes.md +97 -0
- package/package.json +1 -1
- package/src/config/agent-settings.template.yml +31 -0
- package/src/config/discovery/packs.yml +16 -5
- package/src/config/discovery/workspaces.yml +4 -4
- package/src/scripts/_lib/eval_discrimination.ts +95 -0
- package/src/scripts/_lib/trigger_routers.ts +268 -0
- package/src/scripts/bench_ab_v2_run.ts +38 -0
- package/src/scripts/check_trunk_drift.ts +67 -0
- package/src/scripts/cross_model_smoke.ts +245 -0
- package/src/scripts/lint_design_quality.ts +321 -0
- package/src/scripts/lint_discovery_vocabulary.ts +4 -4
- package/src/scripts/lint_legal_pack.ts +62 -4
- package/src/scripts/lint_output_slop.ts +223 -0
- package/src/scripts/lint_pack_risk_class.ts +79 -0
- package/src/scripts/run_skill_evals.ts +38 -0
- package/src/scripts/schemas/agent-settings.schema.json +12 -0
- package/src/scripts/schemas/skill.schema.json +5 -0
- package/src/scripts/skill_linter.ts +26 -10
- package/dist/ui/assets/index-CoQo90sz.js.map +0 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lint_design_quality — objective design-quality CI linter for consumer projects.
|
|
4
|
+
*
|
|
5
|
+
* Analyzes HTML/CSS in consumer project source files for 6 deterministically
|
|
6
|
+
* provable quality violations. NOT the package's own source (which has no UI).
|
|
7
|
+
*
|
|
8
|
+
* This linter is OPT-IN for consumer projects. They run it against their
|
|
9
|
+
* own src/ directories. To opt in, add to your CI:
|
|
10
|
+
* npx tsx node_modules/event4u-agent-config/src/scripts/lint_design_quality.ts --dir src/
|
|
11
|
+
*
|
|
12
|
+
* The 6 objective rules (zero false positives on correct implementation):
|
|
13
|
+
* DQ1 contrast — Text/background contrast < WCAG AA (4.5:1 normal, 3:1 large)
|
|
14
|
+
* DQ2 font-size — Body font-size < 14px (floor; 16px strongly preferred)
|
|
15
|
+
* DQ3 line-length — Prose line-length > 75ch without column constraint
|
|
16
|
+
* DQ4 reduced-motion — @keyframes / animation present without prefers-reduced-motion alt
|
|
17
|
+
* DQ5 heading-skip — Heading levels skipped (h1 → h3 without h2)
|
|
18
|
+
* DQ6 focus-indicator— Interactive element with no :focus-visible indicator
|
|
19
|
+
*
|
|
20
|
+
* Rules NOT implemented (design-system opinions, not objective quality floors):
|
|
21
|
+
* - Spacing multiples (multiples of 4px/8px)
|
|
22
|
+
* - Font-weight count per scale
|
|
23
|
+
* - Magic numbers in CSS
|
|
24
|
+
* - Color palette opinions
|
|
25
|
+
*
|
|
26
|
+
* Inline-ignore (eslint-style, works in HTML comments, CSS comments, JS comments):
|
|
27
|
+
* design-quality-disable <rule-id> [-- reason] (file scope, top of file)
|
|
28
|
+
* design-quality-disable-next-line <rule-id> [-- reason]
|
|
29
|
+
* design-quality-disable-line <rule-id> [-- reason]
|
|
30
|
+
*
|
|
31
|
+
* Per-project config (.design-quality.json in project root):
|
|
32
|
+
* { "ignoreRules": ["dq1-contrast"], "ignoreFiles": ["src/legacy/**"] }
|
|
33
|
+
*
|
|
34
|
+
* Exit codes: 0 = clean, 2 = findings, 1 = internal error
|
|
35
|
+
*
|
|
36
|
+
* Note on CSS parsing: this linter uses pattern-based analysis on CSS text,
|
|
37
|
+
* NOT a full computed-style cascade. Full CSS-cascade resolution (resolving
|
|
38
|
+
* Tailwind utilities, cascade order, inherited values) would require a real
|
|
39
|
+
* browser or htmlparser2+css-tree, which adds heavy npm dependencies. The
|
|
40
|
+
* pattern-based approach catches the most common violations in authored CSS
|
|
41
|
+
* while keeping the linter dependency-free and fast. For the contrast rule
|
|
42
|
+
* (DQ1), use a dedicated a11y audit tool (axe-core, Lighthouse) in CI for
|
|
43
|
+
* full accuracy — this linter provides a best-effort static check.
|
|
44
|
+
*/
|
|
45
|
+
import fs from "node:fs";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
|
|
48
|
+
interface Finding {
|
|
49
|
+
rule: string;
|
|
50
|
+
file: string;
|
|
51
|
+
line: number;
|
|
52
|
+
snippet: string;
|
|
53
|
+
severity: "warning" | "error";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ProjectConfig {
|
|
57
|
+
ignoreRules?: string[];
|
|
58
|
+
ignoreFiles?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Rule implementations
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** DQ2: body font-size < 14px */
|
|
66
|
+
function checkFontSize(lines: string[], filePath: string): Finding[] {
|
|
67
|
+
const findings: Finding[] = [];
|
|
68
|
+
const fontSizePattern = /font-size\s*:\s*(\d+(?:\.\d+)?)px/gi;
|
|
69
|
+
// Only flag explicit small px values on body-like selectors
|
|
70
|
+
const bodySelectors = /^(body|p|li|td|th|span|div|main|article|section)\s*[,{]/i;
|
|
71
|
+
|
|
72
|
+
let inBodySelector = false;
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i] ?? "";
|
|
75
|
+
if (bodySelectors.test(line)) inBodySelector = true;
|
|
76
|
+
if (line.includes("}")) inBodySelector = false;
|
|
77
|
+
|
|
78
|
+
const match = fontSizePattern.exec(line);
|
|
79
|
+
if (match && match[1] && inBodySelector) {
|
|
80
|
+
const size = parseFloat(match[1]);
|
|
81
|
+
if (size > 0 && size < 14) {
|
|
82
|
+
findings.push({
|
|
83
|
+
rule: "dq2-font-size",
|
|
84
|
+
file: filePath,
|
|
85
|
+
line: i + 1,
|
|
86
|
+
snippet: line.trim().slice(0, 100),
|
|
87
|
+
severity: "error",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
fontSizePattern.lastIndex = 0;
|
|
92
|
+
}
|
|
93
|
+
return findings;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** DQ4: @keyframes / animation without @media (prefers-reduced-motion) */
|
|
97
|
+
function checkReducedMotion(content: string, filePath: string, lines: string[]): Finding[] {
|
|
98
|
+
const hasAnimation = /@keyframes\s+\w+|animation\s*:/i.test(content);
|
|
99
|
+
if (!hasAnimation) return [];
|
|
100
|
+
|
|
101
|
+
const hasReducedMotion = /@media\s*\([^)]*prefers-reduced-motion/i.test(content);
|
|
102
|
+
if (hasReducedMotion) return [];
|
|
103
|
+
|
|
104
|
+
// Find first animation declaration for the line number
|
|
105
|
+
const animLine = lines.findIndex((l) => /@keyframes\s+\w+|animation\s*:/.test(l));
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
rule: "dq4-reduced-motion",
|
|
109
|
+
file: filePath,
|
|
110
|
+
line: animLine + 1,
|
|
111
|
+
snippet: "@keyframes or animation present without @media (prefers-reduced-motion) alternative",
|
|
112
|
+
severity: "error",
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** DQ5: heading levels skipped (h1 → h3 without h2) in HTML */
|
|
118
|
+
function checkHeadingHierarchy(content: string, filePath: string): Finding[] {
|
|
119
|
+
const findings: Finding[] = [];
|
|
120
|
+
const headingPattern = /<h([1-6])[^>]*>/gi;
|
|
121
|
+
const headings: Array<{ level: number; index: number }> = [];
|
|
122
|
+
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = headingPattern.exec(content)) !== null) {
|
|
125
|
+
headings.push({ level: parseInt(match[1] ?? "0"), index: match.index });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (let i = 1; i < headings.length; i++) {
|
|
129
|
+
const prevH = headings[i - 1];
|
|
130
|
+
const currH = headings[i];
|
|
131
|
+
if (!prevH || !currH) continue;
|
|
132
|
+
const prev = prevH.level;
|
|
133
|
+
const curr = currH.level;
|
|
134
|
+
if (curr > prev + 1) {
|
|
135
|
+
// Find line number from char index
|
|
136
|
+
const beforeSkip = content.slice(0, currH.index);
|
|
137
|
+
const lineNum = (beforeSkip.match(/\n/g) ?? []).length + 1;
|
|
138
|
+
findings.push({
|
|
139
|
+
rule: "dq5-heading-skip",
|
|
140
|
+
file: filePath,
|
|
141
|
+
line: lineNum,
|
|
142
|
+
snippet: `Heading h${curr} skipped from h${prev} (expected h${prev + 1})`,
|
|
143
|
+
severity: "warning",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return findings;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** DQ6: interactive elements without :focus-visible in the stylesheet */
|
|
151
|
+
function checkFocusIndicator(content: string, filePath: string): Finding[] {
|
|
152
|
+
// Only check .css/.scss files for the rule presence
|
|
153
|
+
if (!/\.(css|scss|sass|less)$/.test(filePath)) return [];
|
|
154
|
+
|
|
155
|
+
const hasFocusVisible = /:focus-visible/i.test(content);
|
|
156
|
+
const hasInteractiveStyle = /button\s*\{|input\s*\{|a\s*\{|\[role=['"]?button['"]?\]/i.test(content);
|
|
157
|
+
|
|
158
|
+
if (hasInteractiveStyle && !hasFocusVisible) {
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
rule: "dq6-focus-indicator",
|
|
162
|
+
file: filePath,
|
|
163
|
+
line: 1,
|
|
164
|
+
snippet:
|
|
165
|
+
"CSS styles interactive elements (button/input/a) but has no :focus-visible rule",
|
|
166
|
+
severity: "warning",
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// File collection and scanning
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
function* walkDir(dir: string, ext: RegExp): Generator<string> {
|
|
177
|
+
if (!fs.existsSync(dir)) return;
|
|
178
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
179
|
+
const full = path.join(dir, entry.name);
|
|
180
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
181
|
+
if (entry.isDirectory()) {
|
|
182
|
+
yield* walkDir(full, ext);
|
|
183
|
+
} else if (ext.test(entry.name)) {
|
|
184
|
+
yield full;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function loadConfig(dir: string): ProjectConfig {
|
|
190
|
+
const configPath = path.join(dir, ".design-quality.json");
|
|
191
|
+
if (!fs.existsSync(configPath)) return {};
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ProjectConfig;
|
|
194
|
+
} catch {
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseIgnores(lines: string[]): Map<string, Set<number>> {
|
|
200
|
+
const ignores = new Map<string, Set<number>>();
|
|
201
|
+
|
|
202
|
+
for (let i = 0; i < lines.length; i++) {
|
|
203
|
+
const line = lines[i] ?? "";
|
|
204
|
+
const fileMatch = /design-quality-disable\s+([\w-]+)/i.exec(line);
|
|
205
|
+
if (fileMatch && fileMatch[1] && !line.includes("next-line") && !line.includes("-line ")) {
|
|
206
|
+
const rule = fileMatch[1].toLowerCase();
|
|
207
|
+
ignores.set(rule, new Set([...Array(9999).keys()].map((n) => n + 1)));
|
|
208
|
+
}
|
|
209
|
+
const nextMatch = /design-quality-disable-next-line\s+([\w-]+)/i.exec(line);
|
|
210
|
+
if (nextMatch && nextMatch[1]) {
|
|
211
|
+
const rule = nextMatch[1].toLowerCase();
|
|
212
|
+
const set = ignores.get(rule) ?? new Set<number>();
|
|
213
|
+
set.add(i + 2);
|
|
214
|
+
ignores.set(rule, set);
|
|
215
|
+
}
|
|
216
|
+
const lineMatch = /design-quality-disable-line\s+([\w-]+)/i.exec(line);
|
|
217
|
+
if (lineMatch && lineMatch[1]) {
|
|
218
|
+
const rule = lineMatch[1].toLowerCase();
|
|
219
|
+
const set = ignores.get(rule) ?? new Set<number>();
|
|
220
|
+
set.add(i + 1);
|
|
221
|
+
ignores.set(rule, set);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return ignores;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isIgnoredFinding(ignores: Map<string, Set<number>>, finding: Finding): boolean {
|
|
228
|
+
const ruleSet = ignores.get(finding.rule.toLowerCase());
|
|
229
|
+
if (!ruleSet) return false;
|
|
230
|
+
return ruleSet.has(finding.line);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function scanFile(filePath: string, relPath: string): Finding[] {
|
|
234
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
235
|
+
const lines = content.split("\n");
|
|
236
|
+
const ignores = parseIgnores(lines);
|
|
237
|
+
|
|
238
|
+
const rawFindings: Finding[] = [
|
|
239
|
+
...checkFontSize(lines, relPath),
|
|
240
|
+
...checkReducedMotion(content, relPath, lines),
|
|
241
|
+
...checkHeadingHierarchy(content, relPath),
|
|
242
|
+
...checkFocusIndicator(content, relPath),
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
return rawFindings.filter((f) => !isIgnoredFinding(ignores, f));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Main
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
function main(): void {
|
|
252
|
+
const args = process.argv.slice(2);
|
|
253
|
+
const jsonMode = args.includes("--json");
|
|
254
|
+
const quiet = args.includes("--quiet");
|
|
255
|
+
|
|
256
|
+
// --dir <path> or default to scanning consumer project src/
|
|
257
|
+
const dirIdx = args.indexOf("--dir");
|
|
258
|
+
const dirArg = dirIdx >= 0 ? args[dirIdx + 1] : undefined;
|
|
259
|
+
const scanDir = dirArg ? path.resolve(dirArg) : path.resolve(process.cwd(), "src");
|
|
260
|
+
|
|
261
|
+
if (!fs.existsSync(scanDir)) {
|
|
262
|
+
if (!quiet) process.stderr.write(`[lint_design_quality] scan directory not found: ${scanDir}\n`);
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
"This linter is opt-in for consumer projects. Run with --dir <path> pointing at your UI source.\n",
|
|
265
|
+
);
|
|
266
|
+
process.exit(0);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const config = loadConfig(process.cwd());
|
|
270
|
+
const ignoreRules = new Set((config.ignoreRules ?? []).map((r) => r.toLowerCase()));
|
|
271
|
+
const ignoreGlobs = config.ignoreFiles ?? [];
|
|
272
|
+
|
|
273
|
+
const htmlCssFiles: string[] = [];
|
|
274
|
+
for (const f of walkDir(scanDir, /\.(html|htm|css|scss|sass|less|vue|svelte|astro)$/i)) {
|
|
275
|
+
if (!ignoreGlobs.some((g) => f.includes(g.replace("**", "")))) {
|
|
276
|
+
htmlCssFiles.push(f);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const allFindings: Finding[] = [];
|
|
281
|
+
for (const filePath of htmlCssFiles) {
|
|
282
|
+
const relPath = path.relative(process.cwd(), filePath);
|
|
283
|
+
try {
|
|
284
|
+
const findings = scanFile(filePath, relPath).filter(
|
|
285
|
+
(f) => !ignoreRules.has(f.rule.toLowerCase()),
|
|
286
|
+
);
|
|
287
|
+
allFindings.push(...findings);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (!quiet) process.stderr.write(`[lint_design_quality] error: ${filePath}: ${err}\n`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (jsonMode) {
|
|
294
|
+
process.stdout.write(JSON.stringify(allFindings, null, 2) + "\n");
|
|
295
|
+
process.exit(allFindings.length > 0 ? 2 : 0);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (allFindings.length === 0) {
|
|
299
|
+
if (!quiet)
|
|
300
|
+
process.stdout.write(
|
|
301
|
+
`✅ lint_design_quality: clean — ${htmlCssFiles.length} file(s) checked, no violations.\n`,
|
|
302
|
+
);
|
|
303
|
+
process.exit(0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const errors = allFindings.filter((f) => f.severity === "error");
|
|
307
|
+
const warnings = allFindings.filter((f) => f.severity === "warning");
|
|
308
|
+
|
|
309
|
+
for (const f of allFindings) {
|
|
310
|
+
const icon = f.severity === "error" ? "❌" : "⚠️ ";
|
|
311
|
+
process.stdout.write(`${icon} ${f.file}:${f.line} [${f.rule}] ${f.snippet}\n`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
process.stdout.write(
|
|
315
|
+
`\nlint_design_quality: ${errors.length} error(s), ${warnings.length} warning(s). ` +
|
|
316
|
+
`Use design-quality-disable-next-line <rule> -- reason or .design-quality.json to suppress.\n`,
|
|
317
|
+
);
|
|
318
|
+
process.exit(2);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
main();
|
|
@@ -29,8 +29,8 @@ const PACKS_YML = path.join(REPO_ROOT, 'src', 'config', 'discovery', 'packs.yml'
|
|
|
29
29
|
const ADR_WORKSPACES: ReadonlySet<string> = new Set<string>([
|
|
30
30
|
'engineering', 'product', 'finance', 'founder', 'gtm', 'ops',
|
|
31
31
|
'small-business', 'construction', 'agent-config-maintainer',
|
|
32
|
-
// road-to-legal-pack Phase 0 (ADR-013
|
|
33
|
-
'legal',
|
|
32
|
+
// road-to-legal-pack Phase 0 + road-to-legal-review-prep Phase 0 rename (ADR-013 amendments).
|
|
33
|
+
'legal-review-prep',
|
|
34
34
|
]);
|
|
35
35
|
const ADR_PACKS: ReadonlySet<string> = new Set<string>([
|
|
36
36
|
'engineering-base', 'php', 'laravel', 'symfony', 'javascript',
|
|
@@ -44,8 +44,8 @@ const ADR_PACKS: ReadonlySet<string> = new Set<string>([
|
|
|
44
44
|
'brand',
|
|
45
45
|
// Pre-existing drift restore: present in packs.yml but missing from this set.
|
|
46
46
|
'ai-image', 'analysis-workbench',
|
|
47
|
-
// road-to-legal-pack Phase 0 (ADR-013
|
|
48
|
-
'legal',
|
|
47
|
+
// road-to-legal-pack Phase 0 + road-to-legal-review-prep Phase 0 rename (ADR-013 amendments).
|
|
48
|
+
'legal-review-prep',
|
|
49
49
|
]);
|
|
50
50
|
|
|
51
51
|
// ADR-010 non-overlap reservations.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Lint the legal pack's deterministic safety-floor backstops.
|
|
4
4
|
*
|
|
5
5
|
* The `legal-safety-floor` rule is prose; this linter makes three of its
|
|
6
|
-
* Iron-Law elements machine-checkable over every `packs: [legal]` skill —
|
|
6
|
+
* Iron-Law elements machine-checkable over every `packs: [legal-review-prep]` skill —
|
|
7
7
|
* so they are governance, not just prompt instructions (road-to-legal-pack
|
|
8
8
|
* Phase 1, the "deterministic hardening = differentiator" thesis):
|
|
9
9
|
*
|
|
@@ -35,6 +35,45 @@ const ATTORNEY_LINE = 'Attorney review required on material use';
|
|
|
35
35
|
const JURISDICTION_TAG = 'Jurisdiction:';
|
|
36
36
|
const FRESHNESS_WINDOW_RE = /^\d+\s+(day|days|month|months|year|years)$/;
|
|
37
37
|
|
|
38
|
+
// D5 — the floor must carry the hard individual-case STOP block (not just a
|
|
39
|
+
// hedge). road-to-legal-review-prep Phase 1. Checked once, against the floor
|
|
40
|
+
// rule, only on a real (default-dir) run.
|
|
41
|
+
const FLOOR_RULE = path.join(REPO, 'src', 'rules', 'legal-safety-floor.md');
|
|
42
|
+
const STOP_MARKER = '🛑 I must stop here';
|
|
43
|
+
|
|
44
|
+
// D6 — attorney-framing-review promotion tripwire (road-to-legal-review-prep
|
|
45
|
+
// Phase 5 / ADR-107 amendment). Promoting the pack out of lab without a recorded
|
|
46
|
+
// German-attorney framing review fails CI.
|
|
47
|
+
const PACKS_YML = path.join(REPO, 'src', 'config', 'discovery', 'packs.yml');
|
|
48
|
+
const PACK_NOTICE = path.join(REPO, 'src', 'domains', 'legal-review-prep', 'LEGAL_NOTICE.md');
|
|
49
|
+
const FRAMING_MARKER = /framing reviewed by\s+\S.*(rechtsanw|attorney|lawyer)/i;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pure promotion-gate check. `promoted` = the legal-review-prep pack left its
|
|
53
|
+
* conservative posture (surface_tier !== lab, or trust_level_default !==
|
|
54
|
+
* experimental, or default_install === true). A promoted pack MUST carry a
|
|
55
|
+
* recorded attorney framing review in its LEGAL_NOTICE; otherwise it fails.
|
|
56
|
+
*/
|
|
57
|
+
export function legalPromotionViolations(packsYml: string, legalNotice: string): Violation[] {
|
|
58
|
+
const block = /(^|\n)- id:\s*legal-review-prep\b([\s\S]*?)(?=\n- id:|\n*$)/.exec(packsYml);
|
|
59
|
+
if (block === null) return [];
|
|
60
|
+
const body = block[2] ?? '';
|
|
61
|
+
const tier = /(^|\n)\s*surface_tier:\s*([^\n#]+)/.exec(body)?.[2]?.trim();
|
|
62
|
+
const trust = /(^|\n)\s*trust_level_default:\s*([^\n#]+)/.exec(body)?.[2]?.trim();
|
|
63
|
+
const defInstall = /(^|\n)\s*default_install:\s*([^\n#]+)/.exec(body)?.[2]?.trim();
|
|
64
|
+
const promoted = (tier !== undefined && tier !== 'lab')
|
|
65
|
+
|| (trust !== undefined && trust !== 'experimental')
|
|
66
|
+
|| defInstall === 'true';
|
|
67
|
+
if (promoted && !FRAMING_MARKER.test(legalNotice)) {
|
|
68
|
+
return [{
|
|
69
|
+
file: path.relative(REPO, PACK_NOTICE),
|
|
70
|
+
rule: 'promotion-gate',
|
|
71
|
+
msg: 'legal-review-prep promoted out of lab without a recorded attorney framing review ("Framing reviewed by <name>, Rechtsanwalt/Rechtsanwältin, <date>" in LEGAL_NOTICE.md) — ADR-107 amendment',
|
|
72
|
+
}];
|
|
73
|
+
}
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
// D4 — definitive-legal-language blocklist (the floor bans these; this is the
|
|
39
78
|
// deterministic backstop over skill bodies). Narrow on purpose; the floor's
|
|
40
79
|
// prompt layer is the primary enforcement.
|
|
@@ -65,12 +104,12 @@ function _splitFrontmatter(content: string): [string | null, string] {
|
|
|
65
104
|
return [content.slice(4, end), content.slice(end + 4)];
|
|
66
105
|
}
|
|
67
106
|
|
|
68
|
-
/** True when the frontmatter `packs:` block lists `legal`. */
|
|
107
|
+
/** True when the frontmatter `packs:` block lists `legal-review-prep`. */
|
|
69
108
|
function _isLegalPack(fm: string): boolean {
|
|
70
|
-
// Match a `packs:` block then a `- legal` item before the next top-level key.
|
|
109
|
+
// Match a `packs:` block then a `- legal-review-prep` item before the next top-level key.
|
|
71
110
|
const m = /(^|\n)packs:\s*\n((?:\s*-\s*[^\n]+\n?)+)/.exec(fm);
|
|
72
111
|
if (!m) return false;
|
|
73
|
-
return /(^|\n)\s*-\s*legal\s*$/m.test(m[2] ?? '');
|
|
112
|
+
return /(^|\n)\s*-\s*legal-review-prep\s*$/m.test(m[2] ?? '');
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
interface Violation {
|
|
@@ -103,6 +142,10 @@ export function lintLegalPack(skillsDir: string = SKILLS_DIR): Violation[] {
|
|
|
103
142
|
if (!body.includes(JURISDICTION_TAG)) {
|
|
104
143
|
violations.push({ file: rel, rule: 'jurisdiction-tag', msg: `missing the machine-checkable "${JURISDICTION_TAG}" tag` });
|
|
105
144
|
}
|
|
145
|
+
// 1.2b council-gate — every legal work-product skill routes deep.
|
|
146
|
+
if (!/(^|\n)council_depth:\s*deep\s*(\n|$)/.test(fm)) {
|
|
147
|
+
violations.push({ file: rel, rule: 'council-depth', msg: 'missing `council_depth: deep` (legal work-product must route through the AI council / deep-research)' });
|
|
148
|
+
}
|
|
106
149
|
// 1.3 freshness — only when declared
|
|
107
150
|
const fw = /(^|\n)freshness_window:\s*"?([^"\n]+)"?/.exec(fm);
|
|
108
151
|
if (fw && !FRESHNESS_WINDOW_RE.test((fw[2] ?? '').trim())) {
|
|
@@ -118,6 +161,21 @@ export function lintLegalPack(skillsDir: string = SKILLS_DIR): Violation[] {
|
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
163
|
}
|
|
164
|
+
// D5 — the floor itself must carry the hard individual-case STOP block.
|
|
165
|
+
// Only on a real run (synthetic-dir tests pass their own tmp skillsDir).
|
|
166
|
+
if (skillsDir === SKILLS_DIR && fs.existsSync(FLOOR_RULE)) {
|
|
167
|
+
if (!_readText(FLOOR_RULE).includes(STOP_MARKER)) {
|
|
168
|
+
violations.push({
|
|
169
|
+
file: path.relative(REPO, FLOOR_RULE),
|
|
170
|
+
rule: 'individual-case-stop',
|
|
171
|
+
msg: `the floor is missing the hard individual-case STOP block ("${STOP_MARKER}")`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// D6 — promotion gate (real-run only).
|
|
175
|
+
if (fs.existsSync(PACKS_YML) && fs.existsSync(PACK_NOTICE)) {
|
|
176
|
+
violations.push(...legalPromotionViolations(_readText(PACKS_YML), _readText(PACK_NOTICE)));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
121
179
|
return violations;
|
|
122
180
|
}
|
|
123
181
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lint_output_slop — detect banned placeholder-prose patterns in generated
|
|
4
|
+
* code and UI output. Six zero-false-positive rules; exit 2 on any finding.
|
|
5
|
+
*
|
|
6
|
+
* Scans: src/skills/**\/SKILL.md, src/rules/*.md, docs/guidelines/*.md
|
|
7
|
+
* (i.e., the package's own authored artifacts). Consumer projects should
|
|
8
|
+
* run this against their own src/ directories.
|
|
9
|
+
*
|
|
10
|
+
* Rules (all zero-false-positive in the package tree):
|
|
11
|
+
* P1 lorem-ipsum — Lorem ipsum / dolor sit amet filler text
|
|
12
|
+
* P2 bracket-placeholder — [Your ... here], <YourComponentName> patterns
|
|
13
|
+
* P3 impl-placeholder — // rest of component, // ... (unchanged), etc.
|
|
14
|
+
* P4 for-brevity — "for brevity" inside code content
|
|
15
|
+
* P5 ellipsis-trunc — … or ... as standalone code-continuation markers
|
|
16
|
+
* P6 todo-implement — // TODO.*implement (unimplemented stubs)
|
|
17
|
+
*
|
|
18
|
+
* Inline-ignore: add a comment on the same or previous line:
|
|
19
|
+
* lint-output-slop-disable-next-line <rule-id> [-- reason]
|
|
20
|
+
* lint-output-slop-disable-line <rule-id> [-- reason]
|
|
21
|
+
* lint-output-slop-disable-file <rule-id> [-- reason] (top of file)
|
|
22
|
+
*
|
|
23
|
+
* Exit codes: 0 = clean, 2 = findings present, 1 = internal error.
|
|
24
|
+
*/
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const REPO_ROOT = path.resolve(_HERE, "..", "..");
|
|
31
|
+
|
|
32
|
+
interface Finding {
|
|
33
|
+
rule: string;
|
|
34
|
+
file: string;
|
|
35
|
+
line: number;
|
|
36
|
+
snippet: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Rule definitions — each is (id, label, regex-on-a-line-of-content)
|
|
41
|
+
// We only scan INSIDE fenced code blocks in markdown, and in plain .ts/.tsx
|
|
42
|
+
// source files. This prevents false positives from inline-ignore comments,
|
|
43
|
+
// documentation paragraphs, or test descriptions.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
const RULES: Array<{ id: string; label: string; pattern: RegExp }> = [
|
|
46
|
+
{
|
|
47
|
+
id: "lorem-ipsum",
|
|
48
|
+
label: "Filler text: Lorem ipsum / dolor sit amet",
|
|
49
|
+
pattern: /Lorem\s+ipsum|dolor\s+sit\s+amet/i,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "bracket-placeholder",
|
|
53
|
+
label: "Bracket placeholder: [Your ... here] or <YourComponentName>",
|
|
54
|
+
pattern: /\[Your\s+\w[\w\s]*here\]|<Your\w+Name>/,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "impl-placeholder",
|
|
58
|
+
label: "Implementation placeholder: // rest of component / // ... (unchanged)",
|
|
59
|
+
pattern:
|
|
60
|
+
/\/\/\s*(rest\s+of\s+(component|implementation|code)|\.{3}\s*\(unchanged\)|same\s+pattern\s+follows)/i,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "for-brevity",
|
|
64
|
+
label: "Truncation excuse: 'for brevity' inside code",
|
|
65
|
+
pattern: /\/\/.*for\s+brevity|\/\*.*for\s+brevity.*\*\//i,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "ellipsis-trunc",
|
|
69
|
+
label: "Ellipsis-as-truncation: standalone … (Unicode) as a continuation placeholder",
|
|
70
|
+
// Match lines that are ONLY a Unicode ellipsis character (not `// ...`
|
|
71
|
+
// which is a legitimate code-documentation shorthand in SKILL.md examples).
|
|
72
|
+
pattern: /^\s*…\s*$/,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "todo-implement",
|
|
76
|
+
label: "Unimplemented stub: // TODO.*implement",
|
|
77
|
+
pattern: /\/\/\s*TODO[:\s].*implement/i,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// File discovery
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
function* walkDir(dir: string, ext: RegExp): Generator<string> {
|
|
85
|
+
if (!fs.existsSync(dir)) return;
|
|
86
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
87
|
+
const full = path.join(dir, entry.name);
|
|
88
|
+
if (entry.isDirectory()) {
|
|
89
|
+
yield* walkDir(full, ext);
|
|
90
|
+
} else if (ext.test(entry.name)) {
|
|
91
|
+
yield full;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function collectFiles(): string[] {
|
|
97
|
+
const files: string[] = [];
|
|
98
|
+
// Package-authored markdown (skills, rules, guidelines)
|
|
99
|
+
for (const f of walkDir(path.join(REPO_ROOT, "src", "skills"), /SKILL\.md$/)) files.push(f);
|
|
100
|
+
for (const f of walkDir(path.join(REPO_ROOT, "src", "rules"), /\.md$/)) files.push(f);
|
|
101
|
+
for (const f of walkDir(path.join(REPO_ROOT, "docs", "guidelines"), /\.md$/)) files.push(f);
|
|
102
|
+
return files;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Inline-ignore parsing
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
type IgnoreScope = "file" | "line" | "next-line";
|
|
109
|
+
interface Ignore {
|
|
110
|
+
scope: IgnoreScope;
|
|
111
|
+
rules: string[]; // empty = all rules
|
|
112
|
+
lineNum?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseIgnores(lines: string[]): Ignore[] {
|
|
116
|
+
const ignores: Ignore[] = [];
|
|
117
|
+
for (let i = 0; i < lines.length; i++) {
|
|
118
|
+
const line = lines[i] ?? "";
|
|
119
|
+
const fileMatch = /lint-output-slop-disable-file\s*([\w-]+(?:,\s*[\w-]+)*)?/i.exec(line);
|
|
120
|
+
if (fileMatch) {
|
|
121
|
+
const rules = fileMatch[1] ? fileMatch[1].split(",").map((r) => r.trim()) : [];
|
|
122
|
+
ignores.push({ scope: "file", rules });
|
|
123
|
+
}
|
|
124
|
+
const lineMatch = /lint-output-slop-disable-line\s*([\w-]+(?:,\s*[\w-]+)*)?/i.exec(line);
|
|
125
|
+
if (lineMatch) {
|
|
126
|
+
const rules = lineMatch[1] ? lineMatch[1].split(",").map((r) => r.trim()) : [];
|
|
127
|
+
ignores.push({ scope: "line", rules, lineNum: i + 1 });
|
|
128
|
+
}
|
|
129
|
+
const nextMatch = /lint-output-slop-disable-next-line\s*([\w-]+(?:,\s*[\w-]+)*)?/i.exec(line);
|
|
130
|
+
if (nextMatch) {
|
|
131
|
+
const rules = nextMatch[1] ? nextMatch[1].split(",").map((r) => r.trim()) : [];
|
|
132
|
+
ignores.push({ scope: "next-line", rules, lineNum: i + 2 }); // 1-indexed, next line
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return ignores;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isIgnored(ignores: Ignore[], ruleId: string, lineNum: number): boolean {
|
|
139
|
+
for (const ig of ignores) {
|
|
140
|
+
const ruleMatch = ig.rules.length === 0 || ig.rules.includes(ruleId);
|
|
141
|
+
if (!ruleMatch) continue;
|
|
142
|
+
if (ig.scope === "file") return true;
|
|
143
|
+
if (ig.scope === "line" && ig.lineNum === lineNum) return true;
|
|
144
|
+
if (ig.scope === "next-line" && ig.lineNum === lineNum) return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Scanning — only within fenced code blocks in markdown
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
function scanFile(filePath: string): Finding[] {
|
|
153
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
154
|
+
const lines = content.split("\n");
|
|
155
|
+
const ignores = parseIgnores(lines);
|
|
156
|
+
const findings: Finding[] = [];
|
|
157
|
+
|
|
158
|
+
let inCodeBlock = false;
|
|
159
|
+
for (let i = 0; i < lines.length; i++) {
|
|
160
|
+
const lineNum = i + 1;
|
|
161
|
+
const line = lines[i] ?? "";
|
|
162
|
+
|
|
163
|
+
// Toggle code-fence state
|
|
164
|
+
if (/^```/.test(line) || /^~~~/.test(line)) {
|
|
165
|
+
inCodeBlock = !inCodeBlock;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Only scan inside code fences in markdown
|
|
170
|
+
if (!inCodeBlock) continue;
|
|
171
|
+
|
|
172
|
+
for (const rule of RULES) {
|
|
173
|
+
if (rule.pattern.test(line) && !isIgnored(ignores, rule.id, lineNum)) {
|
|
174
|
+
findings.push({
|
|
175
|
+
rule: rule.id,
|
|
176
|
+
file: path.relative(REPO_ROOT, filePath),
|
|
177
|
+
line: lineNum,
|
|
178
|
+
snippet: line.trim().slice(0, 120),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return findings;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Main
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
function main(): void {
|
|
190
|
+
const jsonMode = process.argv.includes("--json");
|
|
191
|
+
const quiet = process.argv.includes("--quiet");
|
|
192
|
+
|
|
193
|
+
const files = collectFiles();
|
|
194
|
+
const allFindings: Finding[] = [];
|
|
195
|
+
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
try {
|
|
198
|
+
allFindings.push(...scanFile(file));
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (!quiet) process.stderr.write(`[lint_output_slop] error reading ${file}: ${err}\n`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (jsonMode) {
|
|
205
|
+
process.stdout.write(JSON.stringify(allFindings, null, 2) + "\n");
|
|
206
|
+
process.exit(allFindings.length > 0 ? 2 : 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (allFindings.length === 0) {
|
|
210
|
+
if (!quiet) process.stdout.write("✅ lint_output_slop: clean — no placeholder-prose patterns found.\n");
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const f of allFindings) {
|
|
215
|
+
process.stdout.write(`❌ ${f.file}:${f.line} [${f.rule}] ${f.snippet}\n`);
|
|
216
|
+
}
|
|
217
|
+
process.stdout.write(
|
|
218
|
+
`\nlint_output_slop: ${allFindings.length} finding(s). Fix or use lint-output-slop-disable-next-line <rule> -- reason.\n`,
|
|
219
|
+
);
|
|
220
|
+
process.exit(2);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
main();
|