slopbrick 0.20.0 → 0.21.1

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
@@ -46,7 +46,7 @@ on every scan — your repository, encoded for the next agent.
46
46
  lib, modal system, API client) once. The agent and the linter
47
47
  enforce it together.
48
48
 
49
- **Status:** v0.17.0 (current). See the [CHANGELOG](./CHANGELOG.md) for
49
+ **Status:** v0.21.0 (current). See the [CHANGELOG](./CHANGELOG.md) for
50
50
  the full release notes.
51
51
 
52
52
  ---
@@ -76,21 +76,25 @@ For every other config question, see [`EXAMPLES.md`](./EXAMPLES.md).
76
76
 
77
77
  ---
78
78
 
79
- ## The headlines (4-score model, v0.17.0+)
79
+ ## The headlines (4-score model, v0.21.0+)
80
80
 
81
- > **v0.15.0 introduced the 4-score model; v0.16.0 + v0.17.0 completed it.**
82
- > The single `Slop Index` is replaced by **4 independent scores**
83
- > (all 0-100, **higher = better**). The legacy `slopIndex` field
84
- > is kept as optional on `ProjectReport` for backward compat with
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
85
  > existing test fixtures and historical telemetry; the v0.14-compat
86
86
  > removal is tracked separately.
87
87
 
88
- | Score | What it measures | CI gate? |
89
- |-------|------------------|----------|
90
- | **`aiQuality`** | AI-slop signatures (16 `ai/*` rules). INVERTED from legacy Slop Index. | **Yes** ( 70 passes) |
91
- | **`engineeringHygiene`** | Average of 6 category scores: arch, logic, layout, visual, component, test | No (informational) |
92
- | **`security`** | AI Security Risk band: low=100, medium=67, high=33, critical=0 | No (informational) |
93
- | **`repositoryHealth`** (composite) | Weighted: 0.4×aiQ + 0.3×eng + 0.2×sec + 0.1×test | No (informational) |
88
+ | Score | What it measures | Direction | CI gate? |
89
+ |-------|------------------|-----------|----------|
90
+ | **`aiSlopScore`** | AI-slop signatures (16 `ai/*` rules). | **lower = cleaner** (raw amount) | **Yes** (`≤ meanSlop: 30` passes) |
91
+ | **`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
+
95
+ **Score-band messages** (v0.21.0+): every score ships with a one-line
96
+ 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`.
94
98
 
95
99
  The same numbers are in `.slopbrick/health.json`.
96
100
 
@@ -108,13 +112,13 @@ For per-rule precision/recall/FPR (auditable), see
108
112
  $ npx slopbrick scan
109
113
  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.
110
114
 
111
- AI Quality: 25 / 100 [CONCERNING] ↑5 (worse)
115
+ AI Slop Score: 25 / 100 [HIGH] ↑5 (worse)
112
116
  higher = better · measures AI-slop signatures. The same number in .slopbrick/health.json.
113
117
  ├─ boundary: 10 (40%) — structural integrity
114
118
  ├─ context: 50 (35%) — props / state / imports
115
119
  └─ visual: 5 (25%) — CSS / a11y / layout
116
120
 
117
- Engineering Hygiene: 60 / 100 [NEEDS WORK] — higher = better · measures internal consistency. This is a secondary view; the AI Quality above is the gate.
121
+ Engineering Hygiene: 60 / 100 [NEEDS WORK] — higher = better · measures internal consistency. This is a secondary view; the AI Slop Score above is the gate.
118
122
 
119
123
  Other signals (not the gate):
120
124
  Code Hygiene 75/100
@@ -6424,6 +6424,7 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
6424
6424
  if (source) {
6425
6425
  const { line, column } = positionFrom(node, vctx.lineOffsets);
6426
6426
  const importedNames = [];
6427
+ const isTypeOnly = node.typeOnly === true;
6427
6428
  const specifiers = node.specifiers;
6428
6429
  if (Array.isArray(specifiers)) {
6429
6430
  for (const specifier of specifiers) {
@@ -6439,7 +6440,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
6439
6440
  line,
6440
6441
  column,
6441
6442
  source,
6442
- isReferenced: false
6443
+ isReferenced: false,
6444
+ isTypeOnly
6443
6445
  });
6444
6446
  }
6445
6447
  } else if (specifier.type === "ImportSpecifier") {
@@ -6454,7 +6456,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
6454
6456
  line,
6455
6457
  column,
6456
6458
  source,
6457
- isReferenced: false
6459
+ isReferenced: false,
6460
+ isTypeOnly
6458
6461
  });
6459
6462
  } else if (isObject(local) && local.type === "Identifier" && typeof local.value === "string") {
6460
6463
  const name = local.value;
@@ -6465,7 +6468,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
6465
6468
  line,
6466
6469
  column,
6467
6470
  source,
6468
- isReferenced: false
6471
+ isReferenced: false,
6472
+ isTypeOnly
6469
6473
  });
6470
6474
  }
6471
6475
  }
@@ -6758,7 +6762,7 @@ function handleJSXOpeningElement(node, _parent, _path, vctx) {
6758
6762
  }
6759
6763
  return false;
6760
6764
  }
6761
- function handleVariableDeclarator(node, _parent, path, vctx) {
6765
+ function handleVariableDeclarator(node, parent, path, vctx) {
6762
6766
  if (!isObject(node)) return false;
6763
6767
  const init = node.init;
6764
6768
  if (init && vctx.visit) {
@@ -6773,16 +6777,18 @@ function handleVariableDeclarator(node, _parent, path, vctx) {
6773
6777
  }
6774
6778
  }
6775
6779
  if (bindingNames.length > 0) {
6776
- const parent = node.parent;
6777
- const kind = isObject(parent) && parent.type === "VariableDeclaration" ? String(parent.kind ?? "var") : "var";
6780
+ const declParent = isObject(parent) && parent.type === "ExportNamedDeclaration" ? parent.declaration ?? parent : parent;
6781
+ const kind = isObject(declParent) && declParent.type === "VariableDeclaration" ? String(declParent.kind ?? "var") : "var";
6778
6782
  const { line, column } = positionFrom(id, vctx.lineOffsets);
6783
+ const scope = vctx.ctx.stack.length === 0 ? "module" : "function";
6779
6784
  for (const bindingName of bindingNames) {
6780
6785
  vctx.facts.deadCode.bindings.push({
6781
6786
  name: bindingName,
6782
6787
  kind,
6783
6788
  line,
6784
6789
  column,
6785
- isReferenced: false
6790
+ isReferenced: false,
6791
+ scope
6786
6792
  });
6787
6793
  }
6788
6794
  }
@@ -32882,8 +32888,8 @@ function getCorpusBaselines() {
32882
32888
 
32883
32889
  // src/rules/ai/comment-ratio.ts
32884
32890
  var MIN_FILE_LINES = 20;
32885
- var FALLBACK_LOW = 0.05;
32886
- var FALLBACK_HIGH = 0.45;
32891
+ var FALLBACK_LOW = 0.02;
32892
+ var FALLBACK_HIGH = 0.55;
32887
32893
  var LINE_COMMENT_RE = /^\s*(?:\/\/|#|--|;|%)/;
32888
32894
  var BLOCK_COMMENT_OPEN_RE = /\/\*/;
32889
32895
  var aiCommentRatioRule = createRule({
@@ -33012,9 +33018,9 @@ function std(xs) {
33012
33018
 
33013
33019
  // src/rules/ai/compression-profile.ts
33014
33020
  var MIN_BYTES = 2e3;
33015
- var GZIP_RATIO_AI_THRESHOLD = 0.5;
33016
- var MEAN_LINE_NCD_HUMAN_MAX = 0.3;
33017
- var NCD_CV_AI_MAX = 0.5;
33021
+ var GZIP_RATIO_AI_THRESHOLD = 0.6;
33022
+ var MEAN_LINE_NCD_HUMAN_MAX = 0.25;
33023
+ var NCD_CV_AI_MAX = 0.4;
33018
33024
  var aiCompressionProfileRule = createRule({
33019
33025
  id: "ai/compression-profile",
33020
33026
  category: "ai",
@@ -33169,6 +33175,13 @@ var aiDefaultReactStackRule = createRule({
33169
33175
  category: "ai",
33170
33176
  severity: "low",
33171
33177
  aiSpecific: true,
33178
+ // v0.20.0 calibration: v8.5 verdict = USEFUL but recall 0.001
33179
+ // (fires 1 time per 1,000 files). The "≥3 of default stack"
33180
+ // threshold catches almost nothing on real codebases — the
33181
+ // default stack is now ubiquitous, so the signal is near-zero.
33182
+ // Disable until the threshold is lowered or the rule is split
33183
+ // into per-library detectors.
33184
+ defaultOff: true,
33172
33185
  description: "File uses \u22653 of the default React stack (Next.js + TanStack Query + Zustand + shadcn/ui) \u2014 Sascha 2025 + Nam et al. MSR 2026",
33173
33186
  create(context) {
33174
33187
  return context;
@@ -33421,6 +33434,10 @@ var aiLibraryReinventionRule = createRule({
33421
33434
  category: "ai",
33422
33435
  severity: "medium",
33423
33436
  aiSpecific: true,
33437
+ // v0.20.0 calibration: v8.5 verdict = USEFUL but recall 0.000
33438
+ // (fires never on the corpus). The "≥2 reinvented patterns"
33439
+ // threshold is too strict. Disable until rewritten.
33440
+ defaultOff: true,
33424
33441
  description: "File reinvents \u22652 common patterns (date picker, form validation, chart, modal, etc.) without importing the canonical library \u2014 GitClear 2025 + Cui et al. 2025",
33425
33442
  create(context) {
33426
33443
  return context;
@@ -33607,6 +33624,12 @@ var aiMarkdownLeakageRule = createRule({
33607
33624
  category: "ai",
33608
33625
  severity: "high",
33609
33626
  aiSpecific: true,
33627
+ // v0.20.0 calibration: v8.5 verdict = INVERTED (lift 65,504× but
33628
+ // recall 0.000 — fires never on the corpus, and when it does fire
33629
+ // it's wrong). The rule was on by default, contributing false
33630
+ // positives to the self-scan. Disable until the rule is rewritten
33631
+ // or the v9 corpus shows it works.
33632
+ defaultOff: true,
33610
33633
  description: "Stray Markdown fence or bare language name at top of file \u2014 AI tools leak the fenced-block format into code",
33611
33634
  create(context) {
33612
33635
  return context;
@@ -33833,6 +33856,16 @@ var aiSegmentSurprisalCvRule = createRule({
33833
33856
  category: "ai",
33834
33857
  severity: "medium",
33835
33858
  aiSpecific: true,
33859
+ // v0.21.0: defaultOff. The rule fires on 250/250 files in the
33860
+ // self-scan (1 per file), which means it's not discriminating
33861
+ // between AI and non-AI text — it fires on every file. The
33862
+ // Binoculars-style per-segment entropy CV is too coarse a
33863
+ // signal at the file level; the entropy floor is hit by most
33864
+ // structured TypeScript regardless of whether the author was
33865
+ // an LLM. Re-enable per-file via
33866
+ // `rules: { 'ai/segment-surprisal-cv': 'medium' }` in
33867
+ // slopbrick.config.mjs once a tighter threshold is calibrated.
33868
+ defaultOff: true,
33836
33869
  description: "Per-segment cross-entropy CV is suspiciously low \u2014 Binoculars (Hans 2024): AI text has near-constant per-window entropy",
33837
33870
  create(context) {
33838
33871
  return context;
@@ -34032,6 +34065,11 @@ var aiTextLikeRatioRule = createRule({
34032
34065
  category: "ai",
34033
34066
  severity: "low",
34034
34067
  aiSpecific: true,
34068
+ // v0.20.0 calibration: v8.5 verdict = USEFUL but recall 0.000
34069
+ // (fires never on the corpus). The rule is too specific to ever
34070
+ // fire on real code — the prose-detection heuristic is too
34071
+ // conservative. Disable until rewritten.
34072
+ defaultOff: true,
34035
34073
  description: "High ratio of prose-like lines embedded in code \u2014 AI tools often leave natural-language explanations in source files (Yotkova 2026)",
34036
34074
  create(context) {
34037
34075
  return context;
@@ -34968,6 +35006,7 @@ var unusedImportRule = createRule({
34968
35006
  if (binding.kind !== "import-specifier" && binding.kind !== "import-default" && binding.kind !== "import-namespace") {
34969
35007
  continue;
34970
35008
  }
35009
+ if (binding.isTypeOnly) continue;
34971
35010
  if (binding.isReferenced) continue;
34972
35011
  if (!binding.name) continue;
34973
35012
  const source = binding.source ? ` from '${binding.source}'` : "";
@@ -35041,6 +35080,9 @@ var unusedLocalRule = createRule({
35041
35080
  if (binding.isReferenced) continue;
35042
35081
  if (SKIP_NAMES.has(binding.name)) continue;
35043
35082
  if (binding.name.startsWith("_")) continue;
35083
+ if (binding.scope === "module" && (binding.kind === "const" || binding.kind === "function" || binding.kind === "class" || binding.kind === "type" || binding.kind === "interface" || binding.kind === "enum")) {
35084
+ continue;
35085
+ }
35044
35086
  issues.push({
35045
35087
  ruleId: "dead/unused-local",
35046
35088
  category: "logic",
@@ -35708,7 +35750,7 @@ function collectExports(cwd) {
35708
35750
  /\bexport\s+default\s+(?:function\s+|class\s+)?([A-Za-z_$][\w$]*)/g,
35709
35751
  // v0.18.6: also collect field names from `export interface` and
35710
35752
  // `export type` declarations. Without this, fields like
35711
- // `crossFileDrift`, `aiQuality`, `engineeringHygiene` are
35753
+ // `crossFileDrift`, `aiSlopScore`, `engineeringHygiene` are
35712
35754
  // flagged as stale even though they're valid type fields.
35713
35755
  /^\s*(?:readonly\s+)?([A-Za-z_$][\w$]*)\s*[?:]/gm
35714
35756
  ]) {
@@ -36037,7 +36079,7 @@ var brokenLinkRule = createRule({
36037
36079
 
36038
36080
  // src/rules/dup/identical-block.ts
36039
36081
  var crypto = __toESM(require("crypto"), 1);
36040
- var WINDOW_SIZE = 10;
36082
+ var WINDOW_SIZE = 20;
36041
36083
  var MIN_NORMALIZED_LENGTH = 40;
36042
36084
  var HASH_PREFIX_LENGTH = 16;
36043
36085
  var DEDUP_CACHE = /* @__PURE__ */ new Map();
@@ -36499,6 +36541,9 @@ var javaSystemOutPrintlnRule = createRule({
36499
36541
  var GAP_RE = /\bgap(?:-x|-y)?-(\d+)\b/g;
36500
36542
  var gapMonopolyRule = createRule({
36501
36543
  id: "layout/gap-monopoly",
36544
+ // v0.20.0 calibration: recall 0.000, fires never. Disable
36545
+ // until rewritten.
36546
+ defaultOff: true,
36502
36547
  category: "layout",
36503
36548
  severity: "medium",
36504
36549
  aiSpecific: false,
@@ -36595,6 +36640,9 @@ var mathElementUniformityRule = createRule({
36595
36640
  var GRID_COLS_RE = /\bgrid-cols-(\d+)\b/g;
36596
36641
  var mathGridUniformityRule = createRule({
36597
36642
  id: "layout/math-grid-uniformity",
36643
+ // v0.20.0 calibration: recall 0.000, fires never. Disable
36644
+ // until rewritten.
36645
+ defaultOff: true,
36598
36646
  category: "layout",
36599
36647
  severity: "high",
36600
36648
  aiSpecific: true,
@@ -37046,6 +37094,9 @@ function checkInlineValue(value, scale) {
37046
37094
  }
37047
37095
  var spacingGridRule = createRule({
37048
37096
  id: "layout/spacing-grid",
37097
+ // v0.20.0 calibration: recall 0.000, fires never. Disable
37098
+ // until rewritten.
37099
+ defaultOff: true,
37049
37100
  category: "layout",
37050
37101
  severity: "medium",
37051
37102
  aiSpecific: false,
@@ -37155,15 +37206,9 @@ var boundaryViolationRule = createRule({
37155
37206
  if (context.supportsRsc === false) return [];
37156
37207
  if (!facts.v2) return [];
37157
37208
  const issues = [];
37158
- const hookLines = /* @__PURE__ */ new Map();
37159
- for (const h of facts.v2.logic.hooks) {
37160
- const arr = hookLines.get(h.name) ?? [];
37161
- arr.push({ line: h.line, column: h.column });
37162
- hookLines.set(h.name, arr);
37163
- }
37164
37209
  for (const component of facts.v2.components) {
37165
37210
  if (!component.isServerComponent) continue;
37166
- for (const hook of facts.v2.logic.hooks) {
37211
+ for (const hook of component.hookCalls) {
37167
37212
  if (!context.clientHooks.has(hook.name)) continue;
37168
37213
  issues.push({
37169
37214
  ruleId: "logic/boundary-violation",
@@ -37171,8 +37216,8 @@ var boundaryViolationRule = createRule({
37171
37216
  severity: "high",
37172
37217
  aiSpecific: true,
37173
37218
  message: `'${hook.name}' only works in interactive client components. This component runs on the server.`,
37174
- line: component.line,
37175
- column: component.column,
37219
+ line: hook.line,
37220
+ column: hook.column,
37176
37221
  advice: "Add the 'use client' directive at the top of this file, or move the stateful part to a separate client component.",
37177
37222
  fix: {
37178
37223
  kind: "insert",
@@ -39956,7 +40001,12 @@ var WEAK_MATCHERS = /* @__PURE__ */ new Set([
39956
40001
  "toBeUndefined",
39957
40002
  "toBeTruthy",
39958
40003
  "toBeFalsy",
39959
- "toBeNull"
40004
+ "toBeNull",
40005
+ "toBeGreaterThan",
40006
+ "toBeLessThan",
40007
+ "toContain",
40008
+ "toHaveLength",
40009
+ "toBeInstanceOf"
39960
40010
  ]);
39961
40011
  function isTautologicalAssertion(hit) {
39962
40012
  if (hit.matcherArg && hit.expectArg && hit.expectArg === hit.matcherArg) return true;
@@ -40797,6 +40847,10 @@ function rgbToHue([r, g, b]) {
40797
40847
  }
40798
40848
  var mathColorClusterRule = createRule({
40799
40849
  id: "visual/math-color-cluster",
40850
+ // v0.20.0 calibration: recall 0.000, fires never. The
40851
+ // "math-color cluster" heuristic is too specific to ever fire
40852
+ // on real code. Disable until rewritten.
40853
+ defaultOff: true,
40800
40854
  category: "visual",
40801
40855
  severity: "high",
40802
40856
  aiSpecific: true,
@@ -40851,6 +40905,9 @@ var FONT_IMPORT_HINT_RE = /next\/font|@import\s+url|fonts\.googleapis|fonts\.gst
40851
40905
  var NEXT_FONT_IMPORT_RE = /import\s+.*from\s+['"]next\/font\/(?:google|local)['"]/;
40852
40906
  var mathDefaultFontRule = createRule({
40853
40907
  id: "visual/math-default-font",
40908
+ // v0.20.0 calibration: recall 0.001, fires 1 per 1,000 files.
40909
+ // Too specific to be useful. Disable until rewritten.
40910
+ defaultOff: true,
40854
40911
  category: "visual",
40855
40912
  severity: "high",
40856
40913
  aiSpecific: true,
@@ -40917,6 +40974,9 @@ var TEXT_SIZE_MAP = {
40917
40974
  var TEXT_SIZE_RE = /\btext-(?:xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl)\b/g;
40918
40975
  var mathFontEntropyRule = createRule({
40919
40976
  id: "visual/math-font-entropy",
40977
+ // v0.20.0 calibration: recall 0.004, fires 0 times on self-scan
40978
+ // (verified). Disable until rewritten.
40979
+ defaultOff: true,
40920
40980
  category: "visual",
40921
40981
  severity: "high",
40922
40982
  aiSpecific: true,
@@ -41039,6 +41099,10 @@ var ROUNDED_MAP = {
41039
41099
  var ROUNDED_RE = /\brounded(?:-none|-sm|-md|-lg|-xl|-2xl|-3xl|-full)?\b/g;
41040
41100
  var mathRoundedEntropyRule = createRule({
41041
41101
  id: "visual/math-rounded-entropy",
41102
+ // v0.20.0 calibration: recall 0.004, fires 0 times on self-scan
41103
+ // (verified). The entropy heuristic is too specific to ever fire
41104
+ // on real code. Disable until rewritten.
41105
+ defaultOff: true,
41042
41106
  category: "visual",
41043
41107
  severity: "high",
41044
41108
  aiSpecific: true,
@@ -41079,6 +41143,9 @@ var mathRoundedEntropyRule = createRule({
41079
41143
  var SPACING_PREFIX_RE2 = /\b(p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|gap-x|gap-y)-(\d+)\b/g;
41080
41144
  var mathSpacingEntropyRule = createRule({
41081
41145
  id: "visual/math-spacing-entropy",
41146
+ // v0.20.0 calibration: recall 0.002, fires 0 times on self-scan
41147
+ // (verified). Disable until rewritten.
41148
+ defaultOff: true,
41082
41149
  category: "visual",
41083
41150
  severity: "medium",
41084
41151
  aiSpecific: true,
@@ -41116,7 +41183,7 @@ var mathSpacingEntropyRule = createRule({
41116
41183
 
41117
41184
  // src/rules/visual/naturalness-anomaly.ts
41118
41185
  var MIN_LENGTH = 50;
41119
- var DISTINCT_RATIO_FLOOR = 0.3;
41186
+ var DISTINCT_RATIO_FLOOR = 0.2;
41120
41187
  var naturalnessAnomalyRule = createRule({
41121
41188
  id: "visual/naturalness-anomaly",
41122
41189
  category: "visual",
@@ -41161,6 +41228,9 @@ var naturalnessAnomalyRule = createRule({
41161
41228
  var SCALE_TOLERANCE = 1e-3;
41162
41229
  var radiusScaleViolationRule = createRule({
41163
41230
  id: "visual/radius-scale-violation",
41231
+ // v0.20.0 calibration: recall 0.000, fires never. Disable
41232
+ // until rewritten.
41233
+ defaultOff: true,
41164
41234
  category: "visual",
41165
41235
  severity: "medium",
41166
41236
  aiSpecific: false,
@@ -41345,6 +41415,10 @@ var ALT_HANDLER_RE = /^(?:onClick|onPointerDown|onKey(?:Down|Up|Press))$/;
41345
41415
  var ALT_ROLE_RE = /^(?:button|application|tab|menuitem)$/;
41346
41416
  var draggingMovementsRule = createRule({
41347
41417
  id: "wcag/dragging-movements",
41418
+ // v0.20.0 calibration: v8.5 = DORMANT but defaultOff: false
41419
+ // was a bug — DORMANT rules should be off by default. The
41420
+ // rule was contributing 0 useful fires. Disable.
41421
+ defaultOff: true,
41348
41422
  category: "wcag",
41349
41423
  severity: "medium",
41350
41424
  aiSpecific: false,
@@ -41491,6 +41565,11 @@ function scanForMissingAlt(source) {
41491
41565
  }
41492
41566
  var missingAltRule = createRule({
41493
41567
  id: "wcag/missing-alt",
41568
+ // v0.20.0 calibration: lift 1.0, recall 0.000. Lift of 1.0
41569
+ // means the rule has zero discriminative power — it fires
41570
+ // equally on positive and negative examples. Dead rule.
41571
+ // Disable until rewritten.
41572
+ defaultOff: true,
41494
41573
  category: "wcag",
41495
41574
  severity: "medium",
41496
41575
  aiSpecific: false,
@@ -41958,7 +42037,8 @@ var signal_strength_default = {
41958
42037
  precision: 0.6803,
41959
42038
  lastCalibratedAt: "2026-07-01T00:00:00Z",
41960
42039
  verdict: "USEFUL",
41961
- _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=27874, FP=13097, P=68.0%, FPR=5.20%, lift=13.09. v7 was USEFUL (TP=16673, FP=10112, lift=11.29). v8 was USEFUL (TP=11201, FP=2985).",
42040
+ defaultOff: true,
42041
+ _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=27874, FP=13097, P=68.0%, FPR=5.20%, lift=13.09. v7 was USEFUL (TP=16673, FP=10112, lift=11.29). v8 was USEFUL (TP=11201, FP=2985). v0.21.0: marked defaultOff because the v0.21.0 self-scan fires 109 times across 47 files \u2014 the heuristic counts `{`, `}`, `(`, `)` as raw characters without skipping string contents, template literals, regex literals, or comments. Files with normal closing-brace tails (the last 4-5 lines of a function definition) consistently fire because the tail has 0 opens and 3-4 closes. The corpus-level 13.09 lift doesn't translate to per-file discrimination. v0.22.0 should re-implement the count with a token-aware state machine (string/comment/regex skip) or switch to a parse-error-based signal.",
41962
42042
  aiSpecific: true,
41963
42043
  _v7Verdict: "USEFUL",
41964
42044
  _v7Lift: 11.29,
@@ -42061,7 +42141,8 @@ var signal_strength_default = {
42061
42141
  precision: 0.7495,
42062
42142
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42063
42143
  verdict: "USEFUL",
42064
- _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=59390, FP=19849, P=75.0%, FPR=7.87%, lift=9.52. v7 was USEFUL (TP=43498, FP=14983, lift=9.11). v8 was USEFUL (TP=15892, FP=4866).",
42144
+ defaultOff: true,
42145
+ _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=59390, FP=19849, P=75.0%, FPR=7.87%, lift=9.52. v7 was USEFUL (TP=43498, FP=14983, lift=9.11). v8 was USEFUL (TP=15892, FP=4866). v0.21.0: marked defaultOff because the v0.21.0 self-scan fires 250 times across 250 files (1 per file) \u2014 the Binoculars-style per-segment entropy CV is too coarse a signal at the file level; the entropy floor is hit by most structured TypeScript regardless of whether the author was an LLM. The v8.5 corpus-level lift of 9.52 doesn't translate to per-file discrimination. v0.22.0 should tighten the threshold or re-calibrate against the per-file (not per-corpus) lift.",
42065
42146
  aiSpecific: true,
42066
42147
  _v7Verdict: "USEFUL",
42067
42148
  _v7Lift: 9.11,