slopbrick 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.41.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;
@@ -46884,11 +47021,11 @@ var init_pool = __esm({
46884
47021
  for (let i = 0; i < this.threadCount; i++) {
46885
47022
  spawnWorker();
46886
47023
  }
46887
- return new Promise((resolve42) => {
47024
+ return new Promise((resolve43) => {
46888
47025
  const check = setInterval(() => {
46889
47026
  if (resolved) {
46890
47027
  clearInterval(check);
46891
- resolve42(results);
47028
+ resolve43(results);
46892
47029
  }
46893
47030
  }, 10);
46894
47031
  });
@@ -46905,23 +47042,16 @@ var init_registry = __esm({
46905
47042
  init_builtins();
46906
47043
  RuleRegistry = class {
46907
47044
  rules = /* @__PURE__ */ new Map();
46908
- register(ruleOrId, factory) {
46909
- if (typeof ruleOrId === "string") {
46910
- if (!factory) {
46911
- throw new Error("Factory is required when registering by id");
46912
- }
46913
- const rule = {
46914
- id: ruleOrId,
46915
- category: factory.category,
46916
- severity: factory.severity,
46917
- aiSpecific: factory.aiSpecific,
46918
- create: factory.create,
46919
- analyze: factory.analyze
46920
- };
46921
- this.rules.set(ruleOrId, rule);
46922
- } else {
46923
- this.rules.set(ruleOrId.id, ruleOrId);
46924
- }
47045
+ /**
47046
+ * Register a rule. The 2-arg overload `register(id, factory)` was
47047
+ * deleted in v0.42.0 (architecture review F6): no callers used it,
47048
+ * every `Rule` carries its own `id`, and the per-id registration
47049
+ * was ergonomically redundant with the 1-arg form (`register(rule)`).
47050
+ * External consumers that needed `register-by-id` semantics can
47051
+ * construct a `Rule` directly and pass it in.
47052
+ */
47053
+ register(rule) {
47054
+ this.rules.set(rule.id, rule);
46925
47055
  }
46926
47056
  loadBuiltins(onlyRuleId) {
46927
47057
  for (const rule of builtinRules) {
@@ -46950,6 +47080,15 @@ var init_registry = __esm({
46950
47080
  all() {
46951
47081
  return Array.from(this.rules.values());
46952
47082
  }
47083
+ /** v0.42.0 (§3b.5): look up a registered rule by id.
47084
+ * Returns undefined if not registered. */
47085
+ get(id) {
47086
+ return this.rules.get(id);
47087
+ }
47088
+ /** v0.42.0 (§3b.5): check whether a rule id is registered. */
47089
+ has(id) {
47090
+ return this.rules.has(id);
47091
+ }
46953
47092
  createContexts(config, filePath, cwd, hotspotIssues = []) {
46954
47093
  const context = {
46955
47094
  config,
@@ -50601,6 +50740,10 @@ var init_signal_strength2 = __esm({
50601
50740
  });
50602
50741
 
50603
50742
  // src/engine/worker.ts
50743
+ var worker_exports = {};
50744
+ __export(worker_exports, {
50745
+ scanFile: () => scanFile
50746
+ });
50604
50747
  function buildParserCacheConfig(cwd) {
50605
50748
  const envVal = process.env.SLOP_AUDIT_CACHE;
50606
50749
  const enabled = envVal === "1" || envVal === "true";
@@ -50677,6 +50820,36 @@ async function scanFile(filePath, config, registry, cwd = process.cwd()) {
50677
50820
  const issues = applyRuleOverrides(rawIssues, config.rules);
50678
50821
  const triggeredRuleIds = Array.from(new Set(issues.map((i) => i.ruleId)));
50679
50822
  const compScore = compositeScore(triggeredRuleIds, loadSignalStrength());
50823
+ const fireSet = new Set(triggeredRuleIds);
50824
+ const factsWithFireSet = Object.assign(facts, {
50825
+ compositeFireSet: fireSet
50826
+ });
50827
+ for (const { rule } of rules) {
50828
+ if (!rule.id.startsWith("composite/")) continue;
50829
+ try {
50830
+ const compositeIssues = rule.analyze(
50831
+ rule.create({
50832
+ config,
50833
+ filePath,
50834
+ cwd,
50835
+ framework: config.framework,
50836
+ uiLibraries: config.uiLibraries,
50837
+ hasTailwind: config.hasTailwind,
50838
+ supportsRsc: config.supportsRsc,
50839
+ hotspotIssues: []
50840
+ }),
50841
+ factsWithFireSet
50842
+ );
50843
+ if (compositeIssues.length > 0) {
50844
+ issues.push(...compositeIssues);
50845
+ for (const ci of compositeIssues) fireSet.add(ci.ruleId);
50846
+ }
50847
+ } catch (err) {
50848
+ if (process.env.SLOP_AUDIT_DEBUG === "1") {
50849
+ console.error(`[worker] composite ${rule.id} threw: ${err.message}`);
50850
+ }
50851
+ }
50852
+ }
50680
50853
  const gapValues = collectGapValues(facts);
50681
50854
  const styleSources = collectStyleSources(facts);
50682
50855
  const elementTags = facts.v2.jsx.elements.map((e) => e.tag);
@@ -50854,9 +51027,22 @@ function computeFlywheelOutput(runs, currentTopFiles, recentTopHashes, unmatched
50854
51027
  }
50855
51028
  }
50856
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;
50857
51042
  for (const file of currentTopFiles) {
50858
51043
  const consecutive = countConsecutiveTopFileAppearances(file.hash, recentTopHashes);
50859
51044
  if (consecutive >= CONSECUTIVE_THRESHOLD) {
51045
+ if (skipChronicOnDefaultOffNoise) continue;
50860
51046
  hotspotIssues.push({
50861
51047
  ruleId: "component/chronic-offender",
50862
51048
  category: "component",
@@ -50866,7 +51052,7 @@ function computeFlywheelOutput(runs, currentTopFiles, recentTopHashes, unmatched
50866
51052
  filePath: file.filePath,
50867
51053
  line: 1,
50868
51054
  column: 1,
50869
- 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."
50870
51056
  });
50871
51057
  }
50872
51058
  }
@@ -53200,16 +53386,16 @@ function axisSlopIndex(slopIndex) {
53200
53386
  if (slopIndex === void 0 || Number.isNaN(slopIndex)) {
53201
53387
  return {
53202
53388
  axis: "slopIndex",
53203
- label: "Slop Index",
53389
+ label: "AI Slop Score (raw)",
53204
53390
  health: 70,
53205
53391
  source: "default (not measured) \u2014 neutral 70"
53206
53392
  };
53207
53393
  }
53208
53394
  return {
53209
53395
  axis: "slopIndex",
53210
- label: "Slop Index",
53396
+ label: "AI Slop Score (raw)",
53211
53397
  health: clamp100(100 - slopIndex),
53212
- source: `100 - slopIndex (${slopIndex.toFixed(1)}) \u2192 inverted`
53398
+ source: `100 - aiSlopScore (${slopIndex.toFixed(1)}) \u2192 inverted to cleanliness`
53213
53399
  };
53214
53400
  }
53215
53401
  function axisArchitecture(architectureConsistency) {
@@ -54157,6 +54343,16 @@ function assembleScanReport(input) {
54157
54343
  enrichment
54158
54344
  } = input;
54159
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
+ );
54160
54356
  return {
54161
54357
  version: VERSION,
54162
54358
  generatedAt,
@@ -54212,6 +54408,11 @@ function assembleScanReport(input) {
54212
54408
  parseErrors: parseErrors.length > 0 ? parseErrors : void 0,
54213
54409
  baseline: baselineMeta,
54214
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_,
54215
54416
  topOffenders: topOffenders.length > 0 ? topOffenders : void 0,
54216
54417
  coherence: enrichment.coherence,
54217
54418
  coherenceBreakdown: enrichment.coherenceBreakdown,
@@ -54228,6 +54429,7 @@ var PR_SLOP_WEIGHTS;
54228
54429
  var init_assembleScanReport = __esm({
54229
54430
  "src/cli/report/assembleScanReport.ts"() {
54230
54431
  "use strict";
54432
+ init_threshold();
54231
54433
  init_types();
54232
54434
  PR_SLOP_WEIGHTS = { high: 10, medium: 5, low: 1 };
54233
54435
  }
@@ -54542,29 +54744,29 @@ function renderStructureMarkdown(inventory, constitution) {
54542
54744
  return lines.join("\n");
54543
54745
  }
54544
54746
  async function writeStructureMarkdown(workspaceDir, md) {
54545
- await new Promise((resolve42, reject) => {
54747
+ await new Promise((resolve43, reject) => {
54546
54748
  try {
54547
54749
  const path = (0, import_node_path23.join)(workspaceDir, STRUCTURE_MD_FILE);
54548
54750
  (0, import_node_fs21.mkdirSync)((0, import_node_path23.dirname)(path), { recursive: true });
54549
54751
  (0, import_node_fs21.writeFileSync)(path, md, "utf-8");
54550
- resolve42();
54752
+ resolve43();
54551
54753
  } catch (err) {
54552
54754
  reject(err instanceof Error ? err : new Error(String(err)));
54553
54755
  }
54554
54756
  });
54555
54757
  }
54556
54758
  async function readStructureMarkdown(workspaceDir) {
54557
- return new Promise((resolve42) => {
54759
+ return new Promise((resolve43) => {
54558
54760
  try {
54559
54761
  const path = (0, import_node_path23.join)(workspaceDir, STRUCTURE_MD_FILE);
54560
54762
  if (!(0, import_node_fs21.existsSync)(path)) {
54561
- resolve42(null);
54763
+ resolve43(null);
54562
54764
  return;
54563
54765
  }
54564
54766
  const content = (0, import_node_fs21.readFileSync)(path, "utf-8");
54565
- resolve42(content);
54767
+ resolve43(content);
54566
54768
  } catch {
54567
- resolve42(null);
54769
+ resolve43(null);
54568
54770
  }
54569
54771
  });
54570
54772
  }
@@ -54614,6 +54816,388 @@ var init_structure_md = __esm({
54614
54816
  }
54615
54817
  });
54616
54818
 
54819
+ // src/snippet/data.ts
54820
+ var CATEGORY_DIRECTIVES, RULE_HINTS;
54821
+ var init_data = __esm({
54822
+ "src/snippet/data.ts"() {
54823
+ "use strict";
54824
+ CATEGORY_DIRECTIVES = {
54825
+ visual: 'Avoid the saturated "vibe purple" Tailwind palette (violet-400-700, indigo-400-700). Prefer emerald, sky, amber, rose for accents. Never use arbitrary color values like bg-[#7c3aed] when a token exists.',
54826
+ logic: "Never use explicit `any`. Use `unknown` and narrow. Always add an AbortSignal to fetches. Handle errors with try/catch, never swallow with empty catch. Use `as const` instead of `as Type` casts.",
54827
+ wcag: 'All form inputs must have an accessible label (visible <label>, aria-label, or aria-labelledby). Decorative images must have empty alt="". Buttons must be <button>, never <div onClick>. Touch targets must be \u2265 24\xD724 CSS px.',
54828
+ security: 'Never store tokens in localStorage or sessionStorage \u2014 use httpOnly Secure SameSite cookies. Never put secrets in NEXT_PUBLIC_* / REACT_APP_* / VITE_* env vars. Validate e.origin in postMessage handlers. Never dangerouslySetInnerHTML with user input. Never use target="_blank" without rel="noopener".',
54829
+ perf: "Always use AbortController with fetch. Use image width/height attributes to prevent CLS. Use <Suspense> around async client components. Don't load all images eagerly.",
54830
+ typo: `Never leave TODO / placeholder / "change me" copy in shipped code. Use real i18n strings or the project's content map.`,
54831
+ layout: "Don't stack badge-above-h1 hero patterns. Don't build 3-stat banner rows without explicit user request. Don't use emoji inside nav items (use SVG icons). Use the project's spacing scale (4px or 8px grid), never arbitrary values like p-[13px].",
54832
+ component: "Don't build components > 200 lines. Extract shared subcomponents. Avoid circular prop drilling \u2014 use context.",
54833
+ arch: "For Astro: server-render everything by default; only opt-in to client islands when you need interactivity. Don't put secrets in client-side code.",
54834
+ test: "Use domain-specific fixture data, assert on value shapes not just truthiness, and consolidate repeated setup into helpers. Avoid `expect(x).toBeDefined()` placeholders and textbook fixtures like 'John Doe' or 'test@test.com'."
54835
+ };
54836
+ RULE_HINTS = {
54837
+ // v0.38.0 hygiene: 37 v10-DORMANT rule hints were removed. Every
54838
+ // remaining key matches a rule in src/rules/builtins.ts. No orphans.
54839
+ // (v0.16.0 previously moved 35 out-of-scope orphan hints to
54840
+ // docs/research/backlog-rule-hints.md
54841
+ // for future implementers to paste back when the corresponding rules ship.)
54842
+ "security/hardcoded-secret": "Never inline API keys, JWT secrets, or database passwords in source. Load them from env vars and never commit a .env file. Assume any published secret is compromised and rotate it.",
54843
+ "security/exposed-env-var": "NEVER prefix a secret with NEXT_PUBLIC_, VITE_, REACT_APP_, EXPO_PUBLIC_, GATSBY_, or PUBLIC_ \u2014 those vars are inlined into every browser build.",
54844
+ "security/dangerous-cors": "Don't set Access-Control-Allow-Origin: * on production endpoints. Restrict to an explicit allowlist; never combine wildcard origin with credentials: true.",
54845
+ "security/missing-auth-check": "Every server route handler must perform an authentication + authorization check at the top. Reachability of an endpoint by any user (authenticated or not) is a vulnerability, not a feature.",
54846
+ "security/unsafe-html-render": "Sanitize any non-literal value passed to dangerouslySetInnerHTML with DOMPurify. Better: avoid the prop entirely and let React escape via children.",
54847
+ "security/fail-open-auth": "Don't gate auth bypasses on NODE_ENV. Replace dev-env checks with an explicit AUTH_BYPASS flag that's never set in production.",
54848
+ "security/sql-construction": 'Never build SQL with string concatenation or template-literal interpolation. Use parameterized queries: pg client.query("... WHERE id = $1", [id]) or your ORM query builder.',
54849
+ "security/public-admin-route": "Routes under /admin, /internal, /debug, /staff, /manage, /private need an explicit role check on top of standard auth \u2014 auth alone is not enough for privileged paths.",
54850
+ "visual/arbitrary-escape": "Never use bracket-notation values like text-[13px] or bg-[#7c3aed]. Use design tokens instead.",
54851
+ "visual/spacing-scale-violation": "Use spacing scale tokens (p-2, gap-4, etc.) instead of arbitrary values like p-[13px] or gap-[1.75rem].",
54852
+ "visual/radius-scale-violation": "Use radius scale tokens (rounded-md, rounded-lg, etc.) instead of arbitrary values like rounded-[7px].",
54853
+ // v0.16.0 — in-scope orphans kept here (corresponding rule ships in v0.16.0).
54854
+ "typo/placeholder-text": 'Never leave "TODO", "placeholder", "change me", "your text here" in shipped UI.',
54855
+ "logic/key-prop-missing": "Always provide a stable `key` prop when rendering lists.",
54856
+ "logic/boundary-violation": "Don't import data-layer / DB code into UI components. Server-side only.",
54857
+ "wcag/missing-alt": 'Every <img> needs alt text. Decorative: alt="". Informative: describe the image.',
54858
+ "security/localstorage-token": "Never store JWT / access token / refresh token in localStorage or sessionStorage. Issue as httpOnly cookie.",
54859
+ "security/eval": "Never use eval() or new Function(). These are RCE vectors if the input is ever attacker-controlled.",
54860
+ "security/target-blank-no-noopener": 'Always add rel="noopener" (or rel="noreferrer") to target="_blank" links.',
54861
+ "component/giant-component": "Don't build components > 200 lines. Extract shared subcomponents.",
54862
+ "component/multiple-components-per-file": "One component per file. Move subcomponents into their own files so the Context Window stays small and boundary tests are easy.",
54863
+ "component/shadcn-prop-mismatch": "Select shadcn variants via the `variant` prop, not long `className` overrides. See the component registry for available variants.",
54864
+ "context/import-path-mismatch": "Use only the canonical import paths declared in brick.config.json (e.g. @/components/ui/, @/lib/).",
54865
+ "layout/gap-monopoly": "Mix gap-2 / gap-4 / gap-6 / gap-12 deliberately. Don't repeat the same gap value across the whole project.",
54866
+ "layout/math-element-uniformity": "Human files have lopsided interactive counts (1 button + 12 inputs). AI tends to balance them. Build forms with many inputs and few buttons.",
54867
+ "layout/math-grid-uniformity": "Vary grid-cols-N (grid-cols-2, grid-cols-3, grid-cols-4, grid-cols-6) across sections instead of repeating grid-cols-3.",
54868
+ "layout/spacing-grid": "Use the configured spacing scale (4px or 8px grid). Avoid arbitrary values like p-[13px] that aren't on the scale.",
54869
+ "logic/ghost-defensive": "Use optional chaining (?.) or early returns instead of deep && guards. If a defensive chain runs 3+ levels deep, refactor.",
54870
+ "logic/heaps-deviation": "Inspect for LLM-style vocabulary patterns: this file's vocabulary grows faster (high Heaps \u03BB) or slower (low \u03BB) than typical source code. Verify authorship if unexpected. (v0.12.0 \u2014 Christ et al. EMNLP Findings 2025.)",
54871
+ "logic/ks-distribution-shift": "Inspect the shifted features. KS detects both AI anomalies and production-rot anomalies (it is symmetric); combine with Heaps/Zipf for AI-specific signal. (v0.12.0 \u2014 arXiv:2510.15996, Oct 2025.)",
54872
+ "logic/zipf-slope-anomaly": "Inspect for LLM-style frequency distribution: this file's identifier usage is more peaked or flatter than typical human code. (v0.12.0 \u2014 Christ et al. EMNLP Findings 2025.)",
54873
+ "logic/math-any-density": "Replace `: any` with proper types. Start with the parameter/return types of the most-used functions.",
54874
+ "logic/math-console-log-storm": "Replace debug logs with a proper debugger or logger.debug(). Remove all console.log before shipping.",
54875
+ "logic/math-gini-class-usage": "Spread usage across more class tokens instead of repeating the same handful (p-4, p-8, rounded-lg, etc.).",
54876
+ "logic/math-variable-name-entropy": "Use domain-specific identifier names (reservations, invoices, customers) instead of generic data/items/value.",
54877
+ "logic/optimistic-no-rollback": "In optimistic updates, revert state in the catch block: `setX(prev => prev)`. Never leave stale UI on error.",
54878
+ "logic/reactive-hook-soup": "Coordinate state via a single derived value (useMemo) or a state machine. Avoid chained useEffects that sync local state.",
54879
+ "logic/zombie-state": "Remove unused useState or wire it into the component. Don't leave declared-but-never-read state bindings.",
54880
+ "dead/unused-import": "Remove the import or use the symbol somewhere. Unused imports are the most common AI-iteration signature \u2014 the model added it for a feature, then rewrote the function without cleaning up.",
54881
+ "dead/unused-local": "Remove the declaration or use the variable. AI-iteration signature: the model declared a binding for a feature, then rewrote the function without cleaning up.",
54882
+ "dead/unused-parameter": "Remove the parameter (and update every call site) or use it in the function body. AI-iteration signature: the model added a parameter for a feature, then rewrote the function without removing parameters the new code does not need.",
54883
+ "dead/dead-branch": "Replace the literal boolean with a real condition, or remove the dead branch. AI-iteration signature: a feature flag toggled to a constant, or a wrapper from a previous refactor that was never cleaned up.",
54884
+ "dead/unreachable": "Remove this statement \u2014 code after a return/throw/break/continue is unreachable. AI-iteration signature: the model added an early return for a new error path, then forgot the rest of the function body was still sitting below it.",
54885
+ "perf/cls-image": "Add width/height attributes or an aspect-ratio utility to prevent layout shift.",
54886
+ "perf/css-bloat": "Extract to a CSS variable (`--surface-card`) or a component prop when a class string repeats 5+ times.",
54887
+ "typo/math-button-label-uniformity": 'Mix button lengths deliberately \u2014 pair a short "Save" with a longer "Mark as complete" \u2014 instead of repeating the same template.',
54888
+ "visual/inline-style-dominance": "Replace inline `style={{...}}` with className utilities (e.g. Tailwind `p-4 m-2 gap-3`) or a CSS module class.",
54889
+ "visual/math-default-font": "Import a distinctive font (next/font/google, @font-face, or a CSS variable) instead of relying on the framework default.",
54890
+ "visual/math-font-entropy": "Use a wider range of text sizes (text-xs, text-sm, text-lg, text-xl, text-2xl, text-3xl) for a more deliberate type scale.",
54891
+ "visual/math-rounded-entropy": "Use a wider range of border-radius values (sm, md, 2xl, 3xl) instead of repeating the same lg/xl/full pattern.",
54892
+ "visual/math-spacing-entropy": "Mix more spacing values from the design scale (e.g. 3, 5, 7, 10, 14, 20, 28) instead of repeating the same 4/8 pattern.",
54893
+ "visual/naturalness-anomaly": "Use domain-specific identifier names so the identifier stream reflects the actual problem domain. Hindle 2012 \xA74.3: LLM-generated code reuses a narrow band of training-data identifiers, dropping distinct-token ratio below 30%.",
54894
+ "visual/math-color-cluster": "Use at least 3 distinct hue families (e.g. blue + amber + green) instead of clustering every color in the violet/fuchsia band.",
54895
+ "wcag/focus-appearance": "Add a focus-visible:ring-* class, or remove outline-none. Keyboard users need a visible focus indicator.",
54896
+ "wcag/focus-obscured": "Ensure focused elements are not hidden behind fixed or sticky wrappers.",
54897
+ "test/weak-assertion": "Assert on a specific value or shape: `expect(x).toEqual(expectedValue)`. Avoid `.toBeDefined()` / `.toBeTruthy()` placeholders and tautological `expect(x).toBe(x)`.",
54898
+ "test/duplicate-setup": "Extract shared `beforeEach` / `setupServer` blocks into a single helper (e.g. `renderWithProviders`) so each describe block calls it instead of repeating setup.",
54899
+ "test/fake-placeholder": "Use domain-specific fixture values (`alice@acme-corp.com`, `Order#48231`) or a factory like @faker-js/faker. Avoid textbook placeholders (`John Doe`, `test@test.com`, `id: 1`).",
54900
+ "product/terminology-drift": "Keep the leading noun consistent across files: `PostList`, `PostDetail`, `PostCard` are one entity, not three. AI agents pick slightly different words each invocation; product copy drifts.",
54901
+ "product/ux-pattern-fragmentation": "Keep the per-category count tight: modal \u22643, toast \u22642, button \u22644, input \u22643, card \u22643. Pick the canonical one and alias the rest. `slopbrick patterns` reports the per-category count.",
54902
+ // v0.13.0 — AI-specific rules (peer-reviewed signals).
54903
+ "ai/markdown-leakage": "Delete stray `\\`\\`\\`<lang>\\`\\`\\`` markers; they are Markdown fences, not valid syntax in standalone source files (Yotkova et al. SemEval-2026).",
54904
+ "ai/comment-ratio": "AI tools either skip comments (reductive models) or over-comment (expansive models). Match the corpus mean \xB1 2\u03C3 (Rahman et al. 2024, Bisztray et al. 2025).",
54905
+ "ai/whitespace-regularity": "Vary inter-token spacing (single spaces mostly, occasional alignment in tables). Uniform runs are an AI tell (Shi et al. DetectCodeGPT 2024).",
54906
+ "ai/text-like-ratio": "Move natural-language explanations to README files or doc comments. Inline prose in source code is hard to maintain (Yotkova 2026).",
54907
+ "ai/errors-near-eof": "Check whether the file was truncated by a token limit. Unbalanced delimiters near EOF suggest the model ran out of output budget (Yotkova 2026).",
54908
+ "ai/any-density": "Replace `any` with `unknown`, `Record<string, unknown>`, or a domain type. The `: any` annotation propagates type-errors and defeats TS safety (Lee, Hassan, Hindle MSR 2026).",
54909
+ "ai/renyi-profile": "The token distribution is mass-concentrated on a few high-frequency tokens. Verify authorship if unexpected (R\xE9nyi 1961, Moslonka 2025).",
54910
+ "ai/segment-surprisal-cv": "The cross-entropy is suspiciously uniform across the file. Real codebases have varied registers (Binoculars, Hans 2024).",
54911
+ "ai/compression-profile": "The file compresses unusually well and lines are highly repetitive \u2014 characteristic of AI-generated boilerplate (Cilibrasi 2005, Mahoney 1999).",
54912
+ // v0.14.5b — 6 new AI tendency detection rules (DORMANT in v0.14.5b;
54913
+ // reclassified post-v7 calibration in v0.14.5d)
54914
+ "ai/tailwind-color-overuse": "If most utility classes are bg-violet-500, text-violet-600, ring-violet-400 \u2014 the project is on the AI-default palette. Audit and replace with the project's design tokens.",
54915
+ "ai/default-react-stack": "Every new file is a Vite + React + Tailwind + Zustand + React Hook Form clone. Verify the project actually needs each piece before adding it.",
54916
+ "ai/library-reinvention": "Re-implementing zustand, react-hook-form, or date-fns inline (custom event emitters, useState reducers, manual date math) is a sign of LLM completion-mode code. Use the libraries the project already depends on.",
54917
+ "ai/state-default-overuse": "Wrapping every component in useState + useEffect for transient UI state is the React tutorial default. Real production code uses refs, uncontrolled inputs, or the project's state lib.",
54918
+ "ai/fetch-default-overuse": "Calling fetch() inline in components instead of going through the project's data-fetching layer (react-query, swr, or your own client) bypasses the cache, error boundary, and abort handling.",
54919
+ "ai/console-debug-storm": "5+ console.log calls in a single file is debug-by-print-statement, the LLM training-data default. Remove before commit; use the project's logger or a real debugger.",
54920
+ // v0.17.0 — db/* rules (Postgres static analysis via pgsql-parser)
54921
+ "db/sql-concat": 'Never build SQL with template-literal interpolation \u2014 `db.query(`SELECT \u2026 WHERE id = ${id}`)` is a SQL injection vector. Use parameterized queries (`db.query("\u2026 WHERE id = $1", [id])`) or your ORM query builder.',
54922
+ // v0.17.0 — docs/* rules (markdown drift detection)
54923
+ "docs/stale-package-reference": "Update the doc to reference an installed package, or add the package to package.json. Copy-pasted install commands from a previous project are the #1 doc-drift failure mode.",
54924
+ "docs/stale-function-reference": "Rename the doc reference to match a current export, or add a wrapper export. Stale function callouts in tutorials cost readers 10+ minutes of debugging.",
54925
+ "docs/broken-link": "Create the file or fix the link target. On a public docs site, broken links erode trust more than stale copy.",
54926
+ // v0.18.9 — Rust-specific rules (tree-sitter-backed)
54927
+ "rust/unused-pub-fn": "Remove the function or call it. Rust's compiler doesn't warn on `pub fn` references unless `#![warn(dead_code)]` is set at the crate root. AI agents leave these behind during iterative refactors.",
54928
+ "rust/unwrap-in-production": "Replace `.unwrap()` / `.expect()` with `?` (early-return on Err), `.map_err(...)` for error conversion, or an explicit `match`. They panic in production; reserve them for `#[cfg(test)]` or `#[test]` scopes.",
54929
+ "rust/todo-macro": "Implement the function body or remove the stub. `todo!()` / `unimplemented!()` both expand to `panic!()`, so a hit at runtime crashes the program. Move them into a `#[cfg(test)] mod tests` block to suppress.",
54930
+ "rust/stringly-typed": 'Replace the `String` / `&str` parameter with the typed enum that already exists in the file. Stringly-typed APIs lose type information at the boundary \u2014 typos (`"Click"` vs `"click"`) only fail at runtime.',
54931
+ // v0.19.0 — TypeScript-specific rules
54932
+ "ts/enum-vs-as-const": "Replace `enum Foo { A, B }` with `const Foo = { A: 'A', B: 'B' } as const` (or `const Foo = ['A', 'B'] as const`). Modern TS style guides (Google, TS-eslint) prefer `as const` because enums have surprising runtime semantics.",
54933
+ "ts/import-type-misuse": "Use `import type { X } from '...'` instead of `import { type X } from '...'`. The split form is more common in real codebases and makes the type-only intent unambiguous.",
54934
+ "ts/never-vs-unknown": "The `never` return type means 'this function never returns'. Reserve it for functions that always throw, always loop, or always exit. For 'impossible' branches, use a concrete type (`void`, `Error`, `unknown`) and an exhaustive check.",
54935
+ "ts/excessive-type-assertion": "More than 3 `as` assertions in one function is a strong signal that the type is wrong, not the code. Fix the type definition (or use a type guard) instead of bypassing the type system.",
54936
+ // v0.19.0 — Go-specific rules
54937
+ "go/struct-tag-inconsistency": 'Pick one tag style per struct. If most fields are `json:"foo"`, this field should be too. Real Go code maintains consistency within a struct (or within a package).',
54938
+ "go/nil-slice-vs-empty": "Either declare as `var x = []int{}` or assign with `make([]int, 0)`. The nil/empty inconsistency is an AI signal \u2014 real code picks one form and sticks with it.",
54939
+ // v0.19.0 — Duplication detector
54940
+ "dup/identical-block": "Refactor to a shared helper. This is a Type-1 clone (byte-for-byte identical after normalization). Common in AI-generated code that copy-pastes from training data.",
54941
+ // v0.23.0 — Type-2 near-duplicate detector (MinHash + LSH)
54942
+ "dup/near-duplicate": "Refactor to a shared helper. Type-2 near-duplicate: same identifiers + structure, different whitespace/comments. AI-iterated code regenerates the same function with cosmetic edits. (v0.23.0 \u2014 MinHash + LSH, k=5, thr 0.7.)",
54943
+ // v0.24.0 — Type-3 structural clone detector (two-stage MinHash)
54944
+ "dup/structural-clone": "Refactor to a shared helper. Type-3 clone: same shape as another file after identifier canonicalization, with renames and/or added/removed statements. AI agents and copy-paste both produce this. Extract the shared part. (v0.24.0.)",
54945
+ // v0.24.0 — Kotlin rules (DORMANT until v9 Kotlin corpus calibration)
54946
+ // v0.29.0 — non-AI Kotlin rules (DORMANT until larger pos arm)
54947
+ // v0.30.0 — non-AI Java rules (Option C applied to v9 Java corpus, 92k files)
54948
+ "java/sql-string-concat": "Use a PreparedStatement (JDBC), setParameter (jOOQ), or an ORM (Hibernate, MyBatis with #{}). String concat into SQL is the canonical SQL-injection pattern. OWASP A03:2021. (v0.30.0 \u2014 DORMANT, ratio 0.59.)",
54949
+ "java/thread-sleep-in-loop": "Use ScheduledExecutorService for periodic work, or BlockingQueue.take() for event-driven work. Thread.sleep in a loop is the polling anti-pattern \u2014 ties up Tomcat/Jetty/Netty threads. (v0.30.0 \u2014 DORMANT, era-confounded ratio 0.97.)",
54950
+ // v0.35.0 — content-based detection (CoCoNUTS-inspired)
54951
+ "java/suspicious-implementation": "Function name claims validate/encrypt/hash/sanitize but body is empty, returns null/true, or returns the input. This is a content mismatch \u2014 the function's claimed behavior doesn't match its actual behavior. OWASP A04:2021.",
54952
+ // v0.35.1 — Raidar-inspired content-based detection
54953
+ "java/lost-stack-trace": 'catch block throws a new exception without the original cause \u2014 stack trace is lost. Pass the original exception as the second arg to the new exception\'s constructor: `throw new XxxException("msg", e)`.',
54954
+ // v0.24.0 — Swift rules (DORMANT until v9 Swift corpus calibration)
54955
+ "swift/force-unwrap": "Replace with the safe form: `as?` + guard/if let, `try?`, or `guard let x = optional else { return }`. `!` crashes unconditionally in release. (v0.24.0 \u2014 DORMANT.)",
54956
+ "swift/print-debug": "Replace with `Logger(subsystem:..., category:...).info(...)` (os.log). `print` writes to stdout with no level and no redaction. (v0.24.0 \u2014 DORMANT.)",
54957
+ "swift/fatal-error-thrown": "Replace with `return` of a typed default (e.g. `nil`, `[]()`) or throw a typed `enum MyError: Error`. `fatalError` survives release builds. (v0.24.0 \u2014 DORMANT.)",
54958
+ "swift/implicitly-unwrapped-optional": "Declare as `var name: Type?` and unwrap with `guard let`. IUOs exist for Obj-C bridging / IBOutlets but using them everywhere is an AI signal. (v0.24.0 \u2014 DORMANT.)",
54959
+ "swift/strong-self-capture": "Capture with `[weak self]` (or `[unowned self]` if guaranteed non-nil): `[weak self] in self?.foo = bar`. Strong self capture creates a retain cycle. (v0.24.0 \u2014 DORMANT.)",
54960
+ // v0.24.0 — C++ rules (DORMANT until v9 C++ corpus calibration)
54961
+ "cpp/raw-new-delete": "Replace with `auto p = std::make_unique<T>(...)`. Smart pointers delete on scope exit, never leak on early return / exception, never double-free. (v0.24.0 \u2014 DORMANT.)",
54962
+ "cpp/c-style-cast": "Use the named cast: `static_cast<int>(x)`, `reinterpret_cast<...>(p)`, `const_cast<...>(ref)`. C-style casts silently pick static / reinterpret / const \u2014 impossible to grep. (v0.24.0 \u2014 DORMANT.)",
54963
+ "cpp/printf-debug": "Replace with `spdlog::info(...)`, `LOG(INFO) << ...` (glog), or `ABSL_LOG(INFO) << ...`. `printf` / `std::cout` have no levels, no redaction, no sink routing. (v0.24.0 \u2014 DORMANT.)",
54964
+ "cpp/magic-numbers": "Replace with `constexpr int MAX = 1024;` (or `static constexpr`). Named constants live next to their value, can be searched, force the reader to mean what they say. (v0.24.0 \u2014 DORMANT.)"
54965
+ };
54966
+ }
54967
+ });
54968
+
54969
+ // src/snippet/generators.ts
54970
+ function wrapWithMarkers(level, body) {
54971
+ const head = level === 1 ? "<!-- slopbrick-managed-section -->\n" : "";
54972
+ return `${head}${MARKER_BEGIN}
54973
+ ${body}
54974
+ ${MARKER_END}
54975
+ `;
54976
+ }
54977
+ function generateCursorSnippet(rules) {
54978
+ const lines = [];
54979
+ lines.push("# slopbrick cursor rules");
54980
+ lines.push("");
54981
+ lines.push("Generated by `slopbrick init --cursor`. These rules describe patterns that slopbrick");
54982
+ lines.push("flags as AI-generated frontend slop. Avoid them when writing code in this project.");
54983
+ lines.push("");
54984
+ lines.push("## Category-level directives");
54985
+ lines.push("");
54986
+ for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
54987
+ lines.push("- **" + category + "**: " + directive);
54988
+ }
54989
+ lines.push("");
54990
+ lines.push("## Per-rule directives");
54991
+ lines.push("");
54992
+ const ai = aiSpecificRules(rules);
54993
+ const byCategory = categorizeRules(ai);
54994
+ for (const [category, list] of byCategory) {
54995
+ lines.push("### " + category);
54996
+ lines.push("");
54997
+ for (const rule of list) {
54998
+ const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
54999
+ lines.push("- **`" + rule.id + "`** (" + rule.severity + "): " + hint);
55000
+ }
55001
+ lines.push("");
55002
+ }
55003
+ lines.push("## Verification");
55004
+ lines.push("");
55005
+ lines.push("After writing code, run `slopbrick scan` and check for issues in any of the categories above.");
55006
+ lines.push("Use `slopbrick fix` for auto-fixable rules. Use `slopbrick mcp` from MCP-aware clients to query rules and scan files programmatically.");
55007
+ lines.push("");
55008
+ return lines.join("\n");
55009
+ }
55010
+ function generateAgentsMdSnippet(rules) {
55011
+ return wrapWithMarkers(
55012
+ 1,
55013
+ baseContent(
55014
+ rules,
55015
+ "AGENTS.md",
55016
+ "Generated by `slopbrick init --agents-md`. Read these directives when writing or modifying code in this project. AGENTS.md is the de facto standard read by OpenAI Codex, opencode, Pi, Cline, Continue, Gemini, Claude Code (fallback), and others."
55017
+ ) + "## How to verify\n\nAfter editing code, run `slopbrick scan`. Any issue with a category in this file is something to fix.\n"
55018
+ );
55019
+ }
55020
+ function generateClaudeMdSnippet(rules) {
55021
+ return wrapWithMarkers(
55022
+ 1,
55023
+ baseContent(
55024
+ rules,
55025
+ "CLAUDE.md",
55026
+ "Generated by `slopbrick init --claude-md`. Read this file at the start of every Claude Code session in this project. CLAUDE.md takes precedence over AGENTS.md for Claude Code."
55027
+ ) + "## Verification\n\nRun `slopbrick scan` after edits. If MCP is configured (`slopbrick mcp`), use `slop_scan_file` proactively to check before reporting code as done.\n"
55028
+ );
55029
+ }
55030
+ function generateAiderSnippet(rules) {
55031
+ return baseContent(
55032
+ rules,
55033
+ "CONVENTIONS.md",
55034
+ "Generated by `slopbrick init --aider`. Aider reads CONVENTIONS.md (not AGENTS.md) for project conventions."
55035
+ );
55036
+ }
55037
+ function generateWindsurfSnippet(rules) {
55038
+ return baseContent(
55039
+ rules,
55040
+ ".windsurfrules",
55041
+ "Generated by `slopbrick init --windsurf`. Windsurf Cascade reads .windsurfrules at project root."
55042
+ );
55043
+ }
55044
+ function generateClineRules(rules) {
55045
+ return baseContent(
55046
+ rules,
55047
+ "AGENTS.md",
55048
+ "Generated by `slopbrick init --cline`. Cline reads .clinerules/AGENTS.md from the project root."
55049
+ );
55050
+ }
55051
+ function generateCursorrulesLegacySnippet(rules) {
55052
+ return baseContent(
55053
+ rules,
55054
+ ".cursorrules",
55055
+ "Generated by `slopbrick init --cursorrules`. This is the legacy Cursor rule format. Prefer `.cursor/rules/slopbrick.mdc` (use --cursor) for new projects."
55056
+ );
55057
+ }
55058
+ function generateGeminiSnippet(rules) {
55059
+ return baseContent(
55060
+ rules,
55061
+ "GEMINI.md",
55062
+ "Generated by `slopbrick init --gemini`. Gemini CLI reads .gemini/GEMINI.md from project root."
55063
+ );
55064
+ }
55065
+ function generateCopilotSnippet(rules) {
55066
+ return baseContent(
55067
+ rules,
55068
+ "copilot-instructions.md",
55069
+ "Generated by `slopbrick init --copilot`. GitHub Copilot reads .github/copilot-instructions.md from project root."
55070
+ );
55071
+ }
55072
+ var MARKER_BEGIN, MARKER_END;
55073
+ var init_generators = __esm({
55074
+ "src/snippet/generators.ts"() {
55075
+ "use strict";
55076
+ init_data();
55077
+ init_render2();
55078
+ MARKER_BEGIN = "<!-- slopbrick:begin:v3 -->";
55079
+ MARKER_END = "<!-- slopbrick:end:v3 -->";
55080
+ }
55081
+ });
55082
+
55083
+ // src/snippet/render.ts
55084
+ function aiSpecificRules(rules) {
55085
+ return rules.filter((r) => r.aiSpecific).sort((a, b) => a.id.localeCompare(b.id));
55086
+ }
55087
+ function categorizeRules(rules) {
55088
+ const byCategory = /* @__PURE__ */ new Map();
55089
+ for (const r of rules) {
55090
+ const list = byCategory.get(r.category) ?? [];
55091
+ list.push(r);
55092
+ byCategory.set(r.category, list);
55093
+ }
55094
+ for (const list of byCategory.values()) {
55095
+ list.sort((a, b) => a.id.localeCompare(b.id));
55096
+ }
55097
+ return byCategory;
55098
+ }
55099
+ function baseContent(rules, toolLabel, intro) {
55100
+ const lines = [];
55101
+ lines.push("# " + toolLabel);
55102
+ lines.push("");
55103
+ lines.push(intro);
55104
+ lines.push("");
55105
+ const ai = aiSpecificRules(rules);
55106
+ const byCategory = categorizeRules(ai);
55107
+ lines.push("## Category-level directives");
55108
+ lines.push("");
55109
+ for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
55110
+ lines.push("- **" + category + "**: " + directive);
55111
+ }
55112
+ lines.push("");
55113
+ lines.push("## Per-rule directives");
55114
+ lines.push("");
55115
+ for (const [category, list] of byCategory) {
55116
+ lines.push("### " + category);
55117
+ lines.push("");
55118
+ for (const rule of list) {
55119
+ const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
55120
+ lines.push("- `" + rule.id + "` (" + rule.severity + "): " + hint);
55121
+ }
55122
+ lines.push("");
55123
+ }
55124
+ return lines.join("\n");
55125
+ }
55126
+ function safeRewrite(existing, freshBlockBody) {
55127
+ const beginIdx = existing.indexOf(MARKER_BEGIN);
55128
+ const endIdx = existing.indexOf(MARKER_END);
55129
+ if (beginIdx === -1 && endIdx === -1) {
55130
+ return { content: existing, rewritten: false, failClosed: true, mismatched: false };
55131
+ }
55132
+ if (beginIdx === -1 || endIdx === -1) {
55133
+ return { content: existing, rewritten: false, failClosed: false, mismatched: true };
55134
+ }
55135
+ if (endIdx <= beginIdx) {
55136
+ return { content: existing, rewritten: false, failClosed: false, mismatched: true };
55137
+ }
55138
+ const before = existing.slice(0, beginIdx + MARKER_BEGIN.length);
55139
+ const after = existing.slice(endIdx);
55140
+ const next = `${before}
55141
+ ${freshBlockBody}
55142
+ ${after}`;
55143
+ return { content: next, rewritten: true, failClosed: false, mismatched: false };
55144
+ }
55145
+ var init_render2 = __esm({
55146
+ "src/snippet/render.ts"() {
55147
+ "use strict";
55148
+ init_data();
55149
+ init_generators();
55150
+ }
55151
+ });
55152
+
55153
+ // src/snippet/refresh.ts
55154
+ var refresh_exports = {};
55155
+ __export(refresh_exports, {
55156
+ SNIPPET_TARGETS: () => SNIPPET_TARGETS,
55157
+ refreshSnippets: () => refreshSnippets
55158
+ });
55159
+ function refreshSnippets(cwd, rules, targets = [...SNIPPET_TARGETS]) {
55160
+ const result = { rewritten: [], failClosed: [], mismatched: [], absent: [] };
55161
+ for (const filename of targets) {
55162
+ const path = (0, import_node_path24.join)(cwd, filename);
55163
+ if (!(0, import_node_fs22.existsSync)(path)) {
55164
+ result.absent.push(filename);
55165
+ continue;
55166
+ }
55167
+ const existing = (0, import_node_fs22.readFileSync)(path, "utf-8");
55168
+ const agentName = filename.replace(/\.md$/i, "").toLowerCase();
55169
+ const generator = agentName === "claude" ? generateAgentsMdSnippet : generateAgentsMdSnippet;
55170
+ const newBlock = generator([...rules]).trim();
55171
+ const outcome = safeRewrite(existing, newBlock);
55172
+ if (outcome.rewritten) {
55173
+ const tmp = `${path}.tmp`;
55174
+ (0, import_node_fs22.writeFileSync)(tmp, outcome.content, "utf-8");
55175
+ const fs = require("fs");
55176
+ fs.renameSync(tmp, path);
55177
+ result.rewritten.push(filename);
55178
+ } else if (outcome.failClosed) {
55179
+ result.failClosed.push(filename);
55180
+ } else if (outcome.mismatched) {
55181
+ result.mismatched.push(filename);
55182
+ }
55183
+ }
55184
+ return result;
55185
+ }
55186
+ var import_node_fs22, import_node_path24, SNIPPET_TARGETS;
55187
+ var init_refresh = __esm({
55188
+ "src/snippet/refresh.ts"() {
55189
+ "use strict";
55190
+ import_node_fs22 = require("fs");
55191
+ import_node_path24 = require("path");
55192
+ init_render2();
55193
+ init_generators();
55194
+ SNIPPET_TARGETS = [
55195
+ "AGENTS.md",
55196
+ "CLAUDE.md"
55197
+ ];
55198
+ }
55199
+ });
55200
+
54617
55201
  // src/cli/report/persistRun.ts
54618
55202
  async function persistRun(input) {
54619
55203
  const {
@@ -54676,7 +55260,7 @@ async function persistRun(input) {
54676
55260
  const recentTopHashes = telemetryPayloads.map(
54677
55261
  (payload) => [...payload.files].sort((a, b) => b.score - a.score).slice(0, 10).map((file) => file.hash)
54678
55262
  );
54679
- const currentTopFiles = [...report.components].sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, 10).map((c) => ({ filePath: c.filePath, hash: hashFile((0, import_node_path24.relative)(cwd, c.filePath)) }));
55263
+ const currentTopFiles = [...report.components].sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, 10).map((c) => ({ filePath: c.filePath, hash: hashFile((0, import_node_path25.relative)(cwd, c.filePath)) }));
54680
55264
  const unmatchedStringLiterals = results.flatMap((r) => r.unmatchedStringLiterals ?? []);
54681
55265
  const flywheelOutput = computeFlywheelOutput(
54682
55266
  runs,
@@ -54696,10 +55280,10 @@ async function persistRun(input) {
54696
55280
  report.research = state.research;
54697
55281
  }
54698
55282
  if (flywheelOutput.suggestions.length > 0) {
54699
- const suggestionsDir = (0, import_node_path24.join)(cwd, ".slopbrick", "flywheel");
54700
- if (!(0, import_node_fs22.existsSync)(suggestionsDir)) (0, import_node_fs22.mkdirSync)(suggestionsDir, { recursive: true });
54701
- (0, import_node_fs22.writeFileSync)(
54702
- (0, import_node_path24.join)(suggestionsDir, "rule-suggestions.json"),
55283
+ const suggestionsDir = (0, import_node_path25.join)(cwd, ".slopbrick", "flywheel");
55284
+ if (!(0, import_node_fs23.existsSync)(suggestionsDir)) (0, import_node_fs23.mkdirSync)(suggestionsDir, { recursive: true });
55285
+ (0, import_node_fs23.writeFileSync)(
55286
+ (0, import_node_path25.join)(suggestionsDir, "rule-suggestions.json"),
54703
55287
  JSON.stringify(flywheelOutput.suggestions, null, 2)
54704
55288
  );
54705
55289
  }
@@ -54744,13 +55328,28 @@ async function persistRun(input) {
54744
55328
  }
54745
55329
  }
54746
55330
  recordTelemetry(cwd, report, results, config, patternInventory);
55331
+ if (input.autoRefreshSnippets) {
55332
+ try {
55333
+ const { refreshSnippets: refreshSnippets2 } = await Promise.resolve().then(() => (init_refresh(), refresh_exports));
55334
+ const outcome = refreshSnippets2(cwd, registry.getRules());
55335
+ if (outcome.rewritten.length > 0 && !options.quiet && !machineReadableStdout) {
55336
+ logger.info(
55337
+ `Snippet refresh: rewrote ${outcome.rewritten.length} file(s) (${outcome.rewritten.join(", ")}).`
55338
+ );
55339
+ }
55340
+ } catch (err) {
55341
+ if (options.quiet !== true && !machineReadableStdout) {
55342
+ logger.warn(`snippet refresh failed: ${err instanceof Error ? err.message : String(err)}`);
55343
+ }
55344
+ }
55345
+ }
54747
55346
  }
54748
- var import_node_fs22, import_node_path24;
55347
+ var import_node_fs23, import_node_path25;
54749
55348
  var init_persistRun = __esm({
54750
55349
  "src/cli/report/persistRun.ts"() {
54751
55350
  "use strict";
54752
- import_node_fs22 = require("fs");
54753
- import_node_path24 = require("path");
55351
+ import_node_fs23 = require("fs");
55352
+ import_node_path25 = require("path");
54754
55353
  init_logger();
54755
55354
  init_cache_incremental();
54756
55355
  init_flywheel();
@@ -54889,7 +55488,7 @@ var init_finalizeReport = __esm({
54889
55488
  function buildBaselineCache(report, configHash, gitHead, cwd) {
54890
55489
  const scores = {};
54891
55490
  for (const component of report.components) {
54892
- scores[(0, import_node_path25.relative)(cwd, component.filePath)] = {
55491
+ scores[(0, import_node_path26.relative)(cwd, component.filePath)] = {
54893
55492
  baselineScore: component.componentScore,
54894
55493
  componentCount: component.componentCount
54895
55494
  };
@@ -54904,11 +55503,11 @@ function buildBaselineCache(report, configHash, gitHead, cwd) {
54904
55503
  scores
54905
55504
  };
54906
55505
  }
54907
- var import_node_path25;
55506
+ var import_node_path26;
54908
55507
  var init_baseline_cache = __esm({
54909
55508
  "src/cli/report/baseline-cache.ts"() {
54910
55509
  "use strict";
54911
- import_node_path25 = require("path");
55510
+ import_node_path26 = require("path");
54912
55511
  init_types();
54913
55512
  }
54914
55513
  });
@@ -54972,24 +55571,24 @@ function buildArtifactUri(filePath, cwd) {
54972
55571
  return ".";
54973
55572
  }
54974
55573
  if (cwd) {
54975
- const absoluteFilePath = (0, import_node_path26.isAbsolute)(filePath) ? filePath : (0, import_node_path26.resolve)(cwd, filePath);
54976
- const rel = (0, import_node_path26.relative)(cwd, absoluteFilePath);
55574
+ const absoluteFilePath = (0, import_node_path27.isAbsolute)(filePath) ? filePath : (0, import_node_path27.resolve)(cwd, filePath);
55575
+ const rel = (0, import_node_path27.relative)(cwd, absoluteFilePath);
54977
55576
  if (rel.startsWith("..")) {
54978
- return (0, import_node_path26.basename)(filePath);
55577
+ return (0, import_node_path27.basename)(filePath);
54979
55578
  }
54980
55579
  return rel;
54981
55580
  }
54982
- if ((0, import_node_path26.isAbsolute)(filePath)) {
54983
- return (0, import_node_path26.basename)(filePath);
55581
+ if ((0, import_node_path27.isAbsolute)(filePath)) {
55582
+ return (0, import_node_path27.basename)(filePath);
54984
55583
  }
54985
55584
  return filePath;
54986
55585
  }
54987
55586
  function resolveSourcePath(filePath, cwd) {
54988
55587
  if (!filePath) return null;
54989
55588
  if (cwd) {
54990
- return (0, import_node_path26.isAbsolute)(filePath) ? filePath : (0, import_node_path26.resolve)(cwd, filePath);
55589
+ return (0, import_node_path27.isAbsolute)(filePath) ? filePath : (0, import_node_path27.resolve)(cwd, filePath);
54991
55590
  }
54992
- return (0, import_node_path26.isAbsolute)(filePath) ? filePath : null;
55591
+ return (0, import_node_path27.isAbsolute)(filePath) ? filePath : null;
54993
55592
  }
54994
55593
  function severityToSarifLevel(severity) {
54995
55594
  switch (severity) {
@@ -55072,7 +55671,7 @@ function buildResultFromIssue(issue, cwd, fileContentCache) {
55072
55671
  let content = fileContentCache.get(sourcePath);
55073
55672
  if (content === void 0) {
55074
55673
  try {
55075
- content = (0, import_node_fs23.readFileSync)(sourcePath, "utf-8");
55674
+ content = (0, import_node_fs24.readFileSync)(sourcePath, "utf-8");
55076
55675
  fileContentCache.set(sourcePath, content);
55077
55676
  } catch {
55078
55677
  fileContentCache.set(sourcePath, "");
@@ -55146,13 +55745,13 @@ function formatSarif(report, options) {
55146
55745
  };
55147
55746
  return JSON.stringify(log, null, 2);
55148
55747
  }
55149
- var import_node_crypto9, import_node_fs23, import_node_path26, REPO_INFORMATION_URI, RULES_BASE_URL;
55748
+ var import_node_crypto9, import_node_fs24, import_node_path27, REPO_INFORMATION_URI, RULES_BASE_URL;
55150
55749
  var init_sarif = __esm({
55151
55750
  "src/report/sarif.ts"() {
55152
55751
  "use strict";
55153
55752
  import_node_crypto9 = require("crypto");
55154
- import_node_fs23 = require("fs");
55155
- import_node_path26 = require("path");
55753
+ import_node_fs24 = require("fs");
55754
+ import_node_path27 = require("path");
55156
55755
  REPO_INFORMATION_URI = "https://github.com/usebrick/platform";
55157
55756
  RULES_BASE_URL = "https://github.com/usebrick/platform/blob/main/packages/slopbrick/src/rules";
55158
55757
  }
@@ -55323,11 +55922,13 @@ function extractNamedScores(report) {
55323
55922
  };
55324
55923
  }
55325
55924
  function renderThresholds(report) {
55326
- const r = report;
55327
- const score = typeof r.repositoryHealth === "number" ? r.repositoryHealth : report.repositoryHealth;
55328
- const limit = 70;
55329
- const available = typeof score === "number";
55330
- 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;
55331
55932
  return `
55332
55933
  <section class="thresholds-section">
55333
55934
  <h2>Thresholds</h2>
@@ -55341,9 +55942,14 @@ function renderThresholds(report) {
55341
55942
  </thead>
55342
55943
  <tbody>
55343
55944
  <tr>
55344
- <td>Repository Health (composite)</td>
55345
- <td>${available ? `${score.toFixed(1)} / ${limit}` : "\u2013"}</td>
55346
- <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>
55347
55953
  </tr>
55348
55954
  </tbody>
55349
55955
  </table>
@@ -56337,18 +56943,18 @@ function applyReplaceFixes(content, fixes) {
56337
56943
  return { content, applied, skipped, reasons };
56338
56944
  }
56339
56945
  function applyLayoutTokenFix(filePath, fixes) {
56340
- let content = (0, import_node_fs24.readFileSync)(filePath, "utf-8");
56946
+ let content = (0, import_node_fs25.readFileSync)(filePath, "utf-8");
56341
56947
  const { content: patched, applied, skipped, reasons } = applyReplaceFixes(content, fixes);
56342
56948
  if (patched !== content) {
56343
- (0, import_node_fs24.writeFileSync)(filePath, patched);
56949
+ (0, import_node_fs25.writeFileSync)(filePath, patched);
56344
56950
  }
56345
56951
  return { applied, skipped, reasons };
56346
56952
  }
56347
- var import_node_fs24;
56953
+ var import_node_fs25;
56348
56954
  var init_layout_token = __esm({
56349
56955
  "src/fix/layout-token.ts"() {
56350
56956
  "use strict";
56351
- import_node_fs24 = require("fs");
56957
+ import_node_fs25 = require("fs");
56352
56958
  }
56353
56959
  });
56354
56960
 
@@ -56428,8 +57034,8 @@ function formatUnifiedDiff(report, cwd) {
56428
57034
  const parts = [];
56429
57035
  let hasHunk = false;
56430
57036
  for (const [filePath, fixes] of byFile) {
56431
- if (!(0, import_node_fs25.existsSync)(filePath)) continue;
56432
- const original = (0, import_node_fs25.readFileSync)(filePath, "utf-8");
57037
+ if (!(0, import_node_fs26.existsSync)(filePath)) continue;
57038
+ const original = (0, import_node_fs26.readFileSync)(filePath, "utf-8");
56433
57039
  const patched = applyFixesToString(original, fixes);
56434
57040
  if (patched === original) continue;
56435
57041
  if (!hasHunk) {
@@ -56438,7 +57044,7 @@ function formatUnifiedDiff(report, cwd) {
56438
57044
  parts.push("");
56439
57045
  hasHunk = true;
56440
57046
  }
56441
- const rel = (0, import_node_path27.relative)(cwd, filePath);
57047
+ const rel = (0, import_node_path28.relative)(cwd, filePath);
56442
57048
  parts.push(`--- a/${rel}`);
56443
57049
  parts.push(`+++ b/${rel}`);
56444
57050
  parts.push(...formatHunk(original, patched));
@@ -56449,12 +57055,12 @@ function formatUnifiedDiff(report, cwd) {
56449
57055
  }
56450
57056
  return parts.join("\n");
56451
57057
  }
56452
- var import_node_fs25, import_node_path27;
57058
+ var import_node_fs26, import_node_path28;
56453
57059
  var init_unified_diff = __esm({
56454
57060
  "src/report/unified-diff.ts"() {
56455
57061
  "use strict";
56456
- import_node_fs25 = require("fs");
56457
- import_node_path27 = require("path");
57062
+ import_node_fs26 = require("fs");
57063
+ import_node_path28 = require("path");
56458
57064
  init_layout_token();
56459
57065
  }
56460
57066
  });
@@ -56480,7 +57086,7 @@ async function buildHeatmap(report, cwd, helpers = { getFileEditCount, getFileLa
56480
57086
  const reference = new Date(report.generatedAt);
56481
57087
  const entries = await Promise.all(
56482
57088
  report.components.map(async (component) => {
56483
- const relPath = (0, import_node_path28.relative)(cwd, component.filePath) || component.filePath;
57089
+ const relPath = (0, import_node_path29.relative)(cwd, component.filePath) || component.filePath;
56484
57090
  const [edits, lastModified] = await Promise.all([
56485
57091
  helpers.getFileEditCount(cwd, relPath, RECENCY_DAYS),
56486
57092
  helpers.getFileLastModifiedDate(cwd, relPath)
@@ -56525,11 +57131,11 @@ function formatHeatmap(entries, options = {}) {
56525
57131
  );
56526
57132
  return [header, ...rows].join("\n");
56527
57133
  }
56528
- var import_node_path28, RECENCY_DAYS, MAX_EDITS;
57134
+ var import_node_path29, RECENCY_DAYS, MAX_EDITS;
56529
57135
  var init_heatmap = __esm({
56530
57136
  "src/report/heatmap.ts"() {
56531
57137
  "use strict";
56532
- import_node_path28 = require("path");
57138
+ import_node_path29 = require("path");
56533
57139
  init_git();
56534
57140
  RECENCY_DAYS = 30;
56535
57141
  MAX_EDITS = 10;
@@ -56570,7 +57176,7 @@ function renderOutput(report, options, cwd) {
56570
57176
  if (options.html) {
56571
57177
  const html = formatHtml(report);
56572
57178
  if (typeof options.html === "string") {
56573
- (0, import_node_fs26.writeFileSync)((0, import_node_path29.resolve)(options.html), html);
57179
+ (0, import_node_fs27.writeFileSync)((0, import_node_path30.resolve)(options.html), html);
56574
57180
  if (!options.quiet) {
56575
57181
  logger.info(`Wrote HTML report to ${options.html}`);
56576
57182
  }
@@ -56582,7 +57188,7 @@ function renderOutput(report, options, cwd) {
56582
57188
  if (options.json) {
56583
57189
  const json = formatJson(report);
56584
57190
  if (typeof options.json === "string") {
56585
- (0, import_node_fs26.writeFileSync)((0, import_node_path29.resolve)(options.json), json);
57191
+ (0, import_node_fs27.writeFileSync)((0, import_node_path30.resolve)(options.json), json);
56586
57192
  if (!options.quiet) {
56587
57193
  logger.info(`Wrote JSON report to ${options.json}`);
56588
57194
  }
@@ -56596,7 +57202,7 @@ function renderOutput(report, options, cwd) {
56596
57202
  return;
56597
57203
  }
56598
57204
  if (options.format === "sarif") {
56599
- const cwdSarif = (0, import_node_path29.resolve)(options.workspace ?? process.cwd());
57205
+ const cwdSarif = (0, import_node_path30.resolve)(options.workspace ?? process.cwd());
56600
57206
  logger.info(formatSarif(report, { cwd: cwdSarif }));
56601
57207
  return;
56602
57208
  }
@@ -56617,12 +57223,12 @@ async function outputScanResults(report, options, cwd) {
56617
57223
  }
56618
57224
  renderOutput(report, options, cwd);
56619
57225
  }
56620
- var import_node_path29, import_node_fs26, VALID_FORMATS;
57226
+ var import_node_path30, import_node_fs27, VALID_FORMATS;
56621
57227
  var init_renderOutput = __esm({
56622
57228
  "src/cli/report/renderOutput.ts"() {
56623
57229
  "use strict";
56624
- import_node_path29 = require("path");
56625
- import_node_fs26 = require("fs");
57230
+ import_node_path30 = require("path");
57231
+ import_node_fs27 = require("fs");
56626
57232
  init_logger();
56627
57233
  init_json();
56628
57234
  init_pretty();
@@ -56647,7 +57253,7 @@ async function watchProject(options, cwd, paths) {
56647
57253
  let currentBaseline;
56648
57254
  function getBaselineMtime() {
56649
57255
  try {
56650
- return (0, import_node_fs27.statSync)(baselinePath(cwd)).mtimeMs;
57256
+ return (0, import_node_fs28.statSync)(baselinePath(cwd)).mtimeMs;
56651
57257
  } catch {
56652
57258
  return void 0;
56653
57259
  }
@@ -56755,12 +57361,12 @@ async function watchProject(options, cwd, paths) {
56755
57361
  await doScan(false);
56756
57362
  if (closed) return;
56757
57363
  const currentFiles = new Set(issueGroupsMap.keys());
56758
- watcher = (0, import_node_fs27.watch)(
57364
+ watcher = (0, import_node_fs28.watch)(
56759
57365
  cwd,
56760
57366
  { recursive: true },
56761
57367
  (_eventType, filename) => {
56762
57368
  if (closed || !filename) return;
56763
- const changedPath = (0, import_node_path30.resolve)(cwd, filename.toString());
57369
+ const changedPath = (0, import_node_path31.resolve)(cwd, filename.toString());
56764
57370
  if (debounceTimer) clearTimeout(debounceTimer);
56765
57371
  debounceTimer = setTimeout(() => {
56766
57372
  debounceTimer = void 0;
@@ -56795,12 +57401,12 @@ async function watchProject(options, cwd, paths) {
56795
57401
  }
56796
57402
  );
56797
57403
  }
56798
- var import_node_fs27, import_node_path30;
57404
+ var import_node_fs28, import_node_path31;
56799
57405
  var init_watch = __esm({
56800
57406
  "src/cli/watch.ts"() {
56801
57407
  "use strict";
56802
- import_node_fs27 = require("fs");
56803
- import_node_path30 = require("path");
57408
+ import_node_fs28 = require("fs");
57409
+ import_node_path31 = require("path");
56804
57410
  init_metrics();
56805
57411
  init_worker();
56806
57412
  init_threshold();
@@ -56820,11 +57426,11 @@ async function runScan(options, explicitPaths) {
56820
57426
  setLoggerQuiet(!!options.quiet);
56821
57427
  const startTime = Date.now();
56822
57428
  const scanId = (0, import_node_crypto10.randomUUID)();
56823
- const cwd = (0, import_node_path31.resolve)(options.workspace ?? process.cwd());
56824
- if (!(0, import_node_fs28.existsSync)(cwd)) {
57429
+ const cwd = (0, import_node_path32.resolve)(options.workspace ?? process.cwd());
57430
+ if (!(0, import_node_fs29.existsSync)(cwd)) {
56825
57431
  throw new Error(`Workspace not found: ${cwd}`);
56826
57432
  }
56827
- const cwdStat = (0, import_node_fs28.statSync)(cwd);
57433
+ const cwdStat = (0, import_node_fs29.statSync)(cwd);
56828
57434
  if (!cwdStat.isDirectory()) {
56829
57435
  throw new Error(`Workspace is not a directory: ${cwd}`);
56830
57436
  }
@@ -56851,7 +57457,7 @@ async function runScan(options, explicitPaths) {
56851
57457
  process.env.SLOP_AUDIT_CACHE = "1";
56852
57458
  }
56853
57459
  if (options.tokens) {
56854
- const tokenResult = readDtcgTokensFile((0, import_node_path31.resolve)(cwd, options.tokens));
57460
+ const tokenResult = readDtcgTokensFile((0, import_node_path32.resolve)(cwd, options.tokens));
56855
57461
  if (tokenResult.ok) {
56856
57462
  const extra = tokensToAllowlist(tokenResult.tree);
56857
57463
  config.arbitraryValueAllowlist = [...config.arbitraryValueAllowlist, ...extra];
@@ -56863,14 +57469,14 @@ async function runScan(options, explicitPaths) {
56863
57469
  if (explicitPaths && explicitPaths.length > 0) {
56864
57470
  const { globby: globby4 } = await import("globby");
56865
57471
  const { minimatch: minimatch4 } = await import("minimatch");
56866
- const resolved = explicitPaths.map((p) => (0, import_node_path31.resolve)(cwd, p));
57472
+ const resolved = explicitPaths.map((p) => (0, import_node_path32.resolve)(cwd, p));
56867
57473
  const expanded = [];
56868
57474
  for (const p of resolved) {
56869
- if ((0, import_node_fs28.existsSync)(p) && (0, import_node_fs28.statSync)(p).isDirectory()) {
57475
+ if ((0, import_node_fs29.existsSync)(p) && (0, import_node_fs29.statSync)(p).isDirectory()) {
56870
57476
  const found = await globby4(`${p}/**/*`, { absolute: true, onlyFiles: true });
56871
57477
  for (const f of found) {
56872
- if (!ALL_SOURCE_EXTENSIONS.has((0, import_node_path31.extname)(f).toLowerCase())) continue;
56873
- const rel = (0, import_node_path31.relative)(cwd, f).split(import_node_path31.sep).join("/");
57478
+ if (!ALL_SOURCE_EXTENSIONS.has((0, import_node_path32.extname)(f).toLowerCase())) continue;
57479
+ const rel = (0, import_node_path32.relative)(cwd, f).split(import_node_path32.sep).join("/");
56874
57480
  if (config.include.length > 0 && !config.include.some((pattern) => minimatch4(rel, pattern))) {
56875
57481
  continue;
56876
57482
  }
@@ -56904,12 +57510,16 @@ async function runScan(options, explicitPaths) {
56904
57510
  files = intersectFiles(files, since, cwd);
56905
57511
  }
56906
57512
  let incrementalSummary;
57513
+ let cachePath3;
57514
+ let existingCache;
57515
+ let unchanged = [];
56907
57516
  if (options.incremental) {
56908
- const cachePath3 = options.cachePath ?? ".slopbrick-cache.json";
56909
- const existing = loadCache(cachePath3);
56910
- const { toScan, unchanged } = partitionByCache(files, existing);
56911
- files = toScan;
56912
- 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 };
56913
57523
  }
56914
57524
  const machineReadableStdout = !!options.json || !!options.html || options.format === "json" || options.format === "sarif" || options.format === "html";
56915
57525
  if (files.length === 0 && !options.quiet && !machineReadableStdout) {
@@ -56929,6 +57539,9 @@ async function runScan(options, explicitPaths) {
56929
57539
  logger.warn(
56930
57540
  `No source files matched in ${cwd}. Adjust --include or check your config at ${projectRoot}.`
56931
57541
  );
57542
+ logger.warn(
57543
+ "Tip: run from a directory with source files, or pass --workspace <path> (monorepos usually want `--workspace packages/<name>`)."
57544
+ );
56932
57545
  }
56933
57546
  }
56934
57547
  const configHash = hashConfig(config);
@@ -57020,7 +57633,7 @@ async function runScan(options, explicitPaths) {
57020
57633
  const scannedPaths = new Set(results.map((result) => result.filePath));
57021
57634
  for (const [filePath, cached] of Object.entries(baseline.scores)) {
57022
57635
  if (scannedPaths.has(filePath)) continue;
57023
- if (!(0, import_node_fs28.existsSync)(filePath)) continue;
57636
+ if (!(0, import_node_fs29.existsSync)(filePath)) continue;
57024
57637
  scores.push({
57025
57638
  filePath,
57026
57639
  rawScore: 0,
@@ -57059,7 +57672,12 @@ async function runScan(options, explicitPaths) {
57059
57672
  const { report, noIncreaseFailure } = await finalizeReport({
57060
57673
  cwd,
57061
57674
  config,
57062
- options,
57675
+ options: {
57676
+ ...options,
57677
+ // v0.42.0 (§3a.4): forward the opt-in flag through to
57678
+ // `persistRun`, which calls `refreshSnippets` post-scan.
57679
+ autoRefreshSnippets: Boolean(options.autoRefreshSnippets) || Boolean(config.autoRefreshSnippets)
57680
+ },
57063
57681
  results,
57064
57682
  aggregated,
57065
57683
  allIssues,
@@ -57073,6 +57691,38 @@ async function runScan(options, explicitPaths) {
57073
57691
  telemetryEnabled,
57074
57692
  machineReadableStdout
57075
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
+ }
57076
57726
  return {
57077
57727
  report,
57078
57728
  scores,
@@ -57098,12 +57748,12 @@ async function scanProject(options) {
57098
57748
  const { report } = await runScan({ ...options, workspace: options.cwd });
57099
57749
  return report;
57100
57750
  }
57101
- var import_node_fs28, import_node_path31, import_node_crypto10;
57751
+ var import_node_fs29, import_node_path32, import_node_crypto10;
57102
57752
  var init_scan2 = __esm({
57103
57753
  "src/cli/scan.ts"() {
57104
57754
  "use strict";
57105
- import_node_fs28 = require("fs");
57106
- import_node_path31 = require("path");
57755
+ import_node_fs29 = require("fs");
57756
+ import_node_path32 = require("path");
57107
57757
  import_node_crypto10 = require("crypto");
57108
57758
  init_render();
57109
57759
  init_threshold();
@@ -57131,6 +57781,50 @@ var init_scan2 = __esm({
57131
57781
  }
57132
57782
  });
57133
57783
 
57784
+ // src/rules/composite-seed.ts
57785
+ var composite_seed_exports = {};
57786
+ __export(composite_seed_exports, {
57787
+ HAND_CURATED_SEED: () => HAND_CURATED_SEED,
57788
+ maybeAppendSeed: () => maybeAppendSeed
57789
+ });
57790
+ function maybeAppendSeed(entries) {
57791
+ if (entries.length === 0) {
57792
+ return [...HAND_CURATED_SEED];
57793
+ }
57794
+ return entries;
57795
+ }
57796
+ var HAND_CURATED_SEED;
57797
+ var init_composite_seed = __esm({
57798
+ "src/rules/composite-seed.ts"() {
57799
+ "use strict";
57800
+ HAND_CURATED_SEED = [
57801
+ {
57802
+ id: "composite/ai-fingerprint-cluster",
57803
+ ruleIds: ["ai/compression-profile", "ai/comment-ratio", "ai/segment-surprisal-cv"],
57804
+ minMatch: 2,
57805
+ severity: "medium",
57806
+ defaultOff: true,
57807
+ description: "Composite rule: at least 2 of {ai/compression-profile+ai/comment-ratio+...} fire on the same file. Hand-curated seed from the v0.42.0 brief; superseded by `slopbrick composite discover` once empirical clusters dominate the ledger.",
57808
+ calibration: {
57809
+ recall: 0,
57810
+ FP: 0,
57811
+ precision: 0,
57812
+ F1: 0,
57813
+ nFiles: 0
57814
+ },
57815
+ provenance: {
57816
+ seed: "hand-curated-by-brief",
57817
+ discoveredAt: "2026-07-05T00:00:00.000Z",
57818
+ nFiles: 0,
57819
+ members: 3,
57820
+ npmi: 0,
57821
+ fisherP: 1
57822
+ }
57823
+ }
57824
+ ];
57825
+ }
57826
+ });
57827
+
57134
57828
  // src/index.ts
57135
57829
  var src_exports = {};
57136
57830
  __export(src_exports, {
@@ -57175,6 +57869,7 @@ __export(src_exports, {
57175
57869
  directoryScore: () => directoryScore,
57176
57870
  extractSignatures: () => extractSignatures,
57177
57871
  failedThresholdCount: () => failedThresholdCount,
57872
+ failedThresholds: () => failedThresholds,
57178
57873
  filterByDisabledDirectives: () => filterByDisabledDirectives,
57179
57874
  filterIssues: () => filterIssues,
57180
57875
  findSimilarFunctions: () => findSimilarFunctions,
@@ -57217,7 +57912,7 @@ init_config2();
57217
57912
  init_dist2();
57218
57913
 
57219
57914
  // src/cli/program.ts
57220
- var import_node_path72 = require("path");
57915
+ var import_node_path75 = require("path");
57221
57916
  var import_commander3 = require("commander");
57222
57917
 
57223
57918
  // src/cli/options.ts
@@ -57260,18 +57955,20 @@ init_scan2();
57260
57955
  init_scan2();
57261
57956
 
57262
57957
  // src/cli/init.ts
57263
- var import_node_fs30 = require("fs");
57264
- var import_node_path33 = require("path");
57958
+ var import_chalk2 = __toESM(require("chalk"), 1);
57959
+ var import_node_fs31 = require("fs");
57960
+ var import_node_path34 = require("path");
57265
57961
  var import_node_readline = require("readline");
57266
57962
  init_config2();
57267
57963
  init_discover();
57268
57964
  init_git();
57269
57965
  init_cache();
57270
57966
  init_logger();
57967
+ init_types();
57271
57968
 
57272
57969
  // src/rules/registry-loader.ts
57273
- var import_node_fs29 = require("fs");
57274
- var import_node_path32 = require("path");
57970
+ var import_node_fs30 = require("fs");
57971
+ var import_node_path33 = require("path");
57275
57972
 
57276
57973
  // src/data/shadcn-registry.json
57277
57974
  var shadcn_registry_default = {
@@ -57405,12 +58102,12 @@ var shadcn_registry_default = {
57405
58102
  var REGISTRY_URL = "https://ui.shadcn.com/registry.json";
57406
58103
  var BUNDLED_REGISTRY_VERSION = shadcn_registry_default.version;
57407
58104
  function cachePath2(cwd) {
57408
- return (0, import_node_path32.join)(cwd, ".slopbrick", "cache", "registry-snapshot.json");
58105
+ return (0, import_node_path33.join)(cwd, ".slopbrick", "cache", "registry-snapshot.json");
57409
58106
  }
57410
58107
  function ensureCacheDir(cwd) {
57411
- const dir = (0, import_node_path32.dirname)(cachePath2(cwd));
57412
- if (!(0, import_node_fs29.existsSync)(dir)) {
57413
- (0, import_node_fs29.mkdirSync)(dir, { recursive: true });
58108
+ const dir = (0, import_node_path33.dirname)(cachePath2(cwd));
58109
+ if (!(0, import_node_fs30.existsSync)(dir)) {
58110
+ (0, import_node_fs30.mkdirSync)(dir, { recursive: true });
57414
58111
  }
57415
58112
  }
57416
58113
  function isValidSnapshot(value) {
@@ -57422,9 +58119,9 @@ function isValidSnapshot(value) {
57422
58119
  }
57423
58120
  function isRegistryFresh(cwd) {
57424
58121
  const cached = cachePath2(cwd);
57425
- if (!(0, import_node_fs29.existsSync)(cached)) return false;
58122
+ if (!(0, import_node_fs30.existsSync)(cached)) return false;
57426
58123
  try {
57427
- const parsed = JSON.parse((0, import_node_fs29.readFileSync)(cached, "utf8"));
58124
+ const parsed = JSON.parse((0, import_node_fs30.readFileSync)(cached, "utf8"));
57428
58125
  if (!isValidSnapshot(parsed)) return false;
57429
58126
  return parsed.version === BUNDLED_REGISTRY_VERSION;
57430
58127
  } catch {
@@ -57435,19 +58132,24 @@ async function refreshRegistrySnapshot(cwd, _url = REGISTRY_URL, _timeoutMs = 5e
57435
58132
  try {
57436
58133
  const cacheFile = cachePath2(cwd);
57437
58134
  ensureCacheDir(cwd);
57438
- (0, import_node_fs29.writeFileSync)(cacheFile, JSON.stringify(shadcn_registry_default, null, 2), "utf-8");
58135
+ (0, import_node_fs30.writeFileSync)(cacheFile, JSON.stringify(shadcn_registry_default, null, 2), "utf-8");
57439
58136
  } catch {
57440
58137
  }
57441
58138
  return {
57442
58139
  snapshot: shadcn_registry_default,
57443
58140
  ok: true,
57444
58141
  fresh: false,
57445
- 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)."
57446
58148
  };
57447
58149
  }
57448
58150
  function copyBundledSnapshotToCache(cwd) {
57449
58151
  ensureCacheDir(cwd);
57450
- (0, import_node_fs29.writeFileSync)(cachePath2(cwd), JSON.stringify(shadcn_registry_default, null, 2));
58152
+ (0, import_node_fs30.writeFileSync)(cachePath2(cwd), JSON.stringify(shadcn_registry_default, null, 2));
57451
58153
  }
57452
58154
 
57453
58155
  // src/cli/init.ts
@@ -57476,17 +58178,17 @@ var UI_LIBRARY_OPTIONS = ["shadcn/ui", "mui", "chakra", "radix", "tamagui", "nat
57476
58178
  var STRICTNESS_OPTIONS = ["strict", "balanced", "permissive"];
57477
58179
  var STRUCTURE_OPTIONS = ["feature-based", "layer-based", "flat", "monorepo", "other"];
57478
58180
  function promptText(rl, question, detected) {
57479
- return new Promise((resolve42) => {
58181
+ return new Promise((resolve43) => {
57480
58182
  const lines = [
57481
58183
  `? ${question} (detected: ${detected || "none"}) \u2014 npm package name, or Enter to skip:`
57482
58184
  ];
57483
58185
  rl.question(lines.join("\n") + "\n", (answer) => {
57484
58186
  const trimmed = answer.trim();
57485
58187
  if (trimmed === "") {
57486
- resolve42(void 0);
58188
+ resolve43(void 0);
57487
58189
  return;
57488
58190
  }
57489
- resolve42(trimmed);
58191
+ resolve43(trimmed);
57490
58192
  });
57491
58193
  });
57492
58194
  }
@@ -57494,7 +58196,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57494
58196
  const defaultIndex = options.indexOf(defaultValue);
57495
58197
  const safeDefaultIndex = defaultIndex >= 0 ? defaultIndex : 0;
57496
58198
  const safeDefaultValue = options[safeDefaultIndex];
57497
- return new Promise((resolve42) => {
58199
+ return new Promise((resolve43) => {
57498
58200
  function ask() {
57499
58201
  const lines = [
57500
58202
  `? ${question} (detected: ${safeDefaultValue}):`,
@@ -57504,7 +58206,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57504
58206
  rl.question(lines.join("\n") + "\n", (answer) => {
57505
58207
  const trimmed = answer.trim();
57506
58208
  if (trimmed === "") {
57507
- resolve42(safeDefaultValue);
58209
+ resolve43(safeDefaultValue);
57508
58210
  return;
57509
58211
  }
57510
58212
  const num = parseInt(trimmed, 10);
@@ -57513,7 +58215,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57513
58215
  ask();
57514
58216
  return;
57515
58217
  }
57516
- resolve42(options[num - 1]);
58218
+ resolve43(options[num - 1]);
57517
58219
  });
57518
58220
  }
57519
58221
  ask();
@@ -57523,7 +58225,7 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57523
58225
  const defaultIndices = defaultValue.map((v) => options.indexOf(v)).filter((i) => i >= 0);
57524
58226
  const defaultDisplay = defaultValue.length > 0 ? defaultValue.join(", ") : "none";
57525
58227
  const defaultNumbers = defaultIndices.length > 0 ? defaultIndices.map((i) => i + 2).join(",") : "1";
57526
- return new Promise((resolve42) => {
58228
+ return new Promise((resolve43) => {
57527
58229
  function ask() {
57528
58230
  const lines = [
57529
58231
  `? ${question} (detected: ${defaultDisplay}):`,
@@ -57534,7 +58236,7 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57534
58236
  rl.question(lines.join("\n") + "\n", (answer) => {
57535
58237
  const trimmed = answer.trim();
57536
58238
  if (trimmed === "") {
57537
- resolve42(defaultValue.length > 0 ? defaultValue : []);
58239
+ resolve43(defaultValue.length > 0 ? defaultValue : []);
57538
58240
  return;
57539
58241
  }
57540
58242
  const numbers = trimmed.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n));
@@ -57544,11 +58246,11 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57544
58246
  return;
57545
58247
  }
57546
58248
  if (numbers.includes(1)) {
57547
- resolve42([]);
58249
+ resolve43([]);
57548
58250
  return;
57549
58251
  }
57550
58252
  const selected = [...new Set(numbers.map((n) => options[n - 2]).filter((v) => v !== void 0))];
57551
- resolve42(selected);
58253
+ resolve43(selected);
57552
58254
  });
57553
58255
  }
57554
58256
  ask();
@@ -57613,16 +58315,23 @@ async function runDoctor(cwd) {
57613
58315
  lines.push("slopbrick doctor\n");
57614
58316
  lines.push("Checking your setup:\n");
57615
58317
  const nodeMajor = parseInt(process.versions.node.split(".")[0] ?? "0", 10);
57616
- if (nodeMajor >= 20) ok(`Node ${process.versions.node}`);
58318
+ if (nodeMajor >= 20) ok(`Node ${process.versions.node}, slopbrick ${VERSION}`);
57617
58319
  else fail(`Node ${process.versions.node} \u2014 slopbrick needs Node 20 or newer. Run: nvm install 20`);
57618
58320
  lines.push(`
57619
58321
  Working in: ${cwd}`);
57620
58322
  try {
58323
+ const configPath = resolveConfigPath(cwd);
57621
58324
  const config = await loadConfig(cwd);
57622
- lines.push(`
57623
- \u2713 Config loaded from: ${resolveConfigPath(cwd) ?? "(default)"}`);
57624
- const ruleCount = Object.keys(config.rules ?? {}).length;
57625
- 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
+ }
57626
58335
  } catch (error) {
57627
58336
  fail(`Could not read your config: ${error.message}`);
57628
58337
  exitCode = 3;
@@ -57636,11 +58345,11 @@ async function runDoctor(cwd) {
57636
58345
  }
57637
58346
  try {
57638
58347
  const { parseFile: tryParse } = await Promise.resolve().then(() => (init_dist2(), dist_exports2));
57639
- const testFile = (0, import_node_path33.join)(cwd, ".slopbrick", ".doctor-test.ts");
57640
- (0, import_node_fs30.mkdirSync)((0, import_node_path33.dirname)(testFile), { recursive: true });
57641
- (0, import_node_fs30.writeFileSync)(testFile, "export const x = 1;\n");
58348
+ const testFile = (0, import_node_path34.join)(cwd, ".slopbrick", ".doctor-test.ts");
58349
+ (0, import_node_fs31.mkdirSync)((0, import_node_path34.dirname)(testFile), { recursive: true });
58350
+ (0, import_node_fs31.writeFileSync)(testFile, "export const x = 1;\n");
57642
58351
  await tryParse(testFile);
57643
- (0, import_node_fs30.rmSync)(testFile, { force: true });
58352
+ (0, import_node_fs31.rmSync)(testFile, { force: true });
57644
58353
  ok("Parser is working.");
57645
58354
  } catch (err) {
57646
58355
  const message = err instanceof Error ? err.message : String(err);
@@ -57679,6 +58388,7 @@ async function runDoctor(cwd) {
57679
58388
  ok(`Found ${scannable.length} source files to scan.`);
57680
58389
  } else {
57681
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>`."));
57682
58392
  }
57683
58393
  } catch {
57684
58394
  }
@@ -57729,7 +58439,7 @@ async function runDoctor(cwd) {
57729
58439
  }
57730
58440
 
57731
58441
  // src/cli/commands/badge.ts
57732
- var import_node_path34 = require("path");
58442
+ var import_node_path35 = require("path");
57733
58443
  init_render();
57734
58444
  init_logger();
57735
58445
  init_scan2();
@@ -57738,12 +58448,12 @@ function registerBadge(program) {
57738
58448
  "print a shields.io slop-index badge. Reads .slopbrick/health.json if present (no re-scan); falls back to a fresh scan."
57739
58449
  ).action(async (_cmdOptions, command) => {
57740
58450
  const options = command.optsWithGlobals();
57741
- const cwd = (0, import_node_path34.resolve)(options.workspace ?? process.cwd());
58451
+ const cwd = (0, import_node_path35.resolve)(options.workspace ?? process.cwd());
57742
58452
  const { loadHealth: loadHealth2 } = await Promise.resolve().then(() => (init_dist(), dist_exports));
57743
58453
  const health = loadHealth2(cwd);
57744
58454
  if (health) {
57745
58455
  const synthetic = {
57746
- slopIndex: 100 - health.repositoryHealth
58456
+ aiSlopScore: health.aiSlopScore ?? 0
57747
58457
  };
57748
58458
  logger.info(formatBadge(synthetic));
57749
58459
  process.exit(0);
@@ -57755,7 +58465,7 @@ function registerBadge(program) {
57755
58465
  }
57756
58466
 
57757
58467
  // src/cli/commands/suggest.ts
57758
- var import_node_path35 = require("path");
58468
+ var import_node_path36 = require("path");
57759
58469
  init_advice();
57760
58470
  init_unified_diff();
57761
58471
  init_logger();
@@ -57764,7 +58474,7 @@ function registerSuggest(program) {
57764
58474
  program.command("suggest").description("print remediation advice").action(async (_cmdOptions, command) => {
57765
58475
  const options = command.optsWithGlobals();
57766
58476
  const { report } = await runScan(options);
57767
- const cwd = (0, import_node_path35.resolve)(options.workspace ?? process.cwd());
58477
+ const cwd = (0, import_node_path36.resolve)(options.workspace ?? process.cwd());
57768
58478
  logger.info(formatAdvice(report));
57769
58479
  const diff = formatUnifiedDiff(report, cwd);
57770
58480
  if (diff) logger.info(diff);
@@ -57772,6 +58482,470 @@ function registerSuggest(program) {
57772
58482
  });
57773
58483
  }
57774
58484
 
58485
+ // src/cli/commands/composite.ts
58486
+ var import_node_path38 = require("path");
58487
+ init_logger();
58488
+ init_telemetry();
58489
+
58490
+ // src/engine/cluster.ts
58491
+ var import_node_crypto11 = require("crypto");
58492
+ var DEFAULT_CLUSTER_PARAMS = {
58493
+ minSupport: 0.05,
58494
+ // 5% of files
58495
+ minNPMI: 0.3,
58496
+ // Cohen's-Landis-and-Koch "fair" agreement
58497
+ fisherAlpha: 0.01,
58498
+ // Bonferroni-style floor for many-pair tests
58499
+ minClusterSize: 2
58500
+ };
58501
+ function adaptiveMinSupport(fileCount) {
58502
+ if (fileCount <= 0) return DEFAULT_CLUSTER_PARAMS.minSupport;
58503
+ return Math.max(DEFAULT_CLUSTER_PARAMS.minSupport, 5 / fileCount);
58504
+ }
58505
+ function computeRuleSupport(fireMatrix) {
58506
+ const support = /* @__PURE__ */ new Map();
58507
+ for (const ruleIds of fireMatrix.values()) {
58508
+ for (const ruleId of ruleIds) {
58509
+ support.set(ruleId, (support.get(ruleId) ?? 0) + 1);
58510
+ }
58511
+ }
58512
+ return support;
58513
+ }
58514
+ function prefilterBySupport(support, totalFiles, minSupport) {
58515
+ const kept = /* @__PURE__ */ new Set();
58516
+ const minCount = Math.max(1, Math.ceil(minSupport * totalFiles));
58517
+ for (const [ruleId, count] of support) {
58518
+ if (count >= minCount) kept.add(ruleId);
58519
+ }
58520
+ return kept;
58521
+ }
58522
+ function buildContingency(rA, rB, totalFiles) {
58523
+ let aAndB = 0;
58524
+ let aOnly = 0;
58525
+ let bOnly = 0;
58526
+ for (const file of rA) {
58527
+ if (rB.has(file)) aAndB++;
58528
+ else aOnly++;
58529
+ }
58530
+ for (const file of rB) {
58531
+ if (!rA.has(file)) bOnly++;
58532
+ }
58533
+ return {
58534
+ aAndB,
58535
+ aOnly,
58536
+ bOnly,
58537
+ neither: Math.max(0, totalFiles - aAndB - aOnly - bOnly)
58538
+ };
58539
+ }
58540
+ function fisherExactTwoSided(c) {
58541
+ const N = c.aAndB + c.aOnly + c.bOnly + c.neither;
58542
+ const K1 = c.aAndB + c.aOnly;
58543
+ const K2 = c.aAndB + c.bOnly;
58544
+ const n = K1;
58545
+ const kObs = c.aAndB;
58546
+ const logPmf = (k) => {
58547
+ if (k < Math.max(0, K1 + K2 - N) || k > Math.min(K1, K2)) return -Infinity;
58548
+ return lnChoose(K2, k) + lnChoose(N - K2, K1 - k) - lnChoose(N, K1);
58549
+ };
58550
+ const pObs = Math.exp(logPmf(kObs));
58551
+ const kMin = Math.max(0, K1 + K2 - N);
58552
+ const kMax = Math.min(K1, K2);
58553
+ let p = 0;
58554
+ for (let k = kMin; k <= kMax; k++) {
58555
+ const pk = Math.exp(logPmf(k));
58556
+ if (pk <= pObs + 1e-12) {
58557
+ p += pk;
58558
+ }
58559
+ }
58560
+ return Math.min(1, p);
58561
+ }
58562
+ function lnChoose(n, k) {
58563
+ if (k < 0 || k > n) return -Infinity;
58564
+ if (k === 0 || k === n) return 0;
58565
+ k = Math.min(k, n - k);
58566
+ let s = 0;
58567
+ for (let i = 1; i <= k; i++) {
58568
+ s += Math.log((n - k + i) / i);
58569
+ }
58570
+ return s;
58571
+ }
58572
+ function npmi(c, total) {
58573
+ if (total === 0 || c.aAndB === 0) return -1;
58574
+ const pXY = c.aAndB / total;
58575
+ const pX = (c.aAndB + c.aOnly) / total;
58576
+ const pY = (c.aAndB + c.bOnly) / total;
58577
+ if (pX === 0 || pY === 0 || pXY === 0) return -1;
58578
+ const pmi = Math.log2(pXY / (pX * pY));
58579
+ const norm = -Math.log2(pXY);
58580
+ return norm === 0 ? 1 : pmi / norm;
58581
+ }
58582
+ function computeEdges(fireMatrix, rules, total, params) {
58583
+ const ruleToFiles = /* @__PURE__ */ new Map();
58584
+ for (const ruleId of rules) ruleToFiles.set(ruleId, /* @__PURE__ */ new Set());
58585
+ for (const [file, fired] of fireMatrix) {
58586
+ for (const ruleId of fired) {
58587
+ if (rules.has(ruleId)) ruleToFiles.get(ruleId).add(file);
58588
+ }
58589
+ }
58590
+ const ruleList = Array.from(rules).sort();
58591
+ const edges = [];
58592
+ for (let i = 0; i < ruleList.length; i++) {
58593
+ for (let j = i + 1; j < ruleList.length; j++) {
58594
+ const a = ruleList[i];
58595
+ const b = ruleList[j];
58596
+ const rA = ruleToFiles.get(a);
58597
+ const rB = ruleToFiles.get(b);
58598
+ const c = buildContingency(rA, rB, total);
58599
+ const n = npmi(c, total);
58600
+ if (n < params.minNPMI) continue;
58601
+ const p = fisherExactTwoSided(c);
58602
+ if (p > params.fisherAlpha) continue;
58603
+ edges.push({ ruleIds: [a, b], weight: n, fisherP: p });
58604
+ }
58605
+ }
58606
+ return edges;
58607
+ }
58608
+ function singleLinkageCluster(edges, minClusterSize) {
58609
+ const parent = /* @__PURE__ */ new Map();
58610
+ const find = (x) => {
58611
+ if (!parent.has(x)) {
58612
+ parent.set(x, x);
58613
+ return x;
58614
+ }
58615
+ if (parent.get(x) === x) return x;
58616
+ const root = find(parent.get(x));
58617
+ parent.set(x, root);
58618
+ return root;
58619
+ };
58620
+ const union = (a, b) => {
58621
+ const ra = find(a);
58622
+ const rb = find(b);
58623
+ if (ra !== rb) parent.set(ra, rb);
58624
+ };
58625
+ for (const e of edges) {
58626
+ union(e.ruleIds[0], e.ruleIds[1]);
58627
+ }
58628
+ const groups = /* @__PURE__ */ new Map();
58629
+ for (const k of parent.keys()) {
58630
+ const root = find(k);
58631
+ if (!groups.has(root)) groups.set(root, []);
58632
+ groups.get(root).push(k);
58633
+ }
58634
+ const clusters = [];
58635
+ for (const members of groups.values()) {
58636
+ if (members.length < minClusterSize) continue;
58637
+ members.sort();
58638
+ clusters.push(members);
58639
+ }
58640
+ clusters.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
58641
+ return clusters;
58642
+ }
58643
+ function filesForComposite(fireMatrix, members, minMatch) {
58644
+ const fired = /* @__PURE__ */ new Set();
58645
+ for (const [file, ids] of fireMatrix) {
58646
+ let n = 0;
58647
+ for (const m of members) if (ids.has(m)) n++;
58648
+ if (n >= minMatch) fired.add(file);
58649
+ }
58650
+ return fired;
58651
+ }
58652
+ var DEFAULT_CALIBRATION = {
58653
+ // 1.5× is the documented floor.
58654
+ recallFpRatio: 1.5,
58655
+ // 0.05 absolute FP — the same `min` value most rules get via the
58656
+ // v0.18.x calibration contract.
58657
+ minFpFloor: 0.05
58658
+ };
58659
+ function calibrateCluster(cluster2, edges, fireMatrix, positiveFiles, totalFiles, thresholds = DEFAULT_CALIBRATION) {
58660
+ if (cluster2.length < 2) return null;
58661
+ const clusterSet = new Set(cluster2);
58662
+ const clusterEdges = edges.filter(
58663
+ (e) => clusterSet.has(e.ruleIds[0]) && clusterSet.has(e.ruleIds[1])
58664
+ );
58665
+ if (clusterEdges.length === 0) return null;
58666
+ const strongest = clusterEdges.reduce((a, b) => b.weight > a.weight ? b : a);
58667
+ let best = null;
58668
+ for (let k = 1; k <= cluster2.length; k++) {
58669
+ const firedFiles = filesForComposite(fireMatrix, cluster2, k);
58670
+ const nFiles = firedFiles.size;
58671
+ if (nFiles === 0) continue;
58672
+ let recall = 0;
58673
+ let fp = 0;
58674
+ let precision = 0;
58675
+ let canCalibrate = false;
58676
+ if (positiveFiles && positiveFiles.size > 0) {
58677
+ let tp = 0;
58678
+ let fpc = 0;
58679
+ for (const file of firedFiles) {
58680
+ if (positiveFiles.has(file)) tp++;
58681
+ else fpc++;
58682
+ }
58683
+ const totalPos = positiveFiles.size;
58684
+ const totalNeg = Math.max(1, totalFiles - totalPos);
58685
+ recall = totalPos > 0 ? tp / totalPos : 0;
58686
+ fp = fpc / totalNeg;
58687
+ precision = tp + fpc > 0 ? tp / (tp + fpc) : 0;
58688
+ canCalibrate = true;
58689
+ }
58690
+ const F1 = precision + recall > 0 ? 2 * precision * recall / (precision + recall) : 0;
58691
+ if (canCalibrate) {
58692
+ if (recall / Math.max(fp, 1e-9) < thresholds.recallFpRatio) continue;
58693
+ if (fp < thresholds.minFpFloor && recall < 0.5) continue;
58694
+ }
58695
+ if (best === null || F1 > best.F1 + 1e-9 || Math.abs(F1 - best.F1) < 1e-9 && k > best.minMatch) {
58696
+ best = {
58697
+ minMatch: k,
58698
+ npmi: strongest.weight,
58699
+ fisherP: strongest.fisherP,
58700
+ recall,
58701
+ FP: fp,
58702
+ precision,
58703
+ F1,
58704
+ nFiles
58705
+ };
58706
+ }
58707
+ }
58708
+ return best;
58709
+ }
58710
+ function compositeId(members) {
58711
+ const sorted = [...members].sort();
58712
+ return "composite/" + (0, import_node_crypto11.createHash)("sha1").update(sorted.join("\n")).digest("hex").slice(0, 12);
58713
+ }
58714
+ function worstSeverity(members, memberSeverities) {
58715
+ let worst = "low";
58716
+ const order = { low: 0, medium: 1, high: 2 };
58717
+ for (const m of members) {
58718
+ const s = memberSeverities?.get(m);
58719
+ if (s && order[s] > order[worst]) worst = s;
58720
+ }
58721
+ return worst;
58722
+ }
58723
+ function describeComposite(members, minMatch) {
58724
+ const clusterPart = members.length <= 4 ? members.join("+") : `${members.slice(0, 3).join("+")}+${members.length - 3} more`;
58725
+ return `Composite rule: at least ${minMatch} of {${clusterPart}} fire on the same file.`;
58726
+ }
58727
+ function runClusterer(input) {
58728
+ const total = input.fireMatrix.size;
58729
+ const minSupport = input.params?.minSupport ?? adaptiveMinSupport(total);
58730
+ const params = {
58731
+ minSupport,
58732
+ minNPMI: input.params?.minNPMI ?? DEFAULT_CLUSTER_PARAMS.minNPMI,
58733
+ fisherAlpha: input.params?.fisherAlpha ?? DEFAULT_CLUSTER_PARAMS.fisherAlpha,
58734
+ minClusterSize: input.params?.minClusterSize ?? DEFAULT_CLUSTER_PARAMS.minClusterSize
58735
+ };
58736
+ const support = computeRuleSupport(input.fireMatrix);
58737
+ const rulesKept = prefilterBySupport(support, total, params.minSupport);
58738
+ const edges = computeEdges(input.fireMatrix, rulesKept, total, params);
58739
+ const clusters = singleLinkageCluster(edges, params.minClusterSize);
58740
+ const entries = [];
58741
+ const clusterMaxNPMI = /* @__PURE__ */ new Map();
58742
+ for (const cluster2 of clusters) {
58743
+ const cal = calibrateCluster(
58744
+ cluster2,
58745
+ edges,
58746
+ input.fireMatrix,
58747
+ input.positiveFiles,
58748
+ total
58749
+ );
58750
+ if (!cal) continue;
58751
+ const id = compositeId(cluster2);
58752
+ const severity = worstSeverity(cluster2, input.memberSeverities);
58753
+ entries.push({
58754
+ id,
58755
+ ruleIds: [...cluster2].sort(),
58756
+ minMatch: cal.minMatch,
58757
+ severity,
58758
+ defaultOff: true,
58759
+ description: describeComposite(cluster2, cal.minMatch),
58760
+ calibration: {
58761
+ recall: round3(cal.recall),
58762
+ FP: round3(cal.FP),
58763
+ precision: round3(cal.precision),
58764
+ F1: round3(cal.F1),
58765
+ nFiles: cal.nFiles
58766
+ },
58767
+ provenance: {
58768
+ seed: "auto-cluster",
58769
+ discoveredAt: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
58770
+ nFiles: total,
58771
+ members: cluster2.length,
58772
+ npmi: round3(cal.npmi),
58773
+ fisherP: round3(cal.fisherP)
58774
+ }
58775
+ });
58776
+ clusterMaxNPMI.set(id, cal.npmi);
58777
+ }
58778
+ entries.sort((a, b) => b.calibration.F1 - a.calibration.F1 || a.id.localeCompare(b.id));
58779
+ return { entries, supportCounts: support, clusterMaxNPMI, filesScanned: total, params };
58780
+ }
58781
+ function round3(n) {
58782
+ return Math.round(n * 1e3) / 1e3;
58783
+ }
58784
+
58785
+ // src/rules/composite-loader.ts
58786
+ var import_node_fs32 = require("fs");
58787
+ var import_node_path37 = require("path");
58788
+ var COMPOSITES_FILE = "composites.json";
58789
+ function compositesPath(cwd) {
58790
+ return (0, import_node_path37.join)(cwd, COMPOSITES_FILE);
58791
+ }
58792
+ function readComposites(cwd) {
58793
+ const path = compositesPath(cwd);
58794
+ if (!(0, import_node_fs32.existsSync)(path)) return [];
58795
+ try {
58796
+ const raw = JSON.parse((0, import_node_fs32.readFileSync)(path, "utf-8"));
58797
+ if (!Array.isArray(raw)) return [];
58798
+ return raw.filter(isCompositeRuleEntry);
58799
+ } catch {
58800
+ return [];
58801
+ }
58802
+ }
58803
+ function isCompositeRuleEntry(value) {
58804
+ if (!value || typeof value !== "object") return false;
58805
+ const v = value;
58806
+ return typeof v.id === "string" && Array.isArray(v.ruleIds) && typeof v.minMatch === "number";
58807
+ }
58808
+ function writeComposites(cwd, entries) {
58809
+ const path = compositesPath(cwd);
58810
+ (0, import_node_fs32.mkdirSync)((0, import_node_path37.dirname)(path), { recursive: true });
58811
+ const tmp = `${path}.tmp`;
58812
+ (0, import_node_fs32.writeFileSync)(tmp, JSON.stringify(entries, null, 2), "utf-8");
58813
+ const fs = require("fs");
58814
+ fs.renameSync(tmp, path);
58815
+ }
58816
+
58817
+ // src/cli/commands/composite.ts
58818
+ function registerComposite(program) {
58819
+ const cmd = program.command("composite").description(
58820
+ "discover and enable composite rules (Sprint 3, \xA73b). Run `slopbrick composite discover` to scan scans.jsonl, audit the candidates, then `slopbrick composite enable <id>` to opt a specific composite into slopbrick.config.mjs."
58821
+ );
58822
+ cmd.command("discover").description(
58823
+ "run the empirical composite clusterer on .slopbrick/flywheel/scans.jsonl; writes composites.json"
58824
+ ).action(async (_cmdOptions, command) => {
58825
+ const options = command.optsWithGlobals();
58826
+ const cwd = (0, import_node_path38.resolve)(options.workspace ?? process.cwd());
58827
+ const payloads = readTelemetry(cwd);
58828
+ if (payloads.length === 0) {
58829
+ logger.error(
58830
+ "`composite discover` needs at least 2 scans in .slopbrick/flywheel/scans.jsonl. Run `slopbrick scan` a few times first."
58831
+ );
58832
+ process.exit(1);
58833
+ }
58834
+ const fireMatrix = /* @__PURE__ */ new Map();
58835
+ for (const payload of payloads) {
58836
+ for (const f of payload.files) {
58837
+ const set = fireMatrix.get(f.hash) ?? /* @__PURE__ */ new Set();
58838
+ for (const id of f.ruleIds) set.add(id);
58839
+ if (set.size > 0) fireMatrix.set(f.hash, set);
58840
+ }
58841
+ }
58842
+ const out = runClusterer({
58843
+ fireMatrix,
58844
+ // No labeled positive set; let the clusterer surface for audit.
58845
+ positiveFiles: void 0,
58846
+ now: (/* @__PURE__ */ new Date()).toISOString()
58847
+ });
58848
+ const finalEntries = out.entries.length === 0 ? [...(await Promise.resolve().then(() => (init_composite_seed(), composite_seed_exports))).HAND_CURATED_SEED] : out.entries;
58849
+ writeComposites(cwd, finalEntries);
58850
+ if (out.entries.length === 0) {
58851
+ logger.info("Composite discovery: no clusters passed the empirical gate.");
58852
+ logger.info(
58853
+ " (Try lowering --min-support, or run more scans to densify the ledger.)"
58854
+ );
58855
+ return;
58856
+ }
58857
+ logger.info(
58858
+ `Composite discovery: ${out.entries.length} candidate(s) written to composites.json`
58859
+ );
58860
+ const lines = [];
58861
+ for (const e of out.entries) {
58862
+ lines.push("");
58863
+ lines.push(
58864
+ ` ${e.id} minMatch=${e.minMatch} F1=${e.calibration.F1} (${e.ruleIds.length} members)`
58865
+ );
58866
+ lines.push(` ${describeComposite(e.ruleIds, e.minMatch)}`);
58867
+ lines.push(
58868
+ ` provenance: ${e.provenance.seed} npmi=${e.provenance.npmi} fisherP=${e.provenance.fisherP}`
58869
+ );
58870
+ }
58871
+ logger.info(lines.join("\n"));
58872
+ logger.info(
58873
+ "\nNext step: review each entry. Opt in with `slopbrick composite enable <id>`; opt out by removing the entry."
58874
+ );
58875
+ });
58876
+ cmd.command("enable <id>").description(
58877
+ "opt a specific composite into slopbrick.config.mjs#compositeRules (one composite per call)"
58878
+ ).action(async (id, _cmdOptions, command) => {
58879
+ const options = command.optsWithGlobals();
58880
+ const cwd = (0, import_node_path38.resolve)(options.workspace ?? process.cwd());
58881
+ const composites = readComposites(cwd);
58882
+ const target = composites.find((c) => c.id === id);
58883
+ if (!target) {
58884
+ logger.error(
58885
+ `Composite "${id}" not found in composites.json. Run \`slopbrick composite discover\` first to populate the ledger.`
58886
+ );
58887
+ process.exit(1);
58888
+ }
58889
+ const cfgPath = (0, import_node_path38.resolve)(cwd, "slopbrick.config.mjs");
58890
+ const fs = require("fs");
58891
+ if (!fs.existsSync(cfgPath)) {
58892
+ logger.error(`No slopbrick.config.mjs found at ${cfgPath}.`);
58893
+ process.exit(1);
58894
+ }
58895
+ const src = fs.readFileSync(cfgPath, "utf-8");
58896
+ const compositeSnippet = JSON.stringify(
58897
+ {
58898
+ id: target.id,
58899
+ category: "ai",
58900
+ severity: target.severity,
58901
+ aiSpecific: true,
58902
+ description: target.description,
58903
+ defaultOff: true,
58904
+ ruleIds: target.ruleIds,
58905
+ minMatch: target.minMatch,
58906
+ create: "() => ({})",
58907
+ analyze: "() => []"
58908
+ },
58909
+ null,
58910
+ 2
58911
+ );
58912
+ if (src.includes(`id: '${target.id}'`) || src.includes(`id: "${target.id}"`)) {
58913
+ logger.info(`Composite "${id}" is already enabled in slopbrick.config.mjs (idempotent).`);
58914
+ return;
58915
+ }
58916
+ const updated = src.replace(
58917
+ /(export default\s*{[\s\S]*?)(}\s*;?\s*)$/,
58918
+ (_match, head, tail) => `${head} compositeRules: [
58919
+ ${compositeSnippet}
58920
+ ],
58921
+ ${tail}`
58922
+ );
58923
+ fs.writeFileSync(cfgPath, updated, "utf-8");
58924
+ logger.info(`Composite "${id}" enabled.`);
58925
+ logger.info("Next scan will apply it; rerun `slopbrick scan` to see the effect.");
58926
+ });
58927
+ cmd.command("list").description("list composites currently registered (composites.json + config-declared)").action(async (_cmdOptions, command) => {
58928
+ const options = command.optsWithGlobals();
58929
+ const cwd = (0, import_node_path38.resolve)(options.workspace ?? process.cwd());
58930
+ const composites = readComposites(cwd);
58931
+ if (composites.length === 0) {
58932
+ logger.info("No composites registered. Run `slopbrick composite discover` first.");
58933
+ return;
58934
+ }
58935
+ const lines = ["Registered composites:"];
58936
+ for (const e of composites) {
58937
+ lines.push(
58938
+ `
58939
+ ${e.id}
58940
+ ${describeComposite(e.ruleIds, e.minMatch)}
58941
+ members=[${e.ruleIds.join(", ")}] minMatch=${e.minMatch}
58942
+ F1=${e.calibration.F1} recall=${e.calibration.recall} FP=${e.calibration.FP}`
58943
+ );
58944
+ }
58945
+ logger.info(lines.join("\n"));
58946
+ });
58947
+ }
58948
+
57775
58949
  // src/cli/explain.ts
57776
58950
  var RULES_BASE_URL2 = "https://github.com/usebrick/platform/blob/main/packages/slopbrick/src/rules";
57777
58951
  function ruleIdToFilename2(ruleId) {
@@ -57821,154 +58995,13 @@ function formatExplain(result) {
57821
58995
  // src/cli/commands/explain.ts
57822
58996
  init_logger();
57823
58997
  init_builtins();
57824
-
57825
- // src/snippet/data.ts
57826
- var CATEGORY_DIRECTIVES = {
57827
- visual: 'Avoid the saturated "vibe purple" Tailwind palette (violet-400-700, indigo-400-700). Prefer emerald, sky, amber, rose for accents. Never use arbitrary color values like bg-[#7c3aed] when a token exists.',
57828
- logic: "Never use explicit `any`. Use `unknown` and narrow. Always add an AbortSignal to fetches. Handle errors with try/catch, never swallow with empty catch. Use `as const` instead of `as Type` casts.",
57829
- wcag: 'All form inputs must have an accessible label (visible <label>, aria-label, or aria-labelledby). Decorative images must have empty alt="". Buttons must be <button>, never <div onClick>. Touch targets must be \u2265 24\xD724 CSS px.',
57830
- security: 'Never store tokens in localStorage or sessionStorage \u2014 use httpOnly Secure SameSite cookies. Never put secrets in NEXT_PUBLIC_* / REACT_APP_* / VITE_* env vars. Validate e.origin in postMessage handlers. Never dangerouslySetInnerHTML with user input. Never use target="_blank" without rel="noopener".',
57831
- perf: "Always use AbortController with fetch. Use image width/height attributes to prevent CLS. Use <Suspense> around async client components. Don't load all images eagerly.",
57832
- typo: `Never leave TODO / placeholder / "change me" copy in shipped code. Use real i18n strings or the project's content map.`,
57833
- layout: "Don't stack badge-above-h1 hero patterns. Don't build 3-stat banner rows without explicit user request. Don't use emoji inside nav items (use SVG icons). Use the project's spacing scale (4px or 8px grid), never arbitrary values like p-[13px].",
57834
- component: "Don't build components > 200 lines. Extract shared subcomponents. Avoid circular prop drilling \u2014 use context.",
57835
- arch: "For Astro: server-render everything by default; only opt-in to client islands when you need interactivity. Don't put secrets in client-side code.",
57836
- test: "Use domain-specific fixture data, assert on value shapes not just truthiness, and consolidate repeated setup into helpers. Avoid `expect(x).toBeDefined()` placeholders and textbook fixtures like 'John Doe' or 'test@test.com'."
57837
- };
57838
- var RULE_HINTS = {
57839
- // v0.38.0 hygiene: 37 v10-DORMANT rule hints were removed. Every
57840
- // remaining key matches a rule in src/rules/builtins.ts. No orphans.
57841
- // (v0.16.0 previously moved 35 out-of-scope orphan hints to
57842
- // docs/research/backlog-rule-hints.md
57843
- // for future implementers to paste back when the corresponding rules ship.)
57844
- "security/hardcoded-secret": "Never inline API keys, JWT secrets, or database passwords in source. Load them from env vars and never commit a .env file. Assume any published secret is compromised and rotate it.",
57845
- "security/exposed-env-var": "NEVER prefix a secret with NEXT_PUBLIC_, VITE_, REACT_APP_, EXPO_PUBLIC_, GATSBY_, or PUBLIC_ \u2014 those vars are inlined into every browser build.",
57846
- "security/dangerous-cors": "Don't set Access-Control-Allow-Origin: * on production endpoints. Restrict to an explicit allowlist; never combine wildcard origin with credentials: true.",
57847
- "security/missing-auth-check": "Every server route handler must perform an authentication + authorization check at the top. Reachability of an endpoint by any user (authenticated or not) is a vulnerability, not a feature.",
57848
- "security/unsafe-html-render": "Sanitize any non-literal value passed to dangerouslySetInnerHTML with DOMPurify. Better: avoid the prop entirely and let React escape via children.",
57849
- "security/fail-open-auth": "Don't gate auth bypasses on NODE_ENV. Replace dev-env checks with an explicit AUTH_BYPASS flag that's never set in production.",
57850
- "security/sql-construction": 'Never build SQL with string concatenation or template-literal interpolation. Use parameterized queries: pg client.query("... WHERE id = $1", [id]) or your ORM query builder.',
57851
- "security/public-admin-route": "Routes under /admin, /internal, /debug, /staff, /manage, /private need an explicit role check on top of standard auth \u2014 auth alone is not enough for privileged paths.",
57852
- "visual/arbitrary-escape": "Never use bracket-notation values like text-[13px] or bg-[#7c3aed]. Use design tokens instead.",
57853
- "visual/spacing-scale-violation": "Use spacing scale tokens (p-2, gap-4, etc.) instead of arbitrary values like p-[13px] or gap-[1.75rem].",
57854
- "visual/radius-scale-violation": "Use radius scale tokens (rounded-md, rounded-lg, etc.) instead of arbitrary values like rounded-[7px].",
57855
- // v0.16.0 — in-scope orphans kept here (corresponding rule ships in v0.16.0).
57856
- "typo/placeholder-text": 'Never leave "TODO", "placeholder", "change me", "your text here" in shipped UI.',
57857
- "logic/key-prop-missing": "Always provide a stable `key` prop when rendering lists.",
57858
- "logic/boundary-violation": "Don't import data-layer / DB code into UI components. Server-side only.",
57859
- "wcag/missing-alt": 'Every <img> needs alt text. Decorative: alt="". Informative: describe the image.',
57860
- "security/localstorage-token": "Never store JWT / access token / refresh token in localStorage or sessionStorage. Issue as httpOnly cookie.",
57861
- "security/eval": "Never use eval() or new Function(). These are RCE vectors if the input is ever attacker-controlled.",
57862
- "security/target-blank-no-noopener": 'Always add rel="noopener" (or rel="noreferrer") to target="_blank" links.',
57863
- "component/giant-component": "Don't build components > 200 lines. Extract shared subcomponents.",
57864
- "component/multiple-components-per-file": "One component per file. Move subcomponents into their own files so the Context Window stays small and boundary tests are easy.",
57865
- "component/shadcn-prop-mismatch": "Select shadcn variants via the `variant` prop, not long `className` overrides. See the component registry for available variants.",
57866
- "context/import-path-mismatch": "Use only the canonical import paths declared in brick.config.json (e.g. @/components/ui/, @/lib/).",
57867
- "layout/gap-monopoly": "Mix gap-2 / gap-4 / gap-6 / gap-12 deliberately. Don't repeat the same gap value across the whole project.",
57868
- "layout/math-element-uniformity": "Human files have lopsided interactive counts (1 button + 12 inputs). AI tends to balance them. Build forms with many inputs and few buttons.",
57869
- "layout/math-grid-uniformity": "Vary grid-cols-N (grid-cols-2, grid-cols-3, grid-cols-4, grid-cols-6) across sections instead of repeating grid-cols-3.",
57870
- "layout/spacing-grid": "Use the configured spacing scale (4px or 8px grid). Avoid arbitrary values like p-[13px] that aren't on the scale.",
57871
- "logic/ghost-defensive": "Use optional chaining (?.) or early returns instead of deep && guards. If a defensive chain runs 3+ levels deep, refactor.",
57872
- "logic/heaps-deviation": "Inspect for LLM-style vocabulary patterns: this file's vocabulary grows faster (high Heaps \u03BB) or slower (low \u03BB) than typical source code. Verify authorship if unexpected. (v0.12.0 \u2014 Christ et al. EMNLP Findings 2025.)",
57873
- "logic/ks-distribution-shift": "Inspect the shifted features. KS detects both AI anomalies and production-rot anomalies (it is symmetric); combine with Heaps/Zipf for AI-specific signal. (v0.12.0 \u2014 arXiv:2510.15996, Oct 2025.)",
57874
- "logic/zipf-slope-anomaly": "Inspect for LLM-style frequency distribution: this file's identifier usage is more peaked or flatter than typical human code. (v0.12.0 \u2014 Christ et al. EMNLP Findings 2025.)",
57875
- "logic/math-any-density": "Replace `: any` with proper types. Start with the parameter/return types of the most-used functions.",
57876
- "logic/math-console-log-storm": "Replace debug logs with a proper debugger or logger.debug(). Remove all console.log before shipping.",
57877
- "logic/math-gini-class-usage": "Spread usage across more class tokens instead of repeating the same handful (p-4, p-8, rounded-lg, etc.).",
57878
- "logic/math-variable-name-entropy": "Use domain-specific identifier names (reservations, invoices, customers) instead of generic data/items/value.",
57879
- "logic/optimistic-no-rollback": "In optimistic updates, revert state in the catch block: `setX(prev => prev)`. Never leave stale UI on error.",
57880
- "logic/reactive-hook-soup": "Coordinate state via a single derived value (useMemo) or a state machine. Avoid chained useEffects that sync local state.",
57881
- "logic/zombie-state": "Remove unused useState or wire it into the component. Don't leave declared-but-never-read state bindings.",
57882
- "dead/unused-import": "Remove the import or use the symbol somewhere. Unused imports are the most common AI-iteration signature \u2014 the model added it for a feature, then rewrote the function without cleaning up.",
57883
- "dead/unused-local": "Remove the declaration or use the variable. AI-iteration signature: the model declared a binding for a feature, then rewrote the function without cleaning up.",
57884
- "dead/unused-parameter": "Remove the parameter (and update every call site) or use it in the function body. AI-iteration signature: the model added a parameter for a feature, then rewrote the function without removing parameters the new code does not need.",
57885
- "dead/dead-branch": "Replace the literal boolean with a real condition, or remove the dead branch. AI-iteration signature: a feature flag toggled to a constant, or a wrapper from a previous refactor that was never cleaned up.",
57886
- "dead/unreachable": "Remove this statement \u2014 code after a return/throw/break/continue is unreachable. AI-iteration signature: the model added an early return for a new error path, then forgot the rest of the function body was still sitting below it.",
57887
- "perf/cls-image": "Add width/height attributes or an aspect-ratio utility to prevent layout shift.",
57888
- "perf/css-bloat": "Extract to a CSS variable (`--surface-card`) or a component prop when a class string repeats 5+ times.",
57889
- "typo/math-button-label-uniformity": 'Mix button lengths deliberately \u2014 pair a short "Save" with a longer "Mark as complete" \u2014 instead of repeating the same template.',
57890
- "visual/inline-style-dominance": "Replace inline `style={{...}}` with className utilities (e.g. Tailwind `p-4 m-2 gap-3`) or a CSS module class.",
57891
- "visual/math-default-font": "Import a distinctive font (next/font/google, @font-face, or a CSS variable) instead of relying on the framework default.",
57892
- "visual/math-font-entropy": "Use a wider range of text sizes (text-xs, text-sm, text-lg, text-xl, text-2xl, text-3xl) for a more deliberate type scale.",
57893
- "visual/math-rounded-entropy": "Use a wider range of border-radius values (sm, md, 2xl, 3xl) instead of repeating the same lg/xl/full pattern.",
57894
- "visual/math-spacing-entropy": "Mix more spacing values from the design scale (e.g. 3, 5, 7, 10, 14, 20, 28) instead of repeating the same 4/8 pattern.",
57895
- "visual/naturalness-anomaly": "Use domain-specific identifier names so the identifier stream reflects the actual problem domain. Hindle 2012 \xA74.3: LLM-generated code reuses a narrow band of training-data identifiers, dropping distinct-token ratio below 30%.",
57896
- "visual/math-color-cluster": "Use at least 3 distinct hue families (e.g. blue + amber + green) instead of clustering every color in the violet/fuchsia band.",
57897
- "wcag/focus-appearance": "Add a focus-visible:ring-* class, or remove outline-none. Keyboard users need a visible focus indicator.",
57898
- "wcag/focus-obscured": "Ensure focused elements are not hidden behind fixed or sticky wrappers.",
57899
- "test/weak-assertion": "Assert on a specific value or shape: `expect(x).toEqual(expectedValue)`. Avoid `.toBeDefined()` / `.toBeTruthy()` placeholders and tautological `expect(x).toBe(x)`.",
57900
- "test/duplicate-setup": "Extract shared `beforeEach` / `setupServer` blocks into a single helper (e.g. `renderWithProviders`) so each describe block calls it instead of repeating setup.",
57901
- "test/fake-placeholder": "Use domain-specific fixture values (`alice@acme-corp.com`, `Order#48231`) or a factory like @faker-js/faker. Avoid textbook placeholders (`John Doe`, `test@test.com`, `id: 1`).",
57902
- "product/terminology-drift": "Keep the leading noun consistent across files: `PostList`, `PostDetail`, `PostCard` are one entity, not three. AI agents pick slightly different words each invocation; product copy drifts.",
57903
- "product/ux-pattern-fragmentation": "Keep the per-category count tight: modal \u22643, toast \u22642, button \u22644, input \u22643, card \u22643. Pick the canonical one and alias the rest. `slopbrick patterns` reports the per-category count.",
57904
- // v0.13.0 — AI-specific rules (peer-reviewed signals).
57905
- "ai/markdown-leakage": "Delete stray `\\`\\`\\`<lang>\\`\\`\\`` markers; they are Markdown fences, not valid syntax in standalone source files (Yotkova et al. SemEval-2026).",
57906
- "ai/comment-ratio": "AI tools either skip comments (reductive models) or over-comment (expansive models). Match the corpus mean \xB1 2\u03C3 (Rahman et al. 2024, Bisztray et al. 2025).",
57907
- "ai/whitespace-regularity": "Vary inter-token spacing (single spaces mostly, occasional alignment in tables). Uniform runs are an AI tell (Shi et al. DetectCodeGPT 2024).",
57908
- "ai/text-like-ratio": "Move natural-language explanations to README files or doc comments. Inline prose in source code is hard to maintain (Yotkova 2026).",
57909
- "ai/errors-near-eof": "Check whether the file was truncated by a token limit. Unbalanced delimiters near EOF suggest the model ran out of output budget (Yotkova 2026).",
57910
- "ai/any-density": "Replace `any` with `unknown`, `Record<string, unknown>`, or a domain type. The `: any` annotation propagates type-errors and defeats TS safety (Lee, Hassan, Hindle MSR 2026).",
57911
- "ai/renyi-profile": "The token distribution is mass-concentrated on a few high-frequency tokens. Verify authorship if unexpected (R\xE9nyi 1961, Moslonka 2025).",
57912
- "ai/segment-surprisal-cv": "The cross-entropy is suspiciously uniform across the file. Real codebases have varied registers (Binoculars, Hans 2024).",
57913
- "ai/compression-profile": "The file compresses unusually well and lines are highly repetitive \u2014 characteristic of AI-generated boilerplate (Cilibrasi 2005, Mahoney 1999).",
57914
- // v0.14.5b — 6 new AI tendency detection rules (DORMANT in v0.14.5b;
57915
- // reclassified post-v7 calibration in v0.14.5d)
57916
- "ai/tailwind-color-overuse": "If most utility classes are bg-violet-500, text-violet-600, ring-violet-400 \u2014 the project is on the AI-default palette. Audit and replace with the project's design tokens.",
57917
- "ai/default-react-stack": "Every new file is a Vite + React + Tailwind + Zustand + React Hook Form clone. Verify the project actually needs each piece before adding it.",
57918
- "ai/library-reinvention": "Re-implementing zustand, react-hook-form, or date-fns inline (custom event emitters, useState reducers, manual date math) is a sign of LLM completion-mode code. Use the libraries the project already depends on.",
57919
- "ai/state-default-overuse": "Wrapping every component in useState + useEffect for transient UI state is the React tutorial default. Real production code uses refs, uncontrolled inputs, or the project's state lib.",
57920
- "ai/fetch-default-overuse": "Calling fetch() inline in components instead of going through the project's data-fetching layer (react-query, swr, or your own client) bypasses the cache, error boundary, and abort handling.",
57921
- "ai/console-debug-storm": "5+ console.log calls in a single file is debug-by-print-statement, the LLM training-data default. Remove before commit; use the project's logger or a real debugger.",
57922
- // v0.17.0 — db/* rules (Postgres static analysis via pgsql-parser)
57923
- "db/sql-concat": 'Never build SQL with template-literal interpolation \u2014 `db.query(`SELECT \u2026 WHERE id = ${id}`)` is a SQL injection vector. Use parameterized queries (`db.query("\u2026 WHERE id = $1", [id])`) or your ORM query builder.',
57924
- // v0.17.0 — docs/* rules (markdown drift detection)
57925
- "docs/stale-package-reference": "Update the doc to reference an installed package, or add the package to package.json. Copy-pasted install commands from a previous project are the #1 doc-drift failure mode.",
57926
- "docs/stale-function-reference": "Rename the doc reference to match a current export, or add a wrapper export. Stale function callouts in tutorials cost readers 10+ minutes of debugging.",
57927
- "docs/broken-link": "Create the file or fix the link target. On a public docs site, broken links erode trust more than stale copy.",
57928
- // v0.18.9 — Rust-specific rules (tree-sitter-backed)
57929
- "rust/unused-pub-fn": "Remove the function or call it. Rust's compiler doesn't warn on `pub fn` references unless `#![warn(dead_code)]` is set at the crate root. AI agents leave these behind during iterative refactors.",
57930
- "rust/unwrap-in-production": "Replace `.unwrap()` / `.expect()` with `?` (early-return on Err), `.map_err(...)` for error conversion, or an explicit `match`. They panic in production; reserve them for `#[cfg(test)]` or `#[test]` scopes.",
57931
- "rust/todo-macro": "Implement the function body or remove the stub. `todo!()` / `unimplemented!()` both expand to `panic!()`, so a hit at runtime crashes the program. Move them into a `#[cfg(test)] mod tests` block to suppress.",
57932
- "rust/stringly-typed": 'Replace the `String` / `&str` parameter with the typed enum that already exists in the file. Stringly-typed APIs lose type information at the boundary \u2014 typos (`"Click"` vs `"click"`) only fail at runtime.',
57933
- // v0.19.0 — TypeScript-specific rules
57934
- "ts/enum-vs-as-const": "Replace `enum Foo { A, B }` with `const Foo = { A: 'A', B: 'B' } as const` (or `const Foo = ['A', 'B'] as const`). Modern TS style guides (Google, TS-eslint) prefer `as const` because enums have surprising runtime semantics.",
57935
- "ts/import-type-misuse": "Use `import type { X } from '...'` instead of `import { type X } from '...'`. The split form is more common in real codebases and makes the type-only intent unambiguous.",
57936
- "ts/never-vs-unknown": "The `never` return type means 'this function never returns'. Reserve it for functions that always throw, always loop, or always exit. For 'impossible' branches, use a concrete type (`void`, `Error`, `unknown`) and an exhaustive check.",
57937
- "ts/excessive-type-assertion": "More than 3 `as` assertions in one function is a strong signal that the type is wrong, not the code. Fix the type definition (or use a type guard) instead of bypassing the type system.",
57938
- // v0.19.0 — Go-specific rules
57939
- "go/struct-tag-inconsistency": 'Pick one tag style per struct. If most fields are `json:"foo"`, this field should be too. Real Go code maintains consistency within a struct (or within a package).',
57940
- "go/nil-slice-vs-empty": "Either declare as `var x = []int{}` or assign with `make([]int, 0)`. The nil/empty inconsistency is an AI signal \u2014 real code picks one form and sticks with it.",
57941
- // v0.19.0 — Duplication detector
57942
- "dup/identical-block": "Refactor to a shared helper. This is a Type-1 clone (byte-for-byte identical after normalization). Common in AI-generated code that copy-pastes from training data.",
57943
- // v0.23.0 — Type-2 near-duplicate detector (MinHash + LSH)
57944
- "dup/near-duplicate": "Refactor to a shared helper. Type-2 near-duplicate: same identifiers + structure, different whitespace/comments. AI-iterated code regenerates the same function with cosmetic edits. (v0.23.0 \u2014 MinHash + LSH, k=5, thr 0.7.)",
57945
- // v0.24.0 — Type-3 structural clone detector (two-stage MinHash)
57946
- "dup/structural-clone": "Refactor to a shared helper. Type-3 clone: same shape as another file after identifier canonicalization, with renames and/or added/removed statements. AI agents and copy-paste both produce this. Extract the shared part. (v0.24.0.)",
57947
- // v0.24.0 — Kotlin rules (DORMANT until v9 Kotlin corpus calibration)
57948
- // v0.29.0 — non-AI Kotlin rules (DORMANT until larger pos arm)
57949
- // v0.30.0 — non-AI Java rules (Option C applied to v9 Java corpus, 92k files)
57950
- "java/sql-string-concat": "Use a PreparedStatement (JDBC), setParameter (jOOQ), or an ORM (Hibernate, MyBatis with #{}). String concat into SQL is the canonical SQL-injection pattern. OWASP A03:2021. (v0.30.0 \u2014 DORMANT, ratio 0.59.)",
57951
- "java/thread-sleep-in-loop": "Use ScheduledExecutorService for periodic work, or BlockingQueue.take() for event-driven work. Thread.sleep in a loop is the polling anti-pattern \u2014 ties up Tomcat/Jetty/Netty threads. (v0.30.0 \u2014 DORMANT, era-confounded ratio 0.97.)",
57952
- // v0.35.0 — content-based detection (CoCoNUTS-inspired)
57953
- "java/suspicious-implementation": "Function name claims validate/encrypt/hash/sanitize but body is empty, returns null/true, or returns the input. This is a content mismatch \u2014 the function's claimed behavior doesn't match its actual behavior. OWASP A04:2021.",
57954
- // v0.35.1 — Raidar-inspired content-based detection
57955
- "java/lost-stack-trace": 'catch block throws a new exception without the original cause \u2014 stack trace is lost. Pass the original exception as the second arg to the new exception\'s constructor: `throw new XxxException("msg", e)`.',
57956
- // v0.24.0 — Swift rules (DORMANT until v9 Swift corpus calibration)
57957
- "swift/force-unwrap": "Replace with the safe form: `as?` + guard/if let, `try?`, or `guard let x = optional else { return }`. `!` crashes unconditionally in release. (v0.24.0 \u2014 DORMANT.)",
57958
- "swift/print-debug": "Replace with `Logger(subsystem:..., category:...).info(...)` (os.log). `print` writes to stdout with no level and no redaction. (v0.24.0 \u2014 DORMANT.)",
57959
- "swift/fatal-error-thrown": "Replace with `return` of a typed default (e.g. `nil`, `[]()`) or throw a typed `enum MyError: Error`. `fatalError` survives release builds. (v0.24.0 \u2014 DORMANT.)",
57960
- "swift/implicitly-unwrapped-optional": "Declare as `var name: Type?` and unwrap with `guard let`. IUOs exist for Obj-C bridging / IBOutlets but using them everywhere is an AI signal. (v0.24.0 \u2014 DORMANT.)",
57961
- "swift/strong-self-capture": "Capture with `[weak self]` (or `[unowned self]` if guaranteed non-nil): `[weak self] in self?.foo = bar`. Strong self capture creates a retain cycle. (v0.24.0 \u2014 DORMANT.)",
57962
- // v0.24.0 — C++ rules (DORMANT until v9 C++ corpus calibration)
57963
- "cpp/raw-new-delete": "Replace with `auto p = std::make_unique<T>(...)`. Smart pointers delete on scope exit, never leak on early return / exception, never double-free. (v0.24.0 \u2014 DORMANT.)",
57964
- "cpp/c-style-cast": "Use the named cast: `static_cast<int>(x)`, `reinterpret_cast<...>(p)`, `const_cast<...>(ref)`. C-style casts silently pick static / reinterpret / const \u2014 impossible to grep. (v0.24.0 \u2014 DORMANT.)",
57965
- "cpp/printf-debug": "Replace with `spdlog::info(...)`, `LOG(INFO) << ...` (glog), or `ABSL_LOG(INFO) << ...`. `printf` / `std::cout` have no levels, no redaction, no sink routing. (v0.24.0 \u2014 DORMANT.)",
57966
- "cpp/magic-numbers": "Replace with `constexpr int MAX = 1024;` (or `static constexpr`). Named constants live next to their value, can be searched, force the reader to mean what they say. (v0.24.0 \u2014 DORMANT.)"
57967
- };
57968
-
57969
- // src/cli/commands/explain.ts
58998
+ init_data();
57970
58999
  function registerExplain(program) {
57971
- 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
+ }
57972
59005
  const result = explainRule(ruleId, builtinRules, RULE_HINTS);
57973
59006
  logger.info(formatExplain(result));
57974
59007
  if ("error" in result) process.exit(2);
@@ -57976,7 +59009,7 @@ function registerExplain(program) {
57976
59009
  }
57977
59010
 
57978
59011
  // src/cli/commands/calibration.ts
57979
- var import_node_path36 = require("path");
59012
+ var import_node_path39 = require("path");
57980
59013
  var import_node_url = require("url");
57981
59014
 
57982
59015
  // src/rules/signal-strength.json
@@ -61569,7 +62602,7 @@ var signal_strength_default2 = {
61569
62602
  };
61570
62603
 
61571
62604
  // src/cli/commands/calibration.ts
61572
- var __dirname = (0, import_node_path36.dirname)((0, import_node_url.fileURLToPath)(__importMetaUrl));
62605
+ var __dirname = (0, import_node_path39.dirname)((0, import_node_url.fileURLToPath)(__importMetaUrl));
61573
62606
  function loadData() {
61574
62607
  return signal_strength_default2;
61575
62608
  }
@@ -61690,13 +62723,13 @@ function registerCalibration(program) {
61690
62723
  }
61691
62724
 
61692
62725
  // src/cli/commands/install.ts
61693
- var import_node_path38 = require("path");
62726
+ var import_node_path41 = require("path");
61694
62727
  init_logger();
61695
62728
  init_git();
61696
62729
 
61697
62730
  // src/cli/installer.ts
61698
- var import_node_fs31 = require("fs");
61699
- var import_node_path37 = require("path");
62731
+ var import_node_fs33 = require("fs");
62732
+ var import_node_path40 = require("path");
61700
62733
  var BEGIN_SENTINEL = "# slopbrick-hook-begin";
61701
62734
  var END_SENTINEL = "# slopbrick-hook-end";
61702
62735
  var SENTINEL_BLOCK = `${BEGIN_SENTINEL}
@@ -61704,14 +62737,14 @@ npx slopbrick --staged
61704
62737
  ${END_SENTINEL}
61705
62738
  `;
61706
62739
  function hookPath(gitRoot) {
61707
- const huskyDir = (0, import_node_path37.join)(gitRoot, ".husky");
61708
- if ((0, import_node_fs31.existsSync)(huskyDir)) {
61709
- return (0, import_node_path37.join)(huskyDir, "pre-commit");
62740
+ const huskyDir = (0, import_node_path40.join)(gitRoot, ".husky");
62741
+ if ((0, import_node_fs33.existsSync)(huskyDir)) {
62742
+ return (0, import_node_path40.join)(huskyDir, "pre-commit");
61710
62743
  }
61711
- return (0, import_node_path37.join)(gitRoot, ".git", "hooks", "pre-commit");
62744
+ return (0, import_node_path40.join)(gitRoot, ".git", "hooks", "pre-commit");
61712
62745
  }
61713
62746
  function readHookContent(path) {
61714
- return (0, import_node_fs31.readFileSync)(path, "utf8");
62747
+ return (0, import_node_fs33.readFileSync)(path, "utf8");
61715
62748
  }
61716
62749
  function sentinelsPresent(content) {
61717
62750
  const lines = content.split(/\r?\n/);
@@ -61733,7 +62766,7 @@ function replaceSentinelBlock(content) {
61733
62766
  }
61734
62767
  function installHook(gitRoot) {
61735
62768
  const path = hookPath(gitRoot);
61736
- if ((0, import_node_fs31.existsSync)(path)) {
62769
+ if ((0, import_node_fs33.existsSync)(path)) {
61737
62770
  const content = readHookContent(path);
61738
62771
  const { begin, end } = sentinelsPresent(content);
61739
62772
  if (begin && end) {
@@ -61745,9 +62778,9 @@ function installHook(gitRoot) {
61745
62778
  exitCode: 0
61746
62779
  };
61747
62780
  }
61748
- (0, import_node_fs31.writeFileSync)(path, replaced.endsWith("\n") ? replaced : `${replaced}
62781
+ (0, import_node_fs33.writeFileSync)(path, replaced.endsWith("\n") ? replaced : `${replaced}
61749
62782
  `);
61750
- (0, import_node_fs31.chmodSync)(path, 493);
62783
+ (0, import_node_fs33.chmodSync)(path, 493);
61751
62784
  return {
61752
62785
  ok: true,
61753
62786
  message: "Replaced pre-commit hook block",
@@ -61765,17 +62798,17 @@ function installHook(gitRoot) {
61765
62798
  }
61766
62799
  const normalized = content.length > 0 && !content.endsWith("\n") ? `${content}
61767
62800
  ` : content;
61768
- (0, import_node_fs31.writeFileSync)(path, `${normalized}${SENTINEL_BLOCK}`);
61769
- (0, import_node_fs31.chmodSync)(path, 493);
62801
+ (0, import_node_fs33.writeFileSync)(path, `${normalized}${SENTINEL_BLOCK}`);
62802
+ (0, import_node_fs33.chmodSync)(path, 493);
61770
62803
  return {
61771
62804
  ok: true,
61772
62805
  message: "Installed pre-commit hook",
61773
62806
  exitCode: 0
61774
62807
  };
61775
62808
  }
61776
- (0, import_node_fs31.mkdirSync)((0, import_node_path37.dirname)(path), { recursive: true });
61777
- (0, import_node_fs31.writeFileSync)(path, SENTINEL_BLOCK, { mode: 493 });
61778
- (0, import_node_fs31.chmodSync)(path, 493);
62809
+ (0, import_node_fs33.mkdirSync)((0, import_node_path40.dirname)(path), { recursive: true });
62810
+ (0, import_node_fs33.writeFileSync)(path, SENTINEL_BLOCK, { mode: 493 });
62811
+ (0, import_node_fs33.chmodSync)(path, 493);
61779
62812
  return {
61780
62813
  ok: true,
61781
62814
  message: "Installed pre-commit hook",
@@ -61784,7 +62817,7 @@ function installHook(gitRoot) {
61784
62817
  }
61785
62818
  function uninstallHook(gitRoot) {
61786
62819
  const path = hookPath(gitRoot);
61787
- if (!(0, import_node_fs31.existsSync)(path)) {
62820
+ if (!(0, import_node_fs33.existsSync)(path)) {
61788
62821
  return {
61789
62822
  ok: true,
61790
62823
  message: "Hook not installed",
@@ -61820,7 +62853,7 @@ function uninstallHook(gitRoot) {
61820
62853
  }
61821
62854
  const remaining = [...lines.slice(0, start), ...lines.slice(end + 1)];
61822
62855
  const result = remaining.join("\n");
61823
- (0, import_node_fs31.writeFileSync)(path, result.endsWith("\n") ? result : `${result}
62856
+ (0, import_node_fs33.writeFileSync)(path, result.endsWith("\n") ? result : `${result}
61824
62857
  `);
61825
62858
  return {
61826
62859
  ok: true,
@@ -61833,7 +62866,7 @@ function uninstallHook(gitRoot) {
61833
62866
  function registerInstall(program) {
61834
62867
  program.command("install").description("install the git pre-commit hook").action(async (_cmdOptions, command) => {
61835
62868
  const options = command.optsWithGlobals();
61836
- const cwd = (0, import_node_path38.resolve)(options.workspace ?? process.cwd());
62869
+ const cwd = (0, import_node_path41.resolve)(options.workspace ?? process.cwd());
61837
62870
  const root = getGitRoot(cwd);
61838
62871
  if (!root) {
61839
62872
  logger.error("Not a Git repository. Run `git init` first, or remove --staged from your command.");
@@ -61848,13 +62881,13 @@ function registerInstall(program) {
61848
62881
  }
61849
62882
 
61850
62883
  // src/cli/commands/uninstall.ts
61851
- var import_node_path39 = require("path");
62884
+ var import_node_path42 = require("path");
61852
62885
  init_logger();
61853
62886
  init_git();
61854
62887
  function registerUninstall(program) {
61855
62888
  program.command("uninstall").description("uninstall the git pre-commit hook").action(async (_cmdOptions, command) => {
61856
62889
  const options = command.optsWithGlobals();
61857
- const cwd = (0, import_node_path39.resolve)(options.workspace ?? process.cwd());
62890
+ const cwd = (0, import_node_path42.resolve)(options.workspace ?? process.cwd());
61858
62891
  const root = getGitRoot(cwd);
61859
62892
  if (!root) {
61860
62893
  logger.error("Not a Git repository. Run `git init` first, or remove --staged from your command.");
@@ -61877,8 +62910,8 @@ init_config2();
61877
62910
  init_header();
61878
62911
 
61879
62912
  // src/mcp/tools.ts
61880
- var import_node_fs32 = require("fs");
61881
- var import_node_path40 = require("path");
62913
+ var import_node_fs34 = require("fs");
62914
+ var import_node_path43 = require("path");
61882
62915
  init_worker();
61883
62916
  init_patterns();
61884
62917
  init_architecture_score();
@@ -61887,7 +62920,7 @@ init_structure_md();
61887
62920
  var TOOL_DEFINITIONS = [
61888
62921
  {
61889
62922
  name: "slop_scan_file",
61890
- 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.',
61891
62924
  inputSchema: {
61892
62925
  type: "object",
61893
62926
  properties: {
@@ -61903,7 +62936,7 @@ var TOOL_DEFINITIONS = [
61903
62936
  },
61904
62937
  {
61905
62938
  name: "slop_explain_rule",
61906
- 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.",
61907
62940
  inputSchema: {
61908
62941
  type: "object",
61909
62942
  properties: {
@@ -61924,7 +62957,7 @@ var TOOL_DEFINITIONS = [
61924
62957
  },
61925
62958
  {
61926
62959
  name: "slop_suggest",
61927
- 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.",
61928
62961
  inputSchema: {
61929
62962
  type: "object",
61930
62963
  properties: {
@@ -61956,7 +62989,7 @@ var TOOL_DEFINITIONS = [
61956
62989
  // call slop_suggest and read repositoryHealth /
61957
62990
  // architectureConsistency / businessLogicCoherence.
61958
62991
  name: "slop_check_constitution",
61959
- 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.",
61960
62993
  inputSchema: {
61961
62994
  type: "object",
61962
62995
  properties: {
@@ -61973,7 +63006,7 @@ var TOOL_DEFINITIONS = [
61973
63006
  // (sha256 over sorted hooks ∪ props ∪ params) + Jaccard similarity
61974
63007
  // — no LLM, no embeddings, deterministic. Foundation for StackPick.
61975
63008
  name: "slop_find_similar",
61976
- 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].",
61977
63010
  inputSchema: {
61978
63011
  type: "object",
61979
63012
  properties: {
@@ -62124,10 +63157,10 @@ async function runSuggest(args, ctx, options = {}) {
62124
63157
  function runCheckConstitution(args, ctx) {
62125
63158
  const path = args.path;
62126
63159
  if (!path) return toolError("Missing required argument: path");
62127
- const absPath = (0, import_node_path40.resolve)(ctx.cwd, path);
63160
+ const absPath = (0, import_node_path43.resolve)(ctx.cwd, path);
62128
63161
  let source;
62129
63162
  try {
62130
- source = (0, import_node_fs32.readFileSync)(absPath, "utf-8");
63163
+ source = (0, import_node_fs34.readFileSync)(absPath, "utf-8");
62131
63164
  } catch (err) {
62132
63165
  return toolError(
62133
63166
  `Cannot read file ${absPath}: ${err instanceof Error ? err.message : String(err)}`
@@ -62204,13 +63237,6 @@ async function runFindSimilar(args, ctx) {
62204
63237
  }
62205
63238
  }
62206
63239
  async function handleToolCall(toolName, args, ctx) {
62207
- const deprecation = getDeprecation(toolName);
62208
- const deprecationNotice = deprecation ? {
62209
- tool: toolName,
62210
- replacedBy: deprecation.replacedBy,
62211
- removedIn: deprecation.removedIn ?? "next major",
62212
- reason: deprecation.reason
62213
- } : void 0;
62214
63240
  switch (toolName) {
62215
63241
  case "slop_scan_file":
62216
63242
  return runScanFile(args, ctx);
@@ -62310,7 +63336,7 @@ function errorResponse(id, code, message, data2) {
62310
63336
  return { jsonrpc: "2.0", id, error: { code, message, ...data2 !== void 0 ? { data: data2 } : {} } };
62311
63337
  }
62312
63338
  async function runMcpServer(input, output, cwd) {
62313
- return new Promise((resolve42) => {
63339
+ return new Promise((resolve43) => {
62314
63340
  let buffer = "";
62315
63341
  input.setEncoding("utf-8");
62316
63342
  input.on("data", (chunk) => {
@@ -62348,8 +63374,8 @@ async function runMcpServer(input, output, cwd) {
62348
63374
  nlIdx = buffer.indexOf("\n");
62349
63375
  }
62350
63376
  });
62351
- input.on("end", () => resolve42());
62352
- input.on("close", () => resolve42());
63377
+ input.on("end", () => resolve43());
63378
+ input.on("close", () => resolve43());
62353
63379
  });
62354
63380
  }
62355
63381
 
@@ -62372,7 +63398,7 @@ function registerDoctor(program) {
62372
63398
  }
62373
63399
 
62374
63400
  // src/cli/commands/watch.ts
62375
- var import_node_path41 = require("path");
63401
+ var import_node_path44 = require("path");
62376
63402
  init_watch();
62377
63403
  function registerWatch(program, scanAction) {
62378
63404
  program.command("watch").description("re-run scan on every file change. Flags new violations as you write. The LockBrick prevention loop entry.").action(async (_cmdOptions, command) => {
@@ -62381,19 +63407,19 @@ function registerWatch(program, scanAction) {
62381
63407
  ...rawGlobals,
62382
63408
  noIncrease: rawGlobals.increase === false
62383
63409
  };
62384
- const cwd = (0, import_node_path41.resolve)(options.workspace ?? process.cwd());
63410
+ const cwd = (0, import_node_path44.resolve)(options.workspace ?? process.cwd());
62385
63411
  await scanAction([], options, command);
62386
63412
  await watchProject(options, cwd, []);
62387
63413
  });
62388
63414
  }
62389
63415
 
62390
63416
  // src/cli/commands/lock.ts
62391
- var import_node_path42 = require("path");
63417
+ var import_node_path45 = require("path");
62392
63418
  init_logger();
62393
63419
  function registerLock(program) {
62394
63420
  program.command("lock").description("install a Git pre-commit hook that runs `slopbrick scan --staged` on every commit. The LockBrick prevention loop: block AI-introduced slop from ever reaching the repo.").option("--uninstall", "remove the pre-commit hook instead of installing it").option("--husky", "force-install under .husky/pre-commit (Husky v9). Default auto-detects via .husky/ dir.").option("--workspace <path>", "workspace directory", process.cwd()).action(
62395
63421
  (cmdOptions) => {
62396
- const cwd = (0, import_node_path42.resolve)(cmdOptions.workspace ?? process.cwd());
63422
+ const cwd = (0, import_node_path45.resolve)(cmdOptions.workspace ?? process.cwd());
62397
63423
  if (cmdOptions.uninstall) {
62398
63424
  const result2 = uninstallHook(cwd);
62399
63425
  logger.info(result2.message);
@@ -62414,11 +63440,11 @@ function registerLock(program) {
62414
63440
  }
62415
63441
 
62416
63442
  // src/cli/commands/ci.ts
62417
- var import_node_path43 = require("path");
63443
+ var import_node_path46 = require("path");
62418
63444
  init_logger();
62419
63445
  init_dist();
62420
63446
  function registerCi(program, scanAction) {
62421
- 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(
62422
63448
  async (cmdOptions, command) => {
62423
63449
  const globals = command.optsWithGlobals();
62424
63450
  const options = {
@@ -62429,7 +63455,7 @@ function registerCi(program, scanAction) {
62429
63455
  // scan only changed files
62430
63456
  format: cmdOptions.format ?? "json"
62431
63457
  };
62432
- const cwd = (0, import_node_path43.resolve)(options.workspace ?? process.cwd());
63458
+ const cwd = (0, import_node_path46.resolve)(options.workspace ?? process.cwd());
62433
63459
  await scanAction([], options, command);
62434
63460
  const health = loadHealth(cwd);
62435
63461
  if (!health) {
@@ -62457,12 +63483,12 @@ function registerCi(program, scanAction) {
62457
63483
  }
62458
63484
 
62459
63485
  // src/cli/commands/memory.ts
62460
- var import_node_path44 = require("path");
63486
+ var import_node_path47 = require("path");
62461
63487
  init_logger();
62462
63488
  function registerMemory(program) {
62463
63489
  program.command("memory").description("show or regenerate .slopbrick/structure.md (the agent-readable repository summary) without re-scanning").option("--show", "print the current .slopbrick/structure.md to stdout (default if no flag is passed)").option("--regenerate", "re-render structure.md from the existing inventory.json + constitution.json (no scan)").option("--workspace <path>", "workspace directory", process.cwd()).action(
62464
63490
  async (cmdOptions) => {
62465
- const cwd = (0, import_node_path44.resolve)(cmdOptions.workspace ?? process.cwd());
63491
+ const cwd = (0, import_node_path47.resolve)(cmdOptions.workspace ?? process.cwd());
62466
63492
  const { renderStructureMarkdown: renderStructureMarkdown2, readStructureMarkdown: readStructureMarkdown2, writeStructureMarkdown: writeStructureMarkdown2 } = await Promise.resolve().then(() => (init_structure_md(), structure_md_exports));
62467
63493
  const { loadInventory: loadInventory2, loadConstitution: loadConstitution2, inventoryPath: invPath, constitutionPath: conPath } = await Promise.resolve().then(() => (init_dist(), dist_exports));
62468
63494
  if (cmdOptions.regenerate) {
@@ -62492,49 +63518,49 @@ function registerMemory(program) {
62492
63518
  }
62493
63519
 
62494
63520
  // src/cli/commands/migrate.ts
62495
- var import_node_path46 = require("path");
63521
+ var import_node_path49 = require("path");
62496
63522
  init_logger();
62497
63523
 
62498
63524
  // src/cli/migrate.ts
62499
- var import_node_fs33 = require("fs");
62500
- var import_node_path45 = require("path");
63525
+ var import_node_fs35 = require("fs");
63526
+ var import_node_path48 = require("path");
62501
63527
  init_logger();
62502
63528
  function planMigration(workspaceDir) {
62503
63529
  const moves = [];
62504
63530
  const rewrites = [];
62505
63531
  const gitignoreEdits = [];
62506
- const oldDir = (0, import_node_path45.join)(workspaceDir, ".slop-audit");
62507
- const newDir = (0, import_node_path45.join)(workspaceDir, ".slopbrick");
62508
- if ((0, import_node_fs33.existsSync)(oldDir)) {
63532
+ const oldDir = (0, import_node_path48.join)(workspaceDir, ".slop-audit");
63533
+ const newDir = (0, import_node_path48.join)(workspaceDir, ".slopbrick");
63534
+ if ((0, import_node_fs35.existsSync)(oldDir)) {
62509
63535
  moves.push({ from: oldDir, to: newDir, kind: "dir" });
62510
63536
  rewrites.push({
62511
- path: (0, import_node_path45.join)(newDir, "inventory.json"),
63537
+ path: (0, import_node_path48.join)(newDir, "inventory.json"),
62512
63538
  field: "version",
62513
63539
  from: '"1"',
62514
63540
  to: '"2"'
62515
63541
  });
62516
63542
  rewrites.push({
62517
- path: (0, import_node_path45.join)(newDir, "constitution.json"),
63543
+ path: (0, import_node_path48.join)(newDir, "constitution.json"),
62518
63544
  field: "version",
62519
63545
  from: '"1"',
62520
63546
  to: '"2"'
62521
63547
  });
62522
63548
  }
62523
- const oldCache = (0, import_node_path45.join)(workspaceDir, ".slop-audit-cache.json");
62524
- const newCache = (0, import_node_path45.join)(workspaceDir, ".slopbrick-cache.json");
62525
- if ((0, import_node_fs33.existsSync)(oldCache)) {
63549
+ const oldCache = (0, import_node_path48.join)(workspaceDir, ".slop-audit-cache.json");
63550
+ const newCache = (0, import_node_path48.join)(workspaceDir, ".slopbrick-cache.json");
63551
+ if ((0, import_node_fs35.existsSync)(oldCache)) {
62526
63552
  moves.push({ from: oldCache, to: newCache, kind: "file" });
62527
63553
  }
62528
63554
  for (const ext of ["mjs", "cjs", "js"]) {
62529
- const oldCfg = (0, import_node_path45.join)(workspaceDir, `slop-audit.config.${ext}`);
62530
- const newCfg = (0, import_node_path45.join)(workspaceDir, `slopbrick.config.${ext}`);
62531
- if ((0, import_node_fs33.existsSync)(oldCfg)) {
63555
+ const oldCfg = (0, import_node_path48.join)(workspaceDir, `slop-audit.config.${ext}`);
63556
+ const newCfg = (0, import_node_path48.join)(workspaceDir, `slopbrick.config.${ext}`);
63557
+ if ((0, import_node_fs35.existsSync)(oldCfg)) {
62532
63558
  moves.push({ from: oldCfg, to: newCfg, kind: "config" });
62533
63559
  }
62534
63560
  }
62535
- const gi = (0, import_node_path45.join)(workspaceDir, ".gitignore");
62536
- if ((0, import_node_fs33.existsSync)(gi)) {
62537
- const content = (0, import_node_fs33.readFileSync)(gi, "utf-8");
63561
+ const gi = (0, import_node_path48.join)(workspaceDir, ".gitignore");
63562
+ if ((0, import_node_fs35.existsSync)(gi)) {
63563
+ const content = (0, import_node_fs35.readFileSync)(gi, "utf-8");
62538
63564
  if (content.includes(".slop-audit/")) {
62539
63565
  gitignoreEdits.push({
62540
63566
  path: gi,
@@ -62553,28 +63579,28 @@ function planMigration(workspaceDir) {
62553
63579
  return { moves, rewrites, gitignoreEdits };
62554
63580
  }
62555
63581
  function isAlreadyMigrated(workspaceDir) {
62556
- return (0, import_node_fs33.existsSync)((0, import_node_path45.join)(workspaceDir, ".slopbrick")) && !(0, import_node_fs33.existsSync)((0, import_node_path45.join)(workspaceDir, ".slop-audit"));
63582
+ return (0, import_node_fs35.existsSync)((0, import_node_path48.join)(workspaceDir, ".slopbrick")) && !(0, import_node_fs35.existsSync)((0, import_node_path48.join)(workspaceDir, ".slop-audit"));
62557
63583
  }
62558
63584
  function applyMigration(plan, options = {}) {
62559
63585
  if (options.dryRun) return;
62560
63586
  for (const m of plan.moves) {
62561
- (0, import_node_fs33.renameSync)(m.from, m.to);
63587
+ (0, import_node_fs35.renameSync)(m.from, m.to);
62562
63588
  }
62563
63589
  for (const r of plan.rewrites) {
62564
- if (!(0, import_node_fs33.existsSync)(r.path)) continue;
62565
- const content = (0, import_node_fs33.readFileSync)(r.path, "utf-8");
63590
+ if (!(0, import_node_fs35.existsSync)(r.path)) continue;
63591
+ const content = (0, import_node_fs35.readFileSync)(r.path, "utf-8");
62566
63592
  const next = content.replace(`"version": ${r.from}`, `"version": ${r.to}`);
62567
- (0, import_node_fs33.writeFileSync)(r.path, next);
63593
+ (0, import_node_fs35.writeFileSync)(r.path, next);
62568
63594
  }
62569
63595
  for (const g of plan.gitignoreEdits) {
62570
- const content = (0, import_node_fs33.readFileSync)(g.path, "utf-8");
63596
+ const content = (0, import_node_fs35.readFileSync)(g.path, "utf-8");
62571
63597
  const next = content.replaceAll(g.from, g.to);
62572
- (0, import_node_fs33.writeFileSync)(g.path, next);
63598
+ (0, import_node_fs35.writeFileSync)(g.path, next);
62573
63599
  }
62574
63600
  }
62575
63601
  function runMigrate(options) {
62576
63602
  const { workspace, dryRun = false, force = false } = options;
62577
- if (!(0, import_node_fs33.existsSync)(workspace)) {
63603
+ if (!(0, import_node_fs35.existsSync)(workspace)) {
62578
63604
  return {
62579
63605
  ok: false,
62580
63606
  alreadyMigrated: false,
@@ -62584,9 +63610,9 @@ function runMigrate(options) {
62584
63610
  };
62585
63611
  }
62586
63612
  const alreadyMigrated = isAlreadyMigrated(workspace);
62587
- const newDir = (0, import_node_path45.join)(workspace, ".slopbrick");
62588
- const oldDir = (0, import_node_path45.join)(workspace, ".slop-audit");
62589
- if ((0, import_node_fs33.existsSync)(newDir) && (0, import_node_fs33.existsSync)(oldDir) && !force) {
63613
+ const newDir = (0, import_node_path48.join)(workspace, ".slopbrick");
63614
+ const oldDir = (0, import_node_path48.join)(workspace, ".slop-audit");
63615
+ if ((0, import_node_fs35.existsSync)(newDir) && (0, import_node_fs35.existsSync)(oldDir) && !force) {
62590
63616
  return {
62591
63617
  ok: false,
62592
63618
  alreadyMigrated: false,
@@ -62662,7 +63688,7 @@ function registerMigrate(program) {
62662
63688
  (cmdOptions, command) => {
62663
63689
  const globals = command.optsWithGlobals();
62664
63690
  const format = (cmdOptions.format ?? globals.format) === "json" ? "json" : "pretty";
62665
- const cwd = (0, import_node_path46.resolve)(cmdOptions.workspace ?? process.cwd());
63691
+ const cwd = (0, import_node_path49.resolve)(cmdOptions.workspace ?? process.cwd());
62666
63692
  const result = runMigrate({
62667
63693
  workspace: cwd,
62668
63694
  dryRun: cmdOptions.dryRun,
@@ -62758,7 +63784,25 @@ function registerRules(program) {
62758
63784
  const sev = r.severity.padEnd(8);
62759
63785
  const tag = r.aiSpecific ? "[AI]" : " ";
62760
63786
  lines.push(` ${sev} ${tag} ${r.id}`);
62761
- 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
+ }
62762
63806
  }
62763
63807
  }
62764
63808
  logger.info(lines.join("\n"));
@@ -62766,19 +63810,19 @@ function registerRules(program) {
62766
63810
  }
62767
63811
 
62768
63812
  // src/cli/commands/validate-config.ts
62769
- var import_node_fs34 = require("fs");
62770
- var import_node_path47 = require("path");
63813
+ var import_node_fs36 = require("fs");
63814
+ var import_node_path50 = require("path");
62771
63815
  init_logger();
62772
63816
  init_validation();
62773
63817
  function registerValidateConfig(program) {
62774
63818
  program.command("validate-config [path]").description("Statically validate a slopbrick.config.mjs without scanning").action(async (configPath) => {
62775
- const path = configPath ? (0, import_node_path47.resolve)(configPath) : (0, import_node_path47.resolve)(process.cwd(), "slopbrick.config.mjs");
62776
- if (!(0, import_node_fs34.existsSync)(path)) {
63819
+ const path = configPath ? (0, import_node_path50.resolve)(configPath) : (0, import_node_path50.resolve)(process.cwd(), "slopbrick.config.mjs");
63820
+ if (!(0, import_node_fs36.existsSync)(path)) {
62777
63821
  logger.error(`Error: config file not found: ${path}`);
62778
63822
  process.exit(2);
62779
63823
  }
62780
63824
  try {
62781
- const mod = (0, import_node_path47.extname)(path) === ".cjs" ? require(path) : await import(path);
63825
+ const mod = (0, import_node_path50.extname)(path) === ".cjs" ? require(path) : await import(path);
62782
63826
  const userConfig = mod.default ?? mod;
62783
63827
  const result = validateConfig(userConfig);
62784
63828
  if (result.errors.length === 0) {
@@ -62998,8 +64042,8 @@ ${formatMarkdown(result.report)}`);
62998
64042
  }
62999
64043
 
63000
64044
  // src/cli/commands/calibrate.ts
63001
- var import_node_fs37 = require("fs");
63002
- var import_node_path51 = require("path");
64045
+ var import_node_fs39 = require("fs");
64046
+ var import_node_path54 = require("path");
63003
64047
  init_logger();
63004
64048
 
63005
64049
  // src/research/providers/openai.ts
@@ -63055,8 +64099,8 @@ function createProvider(config) {
63055
64099
  }
63056
64100
 
63057
64101
  // src/research/generator.ts
63058
- var import_node_fs35 = require("fs");
63059
- var import_node_path48 = require("path");
64102
+ var import_node_fs37 = require("fs");
64103
+ var import_node_path51 = require("path");
63060
64104
 
63061
64105
  // src/research/prompts.ts
63062
64106
  var DEFAULT_PROMPT_TEMPLATES = [
@@ -63119,14 +64163,14 @@ async function generateSamples(options) {
63119
64163
  }
63120
64164
  const samples = [];
63121
64165
  const ext = extForFramework(framework);
63122
- const dir = (0, import_node_path48.join)(outputDir, framework, componentType);
63123
- (0, import_node_fs35.mkdirSync)(dir, { recursive: true });
64166
+ const dir = (0, import_node_path51.join)(outputDir, framework, componentType);
64167
+ (0, import_node_fs37.mkdirSync)(dir, { recursive: true });
63124
64168
  for (let i = 1; i <= count; i += 1) {
63125
64169
  const raw = await provider.generateSample(renderPrompt(template), { temperature });
63126
64170
  const code = extractCodeFromMarkdown(raw);
63127
64171
  const fileName = `sample-${i}${ext}`;
63128
- const filePath = (0, import_node_path48.join)(dir, fileName);
63129
- (0, import_node_fs35.writeFileSync)(filePath, code, "utf8");
64172
+ const filePath = (0, import_node_path51.join)(dir, fileName);
64173
+ (0, import_node_fs37.writeFileSync)(filePath, code, "utf8");
63130
64174
  const sample = {
63131
64175
  filePath,
63132
64176
  framework,
@@ -63137,8 +64181,8 @@ async function generateSamples(options) {
63137
64181
  };
63138
64182
  samples.push(sample);
63139
64183
  }
63140
- const metadataPath = (0, import_node_path48.join)(dir, "metadata.json");
63141
- (0, import_node_fs35.writeFileSync)(metadataPath, JSON.stringify(samples, null, 2), "utf8");
64184
+ const metadataPath = (0, import_node_path51.join)(dir, "metadata.json");
64185
+ (0, import_node_fs37.writeFileSync)(metadataPath, JSON.stringify(samples, null, 2), "utf8");
63142
64186
  return samples;
63143
64187
  }
63144
64188
 
@@ -63344,46 +64388,46 @@ function slugify(value) {
63344
64388
 
63345
64389
  // src/research/calibrator.ts
63346
64390
  var import_node_child_process2 = require("child_process");
63347
- var import_node_fs36 = require("fs");
63348
- var import_node_path50 = require("path");
64391
+ var import_node_fs38 = require("fs");
64392
+ var import_node_path53 = require("path");
63349
64393
 
63350
64394
  // src/corpus-paths.ts
63351
- var import_node_path49 = require("path");
64395
+ var import_node_path52 = require("path");
63352
64396
  var CORPUS_ROOT = process.env["SLOPBRICK_CORPUS_DIR"] ?? "/Users/cheng/corpus-expansion";
63353
- var POSITIVE_DIR = (0, import_node_path49.join)(CORPUS_ROOT, "positive");
63354
- var NEGATIVE_DIR = (0, import_node_path49.join)(CORPUS_ROOT, "negative");
63355
- var FILELISTS_DIR = (0, import_node_path49.join)(CORPUS_ROOT, "filelists");
64397
+ var POSITIVE_DIR = (0, import_node_path52.join)(CORPUS_ROOT, "positive");
64398
+ var NEGATIVE_DIR = (0, import_node_path52.join)(CORPUS_ROOT, "negative");
64399
+ var FILELISTS_DIR = (0, import_node_path52.join)(CORPUS_ROOT, "filelists");
63356
64400
 
63357
64401
  // src/research/calibrator.ts
63358
64402
  var DEFAULT_POSITIVE = POSITIVE_DIR;
63359
64403
  var DEFAULT_NEGATIVE = NEGATIVE_DIR;
63360
64404
  function buildFileList(dir, extensions) {
63361
- const tmpList = (0, import_node_path50.join)("/tmp", `cal-build-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
64405
+ const tmpList = (0, import_node_path53.join)("/tmp", `cal-build-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
63362
64406
  const expr = extensions.map((e) => `-name '*.${e}'`).join(" -o ");
63363
64407
  (0, import_node_child_process2.execFileSync)("bash", ["-c", `find ${dir} -maxdepth 8 -type f \\( ${expr} \\) -print0 | xargs -0 realpath > ${tmpList}`]);
63364
- const out = (0, import_node_fs36.readFileSync)(tmpList, "utf8");
64408
+ const out = (0, import_node_fs38.readFileSync)(tmpList, "utf8");
63365
64409
  (0, import_node_child_process2.execFileSync)("rm", ["-f", tmpList]);
63366
64410
  return out.trim().split("\n").filter(Boolean);
63367
64411
  }
63368
64412
  function runScan2(fileListPath) {
63369
- const files = (0, import_node_fs36.readFileSync)(fileListPath, "utf8").trim().split("\n").filter(Boolean);
64413
+ const files = (0, import_node_fs38.readFileSync)(fileListPath, "utf8").trim().split("\n").filter(Boolean);
63370
64414
  const CHUNK = 600;
63371
64415
  const ruleFires = /* @__PURE__ */ new Map();
63372
64416
  const uniqueFilesPerRule = /* @__PURE__ */ new Map();
63373
64417
  let fileCount = 0;
63374
- const tmpOut = (0, import_node_path50.join)("/tmp", `calibrate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
64418
+ const tmpOut = (0, import_node_path53.join)("/tmp", `calibrate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
63375
64419
  for (let i = 0; i < files.length; i += CHUNK) {
63376
64420
  const chunk = files.slice(i, i + CHUNK);
63377
64421
  try {
63378
64422
  (0, import_node_child_process2.execFileSync)(
63379
64423
  "node",
63380
- [(0, import_node_path50.join)(process.cwd(), "bin", "slopbrick.js"), "scan", ...chunk, "--json", tmpOut, "--no-telemetry", "--quiet"],
64424
+ [(0, import_node_path53.join)(process.cwd(), "bin", "slopbrick.js"), "scan", ...chunk, "--json", tmpOut, "--no-telemetry", "--quiet"],
63381
64425
  { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
63382
64426
  );
63383
64427
  } catch {
63384
64428
  }
63385
- if (!(0, import_node_fs36.existsSync)(tmpOut)) continue;
63386
- const report = JSON.parse((0, import_node_fs36.readFileSync)(tmpOut, "utf8"));
64429
+ if (!(0, import_node_fs38.existsSync)(tmpOut)) continue;
64430
+ const report = JSON.parse((0, import_node_fs38.readFileSync)(tmpOut, "utf8"));
63387
64431
  fileCount += report.fileCount;
63388
64432
  for (const issue of report.issues) {
63389
64433
  ruleFires.set(issue.ruleId, (ruleFires.get(issue.ruleId) ?? 0) + 1);
@@ -63411,16 +64455,16 @@ function classify(posFiles, negFiles) {
63411
64455
  async function calibrate(cwd, options = {}) {
63412
64456
  const positiveDir = options.positiveDir ?? DEFAULT_POSITIVE;
63413
64457
  const negativeDir = options.negativeDir ?? DEFAULT_NEGATIVE;
63414
- if (!(0, import_node_fs36.existsSync)(positiveDir)) throw new Error(`Positive corpus not found: ${positiveDir}`);
63415
- if (!(0, import_node_fs36.existsSync)(negativeDir)) throw new Error(`Negative corpus not found: ${negativeDir}`);
64458
+ if (!(0, import_node_fs38.existsSync)(positiveDir)) throw new Error(`Positive corpus not found: ${positiveDir}`);
64459
+ if (!(0, import_node_fs38.existsSync)(negativeDir)) throw new Error(`Negative corpus not found: ${negativeDir}`);
63416
64460
  const positiveFiles = buildFileList(positiveDir, ["tsx", "ts", "jsx", "js"]);
63417
64461
  const negativeFiles = buildFileList(negativeDir, ["tsx", "ts"]);
63418
64462
  const posSample = options.positiveLimit ? positiveFiles.slice(0, options.positiveLimit) : positiveFiles;
63419
64463
  const negSample = options.negativeLimit ? negativeFiles.slice(0, options.negativeLimit) : negativeFiles;
63420
- const posListPath = (0, import_node_path50.join)("/tmp", `cal-pos-${Date.now()}.txt`);
63421
- const negListPath = (0, import_node_path50.join)("/tmp", `cal-neg-${Date.now()}.txt`);
63422
- (0, import_node_fs36.writeFileSync)(posListPath, posSample.join("\n"));
63423
- (0, import_node_fs36.writeFileSync)(negListPath, negSample.join("\n"));
64464
+ const posListPath = (0, import_node_path53.join)("/tmp", `cal-pos-${Date.now()}.txt`);
64465
+ const negListPath = (0, import_node_path53.join)("/tmp", `cal-neg-${Date.now()}.txt`);
64466
+ (0, import_node_fs38.writeFileSync)(posListPath, posSample.join("\n"));
64467
+ (0, import_node_fs38.writeFileSync)(negListPath, negSample.join("\n"));
63424
64468
  const builtins = await Promise.resolve().then(() => (init_builtins(), builtins_exports));
63425
64469
  const builtinRules2 = builtins.builtinRules ?? [];
63426
64470
  const metaById = /* @__PURE__ */ new Map();
@@ -63534,9 +64578,9 @@ function registerCalibrate(program) {
63534
64578
  positiveLimit: cmdOptions.positiveLimit,
63535
64579
  negativeLimit: cmdOptions.negativeLimit
63536
64580
  });
63537
- const outputPath = cmdOptions.output ? (0, import_node_path51.resolve)(cwd, cmdOptions.output) : (0, import_node_path51.resolve)(cwd, "corpus", "calibration-empirical.md");
63538
- (0, import_node_fs37.mkdirSync)((0, import_node_path51.dirname)(outputPath), { recursive: true });
63539
- (0, import_node_fs37.writeFileSync)(outputPath, reportToMarkdown(report), "utf8");
64581
+ const outputPath = cmdOptions.output ? (0, import_node_path54.resolve)(cwd, cmdOptions.output) : (0, import_node_path54.resolve)(cwd, "corpus", "calibration-empirical.md");
64582
+ (0, import_node_fs39.mkdirSync)((0, import_node_path54.dirname)(outputPath), { recursive: true });
64583
+ (0, import_node_fs39.writeFileSync)(outputPath, reportToMarkdown(report), "utf8");
63540
64584
  logger.info(
63541
64585
  "Calibrated " + report.rules.length + " rules across " + report.positiveFileCount + " positive + " + report.negativeFileCount + " negative files."
63542
64586
  );
@@ -63603,19 +64647,20 @@ function trendToText(report) {
63603
64647
  }
63604
64648
  const slopValues = report.points.map((p) => p.slopIndex);
63605
64649
  const spark = sparkline(slopValues);
63606
- 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):");
63607
64651
  lines.push("");
63608
64652
  lines.push(" " + spark + " " + slopValues[0].toFixed(1) + " -> " + slopValues[slopValues.length - 1].toFixed(1));
63609
64653
  lines.push("");
63610
64654
  if (report.first && report.latest) {
63611
64655
  const direction = report.delta < 0 ? "improved" : report.delta > 0 ? "regressed" : "unchanged";
63612
64656
  const arrow = report.delta < 0 ? "v" : report.delta > 0 ? "^" : "=";
63613
- 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)");
63614
64658
  lines.push(" " + report.first.timestamp + " -> " + report.latest.timestamp);
63615
64659
  }
63616
64660
  lines.push("");
63617
64661
  lines.push("Recent scans:");
63618
- lines.push(" Timestamp Slop Health Framework Issues");
64662
+ lines.push(" Timestamp AI Slop Health Framework Issues");
64663
+ lines.push(" (lower=cleaner)");
63619
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");
63620
64665
  for (const p of report.points.slice(-10)) {
63621
64666
  const ts = p.timestamp.slice(0, 19).replace("T", " ");
@@ -63627,14 +64672,14 @@ function trendToText(report) {
63627
64672
  }
63628
64673
  function trendToMarkdown(report) {
63629
64674
  const lines = [];
63630
- lines.push("# Slop Index Trend");
64675
+ lines.push("# AI Slop Score Trend (lower = cleaner since v0.21)");
63631
64676
  lines.push("");
63632
64677
  if (report.points.length === 0) {
63633
64678
  lines.push("No telemetry data yet. Run `slopbrick scan` to start tracking.");
63634
64679
  return lines.join("\n");
63635
64680
  }
63636
64681
  const slopValues = report.points.map((p) => p.slopIndex);
63637
- 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):");
63638
64683
  lines.push("");
63639
64684
  lines.push("```");
63640
64685
  lines.push(sparkline(slopValues));
@@ -63642,10 +64687,10 @@ function trendToMarkdown(report) {
63642
64687
  lines.push("");
63643
64688
  if (report.first && report.latest) {
63644
64689
  const direction = report.delta < 0 ? "improved" : report.delta > 0 ? "regressed" : "unchanged";
63645
- 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).");
63646
64691
  }
63647
64692
  lines.push("");
63648
- lines.push("| Timestamp | Slop Index | Health | Framework | Issues |");
64693
+ lines.push("| Timestamp | AI Slop Score (lower = cleaner) | Health | Framework | Issues |");
63649
64694
  lines.push("|-----------|-----------:|-------:|-----------|-------:|");
63650
64695
  for (const p of report.points) {
63651
64696
  lines.push("| " + p.timestamp + " | " + p.slopIndex.toFixed(1) + " | " + p.assemblyHealth.toFixed(1) + " | " + p.framework + " | " + p.totalIssues + " |");
@@ -63655,7 +64700,7 @@ function trendToMarkdown(report) {
63655
64700
 
63656
64701
  // src/cli/commands/trend.ts
63657
64702
  function registerTrend(program) {
63658
- 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) => {
63659
64704
  try {
63660
64705
  const cwd = process.cwd();
63661
64706
  const report = buildTrend(cwd, cmdOptions.maxPoints);
@@ -63663,7 +64708,14 @@ function registerTrend(program) {
63663
64708
  logger.info(out);
63664
64709
  if (report.delta > 1) {
63665
64710
  logger.info("");
63666
- 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
+ );
63667
64719
  }
63668
64720
  } catch (error) {
63669
64721
  logger.error(error instanceof Error ? error.message : String(error));
@@ -63673,13 +64725,13 @@ function registerTrend(program) {
63673
64725
  }
63674
64726
 
63675
64727
  // src/cli/commands/drift.ts
63676
- var import_node_path53 = require("path");
64728
+ var import_node_path56 = require("path");
63677
64729
  init_logger();
63678
64730
  init_scan2();
63679
64731
 
63680
64732
  // src/cli/drift.ts
63681
- var import_node_fs38 = require("fs");
63682
- var import_node_path52 = require("path");
64733
+ var import_node_fs40 = require("fs");
64734
+ var import_node_path55 = require("path");
63683
64735
  init_discover();
63684
64736
  init_patterns();
63685
64737
  init_telemetry();
@@ -63693,7 +64745,7 @@ async function runDrift(cwd, config, options = {}) {
63693
64745
  for (const absPath of limited) {
63694
64746
  let source;
63695
64747
  try {
63696
- source = (0, import_node_fs38.readFileSync)(absPath, "utf-8");
64748
+ source = (0, import_node_fs40.readFileSync)(absPath, "utf-8");
63697
64749
  } catch {
63698
64750
  continue;
63699
64751
  }
@@ -63704,7 +64756,7 @@ async function runDrift(cwd, config, options = {}) {
63704
64756
  byCategory[v.category] = (byCategory[v.category] ?? 0) + 1;
63705
64757
  byFile.push({
63706
64758
  file: absPath,
63707
- relPath: (0, import_node_path52.relative)(cwd, absPath),
64759
+ relPath: (0, import_node_path55.relative)(cwd, absPath),
63708
64760
  category: v.category,
63709
64761
  import: v.import,
63710
64762
  declared: v.declared,
@@ -63980,7 +65032,7 @@ function registerDrift(program) {
63980
65032
  const options = command.optsWithGlobals();
63981
65033
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
63982
65034
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
63983
- const cwd = (0, import_node_path53.resolve)(options.workspace ?? process.cwd());
65035
+ const cwd = (0, import_node_path56.resolve)(options.workspace ?? process.cwd());
63984
65036
  if (cmdOptions.temporalSince !== void 0) {
63985
65037
  const temporalSince = cmdOptions.temporalSince.trim();
63986
65038
  if (temporalSince.length === 0) {
@@ -64006,13 +65058,13 @@ function registerDrift(program) {
64006
65058
  }
64007
65059
 
64008
65060
  // src/cli/commands/pr.ts
64009
- var import_node_path55 = require("path");
65061
+ var import_node_path58 = require("path");
64010
65062
  init_logger();
64011
65063
  init_scan2();
64012
65064
 
64013
65065
  // src/cli/pr.ts
64014
- var import_node_fs39 = require("fs");
64015
- var import_node_path54 = require("path");
65066
+ var import_node_fs41 = require("fs");
65067
+ var import_node_path57 = require("path");
64016
65068
  var import_node_child_process3 = require("child_process");
64017
65069
  var import_node_util2 = require("util");
64018
65070
  var import_minimatch3 = require("minimatch");
@@ -64058,10 +65110,10 @@ async function discoverPrFiles(cwd, config, base, head, maxFiles) {
64058
65110
  if (gitFiles.length === 0) return [];
64059
65111
  const sourceFiles = [];
64060
65112
  for (const relOrAbs of gitFiles) {
64061
- const abs = (0, import_node_path54.resolve)(cwd, relOrAbs);
64062
- const ext = (0, import_node_path54.extname)(abs).toLowerCase();
65113
+ const abs = (0, import_node_path57.resolve)(cwd, relOrAbs);
65114
+ const ext = (0, import_node_path57.extname)(abs).toLowerCase();
64063
65115
  if (!PR_EXTENSIONS.has(ext)) continue;
64064
- const rel = (0, import_node_path54.relative)(cwd, abs).split("\\").join("/");
65116
+ const rel = (0, import_node_path57.relative)(cwd, abs).split("\\").join("/");
64065
65117
  if (config.include.length > 0 && !config.include.some((pattern) => (0, import_minimatch3.minimatch)(rel, pattern))) {
64066
65118
  continue;
64067
65119
  }
@@ -64089,7 +65141,7 @@ async function runPrScan(cwd, config, options = {}) {
64089
65141
  for (const absPath of candidates) {
64090
65142
  let source;
64091
65143
  try {
64092
- source = (0, import_node_fs39.readFileSync)(absPath, "utf-8");
65144
+ source = (0, import_node_fs41.readFileSync)(absPath, "utf-8");
64093
65145
  } catch {
64094
65146
  continue;
64095
65147
  }
@@ -64132,7 +65184,7 @@ async function runPrScan(cwd, config, options = {}) {
64132
65184
  totalScore += fileScore;
64133
65185
  files.push({
64134
65186
  file: absPath,
64135
- relPath: (0, import_node_path54.relative)(cwd, absPath).split("\\").join("/"),
65187
+ relPath: (0, import_node_path57.relative)(cwd, absPath).split("\\").join("/"),
64136
65188
  score: fileScore,
64137
65189
  issueCount: issues.length,
64138
65190
  constitutionViolationCount: constitutionViolations.length,
@@ -64264,20 +65316,135 @@ function formatPrMarkdown(result) {
64264
65316
  function prExitCode(result) {
64265
65317
  return result.totalScore > result.threshold ? 1 : 0;
64266
65318
  }
65319
+ function parseUnifiedDiffPaths(diff) {
65320
+ const paths = [];
65321
+ const seen = /* @__PURE__ */ new Set();
65322
+ for (const line of diff.split(/\r?\n/)) {
65323
+ const m = /^\+\+\+\s+(?:b\/)?(\S.*)$/.exec(line);
65324
+ if (!m) continue;
65325
+ const path = m[1];
65326
+ if (path === "/dev/null" || path === "dev/null") continue;
65327
+ if (seen.has(path)) continue;
65328
+ seen.add(path);
65329
+ paths.push(path);
65330
+ }
65331
+ return paths;
65332
+ }
65333
+ async function runPrScanFromDiffFile(cwd, config, diffPath, options = {}) {
65334
+ const threshold = options.threshold ?? config.prScoreThreshold ?? 20;
65335
+ const maxFiles = options.maxFiles ?? 500;
65336
+ const { existsSync: existsSync31, readFileSync: readFileSync41 } = await import("fs");
65337
+ const { isAbsolute: isAbsolute4, resolve: resolvePath } = await import("path");
65338
+ if (!existsSync31(diffPath)) {
65339
+ throw new Error(`Diff file not found: ${diffPath}`);
65340
+ }
65341
+ const diffAbs = isAbsolute4(diffPath) ? diffPath : resolvePath(cwd, diffPath);
65342
+ const diff = readFileSync41(diffAbs, "utf-8");
65343
+ const changedPaths = parseUnifiedDiffPaths(diff);
65344
+ if (changedPaths.length === 0) {
65345
+ return emptyPrResult(threshold);
65346
+ }
65347
+ return scoreChangedPaths(cwd, config, changedPaths, maxFiles, threshold);
65348
+ }
65349
+ async function scoreChangedPaths(cwd, config, paths, maxFiles, threshold) {
65350
+ const { existsSync: existsSync31, statSync: statSync8 } = await import("fs");
65351
+ const { resolve: resolvePath } = await import("path");
65352
+ const { readFile: readFile4 } = await import("fs/promises");
65353
+ const { scanFile: scanFile2 } = await Promise.resolve().then(() => (init_worker(), worker_exports));
65354
+ const limited = paths.slice(0, maxFiles);
65355
+ const files = [];
65356
+ let totalScore = 0;
65357
+ const byCategory = {};
65358
+ const bySeverity = { low: 0, medium: 0, high: 0 };
65359
+ let constitutionViolations = 0;
65360
+ for (const rel of limited) {
65361
+ const abs = resolvePath(cwd, rel);
65362
+ if (!existsSync31(abs) || !statSync8(abs).isFile()) continue;
65363
+ let source;
65364
+ try {
65365
+ source = await readFile4(abs, "utf-8");
65366
+ } catch {
65367
+ continue;
65368
+ }
65369
+ let result;
65370
+ try {
65371
+ result = await scanFile2(abs, config, void 0, cwd);
65372
+ } catch {
65373
+ continue;
65374
+ }
65375
+ if (result.parseError) continue;
65376
+ const issues = result.issues;
65377
+ let slopPoints = 0;
65378
+ const issueEntries = [];
65379
+ for (const issue of issues) {
65380
+ slopPoints += SEVERITY_WEIGHTS[issue.severity] ?? 0;
65381
+ byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
65382
+ bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
65383
+ issueEntries.push({
65384
+ ruleId: issue.ruleId,
65385
+ severity: issue.severity,
65386
+ line: issue.line,
65387
+ category: issue.category,
65388
+ message: issue.message
65389
+ });
65390
+ }
65391
+ totalScore += slopPoints;
65392
+ files.push({
65393
+ file: abs,
65394
+ relPath: rel,
65395
+ score: slopPoints,
65396
+ issueCount: issueEntries.length,
65397
+ constitutionViolationCount: 0,
65398
+ slopPoints,
65399
+ issues: issueEntries,
65400
+ constitutionViolations: []
65401
+ });
65402
+ }
65403
+ return {
65404
+ base: "diff",
65405
+ head: "diff",
65406
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65407
+ passed: totalScore <= threshold,
65408
+ threshold,
65409
+ totalScore,
65410
+ filesChanged: files.length,
65411
+ byCategory,
65412
+ bySeverity,
65413
+ files
65414
+ };
65415
+ }
65416
+ function emptyPrResult(threshold) {
65417
+ return {
65418
+ base: "diff",
65419
+ head: "diff",
65420
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65421
+ passed: true,
65422
+ threshold,
65423
+ totalScore: 0,
65424
+ filesChanged: 0,
65425
+ byCategory: {},
65426
+ bySeverity: { low: 0, medium: 0, high: 0 },
65427
+ files: []
65428
+ };
65429
+ }
64267
65430
 
64268
65431
  // src/cli/commands/pr.ts
64269
65432
  function registerPr(program) {
64270
65433
  program.command("pr").description(
64271
65434
  "Score the slop cost of the current PR. Compares --base (default: main) to --head (default: HEAD), scans only changed source files, returns a single weighted score. Exits 1 when score > --threshold (default 20)."
64272
- ).option("--base <ref>", "base git ref to diff from", "main").option("--head <ref>", "head git ref to diff to", "HEAD").option("--format <text|json|markdown>", "output format", "text").option("--threshold <n>", "score threshold (overrides config.prScoreThreshold)", parseThreshold).option("--max-files <n>", "cap on files scanned", parseCount, 500).action(
65435
+ ).option("--base <ref>", "base git ref to diff from", "main").option("--head <ref>", "head git ref to diff to", "HEAD").option("--format <text|json|markdown>", "output format", "text").option("--threshold <n>", "score threshold (overrides config.prScoreThreshold)", parseThreshold).option("--max-files <n>", "cap on files scanned", parseCount, 500).option("--diff <file>", "unified-diff file (overrides --base/--head; meant for CI/Action use)").action(
64273
65436
  async (cmdOptions, command) => {
64274
65437
  try {
64275
65438
  const options = command.optsWithGlobals();
64276
- const cwd = (0, import_node_path55.resolve)(options.workspace ?? process.cwd());
65439
+ const cwd = (0, import_node_path58.resolve)(options.workspace ?? process.cwd());
64277
65440
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64278
65441
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64279
65442
  const { config } = await runScan({ ...options, workspace: cwd });
64280
- const result = await runPrScan(cwd, config, {
65443
+ const result = cmdOptions.diff ? await runPrScanFromDiffFile(cwd, config, cmdOptions.diff, {
65444
+ format,
65445
+ threshold: cmdOptions.threshold,
65446
+ maxFiles: cmdOptions.maxFiles
65447
+ }) : await runPrScan(cwd, config, {
64281
65448
  base: cmdOptions.base,
64282
65449
  head: cmdOptions.head,
64283
65450
  format,
@@ -64295,7 +65462,7 @@ function registerPr(program) {
64295
65462
  }
64296
65463
 
64297
65464
  // src/cli/commands/security.ts
64298
- var import_node_path56 = require("path");
65465
+ var import_node_path59 = require("path");
64299
65466
  init_logger();
64300
65467
  init_scan2();
64301
65468
  init_ai_security_risk();
@@ -64308,7 +65475,7 @@ function registerSecurity(program) {
64308
65475
  const options = command.optsWithGlobals();
64309
65476
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64310
65477
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64311
- const cwd = (0, import_node_path56.resolve)(options.workspace ?? process.cwd());
65478
+ const cwd = (0, import_node_path59.resolve)(options.workspace ?? process.cwd());
64312
65479
  const { report } = await runScan({ ...options, workspace: cwd });
64313
65480
  const securityIssues = report.issues.filter((i) => i.category === "security");
64314
65481
  const { risk, findings } = computeAiSecurityRisk(securityIssues);
@@ -64354,12 +65521,12 @@ function registerSecurity(program) {
64354
65521
  }
64355
65522
 
64356
65523
  // src/cli/commands/test.ts
64357
- var import_node_path58 = require("path");
65524
+ var import_node_path61 = require("path");
64358
65525
  init_logger();
64359
65526
  init_scan2();
64360
65527
 
64361
65528
  // src/cli/test.ts
64362
- var import_node_path57 = require("path");
65529
+ var import_node_path60 = require("path");
64363
65530
  init_scan2();
64364
65531
  init_test_quality();
64365
65532
  init_logger();
@@ -64370,7 +65537,7 @@ var TEST_INCLUDE_GLOBS = [
64370
65537
  "**/__tests__/**/*.{ts,tsx,js,jsx}",
64371
65538
  "**/__fixtures__/**/*.{ts,tsx,js,jsx,json}"
64372
65539
  ];
64373
- async function runTestScan(cwd, config, options = {}) {
65540
+ async function runTestScan(cwd, _config, options = {}) {
64374
65541
  const include = TEST_INCLUDE_GLOBS;
64375
65542
  const cliOptions = {
64376
65543
  include,
@@ -64430,7 +65597,7 @@ function formatTestReport(result, opts = {}) {
64430
65597
  const sortedFiles = [...byFile.keys()].sort();
64431
65598
  for (const file of sortedFiles) {
64432
65599
  const issues = byFile.get(file) ?? [];
64433
- const rel = file.startsWith((0, import_node_path57.resolve)(process.cwd())) ? file : file;
65600
+ const rel = file.startsWith((0, import_node_path60.resolve)(process.cwd())) ? file : file;
64434
65601
  lines.push("");
64435
65602
  lines.push(` ${rel}`);
64436
65603
  for (const issue of issues.slice(0, 20)) {
@@ -64459,7 +65626,7 @@ function registerTest(program) {
64459
65626
  const options = command.optsWithGlobals();
64460
65627
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64461
65628
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64462
- const cwd = (0, import_node_path58.resolve)(options.workspace ?? process.cwd());
65629
+ const cwd = (0, import_node_path61.resolve)(options.workspace ?? process.cwd());
64463
65630
  const { config } = await runScan({ ...options, workspace: cwd });
64464
65631
  const { result } = await runTestScan(cwd, config, { strict: options.strict });
64465
65632
  logger.info(formatTestReport(result, { json: format === "json" }));
@@ -64473,7 +65640,7 @@ function registerTest(program) {
64473
65640
  }
64474
65641
 
64475
65642
  // src/cli/commands/architecture.ts
64476
- var import_node_path59 = require("path");
65643
+ var import_node_path62 = require("path");
64477
65644
  init_logger();
64478
65645
  init_scan2();
64479
65646
  init_architecture_score();
@@ -64486,7 +65653,7 @@ function registerArchitecture(program) {
64486
65653
  const options = command.optsWithGlobals();
64487
65654
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64488
65655
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64489
- const cwd = (0, import_node_path59.resolve)(options.workspace ?? process.cwd());
65656
+ const cwd = (0, import_node_path62.resolve)(options.workspace ?? process.cwd());
64490
65657
  const { config } = await runScan({ ...options, workspace: cwd });
64491
65658
  const score = await buildArchitectureScore(cwd, config, cmdOptions.maxFiles);
64492
65659
  const out = format === "json" ? JSON.stringify(score, null, 2) : formatArchitectureScore(score);
@@ -64501,13 +65668,13 @@ function registerArchitecture(program) {
64501
65668
  }
64502
65669
 
64503
65670
  // src/cli/commands/business-logic.ts
64504
- var import_node_path61 = require("path");
65671
+ var import_node_path64 = require("path");
64505
65672
  init_logger();
64506
65673
  init_scan2();
64507
65674
 
64508
65675
  // src/cli/business-logic.ts
64509
- var import_node_fs40 = require("fs");
64510
- var import_node_path60 = require("path");
65676
+ var import_node_fs42 = require("fs");
65677
+ var import_node_path63 = require("path");
64511
65678
  init_discover();
64512
65679
  init_business_logic();
64513
65680
  async function runBusinessLogicScan(cwd, config, options = {}) {
@@ -64519,7 +65686,7 @@ async function runBusinessLogicScan(cwd, config, options = {}) {
64519
65686
  for (const absPath of limited) {
64520
65687
  let source;
64521
65688
  try {
64522
- source = (0, import_node_fs40.readFileSync)(absPath, "utf-8");
65689
+ source = (0, import_node_fs42.readFileSync)(absPath, "utf-8");
64523
65690
  } catch {
64524
65691
  continue;
64525
65692
  }
@@ -64528,7 +65695,7 @@ async function runBusinessLogicScan(cwd, config, options = {}) {
64528
65695
  for (const issue of fileIssues) {
64529
65696
  issues.push({
64530
65697
  ...issue,
64531
- filePath: (0, import_node_path60.relative)(cwd, absPath) || absPath
65698
+ filePath: (0, import_node_path63.relative)(cwd, absPath) || absPath
64532
65699
  });
64533
65700
  }
64534
65701
  }
@@ -64631,7 +65798,7 @@ function registerBusinessLogic(program) {
64631
65798
  const options = command.optsWithGlobals();
64632
65799
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64633
65800
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64634
- const cwd = (0, import_node_path61.resolve)(options.workspace ?? process.cwd());
65801
+ const cwd = (0, import_node_path64.resolve)(options.workspace ?? process.cwd());
64635
65802
  const { config } = await runScan({ ...options, workspace: cwd });
64636
65803
  const result = await runBusinessLogicScan(cwd, config, {
64637
65804
  maxFiles: cmdOptions.maxFiles
@@ -64647,7 +65814,7 @@ function registerBusinessLogic(program) {
64647
65814
  }
64648
65815
 
64649
65816
  // src/cli/commands/maintenance-cost.ts
64650
- var import_node_path62 = require("path");
65817
+ var import_node_path65 = require("path");
64651
65818
  init_logger();
64652
65819
  init_scan2();
64653
65820
 
@@ -64773,7 +65940,7 @@ function registerMaintenanceCost(program) {
64773
65940
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64774
65941
  const format = rawFormat === "json" || rawFormat === "text" ? rawFormat : "text";
64775
65942
  const strict = options.strict ?? cmdOptions.strict ?? false;
64776
- const cwd = (0, import_node_path62.resolve)(options.workspace ?? process.cwd());
65943
+ const cwd = (0, import_node_path65.resolve)(options.workspace ?? process.cwd());
64777
65944
  const { config } = await runScan({ ...options, workspace: cwd });
64778
65945
  const result = await runMaintenanceCostScan(cwd, config, {
64779
65946
  maxFiles: cmdOptions.maxFiles,
@@ -64790,7 +65957,7 @@ function registerMaintenanceCost(program) {
64790
65957
  }
64791
65958
 
64792
65959
  // src/cli/commands/docs.ts
64793
- var import_node_path63 = require("path");
65960
+ var import_node_path66 = require("path");
64794
65961
  init_logger();
64795
65962
  init_scan2();
64796
65963
 
@@ -64917,7 +66084,7 @@ function registerDocs(program) {
64917
66084
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64918
66085
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64919
66086
  const strict = options.strict ?? cmdOptions.strict ?? false;
64920
- const cwd = (0, import_node_path63.resolve)(options.workspace ?? process.cwd());
66087
+ const cwd = (0, import_node_path66.resolve)(options.workspace ?? process.cwd());
64921
66088
  const { config } = await runScan({ ...options, workspace: cwd });
64922
66089
  const result = await runDocsScan(cwd, config, {
64923
66090
  maxDocFiles: cmdOptions.maxFiles,
@@ -64939,7 +66106,7 @@ function registerDocs(program) {
64939
66106
  }
64940
66107
 
64941
66108
  // src/cli/commands/db.ts
64942
- var import_node_path64 = require("path");
66109
+ var import_node_path67 = require("path");
64943
66110
  init_logger();
64944
66111
  init_scan2();
64945
66112
 
@@ -65060,7 +66227,7 @@ function registerDb(program) {
65060
66227
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
65061
66228
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
65062
66229
  const strict = options.strict ?? cmdOptions.strict ?? false;
65063
- const cwd = (0, import_node_path64.resolve)(options.workspace ?? process.cwd());
66230
+ const cwd = (0, import_node_path67.resolve)(options.workspace ?? process.cwd());
65064
66231
  const { config } = await runScan({ ...options, workspace: cwd });
65065
66232
  const result = await runDbScan(cwd, config, {
65066
66233
  maxFiles: cmdOptions.maxFiles,
@@ -65082,13 +66249,13 @@ function registerDb(program) {
65082
66249
  }
65083
66250
 
65084
66251
  // src/cli/commands/patterns.ts
65085
- var import_node_path66 = require("path");
66252
+ var import_node_path69 = require("path");
65086
66253
  init_logger();
65087
66254
  init_scan2();
65088
66255
 
65089
66256
  // src/engine/patterns.ts
65090
- var import_node_fs41 = require("fs");
65091
- var import_node_path65 = require("path");
66257
+ var import_node_fs43 = require("fs");
66258
+ var import_node_path68 = require("path");
65092
66259
  init_patterns();
65093
66260
  init_discover();
65094
66261
  var PATTERN_CATEGORIES = [
@@ -65123,7 +66290,7 @@ var TOAST_NAME_RE = /^(?:[A-Z][a-zA-Z0-9]+)?(Toast|Notification|Snackbar|Alert|B
65123
66290
  var CARD_NAME_RE = /^(?:[A-Z][a-zA-Z0-9]+)?(Card|Tile|Chip|Badge)$/;
65124
66291
  var API_PATH_RE2 = /(?:^|\/)(?:lib\/api|services|api-client|clients)\//;
65125
66292
  function baseName(filePath) {
65126
- return (0, import_node_path65.basename)(filePath).replace(/\.(tsx|ts|jsx|js|vue|svelte|astro)$/i, "");
66293
+ return (0, import_node_path68.basename)(filePath).replace(/\.(tsx|ts|jsx|js|vue|svelte|astro)$/i, "");
65127
66294
  }
65128
66295
  function makeStats(patterns) {
65129
66296
  const seen = /* @__PURE__ */ new Set();
@@ -65182,7 +66349,7 @@ function detectApiFromFiles(files, cwd) {
65182
66349
  const out = [];
65183
66350
  for (const f of files) {
65184
66351
  if (API_PATH_RE2.test(f)) {
65185
- out.push((0, import_node_path65.relative)(cwd, f).split("\\").join("/"));
66352
+ out.push((0, import_node_path68.relative)(cwd, f).split("\\").join("/"));
65186
66353
  }
65187
66354
  }
65188
66355
  return out;
@@ -65192,7 +66359,7 @@ function detectFormsFromFiles(files) {
65192
66359
  for (const f of files) {
65193
66360
  let source;
65194
66361
  try {
65195
- source = (0, import_node_fs41.readFileSync)(f, "utf-8");
66362
+ source = (0, import_node_fs43.readFileSync)(f, "utf-8");
65196
66363
  } catch {
65197
66364
  continue;
65198
66365
  }
@@ -65319,7 +66486,7 @@ function formatText2(report) {
65319
66486
  const label = CATEGORY_LABELS2[cat].padEnd(20);
65320
66487
  const count = String(stats.count).padStart(2);
65321
66488
  const baseline = String(stats.baseline).padStart(1);
65322
- 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";
65323
66490
  lines.push(` ${label} ${count} implementations (baseline ${baseline}) ${excess}`);
65324
66491
  }
65325
66492
  if (report.doNotCreate.length > 0) {
@@ -65377,7 +66544,7 @@ function formatMarkdown3(report) {
65377
66544
  for (const cat of PATTERN_CATEGORIES) {
65378
66545
  const stats = report.byCategory[cat];
65379
66546
  const label = CATEGORY_LABELS2[cat];
65380
- const excess = stats.excess > 0 ? `+${stats.excess}` : "\u2014";
66547
+ const excess = stats.excess > 0 ? `+${stats.excess}` : stats.count === 0 ? "\u2014" : "\xB7";
65381
66548
  lines.push(`| ${label} | ${stats.count} | ${stats.baseline} | ${excess} |`);
65382
66549
  }
65383
66550
  if (report.doNotCreate.length > 0) {
@@ -65413,7 +66580,7 @@ function registerPatterns(program) {
65413
66580
  const options = command.optsWithGlobals();
65414
66581
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
65415
66582
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
65416
- const cwd = (0, import_node_path66.resolve)(options.workspace ?? process.cwd());
66583
+ const cwd = (0, import_node_path69.resolve)(options.workspace ?? process.cwd());
65417
66584
  const { config } = await runScan({ ...options, workspace: cwd });
65418
66585
  const result = await runPatternsScan(cwd, config, {
65419
66586
  maxFiles: cmdOptions.maxFiles,
@@ -65430,8 +66597,8 @@ function registerPatterns(program) {
65430
66597
  }
65431
66598
 
65432
66599
  // src/cli/commands/research.ts
65433
- var import_node_fs42 = require("fs");
65434
- var import_node_path67 = require("path");
66600
+ var import_node_fs44 = require("fs");
66601
+ var import_node_path70 = require("path");
65435
66602
  init_logger();
65436
66603
  init_config2();
65437
66604
  function registerResearch(program) {
@@ -65448,24 +66615,24 @@ function registerResearch(program) {
65448
66615
  framework: cmdOptions.framework,
65449
66616
  componentType: cmdOptions.componentType,
65450
66617
  provider,
65451
- outputDir: (0, import_node_path67.resolve)(cmdOptions.outputDir),
66618
+ outputDir: (0, import_node_path70.resolve)(cmdOptions.outputDir),
65452
66619
  temperature: cmdOptions.temperature
65453
66620
  });
65454
66621
  logger.info(`Generated ${samples.length} samples in ${cmdOptions.outputDir}`);
65455
66622
  });
65456
66623
  research.command("analyze").description("analyze generated samples and report coverage").requiredOption("--input-dir <path>", "directory with generated samples containing metadata.json").option("--output <path>", "analysis output path", ".slopbrick/flywheel/analysis.json").option("--config <path>", "slopbrick config path").option("--framework <name>", "framework multiplier to apply", "react").action(async (cmdOptions) => {
65457
66624
  try {
65458
- const metadataPath = (0, import_node_path67.resolve)(cmdOptions.inputDir, "metadata.json");
65459
- if (!(0, import_node_fs42.existsSync)(metadataPath)) {
66625
+ const metadataPath = (0, import_node_path70.resolve)(cmdOptions.inputDir, "metadata.json");
66626
+ if (!(0, import_node_fs44.existsSync)(metadataPath)) {
65460
66627
  logger.error(`No metadata.json found in ${cmdOptions.inputDir}`);
65461
66628
  process.exit(2);
65462
66629
  }
65463
- const samples = JSON.parse((0, import_node_fs42.readFileSync)(metadataPath, "utf8"));
66630
+ const samples = JSON.parse((0, import_node_fs44.readFileSync)(metadataPath, "utf8"));
65464
66631
  const config = cmdOptions.config ? await loadConfig(cmdOptions.config) : { ...DEFAULT_CONFIG, framework: cmdOptions.framework };
65465
66632
  const analysis = await analyzeSamples(samples, config);
65466
- const outputPath = (0, import_node_path67.resolve)(cmdOptions.output);
65467
- (0, import_node_fs42.mkdirSync)((0, import_node_path67.dirname)(outputPath), { recursive: true });
65468
- (0, import_node_fs42.writeFileSync)(outputPath, JSON.stringify(analysis, null, 2), "utf8");
66633
+ const outputPath = (0, import_node_path70.resolve)(cmdOptions.output);
66634
+ (0, import_node_fs44.mkdirSync)((0, import_node_path70.dirname)(outputPath), { recursive: true });
66635
+ (0, import_node_fs44.writeFileSync)(outputPath, JSON.stringify(analysis, null, 2), "utf8");
65469
66636
  logger.info(`Analyzed ${analysis.summary.total} samples; coverage: ${analysis.summary.coverage}%`);
65470
66637
  logger.info(`Wrote analysis to ${outputPath}`);
65471
66638
  } catch (error) {
@@ -65475,12 +66642,12 @@ function registerResearch(program) {
65475
66642
  });
65476
66643
  research.command("candidates").description("extract patterns from generated samples and emit candidate rules").requiredOption("--input-dir <path>", "directory with generated samples containing metadata.json").option("--output <path>", "output path", ".slopbrick/flywheel/rule-candidates.json").option("--config <path>", "slopbrick config path").option("--framework <name>", "framework multiplier to apply", "react").option("--min-frequency <n>", "minimum cluster frequency", parseCount, 2).option("--include-covered", "include samples already covered by AI-specific rules").action(async (cmdOptions) => {
65477
66644
  try {
65478
- const metadataPath = (0, import_node_path67.resolve)(cmdOptions.inputDir, "metadata.json");
65479
- if (!(0, import_node_fs42.existsSync)(metadataPath)) {
66645
+ const metadataPath = (0, import_node_path70.resolve)(cmdOptions.inputDir, "metadata.json");
66646
+ if (!(0, import_node_fs44.existsSync)(metadataPath)) {
65480
66647
  logger.error(`No metadata.json found in ${cmdOptions.inputDir}`);
65481
66648
  process.exit(2);
65482
66649
  }
65483
- const samples = JSON.parse((0, import_node_fs42.readFileSync)(metadataPath, "utf8"));
66650
+ const samples = JSON.parse((0, import_node_fs44.readFileSync)(metadataPath, "utf8"));
65484
66651
  const config = cmdOptions.config ? await loadConfig(cmdOptions.config) : { ...DEFAULT_CONFIG, framework: cmdOptions.framework };
65485
66652
  const analysis = await analyzeSamples(samples, config);
65486
66653
  const extraction = extractAndCluster(analysis.samples, {
@@ -65490,8 +66657,8 @@ function registerResearch(program) {
65490
66657
  const candidates = clustersToCandidates(extraction.clusters, {
65491
66658
  minFrequency: cmdOptions.minFrequency
65492
66659
  });
65493
- const outputPath = (0, import_node_path67.resolve)(cmdOptions.output);
65494
- (0, import_node_fs42.mkdirSync)((0, import_node_path67.dirname)(outputPath), { recursive: true });
66660
+ const outputPath = (0, import_node_path70.resolve)(cmdOptions.output);
66661
+ (0, import_node_fs44.mkdirSync)((0, import_node_path70.dirname)(outputPath), { recursive: true });
65495
66662
  const payload = {
65496
66663
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65497
66664
  sampleCount: analysis.summary.total,
@@ -65499,7 +66666,7 @@ function registerResearch(program) {
65499
66666
  fingerprintCount: extraction.total,
65500
66667
  candidates
65501
66668
  };
65502
- (0, import_node_fs42.writeFileSync)(outputPath, JSON.stringify(payload, null, 2), "utf8");
66669
+ (0, import_node_fs44.writeFileSync)(outputPath, JSON.stringify(payload, null, 2), "utf8");
65503
66670
  logger.info(`Extracted ${extraction.total} fingerprints across ${analysis.summary.total} samples`);
65504
66671
  logger.info(`Wrote ${candidates.length} candidate rule(s) to ${outputPath}`);
65505
66672
  } catch (error) {
@@ -65510,8 +66677,8 @@ function registerResearch(program) {
65510
66677
  }
65511
66678
 
65512
66679
  // src/cli/commands/init.ts
65513
- var import_node_fs43 = require("fs");
65514
- var import_node_path69 = require("path");
66680
+ var import_node_fs45 = require("fs");
66681
+ var import_node_path72 = require("path");
65515
66682
  init_logger();
65516
66683
  init_builtins();
65517
66684
  init_scan2();
@@ -65521,145 +66688,9 @@ init_git();
65521
66688
  init_cache();
65522
66689
 
65523
66690
  // src/snippet/targets.ts
65524
- var import_node_path68 = require("path");
65525
-
65526
- // src/snippet/render.ts
65527
- function aiSpecificRules(rules) {
65528
- return rules.filter((r) => r.aiSpecific).sort((a, b) => a.id.localeCompare(b.id));
65529
- }
65530
- function categorizeRules(rules) {
65531
- const byCategory = /* @__PURE__ */ new Map();
65532
- for (const r of rules) {
65533
- const list = byCategory.get(r.category) ?? [];
65534
- list.push(r);
65535
- byCategory.set(r.category, list);
65536
- }
65537
- for (const list of byCategory.values()) {
65538
- list.sort((a, b) => a.id.localeCompare(b.id));
65539
- }
65540
- return byCategory;
65541
- }
65542
- function baseContent(rules, toolLabel, intro) {
65543
- const lines = [];
65544
- lines.push("# " + toolLabel);
65545
- lines.push("");
65546
- lines.push(intro);
65547
- lines.push("");
65548
- const ai = aiSpecificRules(rules);
65549
- const byCategory = categorizeRules(ai);
65550
- lines.push("## Category-level directives");
65551
- lines.push("");
65552
- for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
65553
- lines.push("- **" + category + "**: " + directive);
65554
- }
65555
- lines.push("");
65556
- lines.push("## Per-rule directives");
65557
- lines.push("");
65558
- for (const [category, list] of byCategory) {
65559
- lines.push("### " + category);
65560
- lines.push("");
65561
- for (const rule of list) {
65562
- const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
65563
- lines.push("- `" + rule.id + "` (" + rule.severity + "): " + hint);
65564
- }
65565
- lines.push("");
65566
- }
65567
- return lines.join("\n");
65568
- }
65569
-
65570
- // src/snippet/generators.ts
65571
- function generateCursorSnippet(rules) {
65572
- const lines = [];
65573
- lines.push("# slopbrick cursor rules");
65574
- lines.push("");
65575
- lines.push("Generated by `slopbrick init --cursor`. These rules describe patterns that slopbrick");
65576
- lines.push("flags as AI-generated frontend slop. Avoid them when writing code in this project.");
65577
- lines.push("");
65578
- lines.push("## Category-level directives");
65579
- lines.push("");
65580
- for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
65581
- lines.push("- **" + category + "**: " + directive);
65582
- }
65583
- lines.push("");
65584
- lines.push("## Per-rule directives");
65585
- lines.push("");
65586
- const ai = aiSpecificRules(rules);
65587
- const byCategory = categorizeRules(ai);
65588
- for (const [category, list] of byCategory) {
65589
- lines.push("### " + category);
65590
- lines.push("");
65591
- for (const rule of list) {
65592
- const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
65593
- lines.push("- **`" + rule.id + "`** (" + rule.severity + "): " + hint);
65594
- }
65595
- lines.push("");
65596
- }
65597
- lines.push("## Verification");
65598
- lines.push("");
65599
- lines.push("After writing code, run `slopbrick scan` and check for issues in any of the categories above.");
65600
- lines.push("Use `slopbrick fix` for auto-fixable rules. Use `slopbrick mcp` from MCP-aware clients to query rules and scan files programmatically.");
65601
- lines.push("");
65602
- return lines.join("\n");
65603
- }
65604
- function generateAgentsMdSnippet(rules) {
65605
- return baseContent(
65606
- rules,
65607
- "AGENTS.md",
65608
- "Generated by `slopbrick init --agents-md`. Read these directives when writing or modifying code in this project. AGENTS.md is the de facto standard read by OpenAI Codex, opencode, Pi, Cline, Continue, Gemini, Claude Code (fallback), and others."
65609
- ) + "## How to verify\n\nAfter editing code, run `slopbrick scan`. Any issue with a category in this file is something to fix.\n";
65610
- }
65611
- function generateClaudeMdSnippet(rules) {
65612
- return baseContent(
65613
- rules,
65614
- "CLAUDE.md",
65615
- "Generated by `slopbrick init --claude-md`. Read this file at the start of every Claude Code session in this project. CLAUDE.md takes precedence over AGENTS.md for Claude Code."
65616
- ) + "## Verification\n\nRun `slopbrick scan` after edits. If MCP is configured (`slopbrick mcp`), use `slop_scan_file` proactively to check before reporting code as done.\n";
65617
- }
65618
- function generateAiderSnippet(rules) {
65619
- return baseContent(
65620
- rules,
65621
- "CONVENTIONS.md",
65622
- "Generated by `slopbrick init --aider`. Aider reads CONVENTIONS.md (not AGENTS.md) for project conventions."
65623
- );
65624
- }
65625
- function generateWindsurfSnippet(rules) {
65626
- return baseContent(
65627
- rules,
65628
- ".windsurfrules",
65629
- "Generated by `slopbrick init --windsurf`. Windsurf Cascade reads .windsurfrules at project root."
65630
- );
65631
- }
65632
- function generateClineRules(rules) {
65633
- return baseContent(
65634
- rules,
65635
- "AGENTS.md",
65636
- "Generated by `slopbrick init --cline`. Cline reads .clinerules/AGENTS.md from the project root."
65637
- );
65638
- }
65639
- function generateCursorrulesLegacySnippet(rules) {
65640
- return baseContent(
65641
- rules,
65642
- ".cursorrules",
65643
- "Generated by `slopbrick init --cursorrules`. This is the legacy Cursor rule format. Prefer `.cursor/rules/slopbrick.mdc` (use --cursor) for new projects."
65644
- );
65645
- }
65646
- function generateGeminiSnippet(rules) {
65647
- return baseContent(
65648
- rules,
65649
- "GEMINI.md",
65650
- "Generated by `slopbrick init --gemini`. Gemini CLI reads .gemini/GEMINI.md from project root."
65651
- );
65652
- }
65653
- function generateCopilotSnippet(rules) {
65654
- return baseContent(
65655
- rules,
65656
- "copilot-instructions.md",
65657
- "Generated by `slopbrick init --copilot`. GitHub Copilot reads .github/copilot-instructions.md from project root."
65658
- );
65659
- }
65660
-
65661
- // src/snippet/targets.ts
65662
- var SNIPPET_TARGETS = [
66691
+ var import_node_path71 = require("path");
66692
+ init_generators();
66693
+ var SNIPPET_TARGETS2 = [
65663
66694
  {
65664
66695
  flag: "cursor",
65665
66696
  cliName: "--cursor",
@@ -65752,7 +66783,7 @@ var SNIPPET_TARGETS = [
65752
66783
  }
65753
66784
  ];
65754
66785
  function resolveTargetPath(target) {
65755
- return target.isFolder ? (0, import_node_path68.join)(target.path, target.filename) : target.path;
66786
+ return target.isFolder ? (0, import_node_path71.join)(target.path, target.filename) : target.path;
65756
66787
  }
65757
66788
  function renderMatrix() {
65758
66789
  const lines = [];
@@ -65760,7 +66791,7 @@ function renderMatrix() {
65760
66791
  lines.push("");
65761
66792
  lines.push("| Flag | File | Agent |");
65762
66793
  lines.push("|------|------|-------|");
65763
- for (const t of SNIPPET_TARGETS) {
66794
+ for (const t of SNIPPET_TARGETS2) {
65764
66795
  lines.push("| `" + t.cliName + "` | `" + resolveTargetPath(t) + "` | " + t.agent + " |");
65765
66796
  }
65766
66797
  return lines.join("\n");
@@ -65774,13 +66805,13 @@ function registerInit(program) {
65774
66805
  logger.info(renderMatrix());
65775
66806
  process.exit(0);
65776
66807
  }
65777
- const cwd = (0, import_node_path69.resolve)(options.workspace ?? process.cwd());
65778
- const configPath = (0, import_node_path69.join)(cwd, "slopbrick.config.mjs");
66808
+ const cwd = (0, import_node_path72.resolve)(options.workspace ?? process.cwd());
66809
+ const configPath = (0, import_node_path72.join)(cwd, "slopbrick.config.mjs");
65779
66810
  const detected = detectStack(cwd);
65780
66811
  const fallbackConfig = { ...DEFAULT_CONFIG, ...detected };
65781
66812
  const proposed = serializeConfig(fallbackConfig);
65782
- if ((0, import_node_fs43.existsSync)(configPath) && !cmdOptions.yes) {
65783
- const current = (0, import_node_fs43.readFileSync)(configPath, "utf8");
66813
+ if ((0, import_node_fs45.existsSync)(configPath) && !cmdOptions.yes) {
66814
+ const current = (0, import_node_fs45.readFileSync)(configPath, "utf8");
65784
66815
  logger.error(`A config file already exists at ${configPath}.`);
65785
66816
  logger.error("To overwrite it with defaults, run `slopbrick init --yes`.");
65786
66817
  logger.error("");
@@ -65801,7 +66832,7 @@ function registerInit(program) {
65801
66832
  config = buildInitConfig(detected, answers);
65802
66833
  usedWizard = true;
65803
66834
  }
65804
- (0, import_node_fs43.writeFileSync)(configPath, serializeConfig(config));
66835
+ (0, import_node_fs45.writeFileSync)(configPath, serializeConfig(config));
65805
66836
  appendGitignore(cwd);
65806
66837
  const refresh = await refreshRegistrySnapshot(cwd);
65807
66838
  if (!refresh.ok) {
@@ -65825,27 +66856,27 @@ function registerInit(program) {
65825
66856
  }
65826
66857
  logger.info(refresh.message);
65827
66858
  }
65828
- const targetsToWrite = SNIPPET_TARGETS.filter((t) => {
66859
+ const targetsToWrite = SNIPPET_TARGETS2.filter((t) => {
65829
66860
  if (cmdOptions.all) return true;
65830
66861
  const opts = cmdOptions;
65831
66862
  return Boolean(opts[t.flag]);
65832
66863
  });
65833
66864
  for (const target of targetsToWrite) {
65834
- const snippetPath = (0, import_node_path69.join)(cwd, resolveTargetPath(target));
65835
- (0, import_node_fs43.mkdirSync)((0, import_node_path69.dirname)(snippetPath), { recursive: true });
66865
+ const snippetPath = (0, import_node_path72.join)(cwd, resolveTargetPath(target));
66866
+ (0, import_node_fs45.mkdirSync)((0, import_node_path72.dirname)(snippetPath), { recursive: true });
65836
66867
  const generated = target.generator(builtinRules);
65837
- if (!target.isFolder && (0, import_node_fs43.existsSync)(snippetPath)) {
65838
- const existing = (0, import_node_fs43.readFileSync)(snippetPath, "utf8");
66868
+ if (!target.isFolder && (0, import_node_fs45.existsSync)(snippetPath)) {
66869
+ const existing = (0, import_node_fs45.readFileSync)(snippetPath, "utf8");
65839
66870
  if (existing.includes("<!-- slopbrick:begin -->")) {
65840
66871
  const updated = existing.replace(
65841
66872
  /<!-- slopbrick:begin -->[\s\S]*?<!-- slopbrick:end -->/,
65842
66873
  "<!-- slopbrick:begin -->\n" + generated + "<!-- slopbrick:end -->"
65843
66874
  );
65844
- (0, import_node_fs43.writeFileSync)(snippetPath, updated, "utf8");
66875
+ (0, import_node_fs45.writeFileSync)(snippetPath, updated, "utf8");
65845
66876
  if (!options.quiet) logger.info(`Updated ${snippetPath}`);
65846
66877
  continue;
65847
66878
  }
65848
- (0, import_node_fs43.writeFileSync)(
66879
+ (0, import_node_fs45.writeFileSync)(
65849
66880
  snippetPath,
65850
66881
  existing + (existing.endsWith("\n") ? "\n" : "\n\n") + generated,
65851
66882
  "utf8"
@@ -65853,7 +66884,7 @@ function registerInit(program) {
65853
66884
  if (!options.quiet) logger.info(`Wrote ${snippetPath}`);
65854
66885
  continue;
65855
66886
  }
65856
- (0, import_node_fs43.writeFileSync)(snippetPath, generated, "utf8");
66887
+ (0, import_node_fs45.writeFileSync)(snippetPath, generated, "utf8");
65857
66888
  if (!options.quiet) logger.info(`Wrote ${snippetPath}`);
65858
66889
  }
65859
66890
  if (options.baseline) {
@@ -65871,14 +66902,14 @@ function registerInit(program) {
65871
66902
  }
65872
66903
 
65873
66904
  // src/cli/commands/flywheel.ts
65874
- var import_node_fs45 = require("fs");
65875
- var import_node_path71 = require("path");
66905
+ var import_node_fs47 = require("fs");
66906
+ var import_node_path74 = require("path");
65876
66907
  init_logger();
65877
66908
  init_telemetry();
65878
66909
 
65879
66910
  // src/report/flywheel.ts
65880
- var import_node_fs44 = require("fs");
65881
- var import_node_path70 = require("path");
66911
+ var import_node_fs46 = require("fs");
66912
+ var import_node_path73 = require("path");
65882
66913
  function average(values) {
65883
66914
  if (values.length === 0) return 0;
65884
66915
  return values.reduce((a, b) => a + b, 0) / values.length;
@@ -65938,8 +66969,9 @@ function formatFlywheel(summary, options = {}) {
65938
66969
  if (summary.firstRunAt && summary.latestRunAt) {
65939
66970
  lines.push(` Window: ${summary.firstRunAt} \u2192 ${summary.latestRunAt}`);
65940
66971
  }
65941
- lines.push(` Average slop index: ${summary.averageSlopIndex.toFixed(2)}`);
65942
- 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}`);
65943
66975
  lines.push(` Average assembly health: ${summary.averageAssemblyHealth.toFixed(2)}`);
65944
66976
  lines.push(` Latest assembly health: ${summary.latestAssemblyHealth.toFixed(2)}`);
65945
66977
  if (summary.topViolations.length > 0) {
@@ -65968,7 +67000,7 @@ function formatFlywheel(summary, options = {}) {
65968
67000
  function registerFlywheel(program) {
65969
67001
  program.command("flywheel").description("summarize aggregated scan telemetry").option("--format <pretty|json>", "output format", "pretty").option("--export <path>", "write summary as JSON to <path>").action(async (cmdOptions, command) => {
65970
67002
  const options = command.optsWithGlobals();
65971
- const cwd = (0, import_node_path71.resolve)(options.workspace ?? process.cwd());
67003
+ const cwd = (0, import_node_path74.resolve)(options.workspace ?? process.cwd());
65972
67004
  const payloads = readTelemetry(cwd);
65973
67005
  if (payloads.length === 0) {
65974
67006
  logger.info("No flywheel telemetry found. Run a scan first.");
@@ -65976,9 +67008,9 @@ function registerFlywheel(program) {
65976
67008
  }
65977
67009
  const summary = summarizeTelemetry(payloads);
65978
67010
  if (cmdOptions.export) {
65979
- const exportPath = (0, import_node_path71.resolve)(cmdOptions.export);
65980
- (0, import_node_fs45.mkdirSync)((0, import_node_path71.dirname)(exportPath), { recursive: true });
65981
- (0, import_node_fs45.writeFileSync)(exportPath, JSON.stringify(summary, null, 2), "utf-8");
67011
+ const exportPath = (0, import_node_path74.resolve)(cmdOptions.export);
67012
+ (0, import_node_fs47.mkdirSync)((0, import_node_path74.dirname)(exportPath), { recursive: true });
67013
+ (0, import_node_fs47.writeFileSync)(exportPath, JSON.stringify(summary, null, 2), "utf-8");
65982
67014
  logger.info(`Wrote flywheel summary to ${exportPath}`);
65983
67015
  process.exit(0);
65984
67016
  }
@@ -66005,8 +67037,8 @@ init_dist2();
66005
67037
  init_memory_io();
66006
67038
 
66007
67039
  // src/fix/focus-ring.ts
66008
- var import_node_fs46 = require("fs");
66009
- var import_node_fs47 = require("fs");
67040
+ var import_node_fs48 = require("fs");
67041
+ var import_node_fs49 = require("fs");
66010
67042
  var ANCHOR_START = "/* @slopbrick:v1.0.0:fix:focus-ring */";
66011
67043
  var CSS_BLOCK = `/* @slopbrick:v1.0.0:fix:focus-ring */
66012
67044
  :focus-visible {
@@ -66015,15 +67047,15 @@ var CSS_BLOCK = `/* @slopbrick:v1.0.0:fix:focus-ring */
66015
67047
  }
66016
67048
  /* @slopbrick:v1.0.0:fix:focus-ring-end */`;
66017
67049
  function applyFocusRingFix(targetFile) {
66018
- if (!(0, import_node_fs46.existsSync)(targetFile)) {
67050
+ if (!(0, import_node_fs48.existsSync)(targetFile)) {
66019
67051
  return { applied: false, reason: "missing-global-css-target" };
66020
67052
  }
66021
- const content = (0, import_node_fs47.readFileSync)(targetFile, "utf-8");
67053
+ const content = (0, import_node_fs49.readFileSync)(targetFile, "utf-8");
66022
67054
  if (content.includes(ANCHOR_START)) {
66023
67055
  return { applied: false, reason: "already-present" };
66024
67056
  }
66025
67057
  const separator = content.endsWith("\n") ? "" : "\n";
66026
- (0, import_node_fs47.writeFileSync)(targetFile, `${content}${separator}${CSS_BLOCK}
67058
+ (0, import_node_fs49.writeFileSync)(targetFile, `${content}${separator}${CSS_BLOCK}
66027
67059
  `);
66028
67060
  return { applied: true };
66029
67061
  }
@@ -66032,21 +67064,21 @@ function applyFocusRingFix(targetFile) {
66032
67064
  init_layout_token();
66033
67065
 
66034
67066
  // src/fix/use-client.ts
66035
- var import_node_fs48 = require("fs");
67067
+ var import_node_fs50 = require("fs");
66036
67068
  function applyUseClientFix(filePath) {
66037
- const content = (0, import_node_fs48.readFileSync)(filePath, "utf-8");
67069
+ const content = (0, import_node_fs50.readFileSync)(filePath, "utf-8");
66038
67070
  const trimmed = content.trimStart();
66039
67071
  if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) {
66040
67072
  return { applied: false, reason: "already-present" };
66041
67073
  }
66042
- (0, import_node_fs48.writeFileSync)(filePath, `"use client";
67074
+ (0, import_node_fs50.writeFileSync)(filePath, `"use client";
66043
67075
 
66044
67076
  ${content}`);
66045
67077
  return { applied: true };
66046
67078
  }
66047
67079
 
66048
67080
  // src/fix/visual-codemod.ts
66049
- var import_node_fs49 = require("fs");
67081
+ var import_node_fs51 = require("fs");
66050
67082
 
66051
67083
  // src/fix/visual-codemods/tailwind.ts
66052
67084
  var ARBITRARY_ESCAPE_RE = /\b(p|m|px|py|pt|pb|pl|pr|mx|my|mt|mb|ml|mr|gap|space-[xy]|w|h|min-w|max-w|min-h|max-h|text-\w+|leading-\w+|rounded|border)-?\[(-?\d+(?:\.\d+)?)(px|rem|em|%|vh|vw)?\]/g;
@@ -66255,7 +67287,7 @@ var ALL_CODEMODS = [
66255
67287
  { name: "aria-attr-typo", fn: applyAriaAttrTypoCodemod }
66256
67288
  ];
66257
67289
  function applyVisualCodemods(filePath) {
66258
- const original = (0, import_node_fs49.readFileSync)(filePath, "utf-8");
67290
+ const original = (0, import_node_fs51.readFileSync)(filePath, "utf-8");
66259
67291
  let content = original;
66260
67292
  const reasons = [];
66261
67293
  const seen = /* @__PURE__ */ new Set();
@@ -66271,7 +67303,7 @@ function applyVisualCodemods(filePath) {
66271
67303
  }
66272
67304
  }
66273
67305
  if (content !== original) {
66274
- (0, import_node_fs49.writeFileSync)(filePath, content);
67306
+ (0, import_node_fs51.writeFileSync)(filePath, content);
66275
67307
  }
66276
67308
  return {
66277
67309
  filePath,
@@ -66286,7 +67318,7 @@ function applyVisualCodemods(filePath) {
66286
67318
  function collectAllFixes2(issue) {
66287
67319
  return [...issue.fix ? [issue.fix] : [], ...issue.fixes ?? []];
66288
67320
  }
66289
- async function applyFixes(report, config, scannedFiles) {
67321
+ async function applyFixes(report, _config, scannedFiles) {
66290
67322
  const byFile = /* @__PURE__ */ new Map();
66291
67323
  for (const issue of report.issues) {
66292
67324
  const fixes = collectAllFixes2(issue);
@@ -66347,7 +67379,6 @@ async function applyFixes(report, config, scannedFiles) {
66347
67379
  const result = applyLayoutTokenFix(filePath, group.replaces);
66348
67380
  const total = group.replaceApps.length;
66349
67381
  const appliedCount = Math.min(result.applied, total);
66350
- const skippedCount = total - appliedCount;
66351
67382
  applied.push(...group.replaceApps.slice(0, appliedCount));
66352
67383
  skipped.push(...group.replaceApps.slice(appliedCount));
66353
67384
  } catch (err) {
@@ -66542,6 +67573,40 @@ function formatGroupedHelp(cmd) {
66542
67573
  }
66543
67574
  }
66544
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("");
66545
67610
  lines.push(
66546
67611
  "Use `--help-flat` for the standard un-grouped list. See `https://usebrick.dev/docs/scan-options` for full docs."
66547
67612
  );
@@ -66553,12 +67618,12 @@ var http = __toESM(require("http"), 1);
66553
67618
  var https = __toESM(require("https"), 1);
66554
67619
  var import_node_url2 = require("url");
66555
67620
  var sendBeacon = (url, payload, timeoutMs) => {
66556
- return new Promise((resolve42) => {
67621
+ return new Promise((resolve43) => {
66557
67622
  let parsed;
66558
67623
  try {
66559
67624
  parsed = new import_node_url2.URL(url);
66560
67625
  } catch {
66561
- resolve42();
67626
+ resolve43();
66562
67627
  return;
66563
67628
  }
66564
67629
  const isHttps = parsed.protocol === "https:";
@@ -66579,7 +67644,7 @@ var sendBeacon = (url, payload, timeoutMs) => {
66579
67644
  const settle = () => {
66580
67645
  if (settled) return;
66581
67646
  settled = true;
66582
- resolve42();
67647
+ resolve43();
66583
67648
  };
66584
67649
  let req;
66585
67650
  try {
@@ -66659,7 +67724,7 @@ process.on("uncaughtException", (err) => {
66659
67724
  });
66660
67725
  async function runCli({ start }) {
66661
67726
  try {
66662
- 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("--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)");
66663
67728
  program.helpInformation = () => formatGroupedHelp(program);
66664
67729
  setExitOverride(program);
66665
67730
  registerInit(program);
@@ -66668,6 +67733,7 @@ async function runCli({ start }) {
66668
67733
  registerBadge(program);
66669
67734
  registerSuggest(program);
66670
67735
  registerExplain(program);
67736
+ registerComposite(program);
66671
67737
  registerCalibration(program);
66672
67738
  registerFlywheel(program);
66673
67739
  const scanAction = async (paths, _options, command) => {
@@ -66686,7 +67752,7 @@ async function runCli({ start }) {
66686
67752
  logger.error("--heatmap and --suggest can't be used together. Pick one: a heatmap of severity, or text advice.");
66687
67753
  process.exit(2);
66688
67754
  }
66689
- const cwd = (0, import_node_path72.resolve)(options.workspace ?? process.cwd());
67755
+ const cwd = (0, import_node_path75.resolve)(options.workspace ?? process.cwd());
66690
67756
  if (options.trend !== void 0) {
66691
67757
  const runs = await readRuns(cwd, fsMemoryIO);
66692
67758
  if (runs.length === 0) {
@@ -66734,25 +67800,25 @@ async function runCli({ start }) {
66734
67800
  });
66735
67801
  }
66736
67802
  if (options.baseline) {
66737
- const cwd2 = (0, import_node_path72.resolve)(options.workspace ?? process.cwd());
67803
+ const cwd2 = (0, import_node_path75.resolve)(options.workspace ?? process.cwd());
66738
67804
  const configHash = hashConfig(config);
66739
67805
  const gitHead = await getGitHead(cwd2) ?? "unknown";
66740
67806
  const cache = buildBaselineCache(report, configHash, gitHead, cwd2);
66741
67807
  saveBaseline(cwd2, cache);
66742
- if (!options.quiet) {
67808
+ if (!options.quiet && !machineReadableStdout) {
66743
67809
  logger.info(`Saved baseline to ${baselinePath(cwd2)}`);
66744
67810
  }
66745
67811
  }
66746
67812
  if (options.tighten && baseline) {
66747
67813
  saveBaseline(cwd, baseline);
66748
- if (!options.quiet) {
67814
+ if (!options.quiet && !machineReadableStdout) {
66749
67815
  logger.info(`Tightened baseline saved (revision ${baseline.baseline_revision}).`);
66750
67816
  }
66751
67817
  }
66752
- if (options.doctor && !options.quiet) {
67818
+ if (options.doctor && !options.quiet && !machineReadableStdout) {
66753
67819
  logger.info(`Doctor: bootstrap ${totalElapsed - scanElapsed}ms, scan ${scanElapsed}ms`);
66754
67820
  }
66755
- if (report.baseline && !options.quiet) {
67821
+ if (report.baseline && !options.quiet && !machineReadableStdout) {
66756
67822
  logger.info(baselineStatusMessage(report.baseline));
66757
67823
  }
66758
67824
  if (options.fix) {
@@ -66766,7 +67832,7 @@ async function runCli({ start }) {
66766
67832
  }
66767
67833
  const scannedFiles = results.map((r) => r.filePath);
66768
67834
  const fixResults = await applyFixes(report, config, scannedFiles);
66769
- const { totalApplied, totalSkipped, hasErrors } = printFixSummary(fixResults, options.quiet ?? false);
67835
+ const { hasErrors } = printFixSummary(fixResults, options.quiet ?? false);
66770
67836
  if (!options.showFixesDiff && !options.quiet) {
66771
67837
  logger.info("Tip: pass --show-fixes-diff to see the unified diff of proposed changes.");
66772
67838
  }
@@ -66806,8 +67872,18 @@ async function runCli({ start }) {
66806
67872
  if (options.staged && stagedGatingResult.reason) {
66807
67873
  logger.error(`Gating failure: ${stagedGatingResult.reason}`);
66808
67874
  } else {
66809
- const failed = failedThresholdCount(report, config);
66810
- 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}`);
66811
67887
  }
66812
67888
  }
66813
67889
  if (!options.quiet && !machineReadableStdout) {
@@ -66905,6 +67981,7 @@ init_render();
66905
67981
  directoryScore,
66906
67982
  extractSignatures,
66907
67983
  failedThresholdCount,
67984
+ failedThresholds,
66908
67985
  filterByDisabledDirectives,
66909
67986
  filterIssues,
66910
67987
  findSimilarFunctions,