slopbrick 0.41.0 → 0.43.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/README.md CHANGED
@@ -24,7 +24,7 @@ For the prevention layer:
24
24
 
25
25
  ```bash
26
26
  slopbrick watch # re-run scan on every file change
27
- slopbrick lock # install the Git pre-commit hook
27
+ slopbrick install # install the Git pre-commit hook
28
28
  slopbrick ci # CI gate: exit 1 on constitution violation
29
29
  ```
30
30
 
@@ -40,14 +40,15 @@ on every scan — your repository, encoded for the next agent.
40
40
  inventory, constitution, health) make your codebase queryable by
41
41
  any AI agent in O(read file) instead of O(parse AST).
42
42
  - **LockBrick prevention** — `slopbrick watch` flags violations as you
43
- write, `slopbrick lock` blocks AI-introduced slop at pre-commit,
43
+ write, `slopbrick install` blocks AI-introduced slop at pre-commit,
44
44
  `slopbrick ci` enforces the same in CI.
45
45
  - **Constitution** — declare your canonical stack (state lib, form
46
46
  lib, modal system, API client) once. The agent and the linter
47
47
  enforce it together.
48
48
 
49
- **Status:** v0.38.0 (current). See the [CHANGELOG](./CHANGELOG.md) for
50
- the full release notes.
49
+ **Status:** v0.43.0 (current) (current). Engine: 103 rules in 22 categories,
50
+ 4 headline scores, 9+ language frontends, v10-calibrated against 576,750
51
+ real files. See [CHANGELOG](./CHANGELOG.md) for the full release notes.
51
52
 
52
53
  ---
53
54
 
@@ -78,27 +79,31 @@ For every other config question, see [`EXAMPLES.md`](./EXAMPLES.md).
78
79
 
79
80
  ## The headlines (4-score model, v0.21.0+)
80
81
 
81
- > **v0.15.0 introduced the 4-score model; v0.21.0 FLIPPED `aiSlopScore`
82
- > to the natural-reading "raw amount" direction (0=clean, 100=saturated).**
83
- > The other three scores stay "higher = better". The legacy `slopIndex`
84
- > field is kept as optional on `ProjectReport` for backward compat with
85
- > existing test fixtures and historical telemetry; the v0.14-compat
82
+ > **v0.15.0 introduced the 4-score model. v0.21.0 FLIPPED `aiSlopScore`
83
+ > to the natural-reading "raw amount of slop" direction
84
+ > (0 = clean, 100 = saturated, **lower = cleaner**).** v0.18.0 added
85
+ > the 4th score (security). The other three scores stay "higher = better".
86
+ > The legacy `slopIndex` field is kept as optional on `ProjectReport`
87
+ > for backward compat with existing test fixtures; the v0.14-compat
86
88
  > removal is tracked separately.
87
89
 
88
90
  | Score | What it measures | Direction | CI gate? |
89
91
  |-------|------------------|-----------|----------|
90
- | **`aiSlopScore`** | AI-slop signatures (16 `ai/*` rules). | **lower = cleaner** (raw amount) | **Yes** (`≤ meanSlop: 30` passes) |
92
+ | **`aiSlopScore`** | AI-slop signatures (16 `ai/*` rules). | **lower = cleaner** (raw amount of slop) | **Yes** (`≤ meanSlop` passes; default 30) |
91
93
  | **`engineeringHygiene`** | Average of 6 category scores: arch, logic, layout, visual, component, test | higher = better | No (informational) |
92
- | **`security`** | AI Security Risk band: low=100, medium=67, high=33, critical=0 | higher = better | No (informational) |
93
- | **`repositoryHealth`** (composite) | Weighted: `0.4 × (100 aiSlopScore) + 0.3 × eng + 0.2 × sec + 0.1 × test` | higher = better (inverts `aiSlopScore` internally) | No (informational) |
94
+ | **`security`** | AI Security Risk band: low=100, medium=75, high=40, critical=10 | higher = better | No (informational) |
95
+ | **`repositoryHealth`** | Weighted average of 8 axes (slopIndex, architectureConsistency, aiSecurityRisk, designTokenViolations, testQuality, businessLogicCoherence, docFreshness, dbHealth). Default weights in `REPOSITORY_HEALTH_WEIGHTS`. | higher = better | No (informational) |
94
96
 
95
97
  **Score-band messages** (v0.21.0+): every score ships with a one-line
96
98
  verdict in the pretty output — e.g. `AI Slop Score: 25 → "low amount
97
- of slop"`, `Security Risk: 33 "high risk"`. See `src/report/pretty.ts`.
99
+ of AI slop"`, `Security Risk: low`. The band mapping for aiSlopScore
100
+ is **0–9 no slop**, **10–29 low**, **30–49 medium**, **50–69 high**,
101
+ **70–100 saturated** (v0.21.0 lower-is-better direction). See
102
+ `src/report/pretty.ts`.
98
103
 
99
- The same numbers are in `.slopbrick/health.json`.
104
+ The same numbers live in `.slopbrick/health.json`.
100
105
 
101
- For the full math, the 2×2 quadrant, and which one to focus on, see
106
+ For the full math, the 4-score quadrant, and which one to focus on, see
102
107
  [`docs/scoring-explained.md`](./docs/scoring-explained.md).
103
108
 
104
109
  For per-rule precision/recall/FPR (auditable), see
@@ -110,8 +115,7 @@ For per-rule precision/recall/FPR (auditable), see
110
115
 
111
116
  Starting in **v0.24.0**, slopbrick can send a single one-shot usage
112
117
  ping after `slopbrick scan` completes. This is **opt-in** — the
113
- default is OFF — and is intended for the v9 corpus build script
114
- and self-hosted CI.
118
+ default is OFF — and is intended for self-hosted CI telemetry.
115
119
 
116
120
  ### How to opt in
117
121
 
@@ -134,13 +138,13 @@ A single POST with `Content-Type: application/json` and exactly
134
138
  | Field | Type | Example | Source |
135
139
  |-------|------|---------|--------|
136
140
  | `schema_version` | string | `"1"` | constant |
137
- | `slopbrick_version` | string | `"0.24.0"` | `package.json` |
141
+ | `slopbrick_version` | string | `"0.42.0"` | `package.json` |
138
142
  | `scan_id` | string (UUID v4) | `"f47ac10b-…"` | generated per run |
139
143
  | `file_count` | int | `42` | `results.length` |
140
- | `rule_count` | int | `95` | `builtinRules.length` |
144
+ | `rule_count` | int | `103` | `builtinRules.length` |
141
145
  | `duration_ms` | int | `1834` | wall-clock scan time |
142
146
  | `platform` | string | `"darwin"` | `process.platform` |
143
- | `node_version` | string | `"v20.11.0"` | `process.version` |
147
+ | `node_version` | string | `"v24.15.0"` | `process.version` |
144
148
 
145
149
  ### Privacy promise
146
150
 
@@ -181,42 +185,27 @@ for the receiver.
181
185
  ## Example output
182
186
 
183
187
  ```text
184
- $ npx slopbrick scan
185
- Repo is concerning (25/100). The biggest problem is AI patterns — worst file is src/cli/scan.ts. Run `slopbrick scan --why-failing` for the top 5 rules, or `slopbrick scan --suggest` for fixes.
186
-
187
- AI Slop Score: 25 / 100 [HIGH] ↑5 (worse)
188
- higher = better · measures AI-slop signatures. The same number in .slopbrick/health.json.
189
- ├─ boundary: 10 (40%) — structural integrity
190
- ├─ context: 50 (35%) — props / state / imports
191
- └─ visual: 5 (25%) — CSS / a11y / layout
192
-
193
- Engineering Hygiene: 60 / 100 [NEEDS WORK] — higher = better · measures internal consistency. This is a secondary view; the AI Slop Score above is the gate.
194
-
195
- Other signals (not the gate):
196
- Code Hygiene 75/100
197
- Accessibility 100/100
198
- Performance 100/100
199
- Business Logic 0/100
200
- Security Risk LOW
201
-
202
- ✓ 99 INVERTED/NOISY issues correctly suppressed from 24 default-off rules.
203
-
204
- Category breakdown (what kind of issue, and how much):
205
- AI patterns ████████████████████ 167 — signatures of LLM-generated code
206
- visual style ████████░░░░░░░░░░░░ 70 — colors, spacing, font sizes, layout
207
- logic patterns ████████░░░░░░░░░░░░ 68 — state, hooks, prop usage
208
- 13 other categories are clean
209
-
210
- Next step:
211
- → `slopbrick scan --rule src/cli/scan.ts` to drill into the worst file (4 issues)
212
- → `slopbrick scan --suggest` for auto-fix advice
213
- → `slopbrick scan --baseline` to accept today's scores as the new floor
214
- → `slopbrick scan --why-failing` for the top 5 issues dragging the score down
188
+ $ npx slopbrick scan --brief
189
+ [v0.43.0] auto-suppressed 184 INVERTED/NOISY issue(s) from 18 default-off rule(s).
190
+ Memory persisted to .slopbrick/ (0 patterns, 0 components, 537 bytes of structure.md).
191
+
192
+ Repo is low (25/100). The biggest problem is AI patterns worst file is packages/slopbrick/src/engine/parser-rust.ts.
193
+
194
+ AI Slop Score 25 low (aiSlopScore)
195
+ Engineering Hygiene 100 excellent (engineeringHygiene)
196
+ Security 100 excellent (security)
197
+ Repository Health 57 needs work (repositoryHealth)
198
+
199
+ CI gate: AI Slop Score <= 15 -> fail
200
+
201
+ Scanned 593 files, 346 issues. Run with --all for the full report.
202
+ 1 threshold failed: meanSlop (score 25 > 15)
215
203
  ```
216
204
 
217
205
  `--brief` (CI/scripts): same headline + threshold + delta in 4 lines.
218
206
  `--why-failing`: top 5 rules ranked by weighted impact.
219
207
  `--suggest`: per-rule auto-fix advice.
208
+ `--human-only` / `--ai-only`: filter issues by category.
220
209
 
221
210
  ---
222
211
 
@@ -226,14 +215,14 @@ Next step:
226
215
  |-------------------|-----------|
227
216
  | Add a new rule (most common contribution) | [`CONTRIBUTING.md`](./CONTRIBUTING.md) |
228
217
  | Configure for strict CI, monorepo, Python, etc. | [`EXAMPLES.md`](./EXAMPLES.md) |
229
- | Understand the Slop Index vs Coherence | [`docs/scoring-explained.md`](./docs/scoring-explained.md) |
218
+ | Understand the 4-score model (AI Slop Score, Engineering Hygiene, Security, Repository Health) | [`docs/scoring-explained.md`](./docs/scoring-explained.md) |
230
219
  | Connect Claude Code / Cursor / Copilot | [`docs/MCP.md`](./docs/MCP.md) |
231
220
  | See the 4 `.slopbrick/` artifacts (structure, inventory, ...) | [`docs/repository-structure.md`](./docs/repository-structure.md) |
232
221
  | See the 103 rules (per-rule descriptions + citations) | [`docs/rule-catalog.md`](./docs/rule-catalog.md) |
233
222
  | See how the engine works (parser → facts → rules) | [`docs/architecture.md`](./docs/architecture.md) |
234
223
  | See which frameworks are supported | [`docs/framework-parity-matrix.md`](./docs/framework-parity-matrix.md) |
235
224
  | See what's changed in each release | [`CHANGELOG.md`](./CHANGELOG.md) |
236
- | See the strategic plan (0.140.15 → 1.0) | [`ROADMAP.md`](./ROADMAP.md) |
225
+ | See the strategic plan (v0.xv1.0) | [`ROADMAP.md`](./ROADMAP.md) |
237
226
  | See research behind the calibration | [`docs/research/`](./docs/research/) |
238
227
  | Report a security vulnerability | [`SECURITY.md`](./SECURITY.md) |
239
228
  | Run a CI gate | `slopbrick ci` (see [`EXAMPLES.md`](./EXAMPLES.md#strict-ci-gate)) |
@@ -249,8 +238,9 @@ The 19 subcommands are auto-generated from commander and run
249
238
  npm install -D slopbrick
250
239
  ```
251
240
 
252
- Requires Node 18+. The package ships ESM + CJS dual builds, TypeScript
253
- types, and is published to npm as `slopbrick`.
241
+ Requires Node 20+ (verified by `slopbrick doctor`). The package ships
242
+ ESM + CJS dual builds, TypeScript types, and is published to npm as
243
+ `slopbrick`.
254
244
 
255
245
  For the MCP server, add to your AI agent's config:
256
246
 
package/bin/slopbrick.js CHANGED
@@ -1,6 +1,27 @@
1
1
  #!/usr/bin/env node
2
+ // Top-level error guards: converts unhandled exceptions into a
3
+ // human-readable message + a non-zero exit code, instead of the
4
+ // default Node "UnhandledPromiseRejectionWarning" + stack trace.
5
+ // Without this, a transitive module-throwing-on-load (e.g. a
6
+ // config importer with a syntax error) showed a stack trace and
7
+ // exited with code 1 + a useless warning, confusing new users.
2
8
  (async () => {
3
9
  const start = performance.now();
4
- const { runCli } = await import('../dist/index.js');
5
- await runCli({ start });
10
+ try {
11
+ const { runCli } = await import('../dist/index.js');
12
+ await runCli({ start });
13
+ } catch (err) {
14
+ const msg = err instanceof Error ? err.message : String(err);
15
+ process.stderr.write(`slopbrick: unhandled error — ${msg}\n`);
16
+ process.exit(1);
17
+ }
6
18
  })();
19
+ process.on('unhandledRejection', (reason) => {
20
+ const msg = reason instanceof Error ? reason.message : String(reason);
21
+ process.stderr.write(`slopbrick: unhandled rejection — ${msg}\n`);
22
+ process.exit(1);
23
+ });
24
+ process.on('uncaughtException', (err) => {
25
+ process.stderr.write(`slopbrick: uncaught exception — ${err.message}\n`);
26
+ process.exit(1);
27
+ });
@@ -6897,6 +6897,26 @@ function dispatchNode(node, parent, path, vctx) {
6897
6897
  // src/engine/parser-rust.ts
6898
6898
  var import_tree_sitter = __toESM(require("tree-sitter"), 1);
6899
6899
  var import_tree_sitter_rust = __toESM(require("tree-sitter-rust"), 1);
6900
+
6901
+ // src/engine/parser-shared.ts
6902
+ function parseTreeSitterSource(parserResult, source) {
6903
+ if (!parserResult.ok) return null;
6904
+ if (!source || source.trim() === "") return null;
6905
+ try {
6906
+ const rawTree = parserResult.parser.parse(source);
6907
+ if (!rawTree) return null;
6908
+ const tree = rawTree;
6909
+ if (!tree || !tree.rootNode) return null;
6910
+ if (tree.rootNode.type === "ERROR" && tree.rootNode.childCount === 0) {
6911
+ return null;
6912
+ }
6913
+ return tree;
6914
+ } catch {
6915
+ return null;
6916
+ }
6917
+ }
6918
+
6919
+ // src/engine/parser-rust.ts
6900
6920
  var cachedParser = null;
6901
6921
  var rustLanguage = null;
6902
6922
  var loadError = null;
@@ -6923,21 +6943,7 @@ function effectiveParser() {
6923
6943
  return getRustParser();
6924
6944
  }
6925
6945
  function parseRust(source) {
6926
- const result = effectiveParser();
6927
- if (!result.ok) return null;
6928
- if (!source || source.trim() === "") return null;
6929
- try {
6930
- const rawTree = result.parser.parse(source);
6931
- if (!rawTree) return null;
6932
- const tree = rawTree;
6933
- if (!tree || !tree.rootNode) return null;
6934
- if (tree.rootNode.type === "ERROR" && tree.rootNode.childCount === 0) {
6935
- return null;
6936
- }
6937
- return tree;
6938
- } catch {
6939
- return null;
6940
- }
6946
+ return parseTreeSitterSource(effectiveParser(), source);
6941
6947
  }
6942
6948
  function isRustParserAvailable() {
6943
6949
  const result = getRustParser();
@@ -7551,8 +7557,6 @@ function mergeTemplateClassNames(filePath, source, facts, templateClassNames) {
7551
7557
  }
7552
7558
  function extractFacts(filePath, ast, source, supportsRsc = true, framework = "react", config) {
7553
7559
  const lineOffsets = buildLineOffsets(source);
7554
- const isTypeScript = /\.tsx?$/i.test(filePath);
7555
- const NON_JSX_FRAMEWORKS = /* @__PURE__ */ new Set(["vue", "svelte", "astro"]);
7556
7560
  const facts = {
7557
7561
  filePath,
7558
7562
  components: [],
@@ -32976,7 +32980,7 @@ var aiCommentRatioRule = createRule({
32976
32980
  message: `Comment-line ratio is ${direction} (${ratio.toFixed(2)} vs corpus mean ${mean3}). Reductive LLMs (GPT-3.5, Claude 3 Haiku) skip comments; expansive LLMs (Claude Haiku 4.5, GPT-OSS) over-comment when prompted for "well-commented code".`,
32977
32981
  line: 1,
32978
32982
  column: 1,
32979
- advice: direction === "low" ? "Add doc comments explaining intent (not just restating the next line) for non-obvious logic." : "Trim comments that just restate the next line. Keep comments that explain WHY, not WHAT."
32983
+ advice: direction === "low" ? "Add doc comments explaining intent (not just restating the next line) for non-obvious logic." : "High comment ratio \u2014 consider whether the comments are intentional (api docs, type defs, tutorials). For functional code, trim comments that just restate the next line. Keep comments that explain WHY, not WHAT."
32980
32984
  }
32981
32985
  ];
32982
32986
  return issues;
@@ -33750,7 +33754,6 @@ function buildTrigramLM(samples, alpha = 1) {
33750
33754
  logProb(context, t) {
33751
33755
  const ctxKey = context.slice(-2).join("|");
33752
33756
  const triKey = `${ctxKey}|${t}`;
33753
- const biKey = context.slice(-1).join("|") + "|" + t;
33754
33757
  const triCount = tri.get(triKey) ?? 0;
33755
33758
  const biCount = bi.get(ctxKey) ?? 0;
33756
33759
  const V = uni.size;
@@ -35543,7 +35546,7 @@ var RESERVED = /* @__PURE__ */ new Set([
35543
35546
  ]);
35544
35547
  var SOURCE_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
35545
35548
  var SOURCE_ROOTS = ["src", "lib", "app", "components"];
35546
- var CAP = 200;
35549
+ var CAP = 4e3;
35547
35550
  function walk(dir, out, cap) {
35548
35551
  if (!(0, import_node_fs.existsSync)(dir) || out.length >= cap) return;
35549
35552
  let entries;
@@ -35623,6 +35626,7 @@ var staleFunctionReferenceRule = createRule({
35623
35626
  if (!source) return issues;
35624
35627
  for (const span of extractInlineCodeSpans(source)) {
35625
35628
  const text = span.text;
35629
+ if (span.inBlockComment || span.inComment) continue;
35626
35630
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(text)) continue;
35627
35631
  if (text.length < 3) continue;
35628
35632
  if (RESERVED.has(text.toLowerCase())) continue;
@@ -35785,6 +35789,7 @@ var stalePackageReferenceRule = createRule({
35785
35789
  if (!source) return issues;
35786
35790
  const spans = extractInlineCodeSpans(source);
35787
35791
  for (const span of spans) {
35792
+ if (span.inComment) continue;
35788
35793
  const lineStart = source.lastIndexOf("\n", span.index) + 1;
35789
35794
  const lineEnd = source.indexOf("\n", span.index);
35790
35795
  const line = source.slice(lineStart, lineEnd === -1 ? source.length : lineEnd);
@@ -35830,6 +35835,8 @@ var stalePackageReferenceRule = createRule({
35830
35835
  function extractInlineCodeSpans(source) {
35831
35836
  const hits = [];
35832
35837
  const re = /`([^`\n]+?)`/g;
35838
+ const blockRanges = findBlockCommentRanges(source);
35839
+ const commentLines = findCommentLineRanges(source);
35833
35840
  let m;
35834
35841
  while ((m = re.exec(source)) !== null) {
35835
35842
  const text = m[1] ?? "";
@@ -35837,13 +35844,22 @@ function extractInlineCodeSpans(source) {
35837
35844
  const line = upTo.split("\n").length;
35838
35845
  const lastNl = upTo.lastIndexOf("\n");
35839
35846
  const column = lastNl === -1 ? m.index + 1 : m.index - lastNl;
35840
- hits.push({ text, line, column, index: m.index });
35847
+ hits.push({
35848
+ text,
35849
+ line,
35850
+ column,
35851
+ index: m.index,
35852
+ inBlockComment: isInBlockComment(blockRanges, m.index),
35853
+ inComment: commentLines.has(line)
35854
+ });
35841
35855
  }
35842
35856
  return hits;
35843
35857
  }
35844
35858
  function extractMarkdownLinks(source) {
35845
35859
  const hits = [];
35846
35860
  const re = /(?<!\!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
35861
+ const blockRanges = findBlockCommentRanges(source);
35862
+ const commentLines = findCommentLineRanges(source);
35847
35863
  let m;
35848
35864
  while ((m = re.exec(source)) !== null) {
35849
35865
  const target = m[2] ?? "";
@@ -35851,10 +35867,55 @@ function extractMarkdownLinks(source) {
35851
35867
  const line = upTo.split("\n").length;
35852
35868
  const lastNl = upTo.lastIndexOf("\n");
35853
35869
  const column = lastNl === -1 ? m.index + 1 : m.index - lastNl;
35854
- hits.push({ target, line, column });
35870
+ hits.push({
35871
+ target,
35872
+ line,
35873
+ column,
35874
+ index: m.index,
35875
+ inBlockComment: isInBlockComment(blockRanges, m.index),
35876
+ inComment: commentLines.has(line)
35877
+ });
35855
35878
  }
35856
35879
  return hits;
35857
35880
  }
35881
+ function findBlockCommentRanges(source) {
35882
+ const ranges = [];
35883
+ const stack = [];
35884
+ let i = 0;
35885
+ while (i < source.length) {
35886
+ if (source[i] === "/" && source[i + 1] === "/") {
35887
+ const eol = source.indexOf("\n", i);
35888
+ i = eol === -1 ? source.length : eol + 1;
35889
+ continue;
35890
+ }
35891
+ if (source[i] === "/" && source[i + 1] === "*") {
35892
+ stack.push(i);
35893
+ i += 2;
35894
+ continue;
35895
+ }
35896
+ if (source[i] === "*" && source[i + 1] === "/" && stack.length > 0) {
35897
+ const start = stack.pop();
35898
+ ranges.push([start, i + 2]);
35899
+ i += 2;
35900
+ continue;
35901
+ }
35902
+ i += 1;
35903
+ }
35904
+ ranges.sort((a, b) => a[0] - b[0]);
35905
+ return ranges;
35906
+ }
35907
+ function isInBlockComment(ranges, pos) {
35908
+ let lo = 0;
35909
+ let hi = ranges.length - 1;
35910
+ while (lo <= hi) {
35911
+ const mid = lo + hi >>> 1;
35912
+ const [start, end] = ranges[mid];
35913
+ if (pos < start) hi = mid - 1;
35914
+ else if (pos >= end) lo = mid + 1;
35915
+ else return true;
35916
+ }
35917
+ return false;
35918
+ }
35858
35919
  function declaredPackages(cwd) {
35859
35920
  const out = /* @__PURE__ */ new Set();
35860
35921
  const pkgPath = (0, import_node_path3.join)(cwd, "package.json");
@@ -35874,8 +35935,63 @@ function declaredPackages(cwd) {
35874
35935
  }
35875
35936
  return out;
35876
35937
  }
35938
+ function findCommentLineRanges(source) {
35939
+ const out = /* @__PURE__ */ new Set();
35940
+ const stack = [];
35941
+ let inLineComment = false;
35942
+ const lines = source.split("\n");
35943
+ for (let i = 0; i < lines.length; i++) {
35944
+ const line = lines[i];
35945
+ const lineNo = i + 1;
35946
+ let j = 0;
35947
+ while (j < line.length) {
35948
+ const c = line[j];
35949
+ const next = line[j + 1];
35950
+ if (!stack.length && c === "/" && next === "/") {
35951
+ inLineComment = true;
35952
+ out.add(lineNo);
35953
+ break;
35954
+ }
35955
+ if (c === "/" && next === "*") {
35956
+ stack.push(lineNo);
35957
+ out.add(lineNo);
35958
+ j += 2;
35959
+ continue;
35960
+ }
35961
+ if (c === "*" && next === "/" && stack.length > 0) {
35962
+ stack.pop();
35963
+ j += 2;
35964
+ continue;
35965
+ }
35966
+ if (c === '"' || c === "'" || c === "`") {
35967
+ const quote = c;
35968
+ j += 1;
35969
+ while (j < line.length) {
35970
+ if (line[j] === "\\") {
35971
+ j += 2;
35972
+ continue;
35973
+ }
35974
+ if (line[j] === quote) {
35975
+ j += 1;
35976
+ break;
35977
+ }
35978
+ j += 1;
35979
+ }
35980
+ continue;
35981
+ }
35982
+ j += 1;
35983
+ }
35984
+ if (stack.length > 0) {
35985
+ out.add(lineNo);
35986
+ }
35987
+ }
35988
+ return out;
35989
+ }
35877
35990
 
35878
35991
  // src/rules/docs/broken-link.ts
35992
+ function looksLikeRegexSyntax(target) {
35993
+ return /\[[^\]]*\]/.test(target) || /^\s*[?:=!]/.test(target) || /\\(?:s|d|w|S|D|W|t|n|r|[^\\])/.test(target) || /[{}|]/.test(target);
35994
+ }
35879
35995
  var brokenLinkRule = createRule({
35880
35996
  id: "docs/broken-link",
35881
35997
  category: "docs",
@@ -35898,6 +36014,8 @@ var brokenLinkRule = createRule({
35898
36014
  if (target.startsWith("#")) continue;
35899
36015
  if (target.startsWith("//")) continue;
35900
36016
  if (target.startsWith("/")) continue;
36017
+ if (link.inBlockComment) continue;
36018
+ if (looksLikeRegexSyntax(target)) continue;
35901
36019
  const filePart = target.split("#")[0] ?? target;
35902
36020
  if (filePart === "") continue;
35903
36021
  const resolved = (0, import_node_path4.join)(docDir, filePart);
@@ -37222,8 +37340,16 @@ var DEFAULT_CONFIG3 = {
37222
37340
  // rule definitions are meta-code
37223
37341
  "**/snippet/**",
37224
37342
  // RULE_HINTS examples are intentional bad code
37225
- "**/tests/**"
37343
+ "**/tests/**",
37226
37344
  // all test files (fixtures + unit + integration)
37345
+ // v0.42.0 (post-self-scan cleanup): language visitors are
37346
+ // intentional clones — see the §3 v0.42.0 release notes for
37347
+ // the design rationale. `dup/identical-block` fires 100+
37348
+ // times during self-scan because each visitor shares the
37349
+ // same service-suffix arrays and import-walking preamble.
37350
+ // Suppress these from the self-scan noise floor; they
37351
+ // remain on for user codebases.
37352
+ "**/src/engine/visitors/**"
37227
37353
  ]
37228
37354
  }
37229
37355
  };
@@ -41124,23 +41250,16 @@ var builtinRules = [
41124
41250
  // src/rules/registry.ts
41125
41251
  var RuleRegistry = class {
41126
41252
  rules = /* @__PURE__ */ new Map();
41127
- register(ruleOrId, factory) {
41128
- if (typeof ruleOrId === "string") {
41129
- if (!factory) {
41130
- throw new Error("Factory is required when registering by id");
41131
- }
41132
- const rule = {
41133
- id: ruleOrId,
41134
- category: factory.category,
41135
- severity: factory.severity,
41136
- aiSpecific: factory.aiSpecific,
41137
- create: factory.create,
41138
- analyze: factory.analyze
41139
- };
41140
- this.rules.set(ruleOrId, rule);
41141
- } else {
41142
- this.rules.set(ruleOrId.id, ruleOrId);
41143
- }
41253
+ /**
41254
+ * Register a rule. The 2-arg overload `register(id, factory)` was
41255
+ * deleted in v0.42.0 (architecture review F6): no callers used it,
41256
+ * every `Rule` carries its own `id`, and the per-id registration
41257
+ * was ergonomically redundant with the 1-arg form (`register(rule)`).
41258
+ * External consumers that needed `register-by-id` semantics can
41259
+ * construct a `Rule` directly and pass it in.
41260
+ */
41261
+ register(rule) {
41262
+ this.rules.set(rule.id, rule);
41144
41263
  }
41145
41264
  loadBuiltins(onlyRuleId) {
41146
41265
  for (const rule of builtinRules) {
@@ -41169,6 +41288,15 @@ var RuleRegistry = class {
41169
41288
  all() {
41170
41289
  return Array.from(this.rules.values());
41171
41290
  }
41291
+ /** v0.42.0 (§3b.5): look up a registered rule by id.
41292
+ * Returns undefined if not registered. */
41293
+ get(id) {
41294
+ return this.rules.get(id);
41295
+ }
41296
+ /** v0.42.0 (§3b.5): check whether a rule id is registered. */
41297
+ has(id) {
41298
+ return this.rules.has(id);
41299
+ }
41172
41300
  createContexts(config, filePath, cwd, hotspotIssues = []) {
41173
41301
  const context = {
41174
41302
  config,
@@ -44863,6 +44991,36 @@ async function scanFile(filePath, config, registry, cwd = process.cwd()) {
44863
44991
  const issues = applyRuleOverrides(rawIssues, config.rules);
44864
44992
  const triggeredRuleIds = Array.from(new Set(issues.map((i) => i.ruleId)));
44865
44993
  const compScore = compositeScore(triggeredRuleIds, loadSignalStrength());
44994
+ const fireSet = new Set(triggeredRuleIds);
44995
+ const factsWithFireSet = Object.assign(facts, {
44996
+ compositeFireSet: fireSet
44997
+ });
44998
+ for (const { rule } of rules) {
44999
+ if (!rule.id.startsWith("composite/")) continue;
45000
+ try {
45001
+ const compositeIssues = rule.analyze(
45002
+ rule.create({
45003
+ config,
45004
+ filePath,
45005
+ cwd,
45006
+ framework: config.framework,
45007
+ uiLibraries: config.uiLibraries,
45008
+ hasTailwind: config.hasTailwind,
45009
+ supportsRsc: config.supportsRsc,
45010
+ hotspotIssues: []
45011
+ }),
45012
+ factsWithFireSet
45013
+ );
45014
+ if (compositeIssues.length > 0) {
45015
+ issues.push(...compositeIssues);
45016
+ for (const ci of compositeIssues) fireSet.add(ci.ruleId);
45017
+ }
45018
+ } catch (err) {
45019
+ if (process.env.SLOP_AUDIT_DEBUG === "1") {
45020
+ console.error(`[worker] composite ${rule.id} threw: ${err.message}`);
45021
+ }
45022
+ }
45023
+ }
44866
45024
  const gapValues = collectGapValues(facts);
44867
45025
  const styleSources = collectStyleSources(facts);
44868
45026
  const elementTags = facts.v2.jsx.elements.map((e) => e.tag);