slopbrick 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.42.0";
39
+ VERSION = "0.43.0";
40
40
  }
41
41
  });
42
42
 
@@ -338,8 +338,16 @@ var init_defaults = __esm({
338
338
  // rule definitions are meta-code
339
339
  "**/snippet/**",
340
340
  // RULE_HINTS examples are intentional bad code
341
- "**/tests/**"
341
+ "**/tests/**",
342
342
  // all test files (fixtures + unit + integration)
343
+ // v0.42.0 (post-self-scan cleanup): language visitors are
344
+ // intentional clones — see the §3 v0.42.0 release notes for
345
+ // the design rationale. `dup/identical-block` fires 100+
346
+ // times during self-scan because each visitor shares the
347
+ // same service-suffix arrays and import-walking preamble.
348
+ // Suppress these from the self-scan noise floor; they
349
+ // remain on for user codebases.
350
+ "**/src/engine/visitors/**"
343
351
  ]
344
352
  }
345
353
  };
@@ -26001,7 +26009,7 @@ var init_comment_ratio = __esm({
26001
26009
  message: `Comment-line ratio is ${direction} (${ratio.toFixed(2)} vs corpus mean ${mean3}). Reductive LLMs (GPT-3.5, Claude 3 Haiku) skip comments; expansive LLMs (Claude Haiku 4.5, GPT-OSS) over-comment when prompted for "well-commented code".`,
26002
26010
  line: 1,
26003
26011
  column: 1,
26004
- advice: direction === "low" ? "Add doc comments explaining intent (not just restating the next line) for non-obvious logic." : "Trim comments that just restate the next line. Keep comments that explain WHY, not WHAT."
26012
+ advice: direction === "low" ? "Add doc comments explaining intent (not just restating the next line) for non-obvious logic." : "High comment ratio \u2014 consider whether the comments are intentional (api docs, type defs, tutorials). For functional code, trim comments that just restate the next line. Keep comments that explain WHY, not WHAT."
26005
26013
  }
26006
26014
  ];
26007
26015
  return issues;
@@ -26852,7 +26860,6 @@ function buildTrigramLM(samples, alpha = 1) {
26852
26860
  logProb(context, t) {
26853
26861
  const ctxKey = context.slice(-2).join("|");
26854
26862
  const triKey = `${ctxKey}|${t}`;
26855
- const biKey = context.slice(-1).join("|") + "|" + t;
26856
26863
  const triCount = tri.get(triKey) ?? 0;
26857
26864
  const biCount = bi.get(ctxKey) ?? 0;
26858
26865
  const V = uni.size;
@@ -28860,7 +28867,7 @@ var init_stale_function_reference = __esm({
28860
28867
  ]);
28861
28868
  SOURCE_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
28862
28869
  SOURCE_ROOTS = ["src", "lib", "app", "components"];
28863
- CAP = 200;
28870
+ CAP = 4e3;
28864
28871
  staleFunctionReferenceRule = createRule({
28865
28872
  id: "docs/stale-function-reference",
28866
28873
  category: "docs",
@@ -28876,6 +28883,7 @@ var init_stale_function_reference = __esm({
28876
28883
  if (!source) return issues;
28877
28884
  for (const span of extractInlineCodeSpans(source)) {
28878
28885
  const text = span.text;
28886
+ if (span.inBlockComment || span.inComment) continue;
28879
28887
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(text)) continue;
28880
28888
  if (text.length < 3) continue;
28881
28889
  if (RESERVED.has(text.toLowerCase())) continue;
@@ -29046,6 +29054,7 @@ var init_stale_package_reference = __esm({
29046
29054
  if (!source) return issues;
29047
29055
  const spans = extractInlineCodeSpans(source);
29048
29056
  for (const span of spans) {
29057
+ if (span.inComment) continue;
29049
29058
  const lineStart = source.lastIndexOf("\n", span.index) + 1;
29050
29059
  const lineEnd = source.indexOf("\n", span.index);
29051
29060
  const line = source.slice(lineStart, lineEnd === -1 ? source.length : lineEnd);
@@ -29100,11 +29109,14 @@ __export(doc_freshness_exports, {
29100
29109
  extractExports: () => extractExports,
29101
29110
  extractFencedCodeBlocks: () => extractFencedCodeBlocks,
29102
29111
  extractInlineCodeSpans: () => extractInlineCodeSpans,
29103
- extractMarkdownLinks: () => extractMarkdownLinks
29112
+ extractMarkdownLinks: () => extractMarkdownLinks,
29113
+ findCommentLineRanges: () => findCommentLineRanges
29104
29114
  });
29105
29115
  function extractInlineCodeSpans(source) {
29106
29116
  const hits = [];
29107
29117
  const re = /`([^`\n]+?)`/g;
29118
+ const blockRanges = findBlockCommentRanges(source);
29119
+ const commentLines = findCommentLineRanges(source);
29108
29120
  let m;
29109
29121
  while ((m = re.exec(source)) !== null) {
29110
29122
  const text = m[1] ?? "";
@@ -29112,7 +29124,14 @@ function extractInlineCodeSpans(source) {
29112
29124
  const line = upTo.split("\n").length;
29113
29125
  const lastNl = upTo.lastIndexOf("\n");
29114
29126
  const column = lastNl === -1 ? m.index + 1 : m.index - lastNl;
29115
- hits.push({ text, line, column, index: m.index });
29127
+ hits.push({
29128
+ text,
29129
+ line,
29130
+ column,
29131
+ index: m.index,
29132
+ inBlockComment: isInBlockComment(blockRanges, m.index),
29133
+ inComment: commentLines.has(line)
29134
+ });
29116
29135
  }
29117
29136
  return hits;
29118
29137
  }
@@ -29151,6 +29170,8 @@ function extractFencedCodeBlocks(source) {
29151
29170
  function extractMarkdownLinks(source) {
29152
29171
  const hits = [];
29153
29172
  const re = /(?<!\!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
29173
+ const blockRanges = findBlockCommentRanges(source);
29174
+ const commentLines = findCommentLineRanges(source);
29154
29175
  let m;
29155
29176
  while ((m = re.exec(source)) !== null) {
29156
29177
  const target = m[2] ?? "";
@@ -29158,10 +29179,55 @@ function extractMarkdownLinks(source) {
29158
29179
  const line = upTo.split("\n").length;
29159
29180
  const lastNl = upTo.lastIndexOf("\n");
29160
29181
  const column = lastNl === -1 ? m.index + 1 : m.index - lastNl;
29161
- hits.push({ target, line, column });
29182
+ hits.push({
29183
+ target,
29184
+ line,
29185
+ column,
29186
+ index: m.index,
29187
+ inBlockComment: isInBlockComment(blockRanges, m.index),
29188
+ inComment: commentLines.has(line)
29189
+ });
29162
29190
  }
29163
29191
  return hits;
29164
29192
  }
29193
+ function findBlockCommentRanges(source) {
29194
+ const ranges = [];
29195
+ const stack = [];
29196
+ let i = 0;
29197
+ while (i < source.length) {
29198
+ if (source[i] === "/" && source[i + 1] === "/") {
29199
+ const eol = source.indexOf("\n", i);
29200
+ i = eol === -1 ? source.length : eol + 1;
29201
+ continue;
29202
+ }
29203
+ if (source[i] === "/" && source[i + 1] === "*") {
29204
+ stack.push(i);
29205
+ i += 2;
29206
+ continue;
29207
+ }
29208
+ if (source[i] === "*" && source[i + 1] === "/" && stack.length > 0) {
29209
+ const start = stack.pop();
29210
+ ranges.push([start, i + 2]);
29211
+ i += 2;
29212
+ continue;
29213
+ }
29214
+ i += 1;
29215
+ }
29216
+ ranges.sort((a, b) => a[0] - b[0]);
29217
+ return ranges;
29218
+ }
29219
+ function isInBlockComment(ranges, pos) {
29220
+ let lo = 0;
29221
+ let hi = ranges.length - 1;
29222
+ while (lo <= hi) {
29223
+ const mid = lo + hi >>> 1;
29224
+ const [start, end] = ranges[mid];
29225
+ if (pos < start) hi = mid - 1;
29226
+ else if (pos >= end) lo = mid + 1;
29227
+ else return true;
29228
+ }
29229
+ return false;
29230
+ }
29165
29231
  function declaredPackages(cwd) {
29166
29232
  const out = /* @__PURE__ */ new Set();
29167
29233
  const pkgPath = (0, import_node_path6.join)(cwd, "package.json");
@@ -29316,6 +29382,58 @@ function docDriftFromFreshness(score) {
29316
29382
  if (score >= DOC_FRESHNESS_THRESHOLDS.high) return "high";
29317
29383
  return "critical";
29318
29384
  }
29385
+ function findCommentLineRanges(source) {
29386
+ const out = /* @__PURE__ */ new Set();
29387
+ const stack = [];
29388
+ let inLineComment = false;
29389
+ const lines = source.split("\n");
29390
+ for (let i = 0; i < lines.length; i++) {
29391
+ const line = lines[i];
29392
+ const lineNo = i + 1;
29393
+ let j = 0;
29394
+ while (j < line.length) {
29395
+ const c = line[j];
29396
+ const next = line[j + 1];
29397
+ if (!stack.length && c === "/" && next === "/") {
29398
+ inLineComment = true;
29399
+ out.add(lineNo);
29400
+ break;
29401
+ }
29402
+ if (c === "/" && next === "*") {
29403
+ stack.push(lineNo);
29404
+ out.add(lineNo);
29405
+ j += 2;
29406
+ continue;
29407
+ }
29408
+ if (c === "*" && next === "/" && stack.length > 0) {
29409
+ stack.pop();
29410
+ j += 2;
29411
+ continue;
29412
+ }
29413
+ if (c === '"' || c === "'" || c === "`") {
29414
+ const quote = c;
29415
+ j += 1;
29416
+ while (j < line.length) {
29417
+ if (line[j] === "\\") {
29418
+ j += 2;
29419
+ continue;
29420
+ }
29421
+ if (line[j] === quote) {
29422
+ j += 1;
29423
+ break;
29424
+ }
29425
+ j += 1;
29426
+ }
29427
+ continue;
29428
+ }
29429
+ j += 1;
29430
+ }
29431
+ if (stack.length > 0) {
29432
+ out.add(lineNo);
29433
+ }
29434
+ }
29435
+ return out;
29436
+ }
29319
29437
  var import_node_fs6, import_node_path6, import_globby, DOC_RULE_WEIGHTS, DOC_FRESHNESS_THRESHOLDS;
29320
29438
  var init_doc_freshness = __esm({
29321
29439
  "src/engine/doc-freshness.ts"() {
@@ -29340,6 +29458,9 @@ var init_doc_freshness = __esm({
29340
29458
  });
29341
29459
 
29342
29460
  // src/rules/docs/broken-link.ts
29461
+ function looksLikeRegexSyntax(target) {
29462
+ return /\[[^\]]*\]/.test(target) || /^\s*[?:=!]/.test(target) || /\\(?:s|d|w|S|D|W|t|n|r|[^\\])/.test(target) || /[{}|]/.test(target);
29463
+ }
29343
29464
  var import_node_fs7, import_node_path7, brokenLinkRule;
29344
29465
  var init_broken_link = __esm({
29345
29466
  "src/rules/docs/broken-link.ts"() {
@@ -29370,6 +29491,8 @@ var init_broken_link = __esm({
29370
29491
  if (target.startsWith("#")) continue;
29371
29492
  if (target.startsWith("//")) continue;
29372
29493
  if (target.startsWith("/")) continue;
29494
+ if (link.inBlockComment) continue;
29495
+ if (looksLikeRegexSyntax(target)) continue;
29373
29496
  const filePart = target.split("#")[0] ?? target;
29374
29497
  if (filePart === "") continue;
29375
29498
  const resolved = (0, import_node_path7.join)(docDir, filePart);
@@ -38545,6 +38668,29 @@ var init_stringly_typed = __esm({
38545
38668
  }
38546
38669
  });
38547
38670
 
38671
+ // src/engine/parser-shared.ts
38672
+ function parseTreeSitterSource(parserResult, source) {
38673
+ if (!parserResult.ok) return null;
38674
+ if (!source || source.trim() === "") return null;
38675
+ try {
38676
+ const rawTree = parserResult.parser.parse(source);
38677
+ if (!rawTree) return null;
38678
+ const tree = rawTree;
38679
+ if (!tree || !tree.rootNode) return null;
38680
+ if (tree.rootNode.type === "ERROR" && tree.rootNode.childCount === 0) {
38681
+ return null;
38682
+ }
38683
+ return tree;
38684
+ } catch {
38685
+ return null;
38686
+ }
38687
+ }
38688
+ var init_parser_shared = __esm({
38689
+ "src/engine/parser-shared.ts"() {
38690
+ "use strict";
38691
+ }
38692
+ });
38693
+
38548
38694
  // src/engine/parser-rust.ts
38549
38695
  function getRustParser() {
38550
38696
  if (cachedParser) return { ok: true, parser: cachedParser };
@@ -38568,21 +38714,7 @@ function effectiveParser() {
38568
38714
  return getRustParser();
38569
38715
  }
38570
38716
  function parseRust(source) {
38571
- const result = effectiveParser();
38572
- if (!result.ok) return null;
38573
- if (!source || source.trim() === "") return null;
38574
- try {
38575
- const rawTree = result.parser.parse(source);
38576
- if (!rawTree) return null;
38577
- const tree = rawTree;
38578
- if (!tree || !tree.rootNode) return null;
38579
- if (tree.rootNode.type === "ERROR" && tree.rootNode.childCount === 0) {
38580
- return null;
38581
- }
38582
- return tree;
38583
- } catch {
38584
- return null;
38585
- }
38717
+ return parseTreeSitterSource(effectiveParser(), source);
38586
38718
  }
38587
38719
  function isRustParserAvailable() {
38588
38720
  const result = getRustParser();
@@ -38594,6 +38726,7 @@ var init_parser_rust = __esm({
38594
38726
  "use strict";
38595
38727
  import_tree_sitter = __toESM(require("tree-sitter"), 1);
38596
38728
  import_tree_sitter_rust = __toESM(require("tree-sitter-rust"), 1);
38729
+ init_parser_shared();
38597
38730
  cachedParser = null;
38598
38731
  rustLanguage = null;
38599
38732
  loadError = null;
@@ -43701,8 +43834,6 @@ function mergeTemplateClassNames(filePath, source, facts, templateClassNames) {
43701
43834
  }
43702
43835
  function extractFacts(filePath, ast, source, supportsRsc = true, framework = "react", config) {
43703
43836
  const lineOffsets = buildLineOffsets2(source);
43704
- const isTypeScript = /\.tsx?$/i.test(filePath);
43705
- const NON_JSX_FRAMEWORKS = /* @__PURE__ */ new Set(["vue", "svelte", "astro"]);
43706
43837
  const facts = {
43707
43838
  filePath,
43708
43839
  components: [],
@@ -45641,7 +45772,7 @@ function formatDeltaSuffix(report) {
45641
45772
  }
45642
45773
  function formatVerdict(report) {
45643
45774
  const score = report.aiSlopScore;
45644
- const band = scoreBand(score);
45775
+ const band = slopScoreBand(score);
45645
45776
  const ranked = rankByImpact(report.issues);
45646
45777
  const topRule = ranked[0];
45647
45778
  const topFile = report.topOffenders?.[0];
@@ -45724,7 +45855,8 @@ function formatNextStep(report) {
45724
45855
  ` \u2192 \`slopbrick scan --baseline\` to accept today's scores as the new floor`
45725
45856
  )
45726
45857
  );
45727
- if (report.aiSlopScore > 30) {
45858
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
45859
+ if (report.aiSlopScore > meanSlop) {
45728
45860
  lines.push(
45729
45861
  import_chalk.default.dim(
45730
45862
  ` \u2192 \`slopbrick scan --why-failing\` for the top 5 issues dragging the score down`
@@ -45749,17 +45881,18 @@ function formatWhyFailing(report) {
45749
45881
  return import_chalk.default.green("Nothing is failing the threshold. Score is clean.");
45750
45882
  }
45751
45883
  const headline = report.aiSlopScore;
45752
- const status = headline <= 30 ? "PASS" : "FAIL";
45753
- const colorize = headline <= 30 ? import_chalk.default.green : import_chalk.default.red;
45884
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
45885
+ const status = headline <= meanSlop ? "PASS" : "FAIL";
45886
+ const colorize = headline <= meanSlop ? import_chalk.default.green : import_chalk.default.red;
45887
+ const qualifier = headline <= meanSlop ? `below ${meanSlop}` : `above ${meanSlop}`;
45754
45888
  const lines = [];
45755
- lines.push(colorize.bold(`Headline score: ${headline.toFixed(0)}/100 (${status} \u2014 above 30)`));
45889
+ lines.push(colorize.bold(`Headline score: ${headline.toFixed(0)}/100 (${status} \u2014 ${qualifier})`));
45756
45890
  lines.push("");
45757
45891
  lines.push(import_chalk.default.bold("Top 5 rules dragging the score down:"));
45758
45892
  for (let i = 0; i < ranked.length; i++) {
45759
45893
  const [ruleId, info] = ranked[i];
45760
45894
  const fileCount = info.files.size;
45761
45895
  const fileHint = fileCount > 0 ? ` across ${fileCount} file${fileCount === 1 ? "" : "s"}` : "";
45762
- const catShort = CATEGORY_GLOSSARY[info.count > 0 ? "" : ""]?.short;
45763
45896
  const sampleIssue = report.issues.find((i2) => i2.ruleId === ruleId);
45764
45897
  const cat = sampleIssue ? CATEGORY_GLOSSARY[sampleIssue.category]?.short : null;
45765
45898
  const catHint = cat ? import_chalk.default.dim(` [${cat}]`) : "";
@@ -45819,7 +45952,7 @@ function formatCompositeScore(report) {
45819
45952
  lines.push("");
45820
45953
  lines.push(
45821
45954
  import_chalk.default.dim(
45822
- `Repository Coherence: ${coherenceValue} / 100 [${cohBand.label.toUpperCase()}] \u2014 higher = better \xB7 measures internal consistency. This is a secondary view; the Slop Index above is the gate.`
45955
+ `Repository Coherence: ${coherenceValue} / 100 [${cohBand.label.toUpperCase()}] \u2014 higher = better \xB7 measures internal consistency. This is a secondary view; the AI Slop Score above is the gate.`
45823
45956
  )
45824
45957
  );
45825
45958
  }
@@ -45949,13 +46082,12 @@ function formatDriftSection(report) {
45949
46082
  return lines.join("\n");
45950
46083
  }
45951
46084
  function formatThresholds(report) {
45952
- const limit = report.thresholds.meanSlop;
46085
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
45953
46086
  const headline = report.aiSlopScore;
45954
- const passed = headline >= 70;
45955
- const valueText = `${headline.toFixed(1)} \u2265 70`.padStart(12, " ");
46087
+ const passed = headline <= meanSlop;
46088
+ const valueText = `${headline.toFixed(1)} \u2264 ${meanSlop}`.padStart(12, " ");
45956
46089
  const status = passed ? import_chalk.default.green("pass") : import_chalk.default.red("fail");
45957
46090
  const result = ["Threshold (CI gate)", ` AI Slop Score ${valueText} ${status}`];
45958
- void limit;
45959
46091
  return result;
45960
46092
  }
45961
46093
  function formatTopComponents(components, topOffenders) {
@@ -46033,9 +46165,10 @@ function formatPretty(report) {
46033
46165
  }
46034
46166
  return sections.join("\n\n");
46035
46167
  }
46036
- function formatScoringExplainer(_report) {
46168
+ function formatScoringExplainer(report) {
46169
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
46037
46170
  return import_chalk.default.dim(
46038
- "Four orthogonal scores (all 0-100, higher = better): AI Slop Score (AI-slop signatures; the CI gate, AI Slop Score >= 70), Engineering Hygiene (issues per category across arch/logic/layout/component/test), Security (AI-flagged security risks, inverted from risk level), Repository Health (composite: 0.4*AI Slop Score + 0.3*Engineering Hygiene + 0.2*Security + 0.1*Test Quality). Only AI Slop Score gates CI; the others are informational. Default-off rules (INVERTED/NOISY/DORMANT) are suppressed from the scores automatically."
46171
+ `Four orthogonal scores (all 0-100, **lower AI Slop Score = cleaner**): AI Slop Score (AI-slop signatures; the CI gate, AI Slop Score \u2264 ${meanSlop} passes), Engineering Hygiene (issues per category across arch/logic/layout/component/test; higher = cleaner), Security (AI-flagged security risks, inverted from risk level; higher = cleaner), Repository Health (composite: 0.4*AI Slop Score + 0.3*Engineering Hygiene + 0.2*Security + 0.1*Test Quality). Only AI Slop Score gates CI; the others are informational. Default-off rules (INVERTED/NOISY/DORMANT) are suppressed from the scores automatically.`
46039
46172
  );
46040
46173
  }
46041
46174
  function formatWhyFailingReport(report) {
@@ -46053,7 +46186,7 @@ function formatBriefReport(report) {
46053
46186
  ];
46054
46187
  const deltaSuffix = formatDeltaSuffix(report);
46055
46188
  scoreLines.forEach(({ label, field, value }, idx) => {
46056
- const band = scoreBand(value);
46189
+ const band = field === "aiSlopScore" ? slopScoreBand(value) : scoreBand(value);
46057
46190
  const paddedLabel = label.padEnd(20, " ");
46058
46191
  const valueStr = value.toFixed(0).padStart(3, " ");
46059
46192
  const delta = idx === 0 ? deltaSuffix : "";
@@ -46061,11 +46194,12 @@ function formatBriefReport(report) {
46061
46194
  ` ${paddedLabel} ${band.color(valueStr)} ${import_chalk.default.dim(band.label)} ${import_chalk.default.dim.italic(`(${field})`)}${delta}`
46062
46195
  );
46063
46196
  });
46064
- const passed = report.aiSlopScore <= 30;
46197
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
46198
+ const passed = report.aiSlopScore <= meanSlop;
46065
46199
  lines.push("");
46066
46200
  lines.push(
46067
46201
  import_chalk.default.dim(
46068
- ` CI gate: AI Slop Score <= 30 -> ${passed ? import_chalk.default.green("pass") : import_chalk.default.red("fail")}`
46202
+ ` CI gate: AI Slop Score <= ${meanSlop} -> ${passed ? import_chalk.default.green("pass") : import_chalk.default.red("fail")}`
46069
46203
  )
46070
46204
  );
46071
46205
  const suppressed = report.defaultOffSuppressedCount ?? 0;
@@ -46210,19 +46344,22 @@ function thresholdExceeded(report, config) {
46210
46344
  return categoryThresholdBreached(report, config.thresholds.categoryThresholds);
46211
46345
  }
46212
46346
  function failedThresholdCount(report, config) {
46213
- let count = 0;
46214
- if (report.aiSlopScore > config.thresholds.meanSlop) count += 1;
46215
- if (report.p90Score > config.thresholds.p90Slop) count += 1;
46216
- if (report.peakScore > config.thresholds.individualSlopThreshold) count += 1;
46347
+ return failedThresholds(report, config).length;
46348
+ }
46349
+ function failedThresholds(report, config) {
46350
+ const failed = [];
46351
+ if (report.aiSlopScore > config.thresholds.meanSlop) failed.push("meanSlop");
46352
+ if (report.p90Score > config.thresholds.p90Slop) failed.push("p90Slop");
46353
+ if (report.peakScore > config.thresholds.individualSlopThreshold) failed.push("individualSlopThreshold");
46217
46354
  const cat = config.thresholds.categoryThresholds;
46218
46355
  if (cat) {
46219
46356
  for (const [category, limit] of Object.entries(cat)) {
46220
46357
  if (limit === void 0) continue;
46221
46358
  const score = report.categoryScores[category];
46222
- if (score !== void 0 && score > limit) count += 1;
46359
+ if (score !== void 0 && score > limit) failed.push(`category:${category}`);
46223
46360
  }
46224
46361
  }
46225
- return count;
46362
+ return failed;
46226
46363
  }
46227
46364
  function categoryThresholdBreached(report, categoryThresholds) {
46228
46365
  if (!categoryThresholds) return false;
@@ -50890,9 +51027,22 @@ function computeFlywheelOutput(runs, currentTopFiles, recentTopHashes, unmatched
50890
51027
  }
50891
51028
  }
50892
51029
  if (currentTopFiles.length > 0 && runs.length >= CONSECUTIVE_THRESHOLD) {
51030
+ const latestRun = runs[runs.length - 1];
51031
+ const isNoiseTier = (id) => {
51032
+ const rule = ruleById.get(id);
51033
+ if (!rule) return false;
51034
+ if (rule.defaultOff === true) return true;
51035
+ if (rule.aiSpecific && (rule.severity === "medium" || rule.severity === "low")) return true;
51036
+ return false;
51037
+ };
51038
+ const onRulesInLatestTop = latestRun.topOffenseIds.filter(
51039
+ (id) => !isNoiseTier(id)
51040
+ );
51041
+ const skipChronicOnDefaultOffNoise = onRulesInLatestTop.length === 0;
50893
51042
  for (const file of currentTopFiles) {
50894
51043
  const consecutive = countConsecutiveTopFileAppearances(file.hash, recentTopHashes);
50895
51044
  if (consecutive >= CONSECUTIVE_THRESHOLD) {
51045
+ if (skipChronicOnDefaultOffNoise) continue;
50896
51046
  hotspotIssues.push({
50897
51047
  ruleId: "component/chronic-offender",
50898
51048
  category: "component",
@@ -50902,7 +51052,7 @@ function computeFlywheelOutput(runs, currentTopFiles, recentTopHashes, unmatched
50902
51052
  filePath: file.filePath,
50903
51053
  line: 1,
50904
51054
  column: 1,
50905
- advice: "Prioritize refactoring this file or add it to the baseline."
51055
+ advice: "Run `slopbrick scan --baseline` to accept current scores as the new floor, or refactor the file to reduce its issue count."
50906
51056
  });
50907
51057
  }
50908
51058
  }
@@ -53236,16 +53386,16 @@ function axisSlopIndex(slopIndex) {
53236
53386
  if (slopIndex === void 0 || Number.isNaN(slopIndex)) {
53237
53387
  return {
53238
53388
  axis: "slopIndex",
53239
- label: "Slop Index",
53389
+ label: "AI Slop Score (raw)",
53240
53390
  health: 70,
53241
53391
  source: "default (not measured) \u2014 neutral 70"
53242
53392
  };
53243
53393
  }
53244
53394
  return {
53245
53395
  axis: "slopIndex",
53246
- label: "Slop Index",
53396
+ label: "AI Slop Score (raw)",
53247
53397
  health: clamp100(100 - slopIndex),
53248
- source: `100 - slopIndex (${slopIndex.toFixed(1)}) \u2192 inverted`
53398
+ source: `100 - aiSlopScore (${slopIndex.toFixed(1)}) \u2192 inverted to cleanliness`
53249
53399
  };
53250
53400
  }
53251
53401
  function axisArchitecture(architectureConsistency) {
@@ -54193,6 +54343,16 @@ function assembleScanReport(input) {
54193
54343
  enrichment
54194
54344
  } = input;
54195
54345
  const prSlopScore = computePrSlopScore(diffRef, allIssues);
54346
+ const thresholdReport = {
54347
+ aiSlopScore: aggregated.aiSlopScore,
54348
+ p90Score: aggregated.p90Score,
54349
+ peakScore: aggregated.peakScore,
54350
+ categoryScores: aggregated.categoryScores
54351
+ };
54352
+ const failedThresholds_ = failedThresholds(
54353
+ thresholdReport,
54354
+ config
54355
+ );
54196
54356
  return {
54197
54357
  version: VERSION,
54198
54358
  generatedAt,
@@ -54248,6 +54408,11 @@ function assembleScanReport(input) {
54248
54408
  parseErrors: parseErrors.length > 0 ? parseErrors : void 0,
54249
54409
  baseline: baselineMeta,
54250
54410
  thresholds: config.thresholds,
54411
+ // v0.42.0 (user-review fix): expose which gates tripped so CI
54412
+ // consumers can read the JSON output instead of grepping stderr.
54413
+ // Optional field: absent when no thresholds tripped. Computed from
54414
+ // the report's own scores so the JSON is self-describing.
54415
+ failedThresholds: failedThresholds_,
54251
54416
  topOffenders: topOffenders.length > 0 ? topOffenders : void 0,
54252
54417
  coherence: enrichment.coherence,
54253
54418
  coherenceBreakdown: enrichment.coherenceBreakdown,
@@ -54264,6 +54429,7 @@ var PR_SLOP_WEIGHTS;
54264
54429
  var init_assembleScanReport = __esm({
54265
54430
  "src/cli/report/assembleScanReport.ts"() {
54266
54431
  "use strict";
54432
+ init_threshold();
54267
54433
  init_types();
54268
54434
  PR_SLOP_WEIGHTS = { high: 10, medium: 5, low: 1 };
54269
54435
  }
@@ -55756,11 +55922,13 @@ function extractNamedScores(report) {
55756
55922
  };
55757
55923
  }
55758
55924
  function renderThresholds(report) {
55759
- const r = report;
55760
- const score = typeof r.repositoryHealth === "number" ? r.repositoryHealth : report.repositoryHealth;
55761
- const limit = 70;
55762
- const available = typeof score === "number";
55763
- const failed = available && score < limit;
55925
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
55926
+ const aiAvailable = typeof report.aiSlopScore === "number";
55927
+ const aiPassed = aiAvailable && report.aiSlopScore <= meanSlop;
55928
+ const rh = report.repositoryHealth;
55929
+ const rhAvailable = typeof rh === "number";
55930
+ const rhLimit = 70;
55931
+ const rhPassed = rhAvailable && rh >= rhLimit;
55764
55932
  return `
55765
55933
  <section class="thresholds-section">
55766
55934
  <h2>Thresholds</h2>
@@ -55774,9 +55942,14 @@ function renderThresholds(report) {
55774
55942
  </thead>
55775
55943
  <tbody>
55776
55944
  <tr>
55777
- <td>Repository Health (composite)</td>
55778
- <td>${available ? `${score.toFixed(1)} / ${limit}` : "\u2013"}</td>
55779
- <td><span class="status-badge ${thresholdStatusClass(failed)}">${available ? failed ? "fail" : "pass" : "n/a"}</span></td>
55945
+ <td>AI Slop Score (CI gate)</td>
55946
+ <td>${aiAvailable ? `${report.aiSlopScore.toFixed(1)} / ${meanSlop}` : "\u2013"}</td>
55947
+ <td><span class="status-badge ${thresholdStatusClass(!aiPassed)}">${aiAvailable ? aiPassed ? "pass" : "fail" : "n/a"}</span></td>
55948
+ </tr>
55949
+ <tr>
55950
+ <td>Repository Health (informational)</td>
55951
+ <td>${rhAvailable ? `${rh.toFixed(1)} / ${rhLimit}` : "\u2013"}</td>
55952
+ <td><span class="status-badge ${thresholdStatusClass(!rhPassed)}">${rhAvailable ? rhPassed ? "pass" : "fail" : "n/a"}</span></td>
55780
55953
  </tr>
55781
55954
  </tbody>
55782
55955
  </table>
@@ -57337,12 +57510,16 @@ async function runScan(options, explicitPaths) {
57337
57510
  files = intersectFiles(files, since, cwd);
57338
57511
  }
57339
57512
  let incrementalSummary;
57513
+ let cachePath3;
57514
+ let existingCache;
57515
+ let unchanged = [];
57340
57516
  if (options.incremental) {
57341
- const cachePath3 = options.cachePath ?? ".slopbrick-cache.json";
57342
- const existing = loadCache(cachePath3);
57343
- const { toScan, unchanged } = partitionByCache(files, existing);
57344
- files = toScan;
57345
- incrementalSummary = { skipped: unchanged.length, rescanned: toScan.length };
57517
+ cachePath3 = options.cachePath ?? ".slopbrick-cache.json";
57518
+ existingCache = loadCache(cachePath3);
57519
+ const partition = partitionByCache(files, existingCache);
57520
+ files = partition.toScan;
57521
+ unchanged = partition.unchanged;
57522
+ incrementalSummary = { skipped: unchanged.length, rescanned: partition.toScan.length };
57346
57523
  }
57347
57524
  const machineReadableStdout = !!options.json || !!options.html || options.format === "json" || options.format === "sarif" || options.format === "html";
57348
57525
  if (files.length === 0 && !options.quiet && !machineReadableStdout) {
@@ -57362,6 +57539,9 @@ async function runScan(options, explicitPaths) {
57362
57539
  logger.warn(
57363
57540
  `No source files matched in ${cwd}. Adjust --include or check your config at ${projectRoot}.`
57364
57541
  );
57542
+ logger.warn(
57543
+ "Tip: run from a directory with source files, or pass --workspace <path> (monorepos usually want `--workspace packages/<name>`)."
57544
+ );
57365
57545
  }
57366
57546
  }
57367
57547
  const configHash = hashConfig(config);
@@ -57511,6 +57691,38 @@ async function runScan(options, explicitPaths) {
57511
57691
  telemetryEnabled,
57512
57692
  machineReadableStdout
57513
57693
  });
57694
+ if (options.incremental && cachePath3 !== void 0) {
57695
+ const cachedFiles = {};
57696
+ for (const r of results) {
57697
+ try {
57698
+ cachedFiles[r.filePath] = {
57699
+ hash: computeFileHash(r.filePath),
57700
+ issueCount: r.issues.length,
57701
+ lastScannedAt: (/* @__PURE__ */ new Date()).toISOString()
57702
+ };
57703
+ } catch {
57704
+ }
57705
+ }
57706
+ for (const f of unchanged) {
57707
+ const cached = existingCache?.files[f];
57708
+ if (cached) {
57709
+ cachedFiles[f] = {
57710
+ hash: cached.hash,
57711
+ issueCount: cached.issueCount,
57712
+ lastScannedAt: cached.lastScannedAt
57713
+ };
57714
+ } else {
57715
+ }
57716
+ }
57717
+ try {
57718
+ saveCache(cachePath3, {
57719
+ version: VERSION,
57720
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
57721
+ files: cachedFiles
57722
+ });
57723
+ } catch {
57724
+ }
57725
+ }
57514
57726
  return {
57515
57727
  report,
57516
57728
  scores,
@@ -57657,6 +57869,7 @@ __export(src_exports, {
57657
57869
  directoryScore: () => directoryScore,
57658
57870
  extractSignatures: () => extractSignatures,
57659
57871
  failedThresholdCount: () => failedThresholdCount,
57872
+ failedThresholds: () => failedThresholds,
57660
57873
  filterByDisabledDirectives: () => filterByDisabledDirectives,
57661
57874
  filterIssues: () => filterIssues,
57662
57875
  findSimilarFunctions: () => findSimilarFunctions,
@@ -57742,6 +57955,7 @@ init_scan2();
57742
57955
  init_scan2();
57743
57956
 
57744
57957
  // src/cli/init.ts
57958
+ var import_chalk2 = __toESM(require("chalk"), 1);
57745
57959
  var import_node_fs31 = require("fs");
57746
57960
  var import_node_path34 = require("path");
57747
57961
  var import_node_readline = require("readline");
@@ -57750,6 +57964,7 @@ init_discover();
57750
57964
  init_git();
57751
57965
  init_cache();
57752
57966
  init_logger();
57967
+ init_types();
57753
57968
 
57754
57969
  // src/rules/registry-loader.ts
57755
57970
  var import_node_fs30 = require("fs");
@@ -57924,7 +58139,12 @@ async function refreshRegistrySnapshot(cwd, _url = REGISTRY_URL, _timeoutMs = 5e
57924
58139
  snapshot: shadcn_registry_default,
57925
58140
  ok: true,
57926
58141
  fresh: false,
57927
- message: "Using bundled snapshot (upstream URL returns 404)."
58142
+ // v0.42.0 (user-review fix): the previous message said
58143
+ // "upstream URL returns 404" which is alarming without context —
58144
+ // upstream has been dead since v0.39.0, and the bundled snapshot
58145
+ // IS the snapshot in use. Replaced with a neutral message that
58146
+ // doesn't suggest "something is wrong".
58147
+ message: "Using bundled shadcn/ui snapshot (built-in; no network fetch)."
57928
58148
  };
57929
58149
  }
57930
58150
  function copyBundledSnapshotToCache(cwd) {
@@ -58095,16 +58315,23 @@ async function runDoctor(cwd) {
58095
58315
  lines.push("slopbrick doctor\n");
58096
58316
  lines.push("Checking your setup:\n");
58097
58317
  const nodeMajor = parseInt(process.versions.node.split(".")[0] ?? "0", 10);
58098
- if (nodeMajor >= 20) ok(`Node ${process.versions.node}`);
58318
+ if (nodeMajor >= 20) ok(`Node ${process.versions.node}, slopbrick ${VERSION}`);
58099
58319
  else fail(`Node ${process.versions.node} \u2014 slopbrick needs Node 20 or newer. Run: nvm install 20`);
58100
58320
  lines.push(`
58101
58321
  Working in: ${cwd}`);
58102
58322
  try {
58323
+ const configPath = resolveConfigPath(cwd);
58103
58324
  const config = await loadConfig(cwd);
58104
- lines.push(`
58105
- \u2713 Config loaded from: ${resolveConfigPath(cwd) ?? "(default)"}`);
58106
- const ruleCount = Object.keys(config.rules ?? {}).length;
58107
- lines.push(` ${ruleCount} rules configured in your config file.`);
58325
+ if (configPath) {
58326
+ const ruleCount = Object.keys(config.rules ?? {}).length;
58327
+ lines.push(`
58328
+ \u2713 Config loaded from: ${configPath}`);
58329
+ lines.push(` ${ruleCount} rule${ruleCount === 1 ? "" : "s"} configured in your config file.`);
58330
+ } else {
58331
+ lines.push(`
58332
+ \u2713 Config: using built-in defaults (no config file found).`);
58333
+ lines.push(import_chalk2.default.dim(` Run \`slopbrick init\` to create ${cwd.endsWith("/") ? cwd : cwd + "/"}slopbrick.config.mjs and tune rule severity/rules to your project.`));
58334
+ }
58108
58335
  } catch (error) {
58109
58336
  fail(`Could not read your config: ${error.message}`);
58110
58337
  exitCode = 3;
@@ -58161,6 +58388,7 @@ async function runDoctor(cwd) {
58161
58388
  ok(`Found ${scannable.length} source files to scan.`);
58162
58389
  } else {
58163
58390
  warn("No source files (.ts/.tsx/.js/.jsx/.vue/.svelte/.astro/.mdx) found in this directory.");
58391
+ lines.push(import_chalk2.default.dim(" Run from a directory with source files, or pass --workspace <path>. Monorepos usually want `--workspace packages/<name>`."));
58164
58392
  }
58165
58393
  } catch {
58166
58394
  }
@@ -58225,7 +58453,7 @@ function registerBadge(program) {
58225
58453
  const health = loadHealth2(cwd);
58226
58454
  if (health) {
58227
58455
  const synthetic = {
58228
- slopIndex: 100 - health.repositoryHealth
58456
+ aiSlopScore: health.aiSlopScore ?? 0
58229
58457
  };
58230
58458
  logger.info(formatBadge(synthetic));
58231
58459
  process.exit(0);
@@ -58769,7 +58997,11 @@ init_logger();
58769
58997
  init_builtins();
58770
58998
  init_data();
58771
58999
  function registerExplain(program) {
58772
- program.command("explain <ruleId>").description("Print rationale, pattern, and remediation for a single rule").action((ruleId) => {
59000
+ program.command("explain [ruleId]").description("Print rationale, pattern, and remediation for a single rule. With no ruleId, lists the rules.").action((ruleId) => {
59001
+ if (!ruleId) {
59002
+ logger.warn("Missing ruleId. Run `slopbrick rules` to see available rules, then `slopbrick explain <ruleId>`.");
59003
+ process.exit(2);
59004
+ }
58773
59005
  const result = explainRule(ruleId, builtinRules, RULE_HINTS);
58774
59006
  logger.info(formatExplain(result));
58775
59007
  if ("error" in result) process.exit(2);
@@ -62688,7 +62920,7 @@ init_structure_md();
62688
62920
  var TOOL_DEFINITIONS = [
62689
62921
  {
62690
62922
  name: "slop_scan_file",
62691
- description: "Scan a single TypeScript/JavaScript file for AI-generated frontend slop. Returns issues (ruleId, severity, line, column, message, advice) and the file-level Slop Index.",
62923
+ description: 'Scan a single TypeScript/JavaScript file for AI-generated frontend slop. Returns issues (ruleId, category, severity, line, column, message, advice), a composite AI-likelihood score (probability + confidenceTier), and a componentCount. The composite score is the Bayesian log-likelihood ratio of all rules that fired, NOT a per-file "Slop Index" \u2014 for project-level scores use slop_suggest.',
62692
62924
  inputSchema: {
62693
62925
  type: "object",
62694
62926
  properties: {
@@ -62704,7 +62936,7 @@ var TOOL_DEFINITIONS = [
62704
62936
  },
62705
62937
  {
62706
62938
  name: "slop_explain_rule",
62707
- description: "Return metadata for a single rule (id, category, severity, aiSpecific) plus a rationale and the recommended fix. Use this before auto-applying --fix to understand what the rule catches.",
62939
+ description: "Return metadata for a single rule: ruleId, category, severity, aiSpecific, a brief rationale string, and a whereToLook path into src/rules/. The actual fix-steps live in the rule source (whereToLook) rather than in this response \u2014 read that file to understand the rule before auto-applying --fix.",
62708
62940
  inputSchema: {
62709
62941
  type: "object",
62710
62942
  properties: {
@@ -62725,7 +62957,7 @@ var TOOL_DEFINITIONS = [
62725
62957
  },
62726
62958
  {
62727
62959
  name: "slop_suggest",
62728
- description: "**Primary entry point for AI agents.** Returns the project's existing patterns (modals, buttons, api clients, state libs, data-fetching libs), the do-not-create list (forbidden imports + canonical patterns not to duplicate), top issues by rule, hot files by issue count, and the composite Repository Health score. Call this BEFORE writing new code so the agent reuses existing patterns instead of duplicating them.",
62960
+ description: "**Primary entry point for AI agents.** Returns the project's existing patterns (modals, buttons, api clients, state libs, data-fetching libs), the do-not-create list (forbidden imports + canonical patterns not to duplicate), the declared stack, and (when .slopbrick/health.json exists) a Bayesian composite AI-likelihood score. Call this BEFORE writing new code so the agent reuses existing patterns instead of duplicating them. For per-issue details or per-file hot-spots, use slop_scan_file on each candidate path.",
62729
62961
  inputSchema: {
62730
62962
  type: "object",
62731
62963
  properties: {
@@ -62757,7 +62989,7 @@ var TOOL_DEFINITIONS = [
62757
62989
  // call slop_suggest and read repositoryHealth /
62758
62990
  // architectureConsistency / businessLogicCoherence.
62759
62991
  name: "slop_check_constitution",
62760
- description: "Check a single file against the project's declared constitution (stateManagement, dataFetching, uiLibrary, forms, styling, routing, plus a forbidden deny-list in slopbrick.config.mjs). Returns a list of imports that violate declared values or hit the deny-list. Use this on a newly-written or modified file before suggesting a PR.",
62992
+ description: "Check a single file against the project's declared constitution (stateManagement, dataFetching, uiLibrary, forms, styling, routing, plus a forbidden deny-list in slopbrick.config.mjs). Returns the file path, total import + violation counts, the parsed imports, the list of violations (each with import, category, and reason), and a conventionSource indicating whether the constitution was declared, detected, or absent. Use this on a newly-written or modified file before suggesting a PR.",
62761
62993
  inputSchema: {
62762
62994
  type: "object",
62763
62995
  properties: {
@@ -62774,7 +63006,7 @@ var TOOL_DEFINITIONS = [
62774
63006
  // (sha256 over sorted hooks ∪ props ∪ params) + Jaccard similarity
62775
63007
  // — no LLM, no embeddings, deterministic. Foundation for StackPick.
62776
63008
  name: "slop_find_similar",
62777
- description: "Find the most similar existing function/component implementations across the codebase, ranked by Jaccard similarity over the union of (hooks \u222A props \u222A params). Use this BEFORE writing new code so the agent reuses an existing pattern instead of inventing a new one. Returns top-k matches with name, file, line, fingerprint, and similarity score in [0, 1].",
63009
+ description: "Find the most similar existing function/component implementations across the codebase, ranked by Jaccard similarity over the union of (hooks \u222A props \u222A params). Use this BEFORE writing new code so the agent reuses an existing pattern instead of inventing a new one. Returns up to `limit` matches (default 10) with name, file, line, fingerprint (sha256 over signature), hooks, props, params, and similarity in [0, 1].",
62778
63010
  inputSchema: {
62779
63011
  type: "object",
62780
63012
  properties: {
@@ -63005,13 +63237,6 @@ async function runFindSimilar(args, ctx) {
63005
63237
  }
63006
63238
  }
63007
63239
  async function handleToolCall(toolName, args, ctx) {
63008
- const deprecation = getDeprecation(toolName);
63009
- const deprecationNotice = deprecation ? {
63010
- tool: toolName,
63011
- replacedBy: deprecation.replacedBy,
63012
- removedIn: deprecation.removedIn ?? "next major",
63013
- reason: deprecation.reason
63014
- } : void 0;
63015
63240
  switch (toolName) {
63016
63241
  case "slop_scan_file":
63017
63242
  return runScanFile(args, ctx);
@@ -63219,7 +63444,7 @@ var import_node_path46 = require("path");
63219
63444
  init_logger();
63220
63445
  init_dist();
63221
63446
  function registerCi(program, scanAction) {
63222
- program.command("ci").description("CI gate: run a scan and exit 1 on constitution violations, threshold breach, or new issues since the last run. Use this in GitHub Actions / GitLab CI.").option("--max-slop <n>", "exit 1 if slopIndex exceeds this number", parseCount).option("--max-new-issues <n>", "exit 1 if new issues (vs .slop-audit-cache.json) exceed this number", parseCount).option("--strict-constitution", "exit 1 on any constitution violation").option("--format <pretty|json>", "output format", "json").action(
63447
+ program.command("ci").description("CI gate: run a scan and exit 1 on constitution violations, threshold breach, or new issues since the last run. Use this in GitHub Actions / GitLab CI.").option("--max-slop <n>", "exit 1 if aiSlopScore exceeds this number (lower = cleaner since v0.21)", parseCount).option("--max-new-issues <n>", "exit 1 if new issues (vs .slop-audit-cache.json) exceed this number", parseCount).option("--strict-constitution", "exit 1 on any constitution violation").option("--format <pretty|json>", "output format", "json").action(
63223
63448
  async (cmdOptions, command) => {
63224
63449
  const globals = command.optsWithGlobals();
63225
63450
  const options = {
@@ -63559,7 +63784,25 @@ function registerRules(program) {
63559
63784
  const sev = r.severity.padEnd(8);
63560
63785
  const tag = r.aiSpecific ? "[AI]" : " ";
63561
63786
  lines.push(` ${sev} ${tag} ${r.id}`);
63562
- if (r.description) lines.push(` ${r.description}`);
63787
+ if (r.description) {
63788
+ const cols = process.stdout.isTTY ? process.stdout.columns ?? 100 : 100;
63789
+ const indent = " ";
63790
+ const firstCol = indent.length + 1;
63791
+ const maxWidth = Math.max(40, cols - firstCol);
63792
+ const words = r.description.split(" ");
63793
+ let line = "";
63794
+ for (const word of words) {
63795
+ if (line === "") {
63796
+ line = word;
63797
+ } else if ((line + " " + word).length > maxWidth) {
63798
+ lines.push(indent + line);
63799
+ line = word;
63800
+ } else {
63801
+ line = line + " " + word;
63802
+ }
63803
+ }
63804
+ if (line) lines.push(indent + line);
63805
+ }
63563
63806
  }
63564
63807
  }
63565
63808
  logger.info(lines.join("\n"));
@@ -64404,19 +64647,20 @@ function trendToText(report) {
64404
64647
  }
64405
64648
  const slopValues = report.points.map((p) => p.slopIndex);
64406
64649
  const spark = sparkline(slopValues);
64407
- lines.push("Slop Index trend (last " + report.points.length + " scans, " + report.totalScans + " total):");
64650
+ lines.push("AI Slop Score trend (last " + report.points.length + " scans, " + report.totalScans + " total; lower = cleaner since v0.21):");
64408
64651
  lines.push("");
64409
64652
  lines.push(" " + spark + " " + slopValues[0].toFixed(1) + " -> " + slopValues[slopValues.length - 1].toFixed(1));
64410
64653
  lines.push("");
64411
64654
  if (report.first && report.latest) {
64412
64655
  const direction = report.delta < 0 ? "improved" : report.delta > 0 ? "regressed" : "unchanged";
64413
64656
  const arrow = report.delta < 0 ? "v" : report.delta > 0 ? "^" : "=";
64414
- lines.push(" " + arrow + " Slop Index " + direction + " by " + Math.abs(report.delta).toFixed(1) + " points");
64657
+ lines.push(" " + arrow + " AI Slop Score " + direction + " by " + Math.abs(report.delta).toFixed(1) + " points (lower = cleaner since v0.21)");
64415
64658
  lines.push(" " + report.first.timestamp + " -> " + report.latest.timestamp);
64416
64659
  }
64417
64660
  lines.push("");
64418
64661
  lines.push("Recent scans:");
64419
- lines.push(" Timestamp Slop Health Framework Issues");
64662
+ lines.push(" Timestamp AI Slop Health Framework Issues");
64663
+ lines.push(" (lower=cleaner)");
64420
64664
  lines.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500");
64421
64665
  for (const p of report.points.slice(-10)) {
64422
64666
  const ts = p.timestamp.slice(0, 19).replace("T", " ");
@@ -64428,14 +64672,14 @@ function trendToText(report) {
64428
64672
  }
64429
64673
  function trendToMarkdown(report) {
64430
64674
  const lines = [];
64431
- lines.push("# Slop Index Trend");
64675
+ lines.push("# AI Slop Score Trend (lower = cleaner since v0.21)");
64432
64676
  lines.push("");
64433
64677
  if (report.points.length === 0) {
64434
64678
  lines.push("No telemetry data yet. Run `slopbrick scan` to start tracking.");
64435
64679
  return lines.join("\n");
64436
64680
  }
64437
64681
  const slopValues = report.points.map((p) => p.slopIndex);
64438
- lines.push("Slop Index sparkline (last " + report.points.length + " scans):");
64682
+ lines.push("AI Slop Score sparkline (last " + report.points.length + " scans; lower = cleaner since v0.21):");
64439
64683
  lines.push("");
64440
64684
  lines.push("```");
64441
64685
  lines.push(sparkline(slopValues));
@@ -64443,10 +64687,10 @@ function trendToMarkdown(report) {
64443
64687
  lines.push("");
64444
64688
  if (report.first && report.latest) {
64445
64689
  const direction = report.delta < 0 ? "improved" : report.delta > 0 ? "regressed" : "unchanged";
64446
- lines.push("Slop Index " + direction + " by **" + Math.abs(report.delta).toFixed(1) + " points** between first and latest scan.");
64690
+ lines.push("AI Slop Score " + direction + " by **" + Math.abs(report.delta).toFixed(1) + " points** between first and latest scan (lower = cleaner since v0.21).");
64447
64691
  }
64448
64692
  lines.push("");
64449
- lines.push("| Timestamp | Slop Index | Health | Framework | Issues |");
64693
+ lines.push("| Timestamp | AI Slop Score (lower = cleaner) | Health | Framework | Issues |");
64450
64694
  lines.push("|-----------|-----------:|-------:|-----------|-------:|");
64451
64695
  for (const p of report.points) {
64452
64696
  lines.push("| " + p.timestamp + " | " + p.slopIndex.toFixed(1) + " | " + p.assemblyHealth.toFixed(1) + " | " + p.framework + " | " + p.totalIssues + " |");
@@ -64456,7 +64700,7 @@ function trendToMarkdown(report) {
64456
64700
 
64457
64701
  // src/cli/commands/trend.ts
64458
64702
  function registerTrend(program) {
64459
- program.command("trend").description("Slop Index over time, from .slopbrick/flywheel/scans.jsonl").option("--max-points <n>", "how many recent scans to plot", parseCount, 30).option("--render <kind>", "output rendering: text | markdown", "text").action((cmdOptions) => {
64703
+ program.command("trend").description("AI Slop Score over time, from .slopbrick/flywheel/scans.jsonl (lower = cleaner since v0.21)").option("--max-points <n>", "how many recent scans to plot", parseCount, 30).option("--render <kind>", "output rendering: text | markdown", "text").action((cmdOptions) => {
64460
64704
  try {
64461
64705
  const cwd = process.cwd();
64462
64706
  const report = buildTrend(cwd, cmdOptions.maxPoints);
@@ -64464,7 +64708,14 @@ function registerTrend(program) {
64464
64708
  logger.info(out);
64465
64709
  if (report.delta > 1) {
64466
64710
  logger.info("");
64467
- logger.info("Warning: Slop Index increased by " + report.delta.toFixed(1) + " points over this period.");
64711
+ logger.info(
64712
+ // v0.42.0: the legacy 'Slop Index increased' message implied higher
64713
+ // = worse, which is the v0.15-v0.20.1 inverted reading. Since v0.21,
64714
+ // AI Slop Score is lower-is-better; an INCREASE is worse, but
64715
+ // the warning should say "AI Slop Score got worse" or just
64716
+ // "delta went up" so users don't have to remember the direction.
64717
+ `Warning: AI Slop Score got worse by ${report.delta.toFixed(1)} points over this period (lower = cleaner since v0.21).`
64718
+ );
64468
64719
  }
64469
64720
  } catch (error) {
64470
64721
  logger.error(error instanceof Error ? error.message : String(error));
@@ -65082,13 +65333,13 @@ function parseUnifiedDiffPaths(diff) {
65082
65333
  async function runPrScanFromDiffFile(cwd, config, diffPath, options = {}) {
65083
65334
  const threshold = options.threshold ?? config.prScoreThreshold ?? 20;
65084
65335
  const maxFiles = options.maxFiles ?? 500;
65085
- const { existsSync: existsSync32, readFileSync: readFileSync42 } = await import("fs");
65336
+ const { existsSync: existsSync31, readFileSync: readFileSync41 } = await import("fs");
65086
65337
  const { isAbsolute: isAbsolute4, resolve: resolvePath } = await import("path");
65087
- if (!existsSync32(diffPath)) {
65338
+ if (!existsSync31(diffPath)) {
65088
65339
  throw new Error(`Diff file not found: ${diffPath}`);
65089
65340
  }
65090
65341
  const diffAbs = isAbsolute4(diffPath) ? diffPath : resolvePath(cwd, diffPath);
65091
- const diff = readFileSync42(diffAbs, "utf-8");
65342
+ const diff = readFileSync41(diffAbs, "utf-8");
65092
65343
  const changedPaths = parseUnifiedDiffPaths(diff);
65093
65344
  if (changedPaths.length === 0) {
65094
65345
  return emptyPrResult(threshold);
@@ -65096,7 +65347,7 @@ async function runPrScanFromDiffFile(cwd, config, diffPath, options = {}) {
65096
65347
  return scoreChangedPaths(cwd, config, changedPaths, maxFiles, threshold);
65097
65348
  }
65098
65349
  async function scoreChangedPaths(cwd, config, paths, maxFiles, threshold) {
65099
- const { existsSync: existsSync32, statSync: statSync8 } = await import("fs");
65350
+ const { existsSync: existsSync31, statSync: statSync8 } = await import("fs");
65100
65351
  const { resolve: resolvePath } = await import("path");
65101
65352
  const { readFile: readFile4 } = await import("fs/promises");
65102
65353
  const { scanFile: scanFile2 } = await Promise.resolve().then(() => (init_worker(), worker_exports));
@@ -65108,7 +65359,7 @@ async function scoreChangedPaths(cwd, config, paths, maxFiles, threshold) {
65108
65359
  let constitutionViolations = 0;
65109
65360
  for (const rel of limited) {
65110
65361
  const abs = resolvePath(cwd, rel);
65111
- if (!existsSync32(abs) || !statSync8(abs).isFile()) continue;
65362
+ if (!existsSync31(abs) || !statSync8(abs).isFile()) continue;
65112
65363
  let source;
65113
65364
  try {
65114
65365
  source = await readFile4(abs, "utf-8");
@@ -65286,7 +65537,7 @@ var TEST_INCLUDE_GLOBS = [
65286
65537
  "**/__tests__/**/*.{ts,tsx,js,jsx}",
65287
65538
  "**/__fixtures__/**/*.{ts,tsx,js,jsx,json}"
65288
65539
  ];
65289
- async function runTestScan(cwd, config, options = {}) {
65540
+ async function runTestScan(cwd, _config, options = {}) {
65290
65541
  const include = TEST_INCLUDE_GLOBS;
65291
65542
  const cliOptions = {
65292
65543
  include,
@@ -66235,7 +66486,7 @@ function formatText2(report) {
66235
66486
  const label = CATEGORY_LABELS2[cat].padEnd(20);
66236
66487
  const count = String(stats.count).padStart(2);
66237
66488
  const baseline = String(stats.baseline).padStart(1);
66238
- const excess = stats.excess > 0 ? `\u26A0 +${stats.excess} over` : "\u2713 clean";
66489
+ const excess = stats.excess > 0 ? `\u26A0 +${stats.excess} over` : stats.count === 0 ? "\u2713 none" : "\u2713 within baseline";
66239
66490
  lines.push(` ${label} ${count} implementations (baseline ${baseline}) ${excess}`);
66240
66491
  }
66241
66492
  if (report.doNotCreate.length > 0) {
@@ -66293,7 +66544,7 @@ function formatMarkdown3(report) {
66293
66544
  for (const cat of PATTERN_CATEGORIES) {
66294
66545
  const stats = report.byCategory[cat];
66295
66546
  const label = CATEGORY_LABELS2[cat];
66296
- const excess = stats.excess > 0 ? `+${stats.excess}` : "\u2014";
66547
+ const excess = stats.excess > 0 ? `+${stats.excess}` : stats.count === 0 ? "\u2014" : "\xB7";
66297
66548
  lines.push(`| ${label} | ${stats.count} | ${stats.baseline} | ${excess} |`);
66298
66549
  }
66299
66550
  if (report.doNotCreate.length > 0) {
@@ -66718,8 +66969,9 @@ function formatFlywheel(summary, options = {}) {
66718
66969
  if (summary.firstRunAt && summary.latestRunAt) {
66719
66970
  lines.push(` Window: ${summary.firstRunAt} \u2192 ${summary.latestRunAt}`);
66720
66971
  }
66721
- lines.push(` Average slop index: ${summary.averageSlopIndex.toFixed(2)}`);
66722
- lines.push(` Latest slop index: ${summary.latestSlopIndex.toFixed(2)}`);
66972
+ const directionNote = " (lower = cleaner since v0.21.0)";
66973
+ lines.push(` Average AI Slop Score: ${summary.averageSlopIndex.toFixed(2)}`);
66974
+ lines.push(` Latest AI Slop Score: ${summary.latestSlopIndex.toFixed(2)}${directionNote}`);
66723
66975
  lines.push(` Average assembly health: ${summary.averageAssemblyHealth.toFixed(2)}`);
66724
66976
  lines.push(` Latest assembly health: ${summary.latestAssemblyHealth.toFixed(2)}`);
66725
66977
  if (summary.topViolations.length > 0) {
@@ -67066,7 +67318,7 @@ function applyVisualCodemods(filePath) {
67066
67318
  function collectAllFixes2(issue) {
67067
67319
  return [...issue.fix ? [issue.fix] : [], ...issue.fixes ?? []];
67068
67320
  }
67069
- async function applyFixes(report, config, scannedFiles) {
67321
+ async function applyFixes(report, _config, scannedFiles) {
67070
67322
  const byFile = /* @__PURE__ */ new Map();
67071
67323
  for (const issue of report.issues) {
67072
67324
  const fixes = collectAllFixes2(issue);
@@ -67127,7 +67379,6 @@ async function applyFixes(report, config, scannedFiles) {
67127
67379
  const result = applyLayoutTokenFix(filePath, group.replaces);
67128
67380
  const total = group.replaceApps.length;
67129
67381
  const appliedCount = Math.min(result.applied, total);
67130
- const skippedCount = total - appliedCount;
67131
67382
  applied.push(...group.replaceApps.slice(0, appliedCount));
67132
67383
  skipped.push(...group.replaceApps.slice(appliedCount));
67133
67384
  } catch (err) {
@@ -67322,6 +67573,40 @@ function formatGroupedHelp(cmd) {
67322
67573
  }
67323
67574
  }
67324
67575
  lines.push("");
67576
+ lines.push(
67577
+ "Commands (run `<command> --help` for per-command details):\n" + [
67578
+ " scan score a workspace, with severity gating and report views",
67579
+ " pr score a PR (or a unified-diff file via `--diff <file>`)",
67580
+ " drift detect constitution / forbidden-package violations",
67581
+ " test test quality sub-score (CI gate)",
67582
+ " docs documentation freshness sub-score",
67583
+ " security AI security-risk sub-score",
67584
+ " rules list all built-in rules with categories and severities",
67585
+ " explain show rationale, pattern, and remediation for a single rule",
67586
+ " suggest print per-issue remediation advice",
67587
+ " badge print a shields.io slop-index badge",
67588
+ " doctor check setup, config, and environment for common problems",
67589
+ " init create a slopbrick config file and AGENTS.md/CLAUDE.md snippets",
67590
+ " flywheel summarize aggregated scan telemetry",
67591
+ " composite empirical-composite rule discover / enable (Sprint 3 \xA73b)",
67592
+ " calibrate empirical precision/recall/F1 calibration from held-out corpora",
67593
+ " patterns pattern-fragmentation sub-score",
67594
+ " business-logic business-logic coherence sub-score",
67595
+ " maintenance-cost AI maintenance-cost sub-score",
67596
+ " architecture architecture-consistency sub-score",
67597
+ " memory show patterns detected for `slop_suggest`",
67598
+ " lock install a git pre-commit hook (idempotent)",
67599
+ " migrate v0.10.x slop-audit/ \u2192 v0.11.0+ .slopbrick/ migration",
67600
+ " validate-config validate slopbrick.config.mjs",
67601
+ " tokens merge a design-tokens.json layout values into the allowlist",
67602
+ " report re-render a saved JSON report (from --json <path>)",
67603
+ " install / uninstall manage the git pre-commit hook",
67604
+ " mcp run the MCP server (JSON-RPC 2.0 over stdio)",
67605
+ " watch / --watch re-run scan on file change",
67606
+ " trend sparkline of the last n runs"
67607
+ ].join("\n")
67608
+ );
67609
+ lines.push("");
67325
67610
  lines.push(
67326
67611
  "Use `--help-flat` for the standard un-grouped list. See `https://usebrick.dev/docs/scan-options` for full docs."
67327
67612
  );
@@ -67439,7 +67724,7 @@ process.on("uncaughtException", (err) => {
67439
67724
  });
67440
67725
  async function runCli({ start }) {
67441
67726
  try {
67442
- const program = new import_commander3.Command().name("slopbrick").description("Repository Coherence Scanner \u2014 surface AI-induced pattern drift, secret leaks, and design-token violations").version(VERSION).option("--framework <name>", "framework multiplier to apply").option("--include <glob>", "include pattern (repeatable)", collectGlob, []).option("--exclude <glob>", "exclude pattern (repeatable)", collectGlob, []).option("--ai-only", "only report AI-specific issues").option("--human-only", "only report human-facing issues").option("--ignore-wcag22", "ignore WCAG 2.2 related issues").option("--format <pretty|json|sarif|html>", "output format", "pretty").option("--threads <n>", "number of worker threads", parseThreads).option("--since <ref>", "only scan files changed since git ref").option("--diff <ref>", "alias for --since <ref>; also adds PR Slop Score to the report").option("--workspace <path>", "workspace/project path", process.cwd()).option("--tighten", "tighten baseline allowances").option("--fix", "apply auto-fixes").option("--dry-run", "with --fix: print what would change without writing").option("--show-fixes-diff", "print unified diff of proposed auto-fixes").option("--doctor", "run diagnostics").option("--watch", "watch files and re-run").option("--suggest", "print remediation advice").option("--why-failing", "print the top 5 rules dragging the score down").option("--brief", "terse output (verdict + headline + threshold + delta only)").option("--heatmap", "print migration ROI heatmap").option("--quiet", "suppress non-error output").option("--verbose", "enable debug logging (file paths, timings, rule-fire counts)").option("--strict", "exit 2 if any high-severity issue remains").option("--no-increase", "exit 2 if slop index increased since last run").option("--refresh-snippets", "rewrite the managed slopbrick block in AGENTS.md and CLAUDE.md after a scan").option("--baseline", "save a baseline after this scan").option("--trend [n]", "print a sparkline of the last n runs", parseTrend).option("--json [path]", "write JSON report to path or stdout").option("--html [path]", "write HTML report to path or stdout").option("--staged", "scan only changed files (staged and unstaged)").option("--changed", "scan working-tree changes (staged + unstaged + untracked)").option("--incremental", "skip unchanged files using the persisted hash cache").option("--cache-path <path>", "path to the incremental-scan cache (default: .slopbrick-cache.json)").option("--tokens <path>", "merge tokens.json layout values into the arbitrary-value allowlist").option("--cache", "cache parsed AST results locally").option("--no-color", "suppress ANSI color codes in output").option("--security-only", "run only the security/* rules").option("--full", "show the complete report (all issues, all categories)").option("--report-usage", "opt in to a one-shot usage ping to SLOPBRICK_TELEMETRY_ENDPOINT (no PII)");
67727
+ const program = new import_commander3.Command().name("slopbrick").description("Repository Coherence Scanner \u2014 surface AI-induced pattern drift, secret leaks, and design-token violations").version(VERSION).option("--framework <name>", "framework multiplier to apply").option("--include <glob>", "include pattern (repeatable)", collectGlob, []).option("--exclude <glob>", "exclude pattern (repeatable)", collectGlob, []).option("--ai-only", "only report AI-specific issues").option("--human-only", "only report human-facing issues").option("--ignore-wcag22", "ignore WCAG 2.2 related issues").option("--format <pretty|json|sarif|html>", "output format", "pretty").option("--threads <n>", "number of worker threads", parseThreads).option("--since <ref>", "only scan files changed since git ref").option("--diff <ref>", "alias for --since <ref>; also adds PR Slop Score to the report").option("--workspace <path>", "workspace/project path", process.cwd()).option("--tighten", "tighten baseline allowances").option("--fix", "apply auto-fixes").option("--dry-run", "with --fix: print what would change without writing").option("--show-fixes-diff", "print unified diff of proposed auto-fixes").option("--doctor", "run diagnostics").option("--watch", "watch files and re-run").option("--suggest", "print remediation advice").option("--why-failing", "print the top 5 rules dragging the score down").option("--brief", "terse output (verdict + headline + threshold + delta only)").option("--heatmap", "print migration ROI heatmap").option("--quiet", "suppress non-error output").option("--verbose", "enable debug logging (file paths, timings, rule-fire counts)").option("--strict", "exit 2 if any high-severity issue remains").option("--no-increase", "exit 2 if AI Slop Score increased since last run (lower = cleaner since v0.21)").option("--refresh-snippets", "rewrite the managed slopbrick block in AGENTS.md and CLAUDE.md after a scan").option("--baseline", "save a baseline after this scan").option("--trend [n]", "print a sparkline of the last n runs", parseTrend).option("--json [path]", "write JSON report to path or stdout").option("--html [path]", "write HTML report to path or stdout").option("--staged", "scan only changed files (staged and unstaged)").option("--changed", "scan working-tree changes (staged + unstaged + untracked)").option("--incremental", "skip unchanged files using the persisted hash cache").option("--cache-path <path>", "path to the incremental-scan cache (default: .slopbrick-cache.json)").option("--tokens <path>", "merge tokens.json layout values into the arbitrary-value allowlist").option("--cache", "cache parsed AST results locally").option("--no-color", "suppress ANSI color codes in output").option("--security-only", "run only the security/* rules").option("--full", "show the complete report (all issues, all categories)").option("--report-usage", "opt in to a one-shot usage ping to SLOPBRICK_TELEMETRY_ENDPOINT (no PII)");
67443
67728
  program.helpInformation = () => formatGroupedHelp(program);
67444
67729
  setExitOverride(program);
67445
67730
  registerInit(program);
@@ -67520,20 +67805,20 @@ async function runCli({ start }) {
67520
67805
  const gitHead = await getGitHead(cwd2) ?? "unknown";
67521
67806
  const cache = buildBaselineCache(report, configHash, gitHead, cwd2);
67522
67807
  saveBaseline(cwd2, cache);
67523
- if (!options.quiet) {
67808
+ if (!options.quiet && !machineReadableStdout) {
67524
67809
  logger.info(`Saved baseline to ${baselinePath(cwd2)}`);
67525
67810
  }
67526
67811
  }
67527
67812
  if (options.tighten && baseline) {
67528
67813
  saveBaseline(cwd, baseline);
67529
- if (!options.quiet) {
67814
+ if (!options.quiet && !machineReadableStdout) {
67530
67815
  logger.info(`Tightened baseline saved (revision ${baseline.baseline_revision}).`);
67531
67816
  }
67532
67817
  }
67533
- if (options.doctor && !options.quiet) {
67818
+ if (options.doctor && !options.quiet && !machineReadableStdout) {
67534
67819
  logger.info(`Doctor: bootstrap ${totalElapsed - scanElapsed}ms, scan ${scanElapsed}ms`);
67535
67820
  }
67536
- if (report.baseline && !options.quiet) {
67821
+ if (report.baseline && !options.quiet && !machineReadableStdout) {
67537
67822
  logger.info(baselineStatusMessage(report.baseline));
67538
67823
  }
67539
67824
  if (options.fix) {
@@ -67547,7 +67832,7 @@ async function runCli({ start }) {
67547
67832
  }
67548
67833
  const scannedFiles = results.map((r) => r.filePath);
67549
67834
  const fixResults = await applyFixes(report, config, scannedFiles);
67550
- const { totalApplied, totalSkipped, hasErrors } = printFixSummary(fixResults, options.quiet ?? false);
67835
+ const { hasErrors } = printFixSummary(fixResults, options.quiet ?? false);
67551
67836
  if (!options.showFixesDiff && !options.quiet) {
67552
67837
  logger.info("Tip: pass --show-fixes-diff to see the unified diff of proposed changes.");
67553
67838
  }
@@ -67587,8 +67872,18 @@ async function runCli({ start }) {
67587
67872
  if (options.staged && stagedGatingResult.reason) {
67588
67873
  logger.error(`Gating failure: ${stagedGatingResult.reason}`);
67589
67874
  } else {
67590
- const failed = failedThresholdCount(report, config);
67591
- logger.error(`${failed} threshold${failed === 1 ? "" : "s"} failed. See details above.`);
67875
+ const failedNames = failedThresholds(report, config);
67876
+ const detail = failedNames.map((name) => {
67877
+ if (name === "meanSlop") return `meanSlop (score ${report.aiSlopScore} > ${config.thresholds.meanSlop})`;
67878
+ if (name === "p90Slop") return `p90Slop (score ${report.p90Score} > ${config.thresholds.p90Slop})`;
67879
+ if (name === "individualSlopThreshold") return `individualSlopThreshold (peak ${report.peakScore} > ${config.thresholds.individualSlopThreshold})`;
67880
+ const cat = name.replace(/^category:/, "");
67881
+ const limit = config.thresholds.categoryThresholds?.[cat];
67882
+ const score = report.categoryScores[cat];
67883
+ return `${name} (score ${score} > ${limit})`;
67884
+ }).join(", ");
67885
+ const failed = failedNames.length;
67886
+ logger.error(`${failed} threshold${failed === 1 ? "" : "s"} failed: ${detail}`);
67592
67887
  }
67593
67888
  }
67594
67889
  if (!options.quiet && !machineReadableStdout) {
@@ -67686,6 +67981,7 @@ init_render();
67686
67981
  directoryScore,
67687
67982
  extractSignatures,
67688
67983
  failedThresholdCount,
67984
+ failedThresholds,
67689
67985
  filterByDisabledDirectives,
67690
67986
  filterIssues,
67691
67987
  findSimilarFunctions,