@event4u/agent-config 7.4.0 → 7.5.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 (81) hide show
  1. package/.claude-plugin/marketplace.json +4 -1
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/review-changes.md +14 -7
  5. package/dist/agent-src/rules/artifact-engagement-recording.md +9 -0
  6. package/dist/agent-src/rules/domain-safety-pii.md +4 -3
  7. package/dist/agent-src/rules/icon-consistency.md +1 -0
  8. package/dist/agent-src/rules/legal-safety-floor.md +2 -2
  9. package/dist/agent-src/rules/untrusted-input-defense.md +8 -5
  10. package/dist/agent-src/skills/adr-create/SKILL.md +5 -1
  11. package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
  12. package/dist/agent-src/skills/blade-ui/SKILL.md +4 -0
  13. package/dist/agent-src/skills/brand-to-tokens/SKILL.md +2 -1
  14. package/dist/agent-src/skills/code-review/SKILL.md +4 -0
  15. package/dist/agent-src/skills/context-authoring/SKILL.md +1 -1
  16. package/dist/agent-src/skills/design-intelligence/SKILL.md +40 -0
  17. package/dist/agent-src/skills/design-review/SKILL.md +23 -11
  18. package/dist/agent-src/skills/design-system-capture/SKILL.md +41 -0
  19. package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +62 -0
  20. package/dist/agent-src/skills/design-tokens/SKILL.md +1 -0
  21. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +6 -0
  22. package/dist/agent-src/skills/flux/SKILL.md +4 -0
  23. package/dist/agent-src/skills/iconography/SKILL.md +1 -0
  24. package/dist/agent-src/skills/image-editing/evals/triggers.json +13 -13
  25. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +136 -0
  26. package/dist/agent-src/skills/judge-artifact-completeness/calibration/fixtures.json +149 -0
  27. package/dist/agent-src/skills/judge-artifact-completeness/evals/triggers.json +17 -0
  28. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/architecture-score.json +73 -0
  29. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/pr-review-score.json +65 -0
  30. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/roadmap-score.json +73 -0
  31. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/ticket-quality-score.json +73 -0
  32. package/dist/agent-src/skills/judge-injection-defense/SKILL.md +99 -0
  33. package/dist/agent-src/skills/judge-injection-defense/calibration/fixtures.json +24 -0
  34. package/dist/agent-src/skills/judge-injection-defense/evals/triggers.json +17 -0
  35. package/dist/agent-src/skills/judge-injection-defense/fixtures/behavioral-fixtures.json +63 -0
  36. package/dist/agent-src/skills/judge-injection-defense/fixtures/coverage-report.md +53 -0
  37. package/dist/agent-src/skills/judge-injection-defense/fixtures/perturbation-taxonomy.json +110 -0
  38. package/dist/agent-src/skills/judge-synthesis/SKILL.md +134 -0
  39. package/dist/agent-src/skills/judge-synthesis/evals/triggers.json +17 -0
  40. package/dist/agent-src/skills/logo-generation/SKILL.md +1 -1
  41. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  42. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +18 -0
  43. package/dist/agent-src/skills/react-shadcn-ui/reference/registry.md +59 -0
  44. package/dist/agent-src/skills/refine-ticket/SKILL.md +5 -1
  45. package/dist/agent-src/skills/release-comms/SKILL.md +1 -1
  46. package/dist/agent-src/skills/roadmap-management/SKILL.md +4 -0
  47. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  48. package/dist/agent-src/skills/script-writing/SKILL.md +1 -1
  49. package/dist/agent-src/skills/sql-writing/SKILL.md +1 -1
  50. package/dist/agent-src/skills/systematic-debugging/SKILL.md +1 -1
  51. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +4 -0
  52. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +1 -1
  53. package/dist/agent-src/skills/token-optimizer/SKILL.md +1 -1
  54. package/dist/agent-src/skills/typography-system/SKILL.md +2 -1
  55. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  56. package/dist/discovery/deprecation-report.md +1 -1
  57. package/dist/discovery/discovery-manifest.json +107 -41
  58. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  59. package/dist/discovery/discovery-manifest.summary.md +3 -3
  60. package/dist/discovery/orphan-report.md +1 -1
  61. package/dist/discovery/packs.json +8 -5
  62. package/dist/discovery/trust-report.md +2 -2
  63. package/dist/discovery/workspaces.json +7 -4
  64. package/dist/mcp/registry-manifest.json +3 -3
  65. package/dist/server/schemas/settings.js +3 -0
  66. package/dist/server/schemas/settings.js.map +1 -1
  67. package/docs/architecture.md +2 -2
  68. package/docs/catalog.md +28 -20
  69. package/docs/command-flows.md +1 -1
  70. package/docs/guidelines/design-antipatterns.md +26 -0
  71. package/docs/guidelines/design-canon.md +62 -0
  72. package/package.json +1 -1
  73. package/src/config/agent-settings.template.yml +12 -0
  74. package/src/scripts/design_slop_rules.test.ts +193 -0
  75. package/src/scripts/design_slop_rules.ts +502 -0
  76. package/src/scripts/external_sources_denylist.json +7 -1
  77. package/src/scripts/hook_manifest.yaml +14 -3
  78. package/src/scripts/hooks/design_slop_hook.ts +181 -0
  79. package/src/scripts/lint_agent_security.ts +1 -0
  80. package/src/scripts/lint_confusables.ts +197 -0
  81. package/src/scripts/lint_design_slop.ts +245 -0
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse anti-slop nudge — surfaces high-severity aesthetic tells in
4
+ * proposed UI content BEFORE the write (road-to-anti-slop-detector Phase 3).
5
+ *
6
+ * OPTIONAL, host-limited (pre_tool_use fires on ~2–3 hosts). The universal
7
+ * source of truth is the `lint_design_slop` linter / CI gate; this hook is a
8
+ * convenience layer that runs the same registry against the about-to-be-written
9
+ * content. Default-OFF: no-ops unless `hooks.design_slop.enabled: true` in
10
+ * `.agent-settings.yml`. fail_closed: false (never break a write for a flag).
11
+ *
12
+ * FLAGS, NEVER A BLOCK. On a P0/P1 finding it WARNS (exit 2 + reason); it never
13
+ * returns block (exit 1). Mirrors the injection-scan "warn, don't block" shape.
14
+ *
15
+ * Anti-loop degradation (council-required safety valve): a `file::rule`
16
+ * signature surfaced ≥ DEGRADE_AFTER times is downgraded to silent, so a
17
+ * deliberate-but-undeclared pattern the agent keeps re-writing never traps it.
18
+ * State: <root>/agents/runtime/state/design-slop-hook.json.
19
+ *
20
+ * Exit codes (dispatcher contract): 0 allow · 2 warn (+ JSON reason on stdout).
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { type DesignContext } from "../design_slop_rules.js";
26
+ import { loadDesignContext, scanFile } from "../lint_design_slop.js";
27
+
28
+ const SETTINGS_FILE = ".agent-settings.yml";
29
+ const EXIT_ALLOW = 0;
30
+ const EXIT_WARN = 2;
31
+ const DEGRADE_AFTER = 3; // surfaces of the same file::rule signature before going silent
32
+ const UI_EXT = /\.(html|htm|css|scss|sass|less|vue|svelte|astro|jsx|tsx)$/i;
33
+
34
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
35
+ type JsonObject = { [k: string]: JsonValue };
36
+
37
+ function isObject(v: unknown): v is JsonObject {
38
+ return typeof v === "object" && v !== null && !Array.isArray(v);
39
+ }
40
+
41
+ /** Minimal `.agent-settings.yml` reader for hooks.design_slop.enabled (mirrors injection-scan). */
42
+ function enabled(root: string): boolean {
43
+ const f = path.join(root, SETTINGS_FILE);
44
+ let text: string;
45
+ try {
46
+ if (!fs.statSync(f).isFile()) return false;
47
+ text = fs.readFileSync(f, "utf-8");
48
+ } catch {
49
+ return false;
50
+ }
51
+ let inHooks = false;
52
+ let inDs = false;
53
+ for (const raw of text.split(/\r\n|\r|\n/)) {
54
+ const line = raw.replace(/\s+$/, "");
55
+ if (!line || line.replace(/^\s+/, "").startsWith("#")) continue;
56
+ if (!(line.startsWith(" ") || line.startsWith("\t"))) {
57
+ inHooks = /^hooks\s*:\s*$/.test(line);
58
+ inDs = false;
59
+ continue;
60
+ }
61
+ if (inHooks) {
62
+ if (/^\s+design_slop\s*:\s*$/.test(line)) {
63
+ inDs = true;
64
+ continue;
65
+ }
66
+ if (inDs && /^\s{0,3}\S/.test(line)) inDs = false;
67
+ }
68
+ if (inDs && /^\s+enabled\s*:\s*true\b/.test(line)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** Best-effort: pull (toolName, filePath, proposedContent) from the PreToolUse envelope. */
74
+ function extract(envelope: JsonObject): { file: string; content: string } | null {
75
+ const ti = envelope["tool_input"] ?? envelope["toolInput"] ?? envelope["input"];
76
+ if (!isObject(ti)) return null;
77
+ const fileVal = ti["file_path"] ?? ti["path"] ?? ti["filePath"];
78
+ const file = typeof fileVal === "string" ? fileVal : "";
79
+ // Edit → new_string/new_str; Write → content; some hosts → text
80
+ for (const key of ["content", "new_string", "new_str", "text", "newText"]) {
81
+ const v = ti[key];
82
+ if (typeof v === "string" && v.length > 0) return { file, content: v };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function stateFile(root: string): string {
88
+ return path.join(root, "agents", "runtime", "state", "design-slop-hook.json");
89
+ }
90
+
91
+ function readState(root: string): Record<string, number> {
92
+ try {
93
+ const t = fs.readFileSync(stateFile(root), "utf-8");
94
+ const parsed = JSON.parse(t);
95
+ return isObject(parsed) ? (parsed as Record<string, number>) : {};
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+
101
+ function writeState(root: string, state: Record<string, number>): void {
102
+ try {
103
+ const sf = stateFile(root);
104
+ fs.mkdirSync(path.dirname(sf), { recursive: true });
105
+ fs.writeFileSync(sf, JSON.stringify(state, null, 2) + "\n");
106
+ } catch {
107
+ /* fail-open: never break a write because state could not persist */
108
+ }
109
+ }
110
+
111
+ function readStdin(): string {
112
+ try {
113
+ return fs.readFileSync(0, "utf-8");
114
+ } catch {
115
+ return "";
116
+ }
117
+ }
118
+
119
+ export function main(): number {
120
+ let envelope: JsonValue;
121
+ try {
122
+ const raw = readStdin();
123
+ envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
124
+ } catch {
125
+ return EXIT_ALLOW;
126
+ }
127
+ if (!isObject(envelope)) return EXIT_ALLOW;
128
+
129
+ const rootVal = envelope["cwd"] ?? envelope["project_root"] ?? ".";
130
+ const root = typeof rootVal === "string" && rootVal ? rootVal : ".";
131
+ if (!enabled(root)) return EXIT_ALLOW;
132
+
133
+ const ex = extract(envelope);
134
+ if (!ex || !ex.file || !UI_EXT.test(ex.file)) return EXIT_ALLOW;
135
+
136
+ let ctx: DesignContext;
137
+ try {
138
+ ctx = loadDesignContext(path.dirname(path.resolve(root, ex.file)));
139
+ } catch {
140
+ ctx = { raw: "", has: () => false };
141
+ }
142
+
143
+ let findings;
144
+ try {
145
+ findings = scanFile(ex.content, ex.file, ctx).filter(
146
+ (f) => f.severity === "P0" || f.severity === "P1",
147
+ );
148
+ } catch {
149
+ return EXIT_ALLOW;
150
+ }
151
+ if (findings.length === 0) return EXIT_ALLOW;
152
+
153
+ // Anti-loop degradation: drop any signature already surfaced DEGRADE_AFTER times.
154
+ const state = readState(root);
155
+ const surfaced = findings.filter((f) => {
156
+ const sig = `${ex.file}::${f.rule}`;
157
+ const seen = state[sig] ?? 0;
158
+ if (seen >= DEGRADE_AFTER) return false; // gone silent — never trap the agent
159
+ state[sig] = seen + 1;
160
+ return true;
161
+ });
162
+ writeState(root, state);
163
+
164
+ if (surfaced.length === 0) return EXIT_ALLOW;
165
+
166
+ const lines = surfaced
167
+ .map((f) => `${f.catalogId} (${f.rule}) ${f.file}:${f.line} — ${f.message}`)
168
+ .join("; ");
169
+ const reason =
170
+ "⚠️ Anti-slop: high-severity aesthetic tell(s) in the proposed UI — " +
171
+ lines +
172
+ ". Rebuttable: declare intent in DESIGN.md, add design-slop-disable-next-line, " +
173
+ "or proceed if deliberate (this is a flag, not a block).";
174
+ process.stdout.write(JSON.stringify({ decision: "warn", reason }) + "\n");
175
+ return EXIT_WARN;
176
+ }
177
+
178
+ const isCliEntry =
179
+ process.argv[1] !== undefined &&
180
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
181
+ if (isCliEntry) process.exit(main());
@@ -43,6 +43,7 @@ type Finding = Record<string, unknown>;
43
43
 
44
44
  const LINTERS: ReadonlyArray<[string, string]> = [
45
45
  ["hidden-unicode", "lint_hidden_unicode.ts"],
46
+ ["mixed-script-confusable", "lint_confusables.ts"],
46
47
  ["instruction-smuggling", "lint_instruction_smuggling.ts"],
47
48
  ["mcp-config-security", "lint_mcp_config_security.ts"],
48
49
  ["dangerous-frontmatter", "lint_skill_frontmatter_safety.ts"],
@@ -0,0 +1,197 @@
1
+ // Visible mixed-script confusable / homoglyph linter
2
+ // (road-to-injection-defense-pressure-corpus.md, Phase 1.2).
3
+ //
4
+ // Sibling of `lint_hidden_unicode.ts`. That linter catches the INVISIBLE
5
+ // smuggling class (zero-width, bidi, tag-block). This one catches the VISIBLE
6
+ // class it misses: a Latin word with one or more letters swapped for
7
+ // visually-identical Cyrillic or Greek lookalikes (mixed-script token) — the
8
+ // homoglyph version of the rules-file-backdoor attack. A reviewer sees
9
+ // "ignore"; the model reads a token whose 'o' is U+043E CYRILLIC SMALL LETTER O.
10
+ //
11
+ // Detection signature (high-precision, low-false-positive):
12
+ // A whitespace-delimited token is flagged when ALL hold —
13
+ // 1. it contains >= 1 Latin letter AND >= 1 Cyrillic-or-Greek letter that
14
+ // is in the TR39 confusable set (has a Latin lookalike). Math operators
15
+ // with NO Latin lookalike (Δ Σ Π Ω Φ Θ Λ Ξ Ψ Γ …) are NOT confusables,
16
+ // so legit notation like "ΔNWC" / "Σwᵢ" is never flagged,
17
+ // 2. it has >= MIN_LETTERS total letters, and
18
+ // 3. Latin is the MAJORITY script (the attack is a Latin word with foreign
19
+ // lookalike substitutions, not a foreign word with one Latin letter).
20
+ // Pure-Cyrillic / pure-Greek / CJK prose is single-script per token and is
21
+ // never flagged — legit non-Latin text passes.
22
+ //
23
+ // Scope + containment mirror lint_hidden_unicode: scans every .md under
24
+ // src/{skills,rules,agent-src,domains} via sl.iter_corpus(); a
25
+ // ```security-example fence or a `security-lint: allow mixed-script-confusable`
26
+ // pragma exempts a region/file (a doc teaching confusables needs to show one).
27
+ //
28
+ // Exit 0 clean, 1 on any blocking finding.
29
+ //
30
+ // Usage: ./scripts-run src/scripts/lint_confusables [--json]
31
+
32
+ import * as path from 'node:path';
33
+ import { pathToFileURL } from 'node:url';
34
+
35
+ import * as sl from './_lib/security_lint.js';
36
+
37
+ export const CHECK = 'mixed-script-confusable';
38
+
39
+ const MIN_LETTERS = 3;
40
+
41
+ // Unicode-script classifiers via property escapes (requires the `u` flag).
42
+ const _LATIN = /\p{Script=Latin}/u;
43
+ const _CYRILLIC = /\p{Script=Cyrillic}/u;
44
+ const _GREEK = /\p{Script=Greek}/u;
45
+ const _LETTER = /\p{L}/u;
46
+ // A "token" is a maximal run of letters / combining marks / decimal digits.
47
+ const _TOKEN = /[\p{L}\p{M}\p{Nd}]+/gu;
48
+
49
+ // Cyrillic + Greek codepoints that have a basic-Latin lookalike (Unicode TR39
50
+ // confusables, common subset). A foreign letter only counts toward the
51
+ // confusable signal when it is in this set — Greek math operators with no Latin
52
+ // twin (Δ Σ Π Ω Φ Θ Λ Ξ Ψ Γ δ π ω …) are deliberately excluded so legit
53
+ // notation does not false-positive.
54
+ const _CONFUSABLE_FOREIGN: ReadonlySet<number> = new Set([
55
+ // Cyrillic lowercase ↔ Latin
56
+ 0x0430 /*а→a*/, 0x0435 /*е→e*/, 0x043e /*о→o*/, 0x0440 /*р→p*/, 0x0441 /*с→c*/,
57
+ 0x0443 /*у→y*/, 0x0445 /*х→x*/, 0x0455 /*ѕ→s*/, 0x0456 /*і→i*/, 0x0458 /*ј→j*/,
58
+ 0x04bb /*һ→h*/, 0x0501 /*ԁ→d*/, 0x051b /*ԛ→q*/, 0x0577 /*no*/,
59
+ // Cyrillic uppercase ↔ Latin
60
+ 0x0410 /*А→A*/, 0x0412 /*В→B*/, 0x0415 /*Е→E*/, 0x041a /*К→K*/, 0x041c /*М→M*/,
61
+ 0x041d /*Н→H*/, 0x041e /*О→O*/, 0x0420 /*Р→P*/, 0x0421 /*С→C*/, 0x0422 /*Т→T*/,
62
+ 0x0423 /*У→Y*/, 0x0425 /*Х→X*/, 0x0405 /*Ѕ→S*/, 0x0406 /*І→I*/, 0x0408 /*Ј→J*/,
63
+ // Greek lowercase ↔ Latin
64
+ 0x03b1 /*α→a*/, 0x03b5 /*ε→e*/, 0x03bf /*ο→o*/, 0x03bd /*ν→v*/, 0x03c1 /*ρ→p*/,
65
+ 0x03c5 /*υ→u*/, 0x03c7 /*χ→x*/, 0x03ba /*κ→k*/,
66
+ // Greek uppercase ↔ Latin (only those with a real Latin twin)
67
+ 0x0391 /*Α→A*/, 0x0392 /*Β→B*/, 0x0395 /*Ε→E*/, 0x0396 /*Ζ→Z*/, 0x0397 /*Η→H*/,
68
+ 0x0399 /*Ι→I*/, 0x039a /*Κ→K*/, 0x039c /*Μ→M*/, 0x039d /*Ν→N*/, 0x039f /*Ο→O*/,
69
+ 0x03a1 /*Ρ→P*/, 0x03a4 /*Τ→T*/, 0x03a5 /*Υ→Y*/, 0x03a7 /*Χ→X*/, 0x0392 /*Β→B*/,
70
+ ]);
71
+
72
+ type ScriptName = 'latin' | 'cyrillic' | 'greek' | 'other';
73
+
74
+ function _script_of(ch: string): ScriptName {
75
+ if (_LATIN.test(ch)) return 'latin';
76
+ if (_CYRILLIC.test(ch)) return 'cyrillic';
77
+ if (_GREEK.test(ch)) return 'greek';
78
+ return 'other';
79
+ }
80
+
81
+ /**
82
+ * Classify a single token. Returns the offending mixed-script descriptor when
83
+ * the token matches the homoglyph signature, else null.
84
+ */
85
+ export function _classify_token(token: string): string | null {
86
+ let latin = 0;
87
+ let cyrillic = 0;
88
+ let greek = 0;
89
+ let letters = 0;
90
+ for (const ch of token) {
91
+ if (!_LETTER.test(ch)) continue; // skip combining marks / digits for script vote
92
+ letters += 1;
93
+ const script = _script_of(ch);
94
+ if (script === 'latin') {
95
+ latin += 1;
96
+ } else if (script === 'cyrillic' || script === 'greek') {
97
+ // Only confusable foreign letters (TR39) count — math operators with
98
+ // no Latin twin are ignored, so legit notation never trips.
99
+ if (_CONFUSABLE_FOREIGN.has(ch.codePointAt(0) as number)) {
100
+ if (script === 'cyrillic') cyrillic += 1;
101
+ else greek += 1;
102
+ }
103
+ }
104
+ }
105
+ if (letters < MIN_LETTERS) return null;
106
+ const foreign = cyrillic + greek;
107
+ if (latin === 0 || foreign === 0) return null; // single-script (or no Latin) → legit
108
+ if (latin <= foreign) return null; // majority must be Latin (foreign word ≠ attack)
109
+ const which = cyrillic > 0 && greek > 0
110
+ ? 'cyrillic+greek'
111
+ : cyrillic > 0
112
+ ? 'cyrillic'
113
+ : 'greek';
114
+ return `latin+${which} (${latin} latin / ${foreign} foreign-confusable)`;
115
+ }
116
+
117
+ export function _scan(sf: sl.ScannedFile): sl.Finding[] {
118
+ if (sf.pragma_allows(CHECK)) {
119
+ return [];
120
+ }
121
+ const out: sl.Finding[] = [];
122
+ for (const [lineno, text] of sf.iter_lines({ skip_example_fence: true })) {
123
+ const tokens = text.match(_TOKEN);
124
+ if (tokens === null) continue;
125
+ for (const tok of tokens) {
126
+ const desc = _classify_token(tok);
127
+ if (desc !== null) {
128
+ out.push(
129
+ new sl.Finding(
130
+ sf.rel,
131
+ lineno,
132
+ CHECK,
133
+ 'HIGH',
134
+ `mixed-script confusable token "${tok}" — ${desc}`,
135
+ sf.weight,
136
+ ),
137
+ );
138
+ }
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ interface Args {
145
+ json: boolean;
146
+ }
147
+
148
+ function _argError(msg: string): never {
149
+ process.stderr.write('usage: lint_confusables [-h] [--json]\n');
150
+ process.stderr.write(`lint_confusables: error: ${msg}\n`);
151
+ process.exit(2);
152
+ }
153
+
154
+ function parse_args(argv: readonly string[]): Args {
155
+ const out: Args = { json: false };
156
+ const extra: string[] = [];
157
+ for (const a of argv) {
158
+ if (a === '-h' || a === '--help') {
159
+ process.stdout.write('usage: lint_confusables [-h] [--json]\n');
160
+ process.exit(0);
161
+ } else if (a === '--json') {
162
+ out.json = true;
163
+ } else {
164
+ extra.push(a);
165
+ }
166
+ }
167
+ if (extra.length > 0) {
168
+ _argError(`unrecognized arguments: ${extra.join(' ')}`);
169
+ }
170
+ return out;
171
+ }
172
+
173
+ export function main(argv: readonly string[] | null = null): number {
174
+ const args = parse_args(argv ?? process.argv.slice(2));
175
+
176
+ const findings: sl.Finding[] = [];
177
+ for (const sf of sl.iter_corpus()) {
178
+ for (const h of _scan(sf)) {
179
+ findings.push(h);
180
+ }
181
+ }
182
+
183
+ if (args.json) {
184
+ const payload = sl.py_json_dumps_indent2(findings.map((f) => f.toDict()));
185
+ process.stdout.write(payload + '\n');
186
+ return findings.some((f) => f.is_fail) ? 1 : 0;
187
+ }
188
+
189
+ return sl.report(findings, { check_label: 'mixed-script-confusable' });
190
+ }
191
+
192
+ const _isCliEntry =
193
+ process.argv[1] !== undefined &&
194
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
195
+ if (_isCliEntry) {
196
+ process.exitCode = main();
197
+ }
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint_design_slop — deterministic anti-slop DETECTOR for consumer projects.
4
+ *
5
+ * The aesthetic-PROVENANCE layer that `lint_design_quality` deliberately omits.
6
+ * It scans HTML/CSS/JSX/Markdown for the "AI tells" catalogued in
7
+ * `docs/guidelines/design-antipatterns.md` and emits structured findings
8
+ * (rule-id + catalog-id + severity P0–P3 + file:line). The rule registry lives
9
+ * in `design_slop_rules.ts`.
10
+ *
11
+ * FLAGS, NEVER A HARD BLOCK. Default exit is 0 regardless of findings — slop
12
+ * tells are rebuttable presumptions, not failures. CI opts into failing with
13
+ * `--fail-on <P0|P1|P2|P3>` (fails when any finding at or above that severity
14
+ * remains). This honours the council 2026-06-28 "flags not blocks" verdict.
15
+ *
16
+ * Zero runtime token cost: this runs in Node (CI / pre-commit / optional hook),
17
+ * never in the model's context. `design-review` cites the structured `--json`
18
+ * output instead of reloading the 12 KB prose catalog.
19
+ *
20
+ * Dependency-free pattern analysis (no postcss/@babel) — see the design note in
21
+ * design_slop_rules.ts. Same posture as lint_design_quality.
22
+ *
23
+ * Usage:
24
+ * npx tsx lint_design_slop.ts --dir src/ [--json] [--fail-on P1] [--quiet]
25
+ *
26
+ * Context gate: a consumer DESIGN.md (project root or scan dir) that declares a
27
+ * pattern as intentional suppresses the matching rule (e.g. "warm-neutral" →
28
+ * C5 cream-palette is not flagged). Inline-ignore (eslint-style):
29
+ * design-slop-disable <rule-id> (file scope, top of file)
30
+ * design-slop-disable-next-line <rule-id>
31
+ * design-slop-disable-line <rule-id>
32
+ * Per-project config (.design-quality.json, shared with lint_design_quality):
33
+ * { "ignoreRules": ["slop-c5-cream-palette"], "ignoreFiles": ["src/legacy/**"] }
34
+ *
35
+ * Exit codes: 0 = clean OR findings-below-threshold, 2 = findings at/above
36
+ * --fail-on threshold, 1 = internal error.
37
+ */
38
+ import fs from "node:fs";
39
+ import path from "node:path";
40
+ import {
41
+ SLOP_RULES,
42
+ type DesignContext,
43
+ type Engine,
44
+ type Severity,
45
+ } from "./design_slop_rules.js";
46
+
47
+ interface Finding {
48
+ rule: string;
49
+ catalogId: string;
50
+ severity: Severity;
51
+ file: string;
52
+ line: number;
53
+ snippet: string;
54
+ message: string;
55
+ }
56
+
57
+ interface ProjectConfig {
58
+ ignoreRules?: string[];
59
+ ignoreFiles?: string[];
60
+ }
61
+
62
+ const SEVERITY_ORDER: Severity[] = ["P3", "P2", "P1", "P0"];
63
+ const sevRank = (s: Severity): number => SEVERITY_ORDER.indexOf(s);
64
+
65
+ // ext → which engine-classes a file of that type can contain
66
+ function enginesForExt(ext: string): Set<Engine> {
67
+ if (/\.(css|scss|sass|less)$/i.test(ext)) return new Set<Engine>(["css"]);
68
+ if (/\.(html|htm|vue|svelte|astro)$/i.test(ext)) return new Set<Engine>(["css", "html", "copy"]);
69
+ if (/\.(jsx|tsx)$/i.test(ext)) return new Set<Engine>(["css", "jsx", "copy"]);
70
+ if (/\.(md|mdx)$/i.test(ext)) return new Set<Engine>(["copy"]);
71
+ return new Set<Engine>();
72
+ }
73
+
74
+ function* walkDir(dir: string, ext: RegExp): Generator<string> {
75
+ if (!fs.existsSync(dir)) return;
76
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
77
+ const full = path.join(dir, entry.name);
78
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
79
+ if (entry.isDirectory()) yield* walkDir(full, ext);
80
+ else if (ext.test(entry.name)) yield full;
81
+ }
82
+ }
83
+
84
+ function loadConfig(dir: string): ProjectConfig {
85
+ const p = path.join(dir, ".design-quality.json");
86
+ if (!fs.existsSync(p)) return {};
87
+ try {
88
+ return JSON.parse(fs.readFileSync(p, "utf-8")) as ProjectConfig;
89
+ } catch {
90
+ return {};
91
+ }
92
+ }
93
+
94
+ /** Read DESIGN.md (scan dir, then project root) as the context-gate source. */
95
+ export function loadDesignContext(scanDir: string): DesignContext {
96
+ const candidates = [
97
+ path.join(scanDir, "DESIGN.md"),
98
+ path.join(process.cwd(), "DESIGN.md"),
99
+ path.join(scanDir, "..", "DESIGN.md"),
100
+ ];
101
+ let raw = "";
102
+ for (const c of candidates) {
103
+ if (fs.existsSync(c)) {
104
+ raw = fs.readFileSync(c, "utf-8").toLowerCase();
105
+ break;
106
+ }
107
+ }
108
+ return {
109
+ raw,
110
+ has: (...keywords: string[]) => keywords.some((k) => raw.includes(k.toLowerCase())),
111
+ };
112
+ }
113
+
114
+ function parseIgnores(lines: string[]): Map<string, Set<number>> {
115
+ const ignores = new Map<string, Set<number>>();
116
+ const add = (rule: string, line: number) => {
117
+ const set = ignores.get(rule) ?? new Set<number>();
118
+ set.add(line);
119
+ ignores.set(rule, set);
120
+ };
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i] ?? "";
123
+ const file = /design-slop-disable\s+([\w-]+)/i.exec(line);
124
+ if (file && file[1] && !/-line|-next-line/.test(line)) {
125
+ ignores.set(file[1].toLowerCase(), new Set([...Array(99999).keys()].map((n) => n + 1)));
126
+ }
127
+ const next = /design-slop-disable-next-line\s+([\w-]+)/i.exec(line);
128
+ if (next && next[1]) add(next[1].toLowerCase(), i + 2);
129
+ const cur = /design-slop-disable-line\s+([\w-]+)/i.exec(line);
130
+ if (cur && cur[1]) add(cur[1].toLowerCase(), i + 1);
131
+ }
132
+ return ignores;
133
+ }
134
+
135
+ export function scanFile(
136
+ content: string,
137
+ relPath: string,
138
+ ctx: DesignContext,
139
+ ): Finding[] {
140
+ const lines = content.split("\n");
141
+ const ext = path.extname(relPath).toLowerCase();
142
+ const fileEngines = enginesForExt(ext);
143
+ if (fileEngines.size === 0) return [];
144
+ const ignores = parseIgnores(lines);
145
+
146
+ const out: Finding[] = [];
147
+ for (const rule of SLOP_RULES) {
148
+ if (!rule.engines.some((e) => fileEngines.has(e))) continue;
149
+ if (rule.gated && rule.gated(ctx)) continue; // DESIGN.md declares intent → suppress
150
+ let hits;
151
+ try {
152
+ hits = rule.detect({ content, lines, ext, ctx });
153
+ } catch {
154
+ continue; // a single rule throwing never kills the scan
155
+ }
156
+ for (const h of hits) {
157
+ const ruleIgnore = ignores.get(rule.id.toLowerCase());
158
+ if (ruleIgnore && ruleIgnore.has(h.line)) continue;
159
+ out.push({
160
+ rule: rule.id,
161
+ catalogId: rule.catalogId,
162
+ severity: rule.severity,
163
+ file: relPath,
164
+ line: h.line,
165
+ snippet: h.snippet,
166
+ message: rule.message,
167
+ });
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function main(): void {
174
+ const args = process.argv.slice(2);
175
+ const jsonMode = args.includes("--json");
176
+ const quiet = args.includes("--quiet");
177
+ const dirIdx = args.indexOf("--dir");
178
+ const scanDir = dirIdx >= 0 && args[dirIdx + 1] ? path.resolve(args[dirIdx + 1]!) : path.resolve(process.cwd(), "src");
179
+ const failIdx = args.indexOf("--fail-on");
180
+ const failOn = (failIdx >= 0 ? args[failIdx + 1] : undefined) as Severity | undefined;
181
+
182
+ if (!fs.existsSync(scanDir)) {
183
+ if (!quiet) {
184
+ process.stderr.write(`[lint_design_slop] scan directory not found: ${scanDir}\n`);
185
+ process.stderr.write("Opt-in for consumer projects. Run with --dir <path> at your UI source.\n");
186
+ }
187
+ process.exit(0);
188
+ }
189
+
190
+ const ctx = loadDesignContext(scanDir);
191
+ const config = loadConfig(process.cwd());
192
+ const ignoreRules = new Set((config.ignoreRules ?? []).map((r) => r.toLowerCase()));
193
+ const ignoreGlobs = config.ignoreFiles ?? [];
194
+
195
+ const files: string[] = [];
196
+ for (const f of walkDir(scanDir, /\.(html|htm|css|scss|sass|less|vue|svelte|astro|jsx|tsx|md|mdx)$/i)) {
197
+ if (!ignoreGlobs.some((g) => f.includes(g.replace("**", "")))) files.push(f);
198
+ }
199
+
200
+ const findings: Finding[] = [];
201
+ for (const fp of files) {
202
+ const rel = path.relative(process.cwd(), fp);
203
+ try {
204
+ const fileFindings = scanFile(fs.readFileSync(fp, "utf-8"), rel, ctx).filter(
205
+ (f) => !ignoreRules.has(f.rule.toLowerCase()),
206
+ );
207
+ findings.push(...fileFindings);
208
+ } catch (err) {
209
+ if (!quiet) process.stderr.write(`[lint_design_slop] error: ${fp}: ${err}\n`);
210
+ }
211
+ }
212
+
213
+ findings.sort((a, b) => sevRank(b.severity) - sevRank(a.severity));
214
+
215
+ const overThreshold = failOn
216
+ ? findings.filter((f) => sevRank(f.severity) >= sevRank(failOn))
217
+ : [];
218
+
219
+ if (jsonMode) {
220
+ process.stdout.write(JSON.stringify({ findings, failOn: failOn ?? null, overThreshold: overThreshold.length }, null, 2) + "\n");
221
+ process.exit(overThreshold.length > 0 ? 2 : 0);
222
+ }
223
+
224
+ if (findings.length === 0) {
225
+ if (!quiet) process.stdout.write(`✅ lint_design_slop: no anti-slop tells in ${files.length} file(s).\n`);
226
+ process.exit(0);
227
+ }
228
+
229
+ for (const f of findings) {
230
+ const icon = f.severity === "P0" || f.severity === "P1" ? "⚠️ " : "•";
231
+ process.stdout.write(`${icon} [${f.severity} ${f.catalogId}] ${f.file}:${f.line} (${f.rule}) — ${f.message}\n ${f.snippet}\n`);
232
+ }
233
+ process.stdout.write(
234
+ `\nlint_design_slop: ${findings.length} tell(s) flagged (rebuttable — declare intent in DESIGN.md, ` +
235
+ `design-slop-disable-next-line <rule>, or .design-quality.json).` +
236
+ (failOn ? ` ${overThreshold.length} at/above ${failOn}.` : " Flags only; no CI failure (pass --fail-on to gate).") +
237
+ "\n",
238
+ );
239
+ process.exit(overThreshold.length > 0 ? 2 : 0);
240
+ }
241
+
242
+ // Run only when invoked directly (not when imported by the test guard).
243
+ const invokedDirectly =
244
+ process.argv[1] !== undefined && /lint_design_slop\.ts$/.test(process.argv[1]);
245
+ if (invokedDirectly) main();