slopbrick 0.21.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.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();
@@ -42017,7 +42037,8 @@ var signal_strength_default = {
42017
42037
  precision: 0.6803,
42018
42038
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42019
42039
  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).",
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.",
42021
42042
  aiSpecific: true,
42022
42043
  _v7Verdict: "USEFUL",
42023
42044
  _v7Lift: 11.29,
@@ -42120,7 +42141,8 @@ var signal_strength_default = {
42120
42141
  precision: 0.7495,
42121
42142
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42122
42143
  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).",
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.",
42124
42146
  aiSpecific: true,
42125
42147
  _v7Verdict: "USEFUL",
42126
42148
  _v7Lift: 9.11,
@@ -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();
@@ -41988,7 +42008,8 @@ var signal_strength_default = {
41988
42008
  precision: 0.6803,
41989
42009
  lastCalibratedAt: "2026-07-01T00:00:00Z",
41990
42010
  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).",
42011
+ defaultOff: true,
42012
+ _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
42013
  aiSpecific: true,
41993
42014
  _v7Verdict: "USEFUL",
41994
42015
  _v7Lift: 11.29,
@@ -42091,7 +42112,8 @@ var signal_strength_default = {
42091
42112
  precision: 0.7495,
42092
42113
  lastCalibratedAt: "2026-07-01T00:00:00Z",
42093
42114
  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).",
42115
+ defaultOff: true,
42116
+ _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
42117
  aiSpecific: true,
42096
42118
  _v7Verdict: "USEFUL",
42097
42119
  _v7Lift: 9.11,
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.1";
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();
@@ -43160,6 +43174,7 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43160
43174
  if (source) {
43161
43175
  const { line, column } = positionFrom(node, vctx.lineOffsets);
43162
43176
  const importedNames = [];
43177
+ const isTypeOnly = node.typeOnly === true;
43163
43178
  const specifiers = node.specifiers;
43164
43179
  if (Array.isArray(specifiers)) {
43165
43180
  for (const specifier of specifiers) {
@@ -43175,7 +43190,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43175
43190
  line,
43176
43191
  column,
43177
43192
  source,
43178
- isReferenced: false
43193
+ isReferenced: false,
43194
+ isTypeOnly
43179
43195
  });
43180
43196
  }
43181
43197
  } else if (specifier.type === "ImportSpecifier") {
@@ -43190,7 +43206,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43190
43206
  line,
43191
43207
  column,
43192
43208
  source,
43193
- isReferenced: false
43209
+ isReferenced: false,
43210
+ isTypeOnly
43194
43211
  });
43195
43212
  } else if (isObject(local) && local.type === "Identifier" && typeof local.value === "string") {
43196
43213
  const name = local.value;
@@ -43201,7 +43218,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43201
43218
  line,
43202
43219
  column,
43203
43220
  source,
43204
- isReferenced: false
43221
+ isReferenced: false,
43222
+ isTypeOnly
43205
43223
  });
43206
43224
  }
43207
43225
  }
@@ -43494,7 +43512,7 @@ function handleJSXOpeningElement(node, _parent, _path, vctx) {
43494
43512
  }
43495
43513
  return false;
43496
43514
  }
43497
- function handleVariableDeclarator(node, _parent, path, vctx) {
43515
+ function handleVariableDeclarator(node, parent, path, vctx) {
43498
43516
  if (!isObject(node)) return false;
43499
43517
  const init = node.init;
43500
43518
  if (init && vctx.visit) {
@@ -43509,16 +43527,18 @@ function handleVariableDeclarator(node, _parent, path, vctx) {
43509
43527
  }
43510
43528
  }
43511
43529
  if (bindingNames.length > 0) {
43512
- const parent = node.parent;
43513
- const kind = isObject(parent) && parent.type === "VariableDeclaration" ? String(parent.kind ?? "var") : "var";
43530
+ const declParent = isObject(parent) && parent.type === "ExportNamedDeclaration" ? parent.declaration ?? parent : parent;
43531
+ const kind = isObject(declParent) && declParent.type === "VariableDeclaration" ? String(declParent.kind ?? "var") : "var";
43514
43532
  const { line, column } = positionFrom(id, vctx.lineOffsets);
43533
+ const scope = vctx.ctx.stack.length === 0 ? "module" : "function";
43515
43534
  for (const bindingName of bindingNames) {
43516
43535
  vctx.facts.deadCode.bindings.push({
43517
43536
  name: bindingName,
43518
43537
  kind,
43519
43538
  line,
43520
43539
  column,
43521
- isReferenced: false
43540
+ isReferenced: false,
43541
+ scope
43522
43542
  });
43523
43543
  }
43524
43544
  }
@@ -47897,7 +47917,8 @@ var init_signal_strength = __esm({
47897
47917
  precision: 0.6803,
47898
47918
  lastCalibratedAt: "2026-07-01T00:00:00Z",
47899
47919
  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).",
47920
+ defaultOff: true,
47921
+ _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
47922
  aiSpecific: true,
47902
47923
  _v7Verdict: "USEFUL",
47903
47924
  _v7Lift: 11.29,
@@ -48000,7 +48021,8 @@ var init_signal_strength = __esm({
48000
48021
  precision: 0.7495,
48001
48022
  lastCalibratedAt: "2026-07-01T00:00:00Z",
48002
48023
  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).",
48024
+ defaultOff: true,
48025
+ _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
48026
  aiSpecific: true,
48005
48027
  _v7Verdict: "USEFUL",
48006
48028
  _v7Lift: 9.11,
@@ -57063,7 +57085,6 @@ init_dist2();
57063
57085
 
57064
57086
  // src/cli/program.ts
57065
57087
  var import_node_path70 = require("path");
57066
- var import_node_perf_hooks = require("perf_hooks");
57067
57088
  var import_commander2 = require("commander");
57068
57089
 
57069
57090
  // src/cli/options.ts
@@ -62212,7 +62233,7 @@ async function runCli({ start }) {
62212
62233
  await watchProject(options, cwd, paths);
62213
62234
  return;
62214
62235
  }
62215
- const scanStart = import_node_perf_hooks.performance.now();
62236
+ const scanStart = performance.now();
62216
62237
  const {
62217
62238
  report,
62218
62239
  scores,
@@ -62222,8 +62243,8 @@ async function runCli({ start }) {
62222
62243
  baseline,
62223
62244
  machineReadableStdout
62224
62245
  } = 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);
62246
+ const scanElapsed = Math.round(performance.now() - scanStart);
62247
+ const totalElapsed = Math.round(performance.now() - start);
62227
62248
  if (options.baseline) {
62228
62249
  const cwd2 = (0, import_node_path70.resolve)(options.workspace ?? process.cwd());
62229
62250
  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.1";
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();
@@ -43142,6 +43156,7 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43142
43156
  if (source) {
43143
43157
  const { line, column } = positionFrom(node, vctx.lineOffsets);
43144
43158
  const importedNames = [];
43159
+ const isTypeOnly = node.typeOnly === true;
43145
43160
  const specifiers = node.specifiers;
43146
43161
  if (Array.isArray(specifiers)) {
43147
43162
  for (const specifier of specifiers) {
@@ -43157,7 +43172,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43157
43172
  line,
43158
43173
  column,
43159
43174
  source,
43160
- isReferenced: false
43175
+ isReferenced: false,
43176
+ isTypeOnly
43161
43177
  });
43162
43178
  }
43163
43179
  } else if (specifier.type === "ImportSpecifier") {
@@ -43172,7 +43188,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43172
43188
  line,
43173
43189
  column,
43174
43190
  source,
43175
- isReferenced: false
43191
+ isReferenced: false,
43192
+ isTypeOnly
43176
43193
  });
43177
43194
  } else if (isObject(local) && local.type === "Identifier" && typeof local.value === "string") {
43178
43195
  const name = local.value;
@@ -43183,7 +43200,8 @@ function handleImportDeclaration(node, _parent, _path, vctx) {
43183
43200
  line,
43184
43201
  column,
43185
43202
  source,
43186
- isReferenced: false
43203
+ isReferenced: false,
43204
+ isTypeOnly
43187
43205
  });
43188
43206
  }
43189
43207
  }
@@ -43476,7 +43494,7 @@ function handleJSXOpeningElement(node, _parent, _path, vctx) {
43476
43494
  }
43477
43495
  return false;
43478
43496
  }
43479
- function handleVariableDeclarator(node, _parent, path, vctx) {
43497
+ function handleVariableDeclarator(node, parent, path, vctx) {
43480
43498
  if (!isObject(node)) return false;
43481
43499
  const init = node.init;
43482
43500
  if (init && vctx.visit) {
@@ -43491,16 +43509,18 @@ function handleVariableDeclarator(node, _parent, path, vctx) {
43491
43509
  }
43492
43510
  }
43493
43511
  if (bindingNames.length > 0) {
43494
- const parent = node.parent;
43495
- const kind = isObject(parent) && parent.type === "VariableDeclaration" ? String(parent.kind ?? "var") : "var";
43512
+ const declParent = isObject(parent) && parent.type === "ExportNamedDeclaration" ? parent.declaration ?? parent : parent;
43513
+ const kind = isObject(declParent) && declParent.type === "VariableDeclaration" ? String(declParent.kind ?? "var") : "var";
43496
43514
  const { line, column } = positionFrom(id, vctx.lineOffsets);
43515
+ const scope = vctx.ctx.stack.length === 0 ? "module" : "function";
43497
43516
  for (const bindingName of bindingNames) {
43498
43517
  vctx.facts.deadCode.bindings.push({
43499
43518
  name: bindingName,
43500
43519
  kind,
43501
43520
  line,
43502
43521
  column,
43503
- isReferenced: false
43522
+ isReferenced: false,
43523
+ scope
43504
43524
  });
43505
43525
  }
43506
43526
  }
@@ -47876,7 +47896,8 @@ var init_signal_strength = __esm({
47876
47896
  precision: 0.6803,
47877
47897
  lastCalibratedAt: "2026-07-01T00:00:00Z",
47878
47898
  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).",
47899
+ defaultOff: true,
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). 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
47901
  aiSpecific: true,
47881
47902
  _v7Verdict: "USEFUL",
47882
47903
  _v7Lift: 11.29,
@@ -47979,7 +48000,8 @@ var init_signal_strength = __esm({
47979
48000
  precision: 0.7495,
47980
48001
  lastCalibratedAt: "2026-07-01T00:00:00Z",
47981
48002
  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).",
48003
+ defaultOff: true,
48004
+ _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
48005
  aiSpecific: true,
47984
48006
  _v7Verdict: "USEFUL",
47985
48007
  _v7Lift: 9.11,
@@ -55926,10 +55948,7 @@ var init_renderOutput = __esm({
55926
55948
  });
55927
55949
 
55928
55950
  // src/cli/watch.ts
55929
- import {
55930
- watch,
55931
- statSync as statSync7
55932
- } from "fs";
55951
+ import { watch, statSync as statSync7 } from "fs";
55933
55952
  import { resolve as resolve11 } from "path";
55934
55953
  async function watchProject(options, cwd, paths) {
55935
55954
  let baselineMtime;
@@ -56966,7 +56985,6 @@ init_dist2();
56966
56985
 
56967
56986
  // src/cli/program.ts
56968
56987
  import { resolve as resolve41 } from "path";
56969
- import { performance } from "perf_hooks";
56970
56988
  import { Command } from "commander";
56971
56989
 
56972
56990
  // 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.1",
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"