slopbrick 0.21.0 → 0.21.2

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.20.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.20.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
- | **`aiSlopScore`** | AI-slop signatures (16 `ai/*` rules). Raw amount of slop (0=clean, 100=saturated). | **Yes** ( `meanSlop: 30` 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
 
@@ -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
  }
@@ -33850,6 +33856,16 @@ var aiSegmentSurprisalCvRule = createRule({
33850
33856
  category: "ai",
33851
33857
  severity: "medium",
33852
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,
33853
33869
  description: "Per-segment cross-entropy CV is suspiciously low \u2014 Binoculars (Hans 2024): AI text has near-constant per-window entropy",
33854
33870
  create(context) {
33855
33871
  return context;
@@ -34990,6 +35006,7 @@ var unusedImportRule = createRule({
34990
35006
  if (binding.kind !== "import-specifier" && binding.kind !== "import-default" && binding.kind !== "import-namespace") {
34991
35007
  continue;
34992
35008
  }
35009
+ if (binding.isTypeOnly) continue;
34993
35010
  if (binding.isReferenced) continue;
34994
35011
  if (!binding.name) continue;
34995
35012
  const source = binding.source ? ` from '${binding.source}'` : "";
@@ -35063,6 +35080,9 @@ var unusedLocalRule = createRule({
35063
35080
  if (binding.isReferenced) continue;
35064
35081
  if (SKIP_NAMES.has(binding.name)) continue;
35065
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
+ }
35066
35086
  issues.push({
35067
35087
  ruleId: "dead/unused-local",
35068
35088
  category: "logic",
@@ -36059,7 +36079,7 @@ var brokenLinkRule = createRule({
36059
36079
 
36060
36080
  // src/rules/dup/identical-block.ts
36061
36081
  var crypto = __toESM(require("crypto"), 1);
36062
- var WINDOW_SIZE = 10;
36082
+ var WINDOW_SIZE = 20;
36063
36083
  var MIN_NORMALIZED_LENGTH = 40;
36064
36084
  var HASH_PREFIX_LENGTH = 16;
36065
36085
  var DEDUP_CACHE = /* @__PURE__ */ new Map();
@@ -36284,6 +36304,7 @@ var javaArraylistVsLinkedlistRule = createRule({
36284
36304
  const issues = [];
36285
36305
  const source = facts.v2?._source;
36286
36306
  if (!source) return issues;
36307
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36287
36308
  let m;
36288
36309
  NEW_LINKED_LIST_REGEX.lastIndex = 0;
36289
36310
  while ((m = NEW_LINKED_LIST_REGEX.exec(source)) !== null) {
@@ -36318,6 +36339,7 @@ var javaEmptyCatchBlockRule = createRule({
36318
36339
  const issues = [];
36319
36340
  const source = facts.v2?._source;
36320
36341
  if (!source) return issues;
36342
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36321
36343
  let m;
36322
36344
  SINGLE_LINE_EMPTY_CATCH_REGEX.lastIndex = 0;
36323
36345
  while ((m = SINGLE_LINE_EMPTY_CATCH_REGEX.exec(source)) !== null) {
@@ -36354,6 +36376,7 @@ var javaLegacyDateApiRule = createRule({
36354
36376
  const issues = [];
36355
36377
  const source = facts.v2?._source;
36356
36378
  if (!source) return issues;
36379
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36357
36380
  const flagged = /* @__PURE__ */ new Set();
36358
36381
  let m;
36359
36382
  LEGACY_IMPORT_REGEX.lastIndex = 0;
@@ -36420,6 +36443,7 @@ var javaRawTypeOveruseRule = createRule({
36420
36443
  const issues = [];
36421
36444
  const source = facts.v2?._source;
36422
36445
  if (!source) return issues;
36446
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36423
36447
  let m;
36424
36448
  RAW_TYPE_REGEX.lastIndex = 0;
36425
36449
  while ((m = RAW_TYPE_REGEX.exec(source)) !== null) {
@@ -36455,6 +36479,7 @@ var javaStringConcatLoopRule = createRule({
36455
36479
  const issues = [];
36456
36480
  const source = facts.v2?._source;
36457
36481
  if (!source) return issues;
36482
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36458
36483
  if (!/\b(for|while|do)\b/.test(source)) return issues;
36459
36484
  let m;
36460
36485
  STRING_CONCAT_REGEX.lastIndex = 0;
@@ -36491,6 +36516,7 @@ var javaSystemOutPrintlnRule = createRule({
36491
36516
  const issues = [];
36492
36517
  const source = facts.v2?._source;
36493
36518
  if (!source) return issues;
36519
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36494
36520
  const matches = [];
36495
36521
  let m;
36496
36522
  PRINTLN_REGEX.lastIndex = 0;
@@ -42017,7 +42043,8 @@ var signal_strength_default = {
42017
42043
  precision: 0.6803,
42018
42044
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42019
42045
  verdict: "USEFUL",
42020
- _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).",
42046
+ defaultOff: true,
42047
+ _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.",
42021
42048
  aiSpecific: true,
42022
42049
  _v7Verdict: "USEFUL",
42023
42050
  _v7Lift: 11.29,
@@ -42120,7 +42147,8 @@ var signal_strength_default = {
42120
42147
  precision: 0.7495,
42121
42148
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42122
42149
  verdict: "USEFUL",
42123
- _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).",
42150
+ defaultOff: true,
42151
+ _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.",
42124
42152
  aiSpecific: true,
42125
42153
  _v7Verdict: "USEFUL",
42126
42154
  _v7Lift: 9.11,
@@ -42188,7 +42216,8 @@ var signal_strength_default = {
42188
42216
  precision: 0.5583,
42189
42217
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42190
42218
  verdict: "USEFUL",
42191
- _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=21567, FP=17061, P=55.8%, FPR=6.77%, lift=8.25. v7 was USEFUL (TP=17466, FP=13438, lift=7.71). v8 was USEFUL (TP=4101, FP=3623).",
42219
+ defaultOff: true,
42220
+ _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=21567, FP=17061, P=55.8%, FPR=6.77%, lift=8.25. v7 was USEFUL (TP=17466, FP=13438, lift=7.71). v8 was USEFUL (TP=4101, FP=3623). v0.21.2: marked defaultOff because precision 0.5583 < 0.60 floor (the v9.5 methodology in `docs/research/methodology-minimum-sample-size.md` sets the default-on threshold at P >= 0.60). At 55.8% precision, 44% of fires are false positives \u2014 too noisy to gate on. The rule stays available for opt-in via `rules: { 'ai/whitespace-regularity': 'low' }` in slopbrick.config.mjs. The 18 src/ self-scan fires are the typical noise profile.",
42192
42221
  aiSpecific: true,
42193
42222
  _v7Verdict: "USEFUL",
42194
42223
  _v7Lift: 7.71,
@@ -42536,7 +42565,8 @@ var signal_strength_default = {
42536
42565
  precision: 0,
42537
42566
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42538
42567
  verdict: "DORMANT",
42539
- _calibrationNote: "v0.19: new rule, not yet calibrated. v8.5 calibration does not run dup/* rules. Scheduled for v0.20 calibration on near-dup corpus.",
42568
+ defaultOff: false,
42569
+ _calibrationNote: "v0.19: new rule, not yet calibrated. v8.5 calibration does not run dup/* rules (the calibration script analyzes each file in isolation, but dup/* rules require cross-file state \u2014 the module-scope dedup cache is per-process, not per-file, so per-file analysis never produces cross-file matches and the rule's recall reads as 0). Scheduled for v0.22 calibration on the v9 corpus with a per-project dedup cache. v0.21.1 self-scan: 207 fires in src/ after WINDOW_SIZE 10\u219220 (was 575) \u2014 69% reduction. The actual precision/recall of these 207 fires is unverified ground truth. Default stays on because the rule's value is real (block-level duplications are a classic AI-fingerprint) and the corpus-measured 0.0 is a methodology artifact, not a real measurement.",
42540
42570
  aiSpecific: false,
42541
42571
  _v7Verdict: "DORMANT",
42542
42572
  _v7Lift: 1,
@@ -6395,6 +6395,7 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
6395
6395
  if (source) {
6396
6396
  const { line, column } = positionFrom(node, vctx.lineOffsets);
6397
6397
  const importedNames = [];
6398
+ const isTypeOnly = node.typeOnly === true;
6398
6399
  const specifiers = node.specifiers;
6399
6400
  if (Array.isArray(specifiers)) {
6400
6401
  for (const specifier of specifiers) {
@@ -6410,7 +6411,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
6410
6411
  line,
6411
6412
  column,
6412
6413
  source,
6413
- isReferenced: false
6414
+ isReferenced: false,
6415
+ isTypeOnly
6414
6416
  });
6415
6417
  }
6416
6418
  } else if (specifier.type === "ImportSpecifier") {
@@ -6425,7 +6427,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
6425
6427
  line,
6426
6428
  column,
6427
6429
  source,
6428
- isReferenced: false
6430
+ isReferenced: false,
6431
+ isTypeOnly
6429
6432
  });
6430
6433
  } else if (isObject(local) && local.type === "Identifier" && typeof local.value === "string") {
6431
6434
  const name = local.value;
@@ -6436,7 +6439,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
6436
6439
  line,
6437
6440
  column,
6438
6441
  source,
6439
- isReferenced: false
6442
+ isReferenced: false,
6443
+ isTypeOnly
6440
6444
  });
6441
6445
  }
6442
6446
  }
@@ -6729,7 +6733,7 @@ function handleJSXOpeningElement(node, _parent, _path, vctx) {
6729
6733
  }
6730
6734
  return false;
6731
6735
  }
6732
- function handleVariableDeclarator(node, _parent, path, vctx) {
6736
+ function handleVariableDeclarator(node, parent, path, vctx) {
6733
6737
  if (!isObject(node)) return false;
6734
6738
  const init = node.init;
6735
6739
  if (init && vctx.visit) {
@@ -6744,16 +6748,18 @@ function handleVariableDeclarator(node, _parent, path, vctx) {
6744
6748
  }
6745
6749
  }
6746
6750
  if (bindingNames.length > 0) {
6747
- const parent = node.parent;
6748
- const kind = isObject(parent) && parent.type === "VariableDeclaration" ? String(parent.kind ?? "var") : "var";
6751
+ const declParent = isObject(parent) && parent.type === "ExportNamedDeclaration" ? parent.declaration ?? parent : parent;
6752
+ const kind = isObject(declParent) && declParent.type === "VariableDeclaration" ? String(declParent.kind ?? "var") : "var";
6749
6753
  const { line, column } = positionFrom(id, vctx.lineOffsets);
6754
+ const scope = vctx.ctx.stack.length === 0 ? "module" : "function";
6750
6755
  for (const bindingName of bindingNames) {
6751
6756
  vctx.facts.deadCode.bindings.push({
6752
6757
  name: bindingName,
6753
6758
  kind,
6754
6759
  line,
6755
6760
  column,
6756
- isReferenced: false
6761
+ isReferenced: false,
6762
+ scope
6757
6763
  });
6758
6764
  }
6759
6765
  }
@@ -33821,6 +33827,16 @@ var aiSegmentSurprisalCvRule = createRule({
33821
33827
  category: "ai",
33822
33828
  severity: "medium",
33823
33829
  aiSpecific: true,
33830
+ // v0.21.0: defaultOff. The rule fires on 250/250 files in the
33831
+ // self-scan (1 per file), which means it's not discriminating
33832
+ // between AI and non-AI text — it fires on every file. The
33833
+ // Binoculars-style per-segment entropy CV is too coarse a
33834
+ // signal at the file level; the entropy floor is hit by most
33835
+ // structured TypeScript regardless of whether the author was
33836
+ // an LLM. Re-enable per-file via
33837
+ // `rules: { 'ai/segment-surprisal-cv': 'medium' }` in
33838
+ // slopbrick.config.mjs once a tighter threshold is calibrated.
33839
+ defaultOff: true,
33824
33840
  description: "Per-segment cross-entropy CV is suspiciously low \u2014 Binoculars (Hans 2024): AI text has near-constant per-window entropy",
33825
33841
  create(context) {
33826
33842
  return context;
@@ -34961,6 +34977,7 @@ var unusedImportRule = createRule({
34961
34977
  if (binding.kind !== "import-specifier" && binding.kind !== "import-default" && binding.kind !== "import-namespace") {
34962
34978
  continue;
34963
34979
  }
34980
+ if (binding.isTypeOnly) continue;
34964
34981
  if (binding.isReferenced) continue;
34965
34982
  if (!binding.name) continue;
34966
34983
  const source = binding.source ? ` from '${binding.source}'` : "";
@@ -35034,6 +35051,9 @@ var unusedLocalRule = createRule({
35034
35051
  if (binding.isReferenced) continue;
35035
35052
  if (SKIP_NAMES.has(binding.name)) continue;
35036
35053
  if (binding.name.startsWith("_")) continue;
35054
+ if (binding.scope === "module" && (binding.kind === "const" || binding.kind === "function" || binding.kind === "class" || binding.kind === "type" || binding.kind === "interface" || binding.kind === "enum")) {
35055
+ continue;
35056
+ }
35037
35057
  issues.push({
35038
35058
  ruleId: "dead/unused-local",
35039
35059
  category: "logic",
@@ -36030,7 +36050,7 @@ var brokenLinkRule = createRule({
36030
36050
 
36031
36051
  // src/rules/dup/identical-block.ts
36032
36052
  import * as crypto from "crypto";
36033
- var WINDOW_SIZE = 10;
36053
+ var WINDOW_SIZE = 20;
36034
36054
  var MIN_NORMALIZED_LENGTH = 40;
36035
36055
  var HASH_PREFIX_LENGTH = 16;
36036
36056
  var DEDUP_CACHE = /* @__PURE__ */ new Map();
@@ -36255,6 +36275,7 @@ var javaArraylistVsLinkedlistRule = createRule({
36255
36275
  const issues = [];
36256
36276
  const source = facts.v2?._source;
36257
36277
  if (!source) return issues;
36278
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36258
36279
  let m;
36259
36280
  NEW_LINKED_LIST_REGEX.lastIndex = 0;
36260
36281
  while ((m = NEW_LINKED_LIST_REGEX.exec(source)) !== null) {
@@ -36289,6 +36310,7 @@ var javaEmptyCatchBlockRule = createRule({
36289
36310
  const issues = [];
36290
36311
  const source = facts.v2?._source;
36291
36312
  if (!source) return issues;
36313
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36292
36314
  let m;
36293
36315
  SINGLE_LINE_EMPTY_CATCH_REGEX.lastIndex = 0;
36294
36316
  while ((m = SINGLE_LINE_EMPTY_CATCH_REGEX.exec(source)) !== null) {
@@ -36325,6 +36347,7 @@ var javaLegacyDateApiRule = createRule({
36325
36347
  const issues = [];
36326
36348
  const source = facts.v2?._source;
36327
36349
  if (!source) return issues;
36350
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36328
36351
  const flagged = /* @__PURE__ */ new Set();
36329
36352
  let m;
36330
36353
  LEGACY_IMPORT_REGEX.lastIndex = 0;
@@ -36391,6 +36414,7 @@ var javaRawTypeOveruseRule = createRule({
36391
36414
  const issues = [];
36392
36415
  const source = facts.v2?._source;
36393
36416
  if (!source) return issues;
36417
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36394
36418
  let m;
36395
36419
  RAW_TYPE_REGEX.lastIndex = 0;
36396
36420
  while ((m = RAW_TYPE_REGEX.exec(source)) !== null) {
@@ -36426,6 +36450,7 @@ var javaStringConcatLoopRule = createRule({
36426
36450
  const issues = [];
36427
36451
  const source = facts.v2?._source;
36428
36452
  if (!source) return issues;
36453
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36429
36454
  if (!/\b(for|while|do)\b/.test(source)) return issues;
36430
36455
  let m;
36431
36456
  STRING_CONCAT_REGEX.lastIndex = 0;
@@ -36462,6 +36487,7 @@ var javaSystemOutPrintlnRule = createRule({
36462
36487
  const issues = [];
36463
36488
  const source = facts.v2?._source;
36464
36489
  if (!source) return issues;
36490
+ if (!/\.java$/i.test(facts.filePath)) return issues;
36465
36491
  const matches = [];
36466
36492
  let m;
36467
36493
  PRINTLN_REGEX.lastIndex = 0;
@@ -41988,7 +42014,8 @@ var signal_strength_default = {
41988
42014
  precision: 0.6803,
41989
42015
  lastCalibratedAt: "2026-07-01T00:00:00Z",
41990
42016
  verdict: "USEFUL",
41991
- _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).",
42017
+ defaultOff: true,
42018
+ _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.",
41992
42019
  aiSpecific: true,
41993
42020
  _v7Verdict: "USEFUL",
41994
42021
  _v7Lift: 11.29,
@@ -42091,7 +42118,8 @@ var signal_strength_default = {
42091
42118
  precision: 0.7495,
42092
42119
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42093
42120
  verdict: "USEFUL",
42094
- _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).",
42121
+ defaultOff: true,
42122
+ _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.",
42095
42123
  aiSpecific: true,
42096
42124
  _v7Verdict: "USEFUL",
42097
42125
  _v7Lift: 9.11,
@@ -42159,7 +42187,8 @@ var signal_strength_default = {
42159
42187
  precision: 0.5583,
42160
42188
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42161
42189
  verdict: "USEFUL",
42162
- _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=21567, FP=17061, P=55.8%, FPR=6.77%, lift=8.25. v7 was USEFUL (TP=17466, FP=13438, lift=7.71). v8 was USEFUL (TP=4101, FP=3623).",
42190
+ defaultOff: true,
42191
+ _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=21567, FP=17061, P=55.8%, FPR=6.77%, lift=8.25. v7 was USEFUL (TP=17466, FP=13438, lift=7.71). v8 was USEFUL (TP=4101, FP=3623). v0.21.2: marked defaultOff because precision 0.5583 < 0.60 floor (the v9.5 methodology in `docs/research/methodology-minimum-sample-size.md` sets the default-on threshold at P >= 0.60). At 55.8% precision, 44% of fires are false positives \u2014 too noisy to gate on. The rule stays available for opt-in via `rules: { 'ai/whitespace-regularity': 'low' }` in slopbrick.config.mjs. The 18 src/ self-scan fires are the typical noise profile.",
42163
42192
  aiSpecific: true,
42164
42193
  _v7Verdict: "USEFUL",
42165
42194
  _v7Lift: 7.71,
@@ -42507,7 +42536,8 @@ var signal_strength_default = {
42507
42536
  precision: 0,
42508
42537
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42509
42538
  verdict: "DORMANT",
42510
- _calibrationNote: "v0.19: new rule, not yet calibrated. v8.5 calibration does not run dup/* rules. Scheduled for v0.20 calibration on near-dup corpus.",
42539
+ defaultOff: false,
42540
+ _calibrationNote: "v0.19: new rule, not yet calibrated. v8.5 calibration does not run dup/* rules (the calibration script analyzes each file in isolation, but dup/* rules require cross-file state \u2014 the module-scope dedup cache is per-process, not per-file, so per-file analysis never produces cross-file matches and the rule's recall reads as 0). Scheduled for v0.22 calibration on the v9 corpus with a per-project dedup cache. v0.21.1 self-scan: 207 fires in src/ after WINDOW_SIZE 10\u219220 (was 575) \u2014 69% reduction. The actual precision/recall of these 207 fires is unverified ground truth. Default stays on because the rule's value is real (block-level duplications are a classic AI-fingerprint) and the corpus-measured 0.0 is a methodology artifact, not a real measurement.",
42511
42541
  aiSpecific: false,
42512
42542
  _v7Verdict: "DORMANT",
42513
42543
  _v7Lift: 1,
package/dist/index.cjs CHANGED
@@ -36,7 +36,7 @@ var VERSION;
36
36
  var init_header = __esm({
37
37
  "src/types/_header.ts"() {
38
38
  "use strict";
39
- VERSION = "0.21.0";
39
+ VERSION = "0.21.2";
40
40
  }
41
41
  });
42
42
 
@@ -27003,6 +27003,16 @@ var init_segment_surprisal_cv = __esm({
27003
27003
  category: "ai",
27004
27004
  severity: "medium",
27005
27005
  aiSpecific: true,
27006
+ // v0.21.0: defaultOff. The rule fires on 250/250 files in the
27007
+ // self-scan (1 per file), which means it's not discriminating
27008
+ // between AI and non-AI text — it fires on every file. The
27009
+ // Binoculars-style per-segment entropy CV is too coarse a
27010
+ // signal at the file level; the entropy floor is hit by most
27011
+ // structured TypeScript regardless of whether the author was
27012
+ // an LLM. Re-enable per-file via
27013
+ // `rules: { 'ai/segment-surprisal-cv': 'medium' }` in
27014
+ // slopbrick.config.mjs once a tighter threshold is calibrated.
27015
+ defaultOff: true,
27006
27016
  description: "Per-segment cross-entropy CV is suspiciously low \u2014 Binoculars (Hans 2024): AI text has near-constant per-window entropy",
27007
27017
  create(context) {
27008
27018
  return context;
@@ -28295,6 +28305,7 @@ var init_unused_import = __esm({
28295
28305
  if (binding.kind !== "import-specifier" && binding.kind !== "import-default" && binding.kind !== "import-namespace") {
28296
28306
  continue;
28297
28307
  }
28308
+ if (binding.isTypeOnly) continue;
28298
28309
  if (binding.isReferenced) continue;
28299
28310
  if (!binding.name) continue;
28300
28311
  const source = binding.source ? ` from '${binding.source}'` : "";
@@ -28361,6 +28372,9 @@ var init_unused_local = __esm({
28361
28372
  if (binding.isReferenced) continue;
28362
28373
  if (SKIP_NAMES.has(binding.name)) continue;
28363
28374
  if (binding.name.startsWith("_")) continue;
28375
+ if (binding.scope === "module" && (binding.kind === "const" || binding.kind === "function" || binding.kind === "class" || binding.kind === "type" || binding.kind === "interface" || binding.kind === "enum")) {
28376
+ continue;
28377
+ }
28364
28378
  issues.push({
28365
28379
  ruleId: "dead/unused-local",
28366
28380
  category: "logic",
@@ -29596,7 +29610,7 @@ var init_identical_block = __esm({
29596
29610
  "use strict";
29597
29611
  crypto = __toESM(require("crypto"), 1);
29598
29612
  init_rule();
29599
- WINDOW_SIZE = 10;
29613
+ WINDOW_SIZE = 20;
29600
29614
  MIN_NORMALIZED_LENGTH = 40;
29601
29615
  HASH_PREFIX_LENGTH = 16;
29602
29616
  DEDUP_CACHE = /* @__PURE__ */ new Map();
@@ -29842,6 +29856,7 @@ var init_arraylist_vs_linkedlist = __esm({
29842
29856
  const issues = [];
29843
29857
  const source = facts.v2?._source;
29844
29858
  if (!source) return issues;
29859
+ if (!/\.java$/i.test(facts.filePath)) return issues;
29845
29860
  let m;
29846
29861
  NEW_LINKED_LIST_REGEX.lastIndex = 0;
29847
29862
  while ((m = NEW_LINKED_LIST_REGEX.exec(source)) !== null) {
@@ -29883,6 +29898,7 @@ var init_empty_catch_block = __esm({
29883
29898
  const issues = [];
29884
29899
  const source = facts.v2?._source;
29885
29900
  if (!source) return issues;
29901
+ if (!/\.java$/i.test(facts.filePath)) return issues;
29886
29902
  let m;
29887
29903
  SINGLE_LINE_EMPTY_CATCH_REGEX.lastIndex = 0;
29888
29904
  while ((m = SINGLE_LINE_EMPTY_CATCH_REGEX.exec(source)) !== null) {
@@ -29926,6 +29942,7 @@ var init_legacy_date_api = __esm({
29926
29942
  const issues = [];
29927
29943
  const source = facts.v2?._source;
29928
29944
  if (!source) return issues;
29945
+ if (!/\.java$/i.test(facts.filePath)) return issues;
29929
29946
  const flagged = /* @__PURE__ */ new Set();
29930
29947
  let m;
29931
29948
  LEGACY_IMPORT_REGEX.lastIndex = 0;
@@ -29999,6 +30016,7 @@ var init_raw_type_overuse = __esm({
29999
30016
  const issues = [];
30000
30017
  const source = facts.v2?._source;
30001
30018
  if (!source) return issues;
30019
+ if (!/\.java$/i.test(facts.filePath)) return issues;
30002
30020
  let m;
30003
30021
  RAW_TYPE_REGEX.lastIndex = 0;
30004
30022
  while ((m = RAW_TYPE_REGEX.exec(source)) !== null) {
@@ -30041,6 +30059,7 @@ var init_string_concat_loop = __esm({
30041
30059
  const issues = [];
30042
30060
  const source = facts.v2?._source;
30043
30061
  if (!source) return issues;
30062
+ if (!/\.java$/i.test(facts.filePath)) return issues;
30044
30063
  if (!/\b(for|while|do)\b/.test(source)) return issues;
30045
30064
  let m;
30046
30065
  STRING_CONCAT_REGEX.lastIndex = 0;
@@ -30084,6 +30103,7 @@ var init_system_out_println = __esm({
30084
30103
  const issues = [];
30085
30104
  const source = facts.v2?._source;
30086
30105
  if (!source) return issues;
30106
+ if (!/\.java$/i.test(facts.filePath)) return issues;
30087
30107
  const matches = [];
30088
30108
  let m;
30089
30109
  PRINTLN_REGEX.lastIndex = 0;
@@ -43160,6 +43180,7 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43160
43180
  if (source) {
43161
43181
  const { line, column } = positionFrom(node, vctx.lineOffsets);
43162
43182
  const importedNames = [];
43183
+ const isTypeOnly = node.typeOnly === true;
43163
43184
  const specifiers = node.specifiers;
43164
43185
  if (Array.isArray(specifiers)) {
43165
43186
  for (const specifier of specifiers) {
@@ -43175,7 +43196,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43175
43196
  line,
43176
43197
  column,
43177
43198
  source,
43178
- isReferenced: false
43199
+ isReferenced: false,
43200
+ isTypeOnly
43179
43201
  });
43180
43202
  }
43181
43203
  } else if (specifier.type === "ImportSpecifier") {
@@ -43190,7 +43212,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43190
43212
  line,
43191
43213
  column,
43192
43214
  source,
43193
- isReferenced: false
43215
+ isReferenced: false,
43216
+ isTypeOnly
43194
43217
  });
43195
43218
  } else if (isObject(local) && local.type === "Identifier" && typeof local.value === "string") {
43196
43219
  const name = local.value;
@@ -43201,7 +43224,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43201
43224
  line,
43202
43225
  column,
43203
43226
  source,
43204
- isReferenced: false
43227
+ isReferenced: false,
43228
+ isTypeOnly
43205
43229
  });
43206
43230
  }
43207
43231
  }
@@ -43494,7 +43518,7 @@ function handleJSXOpeningElement(node, _parent, _path, vctx) {
43494
43518
  }
43495
43519
  return false;
43496
43520
  }
43497
- function handleVariableDeclarator(node, _parent, path, vctx) {
43521
+ function handleVariableDeclarator(node, parent, path, vctx) {
43498
43522
  if (!isObject(node)) return false;
43499
43523
  const init = node.init;
43500
43524
  if (init && vctx.visit) {
@@ -43509,16 +43533,18 @@ function handleVariableDeclarator(node, _parent, path, vctx) {
43509
43533
  }
43510
43534
  }
43511
43535
  if (bindingNames.length > 0) {
43512
- const parent = node.parent;
43513
- const kind = isObject(parent) && parent.type === "VariableDeclaration" ? String(parent.kind ?? "var") : "var";
43536
+ const declParent = isObject(parent) && parent.type === "ExportNamedDeclaration" ? parent.declaration ?? parent : parent;
43537
+ const kind = isObject(declParent) && declParent.type === "VariableDeclaration" ? String(declParent.kind ?? "var") : "var";
43514
43538
  const { line, column } = positionFrom(id, vctx.lineOffsets);
43539
+ const scope = vctx.ctx.stack.length === 0 ? "module" : "function";
43515
43540
  for (const bindingName of bindingNames) {
43516
43541
  vctx.facts.deadCode.bindings.push({
43517
43542
  name: bindingName,
43518
43543
  kind,
43519
43544
  line,
43520
43545
  column,
43521
- isReferenced: false
43546
+ isReferenced: false,
43547
+ scope
43522
43548
  });
43523
43549
  }
43524
43550
  }
@@ -47897,7 +47923,8 @@ var init_signal_strength = __esm({
47897
47923
  precision: 0.6803,
47898
47924
  lastCalibratedAt: "2026-07-01T00:00:00Z",
47899
47925
  verdict: "USEFUL",
47900
- _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).",
47926
+ defaultOff: true,
47927
+ _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.",
47901
47928
  aiSpecific: true,
47902
47929
  _v7Verdict: "USEFUL",
47903
47930
  _v7Lift: 11.29,
@@ -48000,7 +48027,8 @@ var init_signal_strength = __esm({
48000
48027
  precision: 0.7495,
48001
48028
  lastCalibratedAt: "2026-07-01T00:00:00Z",
48002
48029
  verdict: "USEFUL",
48003
- _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).",
48030
+ defaultOff: true,
48031
+ _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.",
48004
48032
  aiSpecific: true,
48005
48033
  _v7Verdict: "USEFUL",
48006
48034
  _v7Lift: 9.11,
@@ -48068,7 +48096,8 @@ var init_signal_strength = __esm({
48068
48096
  precision: 0.5583,
48069
48097
  lastCalibratedAt: "2026-07-01T00:00:00Z",
48070
48098
  verdict: "USEFUL",
48071
- _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=21567, FP=17061, P=55.8%, FPR=6.77%, lift=8.25. v7 was USEFUL (TP=17466, FP=13438, lift=7.71). v8 was USEFUL (TP=4101, FP=3623).",
48099
+ defaultOff: true,
48100
+ _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=21567, FP=17061, P=55.8%, FPR=6.77%, lift=8.25. v7 was USEFUL (TP=17466, FP=13438, lift=7.71). v8 was USEFUL (TP=4101, FP=3623). v0.21.2: marked defaultOff because precision 0.5583 < 0.60 floor (the v9.5 methodology in `docs/research/methodology-minimum-sample-size.md` sets the default-on threshold at P >= 0.60). At 55.8% precision, 44% of fires are false positives \u2014 too noisy to gate on. The rule stays available for opt-in via `rules: { 'ai/whitespace-regularity': 'low' }` in slopbrick.config.mjs. The 18 src/ self-scan fires are the typical noise profile.",
48072
48101
  aiSpecific: true,
48073
48102
  _v7Verdict: "USEFUL",
48074
48103
  _v7Lift: 7.71,
@@ -48416,7 +48445,8 @@ var init_signal_strength = __esm({
48416
48445
  precision: 0,
48417
48446
  lastCalibratedAt: "2026-07-01T00:00:00Z",
48418
48447
  verdict: "DORMANT",
48419
- _calibrationNote: "v0.19: new rule, not yet calibrated. v8.5 calibration does not run dup/* rules. Scheduled for v0.20 calibration on near-dup corpus.",
48448
+ defaultOff: false,
48449
+ _calibrationNote: "v0.19: new rule, not yet calibrated. v8.5 calibration does not run dup/* rules (the calibration script analyzes each file in isolation, but dup/* rules require cross-file state \u2014 the module-scope dedup cache is per-process, not per-file, so per-file analysis never produces cross-file matches and the rule's recall reads as 0). Scheduled for v0.22 calibration on the v9 corpus with a per-project dedup cache. v0.21.1 self-scan: 207 fires in src/ after WINDOW_SIZE 10\u219220 (was 575) \u2014 69% reduction. The actual precision/recall of these 207 fires is unverified ground truth. Default stays on because the rule's value is real (block-level duplications are a classic AI-fingerprint) and the corpus-measured 0.0 is a methodology artifact, not a real measurement.",
48420
48450
  aiSpecific: false,
48421
48451
  _v7Verdict: "DORMANT",
48422
48452
  _v7Lift: 1,
@@ -57063,7 +57093,6 @@ init_dist2();
57063
57093
 
57064
57094
  // src/cli/program.ts
57065
57095
  var import_node_path70 = require("path");
57066
- var import_node_perf_hooks = require("perf_hooks");
57067
57096
  var import_commander2 = require("commander");
57068
57097
 
57069
57098
  // src/cli/options.ts
@@ -62212,7 +62241,7 @@ async function runCli({ start }) {
62212
62241
  await watchProject(options, cwd, paths);
62213
62242
  return;
62214
62243
  }
62215
- const scanStart = import_node_perf_hooks.performance.now();
62244
+ const scanStart = performance.now();
62216
62245
  const {
62217
62246
  report,
62218
62247
  scores,
@@ -62222,8 +62251,8 @@ async function runCli({ start }) {
62222
62251
  baseline,
62223
62252
  machineReadableStdout
62224
62253
  } = await runScan(options, paths);
62225
- const scanElapsed = Math.round(import_node_perf_hooks.performance.now() - scanStart);
62226
- const totalElapsed = Math.round(import_node_perf_hooks.performance.now() - start);
62254
+ const scanElapsed = Math.round(performance.now() - scanStart);
62255
+ const totalElapsed = Math.round(performance.now() - start);
62227
62256
  if (options.baseline) {
62228
62257
  const cwd2 = (0, import_node_path70.resolve)(options.workspace ?? process.cwd());
62229
62258
  const configHash = hashConfig(config);
package/dist/index.d.cts CHANGED
@@ -316,12 +316,29 @@ type BindingKind = 'import-specifier' | 'import-default' | 'import-namespace' |
316
316
  * referenced-name set per file (intra-scope shadowing is rare enough
317
317
  * in practice that file-level resolution catches ~95% of dead code
318
318
  * with negligible false positives — the remaining 5% is mostly
319
- * intentional re-exports which are handled separately). */
319
+ * intentional re-exports which are handled separately).
320
+ *
321
+ * `isTypeOnly` (v0.21.0) is set to true when the binding came from
322
+ * an `import type { X }` declaration. The dead/unused-import rule
323
+ * skips these unconditionally — a type-only import is always
324
+ * "referenced" if the type is used (TypeScript guarantees it), and
325
+ * TypeScript itself elides the import at build time, so the unused-
326
+ * import rule has nothing actionable to report. */
320
327
  interface BindingRecord {
321
328
  /** The local name as written (`Button` for `import { Button } from ...`,
322
329
  * `setFoo` for a parameter, etc.). */
323
330
  name: string;
324
331
  kind: BindingKind;
332
+ /** v0.21.0: true when this binding came from `import type { X }`. The
333
+ * dead/unused-import rule skips these. */
334
+ isTypeOnly?: boolean;
335
+ /** v0.21.0: lexical scope of the binding. `'module'` for top-level
336
+ * declarations outside any function; `'function'` for bindings
337
+ * inside a function body. The dead/unused-local rule skips
338
+ * `'module'` consts/functions/classes (they are often intentional
339
+ * placeholder exports, type re-exports, or side-effect-ful
340
+ * constructions — not real dead code). */
341
+ scope?: 'module' | 'function';
325
342
  /** Source line (1-indexed). */
326
343
  line: number;
327
344
  /** Source column (0-indexed). */
package/dist/index.d.ts CHANGED
@@ -316,12 +316,29 @@ type BindingKind = 'import-specifier' | 'import-default' | 'import-namespace' |
316
316
  * referenced-name set per file (intra-scope shadowing is rare enough
317
317
  * in practice that file-level resolution catches ~95% of dead code
318
318
  * with negligible false positives — the remaining 5% is mostly
319
- * intentional re-exports which are handled separately). */
319
+ * intentional re-exports which are handled separately).
320
+ *
321
+ * `isTypeOnly` (v0.21.0) is set to true when the binding came from
322
+ * an `import type { X }` declaration. The dead/unused-import rule
323
+ * skips these unconditionally — a type-only import is always
324
+ * "referenced" if the type is used (TypeScript guarantees it), and
325
+ * TypeScript itself elides the import at build time, so the unused-
326
+ * import rule has nothing actionable to report. */
320
327
  interface BindingRecord {
321
328
  /** The local name as written (`Button` for `import { Button } from ...`,
322
329
  * `setFoo` for a parameter, etc.). */
323
330
  name: string;
324
331
  kind: BindingKind;
332
+ /** v0.21.0: true when this binding came from `import type { X }`. The
333
+ * dead/unused-import rule skips these. */
334
+ isTypeOnly?: boolean;
335
+ /** v0.21.0: lexical scope of the binding. `'module'` for top-level
336
+ * declarations outside any function; `'function'` for bindings
337
+ * inside a function body. The dead/unused-local rule skips
338
+ * `'module'` consts/functions/classes (they are often intentional
339
+ * placeholder exports, type re-exports, or side-effect-ful
340
+ * constructions — not real dead code). */
341
+ scope?: 'module' | 'function';
325
342
  /** Source line (1-indexed). */
326
343
  line: number;
327
344
  /** Source column (0-indexed). */
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ var VERSION;
19
19
  var init_header = __esm({
20
20
  "src/types/_header.ts"() {
21
21
  "use strict";
22
- VERSION = "0.21.0";
22
+ VERSION = "0.21.2";
23
23
  }
24
24
  });
25
25
 
@@ -26985,6 +26985,16 @@ var init_segment_surprisal_cv = __esm({
26985
26985
  category: "ai",
26986
26986
  severity: "medium",
26987
26987
  aiSpecific: true,
26988
+ // v0.21.0: defaultOff. The rule fires on 250/250 files in the
26989
+ // self-scan (1 per file), which means it's not discriminating
26990
+ // between AI and non-AI text — it fires on every file. The
26991
+ // Binoculars-style per-segment entropy CV is too coarse a
26992
+ // signal at the file level; the entropy floor is hit by most
26993
+ // structured TypeScript regardless of whether the author was
26994
+ // an LLM. Re-enable per-file via
26995
+ // `rules: { 'ai/segment-surprisal-cv': 'medium' }` in
26996
+ // slopbrick.config.mjs once a tighter threshold is calibrated.
26997
+ defaultOff: true,
26988
26998
  description: "Per-segment cross-entropy CV is suspiciously low \u2014 Binoculars (Hans 2024): AI text has near-constant per-window entropy",
26989
26999
  create(context) {
26990
27000
  return context;
@@ -28277,6 +28287,7 @@ var init_unused_import = __esm({
28277
28287
  if (binding.kind !== "import-specifier" && binding.kind !== "import-default" && binding.kind !== "import-namespace") {
28278
28288
  continue;
28279
28289
  }
28290
+ if (binding.isTypeOnly) continue;
28280
28291
  if (binding.isReferenced) continue;
28281
28292
  if (!binding.name) continue;
28282
28293
  const source = binding.source ? ` from '${binding.source}'` : "";
@@ -28343,6 +28354,9 @@ var init_unused_local = __esm({
28343
28354
  if (binding.isReferenced) continue;
28344
28355
  if (SKIP_NAMES.has(binding.name)) continue;
28345
28356
  if (binding.name.startsWith("_")) continue;
28357
+ if (binding.scope === "module" && (binding.kind === "const" || binding.kind === "function" || binding.kind === "class" || binding.kind === "type" || binding.kind === "interface" || binding.kind === "enum")) {
28358
+ continue;
28359
+ }
28346
28360
  issues.push({
28347
28361
  ruleId: "dead/unused-local",
28348
28362
  category: "logic",
@@ -29578,7 +29592,7 @@ var init_identical_block = __esm({
29578
29592
  "src/rules/dup/identical-block.ts"() {
29579
29593
  "use strict";
29580
29594
  init_rule();
29581
- WINDOW_SIZE = 10;
29595
+ WINDOW_SIZE = 20;
29582
29596
  MIN_NORMALIZED_LENGTH = 40;
29583
29597
  HASH_PREFIX_LENGTH = 16;
29584
29598
  DEDUP_CACHE = /* @__PURE__ */ new Map();
@@ -29824,6 +29838,7 @@ var init_arraylist_vs_linkedlist = __esm({
29824
29838
  const issues = [];
29825
29839
  const source = facts.v2?._source;
29826
29840
  if (!source) return issues;
29841
+ if (!/\.java$/i.test(facts.filePath)) return issues;
29827
29842
  let m;
29828
29843
  NEW_LINKED_LIST_REGEX.lastIndex = 0;
29829
29844
  while ((m = NEW_LINKED_LIST_REGEX.exec(source)) !== null) {
@@ -29865,6 +29880,7 @@ var init_empty_catch_block = __esm({
29865
29880
  const issues = [];
29866
29881
  const source = facts.v2?._source;
29867
29882
  if (!source) return issues;
29883
+ if (!/\.java$/i.test(facts.filePath)) return issues;
29868
29884
  let m;
29869
29885
  SINGLE_LINE_EMPTY_CATCH_REGEX.lastIndex = 0;
29870
29886
  while ((m = SINGLE_LINE_EMPTY_CATCH_REGEX.exec(source)) !== null) {
@@ -29908,6 +29924,7 @@ var init_legacy_date_api = __esm({
29908
29924
  const issues = [];
29909
29925
  const source = facts.v2?._source;
29910
29926
  if (!source) return issues;
29927
+ if (!/\.java$/i.test(facts.filePath)) return issues;
29911
29928
  const flagged = /* @__PURE__ */ new Set();
29912
29929
  let m;
29913
29930
  LEGACY_IMPORT_REGEX.lastIndex = 0;
@@ -29981,6 +29998,7 @@ var init_raw_type_overuse = __esm({
29981
29998
  const issues = [];
29982
29999
  const source = facts.v2?._source;
29983
30000
  if (!source) return issues;
30001
+ if (!/\.java$/i.test(facts.filePath)) return issues;
29984
30002
  let m;
29985
30003
  RAW_TYPE_REGEX.lastIndex = 0;
29986
30004
  while ((m = RAW_TYPE_REGEX.exec(source)) !== null) {
@@ -30023,6 +30041,7 @@ var init_string_concat_loop = __esm({
30023
30041
  const issues = [];
30024
30042
  const source = facts.v2?._source;
30025
30043
  if (!source) return issues;
30044
+ if (!/\.java$/i.test(facts.filePath)) return issues;
30026
30045
  if (!/\b(for|while|do)\b/.test(source)) return issues;
30027
30046
  let m;
30028
30047
  STRING_CONCAT_REGEX.lastIndex = 0;
@@ -30066,6 +30085,7 @@ var init_system_out_println = __esm({
30066
30085
  const issues = [];
30067
30086
  const source = facts.v2?._source;
30068
30087
  if (!source) return issues;
30088
+ if (!/\.java$/i.test(facts.filePath)) return issues;
30069
30089
  const matches = [];
30070
30090
  let m;
30071
30091
  PRINTLN_REGEX.lastIndex = 0;
@@ -43142,6 +43162,7 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43142
43162
  if (source) {
43143
43163
  const { line, column } = positionFrom(node, vctx.lineOffsets);
43144
43164
  const importedNames = [];
43165
+ const isTypeOnly = node.typeOnly === true;
43145
43166
  const specifiers = node.specifiers;
43146
43167
  if (Array.isArray(specifiers)) {
43147
43168
  for (const specifier of specifiers) {
@@ -43157,7 +43178,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43157
43178
  line,
43158
43179
  column,
43159
43180
  source,
43160
- isReferenced: false
43181
+ isReferenced: false,
43182
+ isTypeOnly
43161
43183
  });
43162
43184
  }
43163
43185
  } else if (specifier.type === "ImportSpecifier") {
@@ -43172,7 +43194,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43172
43194
  line,
43173
43195
  column,
43174
43196
  source,
43175
- isReferenced: false
43197
+ isReferenced: false,
43198
+ isTypeOnly
43176
43199
  });
43177
43200
  } else if (isObject(local) && local.type === "Identifier" && typeof local.value === "string") {
43178
43201
  const name = local.value;
@@ -43183,7 +43206,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43183
43206
  line,
43184
43207
  column,
43185
43208
  source,
43186
- isReferenced: false
43209
+ isReferenced: false,
43210
+ isTypeOnly
43187
43211
  });
43188
43212
  }
43189
43213
  }
@@ -43476,7 +43500,7 @@ function handleJSXOpeningElement(node, _parent, _path, vctx) {
43476
43500
  }
43477
43501
  return false;
43478
43502
  }
43479
- function handleVariableDeclarator(node, _parent, path, vctx) {
43503
+ function handleVariableDeclarator(node, parent, path, vctx) {
43480
43504
  if (!isObject(node)) return false;
43481
43505
  const init = node.init;
43482
43506
  if (init && vctx.visit) {
@@ -43491,16 +43515,18 @@ function handleVariableDeclarator(node, _parent, path, vctx) {
43491
43515
  }
43492
43516
  }
43493
43517
  if (bindingNames.length > 0) {
43494
- const parent = node.parent;
43495
- const kind = isObject(parent) && parent.type === "VariableDeclaration" ? String(parent.kind ?? "var") : "var";
43518
+ const declParent = isObject(parent) && parent.type === "ExportNamedDeclaration" ? parent.declaration ?? parent : parent;
43519
+ const kind = isObject(declParent) && declParent.type === "VariableDeclaration" ? String(declParent.kind ?? "var") : "var";
43496
43520
  const { line, column } = positionFrom(id, vctx.lineOffsets);
43521
+ const scope = vctx.ctx.stack.length === 0 ? "module" : "function";
43497
43522
  for (const bindingName of bindingNames) {
43498
43523
  vctx.facts.deadCode.bindings.push({
43499
43524
  name: bindingName,
43500
43525
  kind,
43501
43526
  line,
43502
43527
  column,
43503
- isReferenced: false
43528
+ isReferenced: false,
43529
+ scope
43504
43530
  });
43505
43531
  }
43506
43532
  }
@@ -47876,7 +47902,8 @@ var init_signal_strength = __esm({
47876
47902
  precision: 0.6803,
47877
47903
  lastCalibratedAt: "2026-07-01T00:00:00Z",
47878
47904
  verdict: "USEFUL",
47879
- _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).",
47905
+ defaultOff: true,
47906
+ _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.",
47880
47907
  aiSpecific: true,
47881
47908
  _v7Verdict: "USEFUL",
47882
47909
  _v7Lift: 11.29,
@@ -47979,7 +48006,8 @@ var init_signal_strength = __esm({
47979
48006
  precision: 0.7495,
47980
48007
  lastCalibratedAt: "2026-07-01T00:00:00Z",
47981
48008
  verdict: "USEFUL",
47982
- _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).",
48009
+ defaultOff: true,
48010
+ _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.",
47983
48011
  aiSpecific: true,
47984
48012
  _v7Verdict: "USEFUL",
47985
48013
  _v7Lift: 9.11,
@@ -48047,7 +48075,8 @@ var init_signal_strength = __esm({
48047
48075
  precision: 0.5583,
48048
48076
  lastCalibratedAt: "2026-07-01T00:00:00Z",
48049
48077
  verdict: "USEFUL",
48050
- _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=21567, FP=17061, P=55.8%, FPR=6.77%, lift=8.25. v7 was USEFUL (TP=17466, FP=13438, lift=7.71). v8 was USEFUL (TP=4101, FP=3623).",
48078
+ defaultOff: true,
48079
+ _calibrationNote: "v8.5 calibration (v0.18.9, 2026-07-01): v7+v8 combined corpus (252080 neg + 294178 pos). v8.5 TP=21567, FP=17061, P=55.8%, FPR=6.77%, lift=8.25. v7 was USEFUL (TP=17466, FP=13438, lift=7.71). v8 was USEFUL (TP=4101, FP=3623). v0.21.2: marked defaultOff because precision 0.5583 < 0.60 floor (the v9.5 methodology in `docs/research/methodology-minimum-sample-size.md` sets the default-on threshold at P >= 0.60). At 55.8% precision, 44% of fires are false positives \u2014 too noisy to gate on. The rule stays available for opt-in via `rules: { 'ai/whitespace-regularity': 'low' }` in slopbrick.config.mjs. The 18 src/ self-scan fires are the typical noise profile.",
48051
48080
  aiSpecific: true,
48052
48081
  _v7Verdict: "USEFUL",
48053
48082
  _v7Lift: 7.71,
@@ -48395,7 +48424,8 @@ var init_signal_strength = __esm({
48395
48424
  precision: 0,
48396
48425
  lastCalibratedAt: "2026-07-01T00:00:00Z",
48397
48426
  verdict: "DORMANT",
48398
- _calibrationNote: "v0.19: new rule, not yet calibrated. v8.5 calibration does not run dup/* rules. Scheduled for v0.20 calibration on near-dup corpus.",
48427
+ defaultOff: false,
48428
+ _calibrationNote: "v0.19: new rule, not yet calibrated. v8.5 calibration does not run dup/* rules (the calibration script analyzes each file in isolation, but dup/* rules require cross-file state \u2014 the module-scope dedup cache is per-process, not per-file, so per-file analysis never produces cross-file matches and the rule's recall reads as 0). Scheduled for v0.22 calibration on the v9 corpus with a per-project dedup cache. v0.21.1 self-scan: 207 fires in src/ after WINDOW_SIZE 10\u219220 (was 575) \u2014 69% reduction. The actual precision/recall of these 207 fires is unverified ground truth. Default stays on because the rule's value is real (block-level duplications are a classic AI-fingerprint) and the corpus-measured 0.0 is a methodology artifact, not a real measurement.",
48399
48429
  aiSpecific: false,
48400
48430
  _v7Verdict: "DORMANT",
48401
48431
  _v7Lift: 1,
@@ -55926,10 +55956,7 @@ var init_renderOutput = __esm({
55926
55956
  });
55927
55957
 
55928
55958
  // src/cli/watch.ts
55929
- import {
55930
- watch,
55931
- statSync as statSync7
55932
- } from "fs";
55959
+ import { watch, statSync as statSync7 } from "fs";
55933
55960
  import { resolve as resolve11 } from "path";
55934
55961
  async function watchProject(options, cwd, paths) {
55935
55962
  let baselineMtime;
@@ -56966,7 +56993,6 @@ init_dist2();
56966
56993
 
56967
56994
  // src/cli/program.ts
56968
56995
  import { resolve as resolve41 } from "path";
56969
- import { performance } from "perf_hooks";
56970
56996
  import { Command } from "commander";
56971
56997
 
56972
56998
  // src/cli/options.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "slopbrick",
3
- "version": "0.21.0",
4
- "description": "Discovered, modeled, and governed repository structure. SlopBrick scans source code, classifies it against 95 rules in 15 categories, computes 4 scores (aiQuality, engineeringHygiene, security, repositoryHealth), and persists the structure for AI agents and CI.",
3
+ "version": "0.21.2",
4
+ "description": "Discovered, modeled, and governed repository structure. SlopBrick scans source code, classifies it against 95+ rules in 15 categories, computes 4 scores (aiSlopScore: lower=cleaner, engineeringHygiene, security, repositoryHealth composite), and persists the structure for AI agents and CI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "slopbrick": "bin/slopbrick.js"