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.js CHANGED
@@ -19,7 +19,7 @@ var VERSION;
19
19
  var init_header = __esm({
20
20
  "src/types/_header.ts"() {
21
21
  "use strict";
22
- VERSION = "0.41.0";
22
+ VERSION = "0.43.0";
23
23
  }
24
24
  });
25
25
 
@@ -321,8 +321,16 @@ var init_defaults = __esm({
321
321
  // rule definitions are meta-code
322
322
  "**/snippet/**",
323
323
  // RULE_HINTS examples are intentional bad code
324
- "**/tests/**"
324
+ "**/tests/**",
325
325
  // all test files (fixtures + unit + integration)
326
+ // v0.42.0 (post-self-scan cleanup): language visitors are
327
+ // intentional clones — see the §3 v0.42.0 release notes for
328
+ // the design rationale. `dup/identical-block` fires 100+
329
+ // times during self-scan because each visitor shares the
330
+ // same service-suffix arrays and import-walking preamble.
331
+ // Suppress these from the self-scan noise floor; they
332
+ // remain on for user codebases.
333
+ "**/src/engine/visitors/**"
326
334
  ]
327
335
  }
328
336
  };
@@ -25984,7 +25992,7 @@ var init_comment_ratio = __esm({
25984
25992
  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".`,
25985
25993
  line: 1,
25986
25994
  column: 1,
25987
- 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."
25995
+ 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."
25988
25996
  }
25989
25997
  ];
25990
25998
  return issues;
@@ -26834,7 +26842,6 @@ function buildTrigramLM(samples, alpha = 1) {
26834
26842
  logProb(context, t) {
26835
26843
  const ctxKey = context.slice(-2).join("|");
26836
26844
  const triKey = `${ctxKey}|${t}`;
26837
- const biKey = context.slice(-1).join("|") + "|" + t;
26838
26845
  const triCount = tri.get(triKey) ?? 0;
26839
26846
  const biCount = bi.get(ctxKey) ?? 0;
26840
26847
  const V = uni.size;
@@ -28842,7 +28849,7 @@ var init_stale_function_reference = __esm({
28842
28849
  ]);
28843
28850
  SOURCE_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
28844
28851
  SOURCE_ROOTS = ["src", "lib", "app", "components"];
28845
- CAP = 200;
28852
+ CAP = 4e3;
28846
28853
  staleFunctionReferenceRule = createRule({
28847
28854
  id: "docs/stale-function-reference",
28848
28855
  category: "docs",
@@ -28858,6 +28865,7 @@ var init_stale_function_reference = __esm({
28858
28865
  if (!source) return issues;
28859
28866
  for (const span of extractInlineCodeSpans(source)) {
28860
28867
  const text = span.text;
28868
+ if (span.inBlockComment || span.inComment) continue;
28861
28869
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(text)) continue;
28862
28870
  if (text.length < 3) continue;
28863
28871
  if (RESERVED.has(text.toLowerCase())) continue;
@@ -29028,6 +29036,7 @@ var init_stale_package_reference = __esm({
29028
29036
  if (!source) return issues;
29029
29037
  const spans = extractInlineCodeSpans(source);
29030
29038
  for (const span of spans) {
29039
+ if (span.inComment) continue;
29031
29040
  const lineStart = source.lastIndexOf("\n", span.index) + 1;
29032
29041
  const lineEnd = source.indexOf("\n", span.index);
29033
29042
  const line = source.slice(lineStart, lineEnd === -1 ? source.length : lineEnd);
@@ -29082,7 +29091,8 @@ __export(doc_freshness_exports, {
29082
29091
  extractExports: () => extractExports,
29083
29092
  extractFencedCodeBlocks: () => extractFencedCodeBlocks,
29084
29093
  extractInlineCodeSpans: () => extractInlineCodeSpans,
29085
- extractMarkdownLinks: () => extractMarkdownLinks
29094
+ extractMarkdownLinks: () => extractMarkdownLinks,
29095
+ findCommentLineRanges: () => findCommentLineRanges
29086
29096
  });
29087
29097
  import { readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
29088
29098
  import { join as join6, dirname as dirname2, relative } from "path";
@@ -29090,6 +29100,8 @@ import { globby } from "globby";
29090
29100
  function extractInlineCodeSpans(source) {
29091
29101
  const hits = [];
29092
29102
  const re = /`([^`\n]+?)`/g;
29103
+ const blockRanges = findBlockCommentRanges(source);
29104
+ const commentLines = findCommentLineRanges(source);
29093
29105
  let m;
29094
29106
  while ((m = re.exec(source)) !== null) {
29095
29107
  const text = m[1] ?? "";
@@ -29097,7 +29109,14 @@ function extractInlineCodeSpans(source) {
29097
29109
  const line = upTo.split("\n").length;
29098
29110
  const lastNl = upTo.lastIndexOf("\n");
29099
29111
  const column = lastNl === -1 ? m.index + 1 : m.index - lastNl;
29100
- hits.push({ text, line, column, index: m.index });
29112
+ hits.push({
29113
+ text,
29114
+ line,
29115
+ column,
29116
+ index: m.index,
29117
+ inBlockComment: isInBlockComment(blockRanges, m.index),
29118
+ inComment: commentLines.has(line)
29119
+ });
29101
29120
  }
29102
29121
  return hits;
29103
29122
  }
@@ -29136,6 +29155,8 @@ function extractFencedCodeBlocks(source) {
29136
29155
  function extractMarkdownLinks(source) {
29137
29156
  const hits = [];
29138
29157
  const re = /(?<!\!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
29158
+ const blockRanges = findBlockCommentRanges(source);
29159
+ const commentLines = findCommentLineRanges(source);
29139
29160
  let m;
29140
29161
  while ((m = re.exec(source)) !== null) {
29141
29162
  const target = m[2] ?? "";
@@ -29143,10 +29164,55 @@ function extractMarkdownLinks(source) {
29143
29164
  const line = upTo.split("\n").length;
29144
29165
  const lastNl = upTo.lastIndexOf("\n");
29145
29166
  const column = lastNl === -1 ? m.index + 1 : m.index - lastNl;
29146
- hits.push({ target, line, column });
29167
+ hits.push({
29168
+ target,
29169
+ line,
29170
+ column,
29171
+ index: m.index,
29172
+ inBlockComment: isInBlockComment(blockRanges, m.index),
29173
+ inComment: commentLines.has(line)
29174
+ });
29147
29175
  }
29148
29176
  return hits;
29149
29177
  }
29178
+ function findBlockCommentRanges(source) {
29179
+ const ranges = [];
29180
+ const stack = [];
29181
+ let i = 0;
29182
+ while (i < source.length) {
29183
+ if (source[i] === "/" && source[i + 1] === "/") {
29184
+ const eol = source.indexOf("\n", i);
29185
+ i = eol === -1 ? source.length : eol + 1;
29186
+ continue;
29187
+ }
29188
+ if (source[i] === "/" && source[i + 1] === "*") {
29189
+ stack.push(i);
29190
+ i += 2;
29191
+ continue;
29192
+ }
29193
+ if (source[i] === "*" && source[i + 1] === "/" && stack.length > 0) {
29194
+ const start = stack.pop();
29195
+ ranges.push([start, i + 2]);
29196
+ i += 2;
29197
+ continue;
29198
+ }
29199
+ i += 1;
29200
+ }
29201
+ ranges.sort((a, b) => a[0] - b[0]);
29202
+ return ranges;
29203
+ }
29204
+ function isInBlockComment(ranges, pos) {
29205
+ let lo = 0;
29206
+ let hi = ranges.length - 1;
29207
+ while (lo <= hi) {
29208
+ const mid = lo + hi >>> 1;
29209
+ const [start, end] = ranges[mid];
29210
+ if (pos < start) hi = mid - 1;
29211
+ else if (pos >= end) lo = mid + 1;
29212
+ else return true;
29213
+ }
29214
+ return false;
29215
+ }
29150
29216
  function declaredPackages(cwd) {
29151
29217
  const out = /* @__PURE__ */ new Set();
29152
29218
  const pkgPath = join6(cwd, "package.json");
@@ -29301,6 +29367,58 @@ function docDriftFromFreshness(score) {
29301
29367
  if (score >= DOC_FRESHNESS_THRESHOLDS.high) return "high";
29302
29368
  return "critical";
29303
29369
  }
29370
+ function findCommentLineRanges(source) {
29371
+ const out = /* @__PURE__ */ new Set();
29372
+ const stack = [];
29373
+ let inLineComment = false;
29374
+ const lines = source.split("\n");
29375
+ for (let i = 0; i < lines.length; i++) {
29376
+ const line = lines[i];
29377
+ const lineNo = i + 1;
29378
+ let j = 0;
29379
+ while (j < line.length) {
29380
+ const c = line[j];
29381
+ const next = line[j + 1];
29382
+ if (!stack.length && c === "/" && next === "/") {
29383
+ inLineComment = true;
29384
+ out.add(lineNo);
29385
+ break;
29386
+ }
29387
+ if (c === "/" && next === "*") {
29388
+ stack.push(lineNo);
29389
+ out.add(lineNo);
29390
+ j += 2;
29391
+ continue;
29392
+ }
29393
+ if (c === "*" && next === "/" && stack.length > 0) {
29394
+ stack.pop();
29395
+ j += 2;
29396
+ continue;
29397
+ }
29398
+ if (c === '"' || c === "'" || c === "`") {
29399
+ const quote = c;
29400
+ j += 1;
29401
+ while (j < line.length) {
29402
+ if (line[j] === "\\") {
29403
+ j += 2;
29404
+ continue;
29405
+ }
29406
+ if (line[j] === quote) {
29407
+ j += 1;
29408
+ break;
29409
+ }
29410
+ j += 1;
29411
+ }
29412
+ continue;
29413
+ }
29414
+ j += 1;
29415
+ }
29416
+ if (stack.length > 0) {
29417
+ out.add(lineNo);
29418
+ }
29419
+ }
29420
+ return out;
29421
+ }
29304
29422
  var DOC_RULE_WEIGHTS, DOC_FRESHNESS_THRESHOLDS;
29305
29423
  var init_doc_freshness = __esm({
29306
29424
  "src/engine/doc-freshness.ts"() {
@@ -29324,6 +29442,9 @@ var init_doc_freshness = __esm({
29324
29442
  // src/rules/docs/broken-link.ts
29325
29443
  import { existsSync as existsSync6 } from "fs";
29326
29444
  import { dirname as dirname3, join as join7, resolve as resolve4 } from "path";
29445
+ function looksLikeRegexSyntax(target) {
29446
+ return /\[[^\]]*\]/.test(target) || /^\s*[?:=!]/.test(target) || /\\(?:s|d|w|S|D|W|t|n|r|[^\\])/.test(target) || /[{}|]/.test(target);
29447
+ }
29327
29448
  var brokenLinkRule;
29328
29449
  var init_broken_link = __esm({
29329
29450
  "src/rules/docs/broken-link.ts"() {
@@ -29352,6 +29473,8 @@ var init_broken_link = __esm({
29352
29473
  if (target.startsWith("#")) continue;
29353
29474
  if (target.startsWith("//")) continue;
29354
29475
  if (target.startsWith("/")) continue;
29476
+ if (link.inBlockComment) continue;
29477
+ if (looksLikeRegexSyntax(target)) continue;
29355
29478
  const filePart = target.split("#")[0] ?? target;
29356
29479
  if (filePart === "") continue;
29357
29480
  const resolved = join7(docDir, filePart);
@@ -38526,6 +38649,29 @@ var init_stringly_typed = __esm({
38526
38649
  }
38527
38650
  });
38528
38651
 
38652
+ // src/engine/parser-shared.ts
38653
+ function parseTreeSitterSource(parserResult, source) {
38654
+ if (!parserResult.ok) return null;
38655
+ if (!source || source.trim() === "") return null;
38656
+ try {
38657
+ const rawTree = parserResult.parser.parse(source);
38658
+ if (!rawTree) return null;
38659
+ const tree = rawTree;
38660
+ if (!tree || !tree.rootNode) return null;
38661
+ if (tree.rootNode.type === "ERROR" && tree.rootNode.childCount === 0) {
38662
+ return null;
38663
+ }
38664
+ return tree;
38665
+ } catch {
38666
+ return null;
38667
+ }
38668
+ }
38669
+ var init_parser_shared = __esm({
38670
+ "src/engine/parser-shared.ts"() {
38671
+ "use strict";
38672
+ }
38673
+ });
38674
+
38529
38675
  // src/engine/parser-rust.ts
38530
38676
  import Parser from "tree-sitter";
38531
38677
  import Rust from "tree-sitter-rust";
@@ -38551,21 +38697,7 @@ function effectiveParser() {
38551
38697
  return getRustParser();
38552
38698
  }
38553
38699
  function parseRust(source) {
38554
- const result = effectiveParser();
38555
- if (!result.ok) return null;
38556
- if (!source || source.trim() === "") return null;
38557
- try {
38558
- const rawTree = result.parser.parse(source);
38559
- if (!rawTree) return null;
38560
- const tree = rawTree;
38561
- if (!tree || !tree.rootNode) return null;
38562
- if (tree.rootNode.type === "ERROR" && tree.rootNode.childCount === 0) {
38563
- return null;
38564
- }
38565
- return tree;
38566
- } catch {
38567
- return null;
38568
- }
38700
+ return parseTreeSitterSource(effectiveParser(), source);
38569
38701
  }
38570
38702
  function isRustParserAvailable() {
38571
38703
  const result = getRustParser();
@@ -38575,6 +38707,7 @@ var cachedParser, rustLanguage, loadError, forcedFailure;
38575
38707
  var init_parser_rust = __esm({
38576
38708
  "src/engine/parser-rust.ts"() {
38577
38709
  "use strict";
38710
+ init_parser_shared();
38578
38711
  cachedParser = null;
38579
38712
  rustLanguage = null;
38580
38713
  loadError = null;
@@ -43682,8 +43815,6 @@ function mergeTemplateClassNames(filePath, source, facts, templateClassNames) {
43682
43815
  }
43683
43816
  function extractFacts(filePath, ast, source, supportsRsc = true, framework = "react", config) {
43684
43817
  const lineOffsets = buildLineOffsets2(source);
43685
- const isTypeScript = /\.tsx?$/i.test(filePath);
43686
- const NON_JSX_FRAMEWORKS = /* @__PURE__ */ new Set(["vue", "svelte", "astro"]);
43687
43818
  const facts = {
43688
43819
  filePath,
43689
43820
  components: [],
@@ -45622,7 +45753,7 @@ function formatDeltaSuffix(report) {
45622
45753
  }
45623
45754
  function formatVerdict(report) {
45624
45755
  const score = report.aiSlopScore;
45625
- const band = scoreBand(score);
45756
+ const band = slopScoreBand(score);
45626
45757
  const ranked = rankByImpact(report.issues);
45627
45758
  const topRule = ranked[0];
45628
45759
  const topFile = report.topOffenders?.[0];
@@ -45705,7 +45836,8 @@ function formatNextStep(report) {
45705
45836
  ` \u2192 \`slopbrick scan --baseline\` to accept today's scores as the new floor`
45706
45837
  )
45707
45838
  );
45708
- if (report.aiSlopScore > 30) {
45839
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
45840
+ if (report.aiSlopScore > meanSlop) {
45709
45841
  lines.push(
45710
45842
  chalk.dim(
45711
45843
  ` \u2192 \`slopbrick scan --why-failing\` for the top 5 issues dragging the score down`
@@ -45730,17 +45862,18 @@ function formatWhyFailing(report) {
45730
45862
  return chalk.green("Nothing is failing the threshold. Score is clean.");
45731
45863
  }
45732
45864
  const headline = report.aiSlopScore;
45733
- const status = headline <= 30 ? "PASS" : "FAIL";
45734
- const colorize = headline <= 30 ? chalk.green : chalk.red;
45865
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
45866
+ const status = headline <= meanSlop ? "PASS" : "FAIL";
45867
+ const colorize = headline <= meanSlop ? chalk.green : chalk.red;
45868
+ const qualifier = headline <= meanSlop ? `below ${meanSlop}` : `above ${meanSlop}`;
45735
45869
  const lines = [];
45736
- lines.push(colorize.bold(`Headline score: ${headline.toFixed(0)}/100 (${status} \u2014 above 30)`));
45870
+ lines.push(colorize.bold(`Headline score: ${headline.toFixed(0)}/100 (${status} \u2014 ${qualifier})`));
45737
45871
  lines.push("");
45738
45872
  lines.push(chalk.bold("Top 5 rules dragging the score down:"));
45739
45873
  for (let i = 0; i < ranked.length; i++) {
45740
45874
  const [ruleId, info] = ranked[i];
45741
45875
  const fileCount = info.files.size;
45742
45876
  const fileHint = fileCount > 0 ? ` across ${fileCount} file${fileCount === 1 ? "" : "s"}` : "";
45743
- const catShort = CATEGORY_GLOSSARY[info.count > 0 ? "" : ""]?.short;
45744
45877
  const sampleIssue = report.issues.find((i2) => i2.ruleId === ruleId);
45745
45878
  const cat = sampleIssue ? CATEGORY_GLOSSARY[sampleIssue.category]?.short : null;
45746
45879
  const catHint = cat ? chalk.dim(` [${cat}]`) : "";
@@ -45800,7 +45933,7 @@ function formatCompositeScore(report) {
45800
45933
  lines.push("");
45801
45934
  lines.push(
45802
45935
  chalk.dim(
45803
- `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.`
45936
+ `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.`
45804
45937
  )
45805
45938
  );
45806
45939
  }
@@ -45930,13 +46063,12 @@ function formatDriftSection(report) {
45930
46063
  return lines.join("\n");
45931
46064
  }
45932
46065
  function formatThresholds(report) {
45933
- const limit = report.thresholds.meanSlop;
46066
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
45934
46067
  const headline = report.aiSlopScore;
45935
- const passed = headline >= 70;
45936
- const valueText = `${headline.toFixed(1)} \u2265 70`.padStart(12, " ");
46068
+ const passed = headline <= meanSlop;
46069
+ const valueText = `${headline.toFixed(1)} \u2264 ${meanSlop}`.padStart(12, " ");
45937
46070
  const status = passed ? chalk.green("pass") : chalk.red("fail");
45938
46071
  const result = ["Threshold (CI gate)", ` AI Slop Score ${valueText} ${status}`];
45939
- void limit;
45940
46072
  return result;
45941
46073
  }
45942
46074
  function formatTopComponents(components, topOffenders) {
@@ -46014,9 +46146,10 @@ function formatPretty(report) {
46014
46146
  }
46015
46147
  return sections.join("\n\n");
46016
46148
  }
46017
- function formatScoringExplainer(_report) {
46149
+ function formatScoringExplainer(report) {
46150
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
46018
46151
  return chalk.dim(
46019
- "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."
46152
+ `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.`
46020
46153
  );
46021
46154
  }
46022
46155
  function formatWhyFailingReport(report) {
@@ -46034,7 +46167,7 @@ function formatBriefReport(report) {
46034
46167
  ];
46035
46168
  const deltaSuffix = formatDeltaSuffix(report);
46036
46169
  scoreLines.forEach(({ label, field, value }, idx) => {
46037
- const band = scoreBand(value);
46170
+ const band = field === "aiSlopScore" ? slopScoreBand(value) : scoreBand(value);
46038
46171
  const paddedLabel = label.padEnd(20, " ");
46039
46172
  const valueStr = value.toFixed(0).padStart(3, " ");
46040
46173
  const delta = idx === 0 ? deltaSuffix : "";
@@ -46042,11 +46175,12 @@ function formatBriefReport(report) {
46042
46175
  ` ${paddedLabel} ${band.color(valueStr)} ${chalk.dim(band.label)} ${chalk.dim.italic(`(${field})`)}${delta}`
46043
46176
  );
46044
46177
  });
46045
- const passed = report.aiSlopScore <= 30;
46178
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
46179
+ const passed = report.aiSlopScore <= meanSlop;
46046
46180
  lines.push("");
46047
46181
  lines.push(
46048
46182
  chalk.dim(
46049
- ` CI gate: AI Slop Score <= 30 -> ${passed ? chalk.green("pass") : chalk.red("fail")}`
46183
+ ` CI gate: AI Slop Score <= ${meanSlop} -> ${passed ? chalk.green("pass") : chalk.red("fail")}`
46050
46184
  )
46051
46185
  );
46052
46186
  const suppressed = report.defaultOffSuppressedCount ?? 0;
@@ -46192,19 +46326,22 @@ function thresholdExceeded(report, config) {
46192
46326
  return categoryThresholdBreached(report, config.thresholds.categoryThresholds);
46193
46327
  }
46194
46328
  function failedThresholdCount(report, config) {
46195
- let count = 0;
46196
- if (report.aiSlopScore > config.thresholds.meanSlop) count += 1;
46197
- if (report.p90Score > config.thresholds.p90Slop) count += 1;
46198
- if (report.peakScore > config.thresholds.individualSlopThreshold) count += 1;
46329
+ return failedThresholds(report, config).length;
46330
+ }
46331
+ function failedThresholds(report, config) {
46332
+ const failed = [];
46333
+ if (report.aiSlopScore > config.thresholds.meanSlop) failed.push("meanSlop");
46334
+ if (report.p90Score > config.thresholds.p90Slop) failed.push("p90Slop");
46335
+ if (report.peakScore > config.thresholds.individualSlopThreshold) failed.push("individualSlopThreshold");
46199
46336
  const cat = config.thresholds.categoryThresholds;
46200
46337
  if (cat) {
46201
46338
  for (const [category, limit] of Object.entries(cat)) {
46202
46339
  if (limit === void 0) continue;
46203
46340
  const score = report.categoryScores[category];
46204
- if (score !== void 0 && score > limit) count += 1;
46341
+ if (score !== void 0 && score > limit) failed.push(`category:${category}`);
46205
46342
  }
46206
46343
  }
46207
- return count;
46344
+ return failed;
46208
46345
  }
46209
46346
  function categoryThresholdBreached(report, categoryThresholds) {
46210
46347
  if (!categoryThresholds) return false;
@@ -46862,11 +46999,11 @@ var init_pool = __esm({
46862
46999
  for (let i = 0; i < this.threadCount; i++) {
46863
47000
  spawnWorker();
46864
47001
  }
46865
- return new Promise((resolve42) => {
47002
+ return new Promise((resolve43) => {
46866
47003
  const check = setInterval(() => {
46867
47004
  if (resolved) {
46868
47005
  clearInterval(check);
46869
- resolve42(results);
47006
+ resolve43(results);
46870
47007
  }
46871
47008
  }, 10);
46872
47009
  });
@@ -46883,23 +47020,16 @@ var init_registry = __esm({
46883
47020
  init_builtins();
46884
47021
  RuleRegistry = class {
46885
47022
  rules = /* @__PURE__ */ new Map();
46886
- register(ruleOrId, factory) {
46887
- if (typeof ruleOrId === "string") {
46888
- if (!factory) {
46889
- throw new Error("Factory is required when registering by id");
46890
- }
46891
- const rule = {
46892
- id: ruleOrId,
46893
- category: factory.category,
46894
- severity: factory.severity,
46895
- aiSpecific: factory.aiSpecific,
46896
- create: factory.create,
46897
- analyze: factory.analyze
46898
- };
46899
- this.rules.set(ruleOrId, rule);
46900
- } else {
46901
- this.rules.set(ruleOrId.id, ruleOrId);
46902
- }
47023
+ /**
47024
+ * Register a rule. The 2-arg overload `register(id, factory)` was
47025
+ * deleted in v0.42.0 (architecture review F6): no callers used it,
47026
+ * every `Rule` carries its own `id`, and the per-id registration
47027
+ * was ergonomically redundant with the 1-arg form (`register(rule)`).
47028
+ * External consumers that needed `register-by-id` semantics can
47029
+ * construct a `Rule` directly and pass it in.
47030
+ */
47031
+ register(rule) {
47032
+ this.rules.set(rule.id, rule);
46903
47033
  }
46904
47034
  loadBuiltins(onlyRuleId) {
46905
47035
  for (const rule of builtinRules) {
@@ -46928,6 +47058,15 @@ var init_registry = __esm({
46928
47058
  all() {
46929
47059
  return Array.from(this.rules.values());
46930
47060
  }
47061
+ /** v0.42.0 (§3b.5): look up a registered rule by id.
47062
+ * Returns undefined if not registered. */
47063
+ get(id) {
47064
+ return this.rules.get(id);
47065
+ }
47066
+ /** v0.42.0 (§3b.5): check whether a rule id is registered. */
47067
+ has(id) {
47068
+ return this.rules.has(id);
47069
+ }
46931
47070
  createContexts(config, filePath, cwd, hotspotIssues = []) {
46932
47071
  const context = {
46933
47072
  config,
@@ -50579,6 +50718,10 @@ var init_signal_strength2 = __esm({
50579
50718
  });
50580
50719
 
50581
50720
  // src/engine/worker.ts
50721
+ var worker_exports = {};
50722
+ __export(worker_exports, {
50723
+ scanFile: () => scanFile
50724
+ });
50582
50725
  import { isMainThread, parentPort, workerData } from "worker_threads";
50583
50726
  import { extname as extname5 } from "path";
50584
50727
  import { join as join14, relative as relative5, sep as sep2 } from "path";
@@ -50659,6 +50802,36 @@ async function scanFile(filePath, config, registry, cwd = process.cwd()) {
50659
50802
  const issues = applyRuleOverrides(rawIssues, config.rules);
50660
50803
  const triggeredRuleIds = Array.from(new Set(issues.map((i) => i.ruleId)));
50661
50804
  const compScore = compositeScore(triggeredRuleIds, loadSignalStrength());
50805
+ const fireSet = new Set(triggeredRuleIds);
50806
+ const factsWithFireSet = Object.assign(facts, {
50807
+ compositeFireSet: fireSet
50808
+ });
50809
+ for (const { rule } of rules) {
50810
+ if (!rule.id.startsWith("composite/")) continue;
50811
+ try {
50812
+ const compositeIssues = rule.analyze(
50813
+ rule.create({
50814
+ config,
50815
+ filePath,
50816
+ cwd,
50817
+ framework: config.framework,
50818
+ uiLibraries: config.uiLibraries,
50819
+ hasTailwind: config.hasTailwind,
50820
+ supportsRsc: config.supportsRsc,
50821
+ hotspotIssues: []
50822
+ }),
50823
+ factsWithFireSet
50824
+ );
50825
+ if (compositeIssues.length > 0) {
50826
+ issues.push(...compositeIssues);
50827
+ for (const ci of compositeIssues) fireSet.add(ci.ruleId);
50828
+ }
50829
+ } catch (err) {
50830
+ if (process.env.SLOP_AUDIT_DEBUG === "1") {
50831
+ console.error(`[worker] composite ${rule.id} threw: ${err.message}`);
50832
+ }
50833
+ }
50834
+ }
50662
50835
  const gapValues = collectGapValues(facts);
50663
50836
  const styleSources = collectStyleSources(facts);
50664
50837
  const elementTags = facts.v2.jsx.elements.map((e) => e.tag);
@@ -50834,9 +51007,22 @@ function computeFlywheelOutput(runs, currentTopFiles, recentTopHashes, unmatched
50834
51007
  }
50835
51008
  }
50836
51009
  if (currentTopFiles.length > 0 && runs.length >= CONSECUTIVE_THRESHOLD) {
51010
+ const latestRun = runs[runs.length - 1];
51011
+ const isNoiseTier = (id) => {
51012
+ const rule = ruleById.get(id);
51013
+ if (!rule) return false;
51014
+ if (rule.defaultOff === true) return true;
51015
+ if (rule.aiSpecific && (rule.severity === "medium" || rule.severity === "low")) return true;
51016
+ return false;
51017
+ };
51018
+ const onRulesInLatestTop = latestRun.topOffenseIds.filter(
51019
+ (id) => !isNoiseTier(id)
51020
+ );
51021
+ const skipChronicOnDefaultOffNoise = onRulesInLatestTop.length === 0;
50837
51022
  for (const file of currentTopFiles) {
50838
51023
  const consecutive = countConsecutiveTopFileAppearances(file.hash, recentTopHashes);
50839
51024
  if (consecutive >= CONSECUTIVE_THRESHOLD) {
51025
+ if (skipChronicOnDefaultOffNoise) continue;
50840
51026
  hotspotIssues.push({
50841
51027
  ruleId: "component/chronic-offender",
50842
51028
  category: "component",
@@ -50846,7 +51032,7 @@ function computeFlywheelOutput(runs, currentTopFiles, recentTopHashes, unmatched
50846
51032
  filePath: file.filePath,
50847
51033
  line: 1,
50848
51034
  column: 1,
50849
- advice: "Prioritize refactoring this file or add it to the baseline."
51035
+ advice: "Run `slopbrick scan --baseline` to accept current scores as the new floor, or refactor the file to reduce its issue count."
50850
51036
  });
50851
51037
  }
50852
51038
  }
@@ -51534,14 +51720,14 @@ var init_tokens = __esm({
51534
51720
 
51535
51721
  // src/cli/memory-io.ts
51536
51722
  import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, access as access2 } from "fs/promises";
51537
- import { dirname as dirname10 } from "path";
51723
+ import { dirname as dirname9 } from "path";
51538
51724
  var fsMemoryIO;
51539
51725
  var init_memory_io = __esm({
51540
51726
  "src/cli/memory-io.ts"() {
51541
51727
  "use strict";
51542
51728
  fsMemoryIO = {
51543
51729
  read: (path) => readFile3(path, "utf-8").catch(() => null),
51544
- write: (path, content) => mkdir2(dirname10(path), { recursive: true }).then(
51730
+ write: (path, content) => mkdir2(dirname9(path), { recursive: true }).then(
51545
51731
  () => writeFile2(path, content, "utf-8")
51546
51732
  ),
51547
51733
  exists: (path) => access2(path).then(
@@ -53177,16 +53363,16 @@ function axisSlopIndex(slopIndex) {
53177
53363
  if (slopIndex === void 0 || Number.isNaN(slopIndex)) {
53178
53364
  return {
53179
53365
  axis: "slopIndex",
53180
- label: "Slop Index",
53366
+ label: "AI Slop Score (raw)",
53181
53367
  health: 70,
53182
53368
  source: "default (not measured) \u2014 neutral 70"
53183
53369
  };
53184
53370
  }
53185
53371
  return {
53186
53372
  axis: "slopIndex",
53187
- label: "Slop Index",
53373
+ label: "AI Slop Score (raw)",
53188
53374
  health: clamp100(100 - slopIndex),
53189
- source: `100 - slopIndex (${slopIndex.toFixed(1)}) \u2192 inverted`
53375
+ source: `100 - aiSlopScore (${slopIndex.toFixed(1)}) \u2192 inverted to cleanliness`
53190
53376
  };
53191
53377
  }
53192
53378
  function axisArchitecture(architectureConsistency) {
@@ -54133,6 +54319,16 @@ function assembleScanReport(input) {
54133
54319
  enrichment
54134
54320
  } = input;
54135
54321
  const prSlopScore = computePrSlopScore(diffRef, allIssues);
54322
+ const thresholdReport = {
54323
+ aiSlopScore: aggregated.aiSlopScore,
54324
+ p90Score: aggregated.p90Score,
54325
+ peakScore: aggregated.peakScore,
54326
+ categoryScores: aggregated.categoryScores
54327
+ };
54328
+ const failedThresholds_ = failedThresholds(
54329
+ thresholdReport,
54330
+ config
54331
+ );
54136
54332
  return {
54137
54333
  version: VERSION,
54138
54334
  generatedAt,
@@ -54188,6 +54384,11 @@ function assembleScanReport(input) {
54188
54384
  parseErrors: parseErrors.length > 0 ? parseErrors : void 0,
54189
54385
  baseline: baselineMeta,
54190
54386
  thresholds: config.thresholds,
54387
+ // v0.42.0 (user-review fix): expose which gates tripped so CI
54388
+ // consumers can read the JSON output instead of grepping stderr.
54389
+ // Optional field: absent when no thresholds tripped. Computed from
54390
+ // the report's own scores so the JSON is self-describing.
54391
+ failedThresholds: failedThresholds_,
54191
54392
  topOffenders: topOffenders.length > 0 ? topOffenders : void 0,
54192
54393
  coherence: enrichment.coherence,
54193
54394
  coherenceBreakdown: enrichment.coherenceBreakdown,
@@ -54204,6 +54405,7 @@ var PR_SLOP_WEIGHTS;
54204
54405
  var init_assembleScanReport = __esm({
54205
54406
  "src/cli/report/assembleScanReport.ts"() {
54206
54407
  "use strict";
54408
+ init_threshold();
54207
54409
  init_types();
54208
54410
  PR_SLOP_WEIGHTS = { high: 10, medium: 5, low: 1 };
54209
54411
  }
@@ -54221,7 +54423,7 @@ import {
54221
54423
  statSync as statSync5
54222
54424
  } from "fs";
54223
54425
  import { createHash as createHash10 } from "crypto";
54224
- import { dirname as dirname11, join as join17, relative as relative9 } from "path";
54426
+ import { dirname as dirname10, join as join17, relative as relative9 } from "path";
54225
54427
  function telemetryPath2(cwd) {
54226
54428
  return join17(cwd, TELEMETRY_DIR, TELEMETRY_FILE2);
54227
54429
  }
@@ -54298,7 +54500,7 @@ function rotateTelemetry(cwd) {
54298
54500
  if (stats.size < MAX_TELEMETRY_BYTES) {
54299
54501
  return;
54300
54502
  }
54301
- const dir = dirname11(path);
54503
+ const dir = dirname10(path);
54302
54504
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
54303
54505
  renameSync3(path, join17(dir, `scans-${timestamp}.jsonl`));
54304
54506
  const rotated = readdirSync5(dir).filter(isTelemetryFile).map((name) => ({ name, mtime: statSync5(join17(dir, name)).mtimeMs })).sort((a, b) => a.mtime - b.mtime);
@@ -54360,7 +54562,7 @@ function recordTelemetry(cwd, report, results, config, inventory) {
54360
54562
  ...inventory ? { inventory: buildInventorySummary(inventory) } : {}
54361
54563
  };
54362
54564
  const path = telemetryPath2(cwd);
54363
- const dir = dirname11(path);
54565
+ const dir = dirname10(path);
54364
54566
  if (!existsSync15(dir)) {
54365
54567
  mkdirSync5(dir, { recursive: true });
54366
54568
  }
@@ -54388,7 +54590,7 @@ __export(structure_md_exports, {
54388
54590
  writeStructureMarkdown: () => writeStructureMarkdown
54389
54591
  });
54390
54592
  import { existsSync as existsSync16, mkdirSync as mkdirSync6, readFileSync as readFileSync20, writeFileSync as writeFileSync6 } from "fs";
54391
- import { dirname as dirname12, join as join18 } from "path";
54593
+ import { dirname as dirname11, join as join18 } from "path";
54392
54594
  function formatDuration(ms) {
54393
54595
  if (!Number.isFinite(ms) || ms < 0) return "unknown";
54394
54596
  if (ms < 1e3) return `${Math.round(ms)}ms`;
@@ -54529,29 +54731,29 @@ function renderStructureMarkdown(inventory, constitution) {
54529
54731
  return lines.join("\n");
54530
54732
  }
54531
54733
  async function writeStructureMarkdown(workspaceDir, md) {
54532
- await new Promise((resolve42, reject) => {
54734
+ await new Promise((resolve43, reject) => {
54533
54735
  try {
54534
54736
  const path = join18(workspaceDir, STRUCTURE_MD_FILE);
54535
- mkdirSync6(dirname12(path), { recursive: true });
54737
+ mkdirSync6(dirname11(path), { recursive: true });
54536
54738
  writeFileSync6(path, md, "utf-8");
54537
- resolve42();
54739
+ resolve43();
54538
54740
  } catch (err) {
54539
54741
  reject(err instanceof Error ? err : new Error(String(err)));
54540
54742
  }
54541
54743
  });
54542
54744
  }
54543
54745
  async function readStructureMarkdown(workspaceDir) {
54544
- return new Promise((resolve42) => {
54746
+ return new Promise((resolve43) => {
54545
54747
  try {
54546
54748
  const path = join18(workspaceDir, STRUCTURE_MD_FILE);
54547
54749
  if (!existsSync16(path)) {
54548
- resolve42(null);
54750
+ resolve43(null);
54549
54751
  return;
54550
54752
  }
54551
54753
  const content = readFileSync20(path, "utf-8");
54552
- resolve42(content);
54754
+ resolve43(content);
54553
54755
  } catch {
54554
- resolve42(null);
54756
+ resolve43(null);
54555
54757
  }
54556
54758
  });
54557
54759
  }
@@ -54599,9 +54801,391 @@ var init_structure_md = __esm({
54599
54801
  }
54600
54802
  });
54601
54803
 
54804
+ // src/snippet/data.ts
54805
+ var CATEGORY_DIRECTIVES, RULE_HINTS;
54806
+ var init_data = __esm({
54807
+ "src/snippet/data.ts"() {
54808
+ "use strict";
54809
+ CATEGORY_DIRECTIVES = {
54810
+ 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.',
54811
+ 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.",
54812
+ 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.',
54813
+ 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".',
54814
+ 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.",
54815
+ typo: `Never leave TODO / placeholder / "change me" copy in shipped code. Use real i18n strings or the project's content map.`,
54816
+ 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].",
54817
+ component: "Don't build components > 200 lines. Extract shared subcomponents. Avoid circular prop drilling \u2014 use context.",
54818
+ 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.",
54819
+ 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'."
54820
+ };
54821
+ RULE_HINTS = {
54822
+ // v0.38.0 hygiene: 37 v10-DORMANT rule hints were removed. Every
54823
+ // remaining key matches a rule in src/rules/builtins.ts. No orphans.
54824
+ // (v0.16.0 previously moved 35 out-of-scope orphan hints to
54825
+ // docs/research/backlog-rule-hints.md
54826
+ // for future implementers to paste back when the corresponding rules ship.)
54827
+ "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.",
54828
+ "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.",
54829
+ "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.",
54830
+ "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.",
54831
+ "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.",
54832
+ "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.",
54833
+ "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.',
54834
+ "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.",
54835
+ "visual/arbitrary-escape": "Never use bracket-notation values like text-[13px] or bg-[#7c3aed]. Use design tokens instead.",
54836
+ "visual/spacing-scale-violation": "Use spacing scale tokens (p-2, gap-4, etc.) instead of arbitrary values like p-[13px] or gap-[1.75rem].",
54837
+ "visual/radius-scale-violation": "Use radius scale tokens (rounded-md, rounded-lg, etc.) instead of arbitrary values like rounded-[7px].",
54838
+ // v0.16.0 — in-scope orphans kept here (corresponding rule ships in v0.16.0).
54839
+ "typo/placeholder-text": 'Never leave "TODO", "placeholder", "change me", "your text here" in shipped UI.',
54840
+ "logic/key-prop-missing": "Always provide a stable `key` prop when rendering lists.",
54841
+ "logic/boundary-violation": "Don't import data-layer / DB code into UI components. Server-side only.",
54842
+ "wcag/missing-alt": 'Every <img> needs alt text. Decorative: alt="". Informative: describe the image.',
54843
+ "security/localstorage-token": "Never store JWT / access token / refresh token in localStorage or sessionStorage. Issue as httpOnly cookie.",
54844
+ "security/eval": "Never use eval() or new Function(). These are RCE vectors if the input is ever attacker-controlled.",
54845
+ "security/target-blank-no-noopener": 'Always add rel="noopener" (or rel="noreferrer") to target="_blank" links.',
54846
+ "component/giant-component": "Don't build components > 200 lines. Extract shared subcomponents.",
54847
+ "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.",
54848
+ "component/shadcn-prop-mismatch": "Select shadcn variants via the `variant` prop, not long `className` overrides. See the component registry for available variants.",
54849
+ "context/import-path-mismatch": "Use only the canonical import paths declared in brick.config.json (e.g. @/components/ui/, @/lib/).",
54850
+ "layout/gap-monopoly": "Mix gap-2 / gap-4 / gap-6 / gap-12 deliberately. Don't repeat the same gap value across the whole project.",
54851
+ "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.",
54852
+ "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.",
54853
+ "layout/spacing-grid": "Use the configured spacing scale (4px or 8px grid). Avoid arbitrary values like p-[13px] that aren't on the scale.",
54854
+ "logic/ghost-defensive": "Use optional chaining (?.) or early returns instead of deep && guards. If a defensive chain runs 3+ levels deep, refactor.",
54855
+ "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.)",
54856
+ "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.)",
54857
+ "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.)",
54858
+ "logic/math-any-density": "Replace `: any` with proper types. Start with the parameter/return types of the most-used functions.",
54859
+ "logic/math-console-log-storm": "Replace debug logs with a proper debugger or logger.debug(). Remove all console.log before shipping.",
54860
+ "logic/math-gini-class-usage": "Spread usage across more class tokens instead of repeating the same handful (p-4, p-8, rounded-lg, etc.).",
54861
+ "logic/math-variable-name-entropy": "Use domain-specific identifier names (reservations, invoices, customers) instead of generic data/items/value.",
54862
+ "logic/optimistic-no-rollback": "In optimistic updates, revert state in the catch block: `setX(prev => prev)`. Never leave stale UI on error.",
54863
+ "logic/reactive-hook-soup": "Coordinate state via a single derived value (useMemo) or a state machine. Avoid chained useEffects that sync local state.",
54864
+ "logic/zombie-state": "Remove unused useState or wire it into the component. Don't leave declared-but-never-read state bindings.",
54865
+ "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.",
54866
+ "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.",
54867
+ "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.",
54868
+ "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.",
54869
+ "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.",
54870
+ "perf/cls-image": "Add width/height attributes or an aspect-ratio utility to prevent layout shift.",
54871
+ "perf/css-bloat": "Extract to a CSS variable (`--surface-card`) or a component prop when a class string repeats 5+ times.",
54872
+ "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.',
54873
+ "visual/inline-style-dominance": "Replace inline `style={{...}}` with className utilities (e.g. Tailwind `p-4 m-2 gap-3`) or a CSS module class.",
54874
+ "visual/math-default-font": "Import a distinctive font (next/font/google, @font-face, or a CSS variable) instead of relying on the framework default.",
54875
+ "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.",
54876
+ "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.",
54877
+ "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.",
54878
+ "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%.",
54879
+ "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.",
54880
+ "wcag/focus-appearance": "Add a focus-visible:ring-* class, or remove outline-none. Keyboard users need a visible focus indicator.",
54881
+ "wcag/focus-obscured": "Ensure focused elements are not hidden behind fixed or sticky wrappers.",
54882
+ "test/weak-assertion": "Assert on a specific value or shape: `expect(x).toEqual(expectedValue)`. Avoid `.toBeDefined()` / `.toBeTruthy()` placeholders and tautological `expect(x).toBe(x)`.",
54883
+ "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.",
54884
+ "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`).",
54885
+ "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.",
54886
+ "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.",
54887
+ // v0.13.0 — AI-specific rules (peer-reviewed signals).
54888
+ "ai/markdown-leakage": "Delete stray `\\`\\`\\`<lang>\\`\\`\\`` markers; they are Markdown fences, not valid syntax in standalone source files (Yotkova et al. SemEval-2026).",
54889
+ "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).",
54890
+ "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).",
54891
+ "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).",
54892
+ "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).",
54893
+ "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).",
54894
+ "ai/renyi-profile": "The token distribution is mass-concentrated on a few high-frequency tokens. Verify authorship if unexpected (R\xE9nyi 1961, Moslonka 2025).",
54895
+ "ai/segment-surprisal-cv": "The cross-entropy is suspiciously uniform across the file. Real codebases have varied registers (Binoculars, Hans 2024).",
54896
+ "ai/compression-profile": "The file compresses unusually well and lines are highly repetitive \u2014 characteristic of AI-generated boilerplate (Cilibrasi 2005, Mahoney 1999).",
54897
+ // v0.14.5b — 6 new AI tendency detection rules (DORMANT in v0.14.5b;
54898
+ // reclassified post-v7 calibration in v0.14.5d)
54899
+ "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.",
54900
+ "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.",
54901
+ "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.",
54902
+ "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.",
54903
+ "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.",
54904
+ "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.",
54905
+ // v0.17.0 — db/* rules (Postgres static analysis via pgsql-parser)
54906
+ "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.',
54907
+ // v0.17.0 — docs/* rules (markdown drift detection)
54908
+ "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.",
54909
+ "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.",
54910
+ "docs/broken-link": "Create the file or fix the link target. On a public docs site, broken links erode trust more than stale copy.",
54911
+ // v0.18.9 — Rust-specific rules (tree-sitter-backed)
54912
+ "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.",
54913
+ "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.",
54914
+ "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.",
54915
+ "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.',
54916
+ // v0.19.0 — TypeScript-specific rules
54917
+ "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.",
54918
+ "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.",
54919
+ "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.",
54920
+ "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.",
54921
+ // v0.19.0 — Go-specific rules
54922
+ "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).',
54923
+ "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.",
54924
+ // v0.19.0 — Duplication detector
54925
+ "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.",
54926
+ // v0.23.0 — Type-2 near-duplicate detector (MinHash + LSH)
54927
+ "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.)",
54928
+ // v0.24.0 — Type-3 structural clone detector (two-stage MinHash)
54929
+ "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.)",
54930
+ // v0.24.0 — Kotlin rules (DORMANT until v9 Kotlin corpus calibration)
54931
+ // v0.29.0 — non-AI Kotlin rules (DORMANT until larger pos arm)
54932
+ // v0.30.0 — non-AI Java rules (Option C applied to v9 Java corpus, 92k files)
54933
+ "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.)",
54934
+ "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.)",
54935
+ // v0.35.0 — content-based detection (CoCoNUTS-inspired)
54936
+ "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.",
54937
+ // v0.35.1 — Raidar-inspired content-based detection
54938
+ "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)`.',
54939
+ // v0.24.0 — Swift rules (DORMANT until v9 Swift corpus calibration)
54940
+ "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.)",
54941
+ "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.)",
54942
+ "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.)",
54943
+ "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.)",
54944
+ "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.)",
54945
+ // v0.24.0 — C++ rules (DORMANT until v9 C++ corpus calibration)
54946
+ "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.)",
54947
+ "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.)",
54948
+ "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.)",
54949
+ "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.)"
54950
+ };
54951
+ }
54952
+ });
54953
+
54954
+ // src/snippet/generators.ts
54955
+ function wrapWithMarkers(level, body) {
54956
+ const head = level === 1 ? "<!-- slopbrick-managed-section -->\n" : "";
54957
+ return `${head}${MARKER_BEGIN}
54958
+ ${body}
54959
+ ${MARKER_END}
54960
+ `;
54961
+ }
54962
+ function generateCursorSnippet(rules) {
54963
+ const lines = [];
54964
+ lines.push("# slopbrick cursor rules");
54965
+ lines.push("");
54966
+ lines.push("Generated by `slopbrick init --cursor`. These rules describe patterns that slopbrick");
54967
+ lines.push("flags as AI-generated frontend slop. Avoid them when writing code in this project.");
54968
+ lines.push("");
54969
+ lines.push("## Category-level directives");
54970
+ lines.push("");
54971
+ for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
54972
+ lines.push("- **" + category + "**: " + directive);
54973
+ }
54974
+ lines.push("");
54975
+ lines.push("## Per-rule directives");
54976
+ lines.push("");
54977
+ const ai = aiSpecificRules(rules);
54978
+ const byCategory = categorizeRules(ai);
54979
+ for (const [category, list] of byCategory) {
54980
+ lines.push("### " + category);
54981
+ lines.push("");
54982
+ for (const rule of list) {
54983
+ const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
54984
+ lines.push("- **`" + rule.id + "`** (" + rule.severity + "): " + hint);
54985
+ }
54986
+ lines.push("");
54987
+ }
54988
+ lines.push("## Verification");
54989
+ lines.push("");
54990
+ lines.push("After writing code, run `slopbrick scan` and check for issues in any of the categories above.");
54991
+ lines.push("Use `slopbrick fix` for auto-fixable rules. Use `slopbrick mcp` from MCP-aware clients to query rules and scan files programmatically.");
54992
+ lines.push("");
54993
+ return lines.join("\n");
54994
+ }
54995
+ function generateAgentsMdSnippet(rules) {
54996
+ return wrapWithMarkers(
54997
+ 1,
54998
+ baseContent(
54999
+ rules,
55000
+ "AGENTS.md",
55001
+ "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."
55002
+ ) + "## How to verify\n\nAfter editing code, run `slopbrick scan`. Any issue with a category in this file is something to fix.\n"
55003
+ );
55004
+ }
55005
+ function generateClaudeMdSnippet(rules) {
55006
+ return wrapWithMarkers(
55007
+ 1,
55008
+ baseContent(
55009
+ rules,
55010
+ "CLAUDE.md",
55011
+ "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."
55012
+ ) + "## 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"
55013
+ );
55014
+ }
55015
+ function generateAiderSnippet(rules) {
55016
+ return baseContent(
55017
+ rules,
55018
+ "CONVENTIONS.md",
55019
+ "Generated by `slopbrick init --aider`. Aider reads CONVENTIONS.md (not AGENTS.md) for project conventions."
55020
+ );
55021
+ }
55022
+ function generateWindsurfSnippet(rules) {
55023
+ return baseContent(
55024
+ rules,
55025
+ ".windsurfrules",
55026
+ "Generated by `slopbrick init --windsurf`. Windsurf Cascade reads .windsurfrules at project root."
55027
+ );
55028
+ }
55029
+ function generateClineRules(rules) {
55030
+ return baseContent(
55031
+ rules,
55032
+ "AGENTS.md",
55033
+ "Generated by `slopbrick init --cline`. Cline reads .clinerules/AGENTS.md from the project root."
55034
+ );
55035
+ }
55036
+ function generateCursorrulesLegacySnippet(rules) {
55037
+ return baseContent(
55038
+ rules,
55039
+ ".cursorrules",
55040
+ "Generated by `slopbrick init --cursorrules`. This is the legacy Cursor rule format. Prefer `.cursor/rules/slopbrick.mdc` (use --cursor) for new projects."
55041
+ );
55042
+ }
55043
+ function generateGeminiSnippet(rules) {
55044
+ return baseContent(
55045
+ rules,
55046
+ "GEMINI.md",
55047
+ "Generated by `slopbrick init --gemini`. Gemini CLI reads .gemini/GEMINI.md from project root."
55048
+ );
55049
+ }
55050
+ function generateCopilotSnippet(rules) {
55051
+ return baseContent(
55052
+ rules,
55053
+ "copilot-instructions.md",
55054
+ "Generated by `slopbrick init --copilot`. GitHub Copilot reads .github/copilot-instructions.md from project root."
55055
+ );
55056
+ }
55057
+ var MARKER_BEGIN, MARKER_END;
55058
+ var init_generators = __esm({
55059
+ "src/snippet/generators.ts"() {
55060
+ "use strict";
55061
+ init_data();
55062
+ init_render2();
55063
+ MARKER_BEGIN = "<!-- slopbrick:begin:v3 -->";
55064
+ MARKER_END = "<!-- slopbrick:end:v3 -->";
55065
+ }
55066
+ });
55067
+
55068
+ // src/snippet/render.ts
55069
+ function aiSpecificRules(rules) {
55070
+ return rules.filter((r) => r.aiSpecific).sort((a, b) => a.id.localeCompare(b.id));
55071
+ }
55072
+ function categorizeRules(rules) {
55073
+ const byCategory = /* @__PURE__ */ new Map();
55074
+ for (const r of rules) {
55075
+ const list = byCategory.get(r.category) ?? [];
55076
+ list.push(r);
55077
+ byCategory.set(r.category, list);
55078
+ }
55079
+ for (const list of byCategory.values()) {
55080
+ list.sort((a, b) => a.id.localeCompare(b.id));
55081
+ }
55082
+ return byCategory;
55083
+ }
55084
+ function baseContent(rules, toolLabel, intro) {
55085
+ const lines = [];
55086
+ lines.push("# " + toolLabel);
55087
+ lines.push("");
55088
+ lines.push(intro);
55089
+ lines.push("");
55090
+ const ai = aiSpecificRules(rules);
55091
+ const byCategory = categorizeRules(ai);
55092
+ lines.push("## Category-level directives");
55093
+ lines.push("");
55094
+ for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
55095
+ lines.push("- **" + category + "**: " + directive);
55096
+ }
55097
+ lines.push("");
55098
+ lines.push("## Per-rule directives");
55099
+ lines.push("");
55100
+ for (const [category, list] of byCategory) {
55101
+ lines.push("### " + category);
55102
+ lines.push("");
55103
+ for (const rule of list) {
55104
+ const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
55105
+ lines.push("- `" + rule.id + "` (" + rule.severity + "): " + hint);
55106
+ }
55107
+ lines.push("");
55108
+ }
55109
+ return lines.join("\n");
55110
+ }
55111
+ function safeRewrite(existing, freshBlockBody) {
55112
+ const beginIdx = existing.indexOf(MARKER_BEGIN);
55113
+ const endIdx = existing.indexOf(MARKER_END);
55114
+ if (beginIdx === -1 && endIdx === -1) {
55115
+ return { content: existing, rewritten: false, failClosed: true, mismatched: false };
55116
+ }
55117
+ if (beginIdx === -1 || endIdx === -1) {
55118
+ return { content: existing, rewritten: false, failClosed: false, mismatched: true };
55119
+ }
55120
+ if (endIdx <= beginIdx) {
55121
+ return { content: existing, rewritten: false, failClosed: false, mismatched: true };
55122
+ }
55123
+ const before = existing.slice(0, beginIdx + MARKER_BEGIN.length);
55124
+ const after = existing.slice(endIdx);
55125
+ const next = `${before}
55126
+ ${freshBlockBody}
55127
+ ${after}`;
55128
+ return { content: next, rewritten: true, failClosed: false, mismatched: false };
55129
+ }
55130
+ var init_render2 = __esm({
55131
+ "src/snippet/render.ts"() {
55132
+ "use strict";
55133
+ init_data();
55134
+ init_generators();
55135
+ }
55136
+ });
55137
+
55138
+ // src/snippet/refresh.ts
55139
+ var refresh_exports = {};
55140
+ __export(refresh_exports, {
55141
+ SNIPPET_TARGETS: () => SNIPPET_TARGETS,
55142
+ refreshSnippets: () => refreshSnippets
55143
+ });
55144
+ import { existsSync as existsSync17, readFileSync as readFileSync21, writeFileSync as writeFileSync7 } from "fs";
55145
+ import { join as join19 } from "path";
55146
+ function refreshSnippets(cwd, rules, targets = [...SNIPPET_TARGETS]) {
55147
+ const result = { rewritten: [], failClosed: [], mismatched: [], absent: [] };
55148
+ for (const filename of targets) {
55149
+ const path = join19(cwd, filename);
55150
+ if (!existsSync17(path)) {
55151
+ result.absent.push(filename);
55152
+ continue;
55153
+ }
55154
+ const existing = readFileSync21(path, "utf-8");
55155
+ const agentName = filename.replace(/\.md$/i, "").toLowerCase();
55156
+ const generator = agentName === "claude" ? generateAgentsMdSnippet : generateAgentsMdSnippet;
55157
+ const newBlock = generator([...rules]).trim();
55158
+ const outcome = safeRewrite(existing, newBlock);
55159
+ if (outcome.rewritten) {
55160
+ const tmp = `${path}.tmp`;
55161
+ writeFileSync7(tmp, outcome.content, "utf-8");
55162
+ const fs = __require("fs");
55163
+ fs.renameSync(tmp, path);
55164
+ result.rewritten.push(filename);
55165
+ } else if (outcome.failClosed) {
55166
+ result.failClosed.push(filename);
55167
+ } else if (outcome.mismatched) {
55168
+ result.mismatched.push(filename);
55169
+ }
55170
+ }
55171
+ return result;
55172
+ }
55173
+ var SNIPPET_TARGETS;
55174
+ var init_refresh = __esm({
55175
+ "src/snippet/refresh.ts"() {
55176
+ "use strict";
55177
+ init_render2();
55178
+ init_generators();
55179
+ SNIPPET_TARGETS = [
55180
+ "AGENTS.md",
55181
+ "CLAUDE.md"
55182
+ ];
55183
+ }
55184
+ });
55185
+
54602
55186
  // src/cli/report/persistRun.ts
54603
- import { existsSync as existsSync17, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7 } from "fs";
54604
- import { join as join19, relative as relative10 } from "path";
55187
+ import { existsSync as existsSync18, writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
55188
+ import { join as join20, relative as relative10 } from "path";
54605
55189
  async function persistRun(input) {
54606
55190
  const {
54607
55191
  cwd,
@@ -54683,10 +55267,10 @@ async function persistRun(input) {
54683
55267
  report.research = state.research;
54684
55268
  }
54685
55269
  if (flywheelOutput.suggestions.length > 0) {
54686
- const suggestionsDir = join19(cwd, ".slopbrick", "flywheel");
54687
- if (!existsSync17(suggestionsDir)) mkdirSync7(suggestionsDir, { recursive: true });
54688
- writeFileSync7(
54689
- join19(suggestionsDir, "rule-suggestions.json"),
55270
+ const suggestionsDir = join20(cwd, ".slopbrick", "flywheel");
55271
+ if (!existsSync18(suggestionsDir)) mkdirSync7(suggestionsDir, { recursive: true });
55272
+ writeFileSync8(
55273
+ join20(suggestionsDir, "rule-suggestions.json"),
54690
55274
  JSON.stringify(flywheelOutput.suggestions, null, 2)
54691
55275
  );
54692
55276
  }
@@ -54731,6 +55315,21 @@ async function persistRun(input) {
54731
55315
  }
54732
55316
  }
54733
55317
  recordTelemetry(cwd, report, results, config, patternInventory);
55318
+ if (input.autoRefreshSnippets) {
55319
+ try {
55320
+ const { refreshSnippets: refreshSnippets2 } = await Promise.resolve().then(() => (init_refresh(), refresh_exports));
55321
+ const outcome = refreshSnippets2(cwd, registry.getRules());
55322
+ if (outcome.rewritten.length > 0 && !options.quiet && !machineReadableStdout) {
55323
+ logger.info(
55324
+ `Snippet refresh: rewrote ${outcome.rewritten.length} file(s) (${outcome.rewritten.join(", ")}).`
55325
+ );
55326
+ }
55327
+ } catch (err) {
55328
+ if (options.quiet !== true && !machineReadableStdout) {
55329
+ logger.warn(`snippet refresh failed: ${err instanceof Error ? err.message : String(err)}`);
55330
+ }
55331
+ }
55332
+ }
54734
55333
  }
54735
55334
  var init_persistRun = __esm({
54736
55335
  "src/cli/report/persistRun.ts"() {
@@ -54951,7 +55550,7 @@ var init_json = __esm({
54951
55550
 
54952
55551
  // src/report/sarif.ts
54953
55552
  import { createHash as createHash11 } from "crypto";
54954
- import { readFileSync as readFileSync21 } from "fs";
55553
+ import { readFileSync as readFileSync22 } from "fs";
54955
55554
  import { basename as basename3, isAbsolute as isAbsolute3, relative as relative12, resolve as resolve9 } from "path";
54956
55555
  function buildArtifactUri(filePath, cwd) {
54957
55556
  if (!filePath) {
@@ -55058,7 +55657,7 @@ function buildResultFromIssue(issue, cwd, fileContentCache) {
55058
55657
  let content = fileContentCache.get(sourcePath);
55059
55658
  if (content === void 0) {
55060
55659
  try {
55061
- content = readFileSync21(sourcePath, "utf-8");
55660
+ content = readFileSync22(sourcePath, "utf-8");
55062
55661
  fileContentCache.set(sourcePath, content);
55063
55662
  } catch {
55064
55663
  fileContentCache.set(sourcePath, "");
@@ -55306,11 +55905,13 @@ function extractNamedScores(report) {
55306
55905
  };
55307
55906
  }
55308
55907
  function renderThresholds(report) {
55309
- const r = report;
55310
- const score = typeof r.repositoryHealth === "number" ? r.repositoryHealth : report.repositoryHealth;
55311
- const limit = 70;
55312
- const available = typeof score === "number";
55313
- const failed = available && score < limit;
55908
+ const meanSlop = report.thresholds?.meanSlop ?? 30;
55909
+ const aiAvailable = typeof report.aiSlopScore === "number";
55910
+ const aiPassed = aiAvailable && report.aiSlopScore <= meanSlop;
55911
+ const rh = report.repositoryHealth;
55912
+ const rhAvailable = typeof rh === "number";
55913
+ const rhLimit = 70;
55914
+ const rhPassed = rhAvailable && rh >= rhLimit;
55314
55915
  return `
55315
55916
  <section class="thresholds-section">
55316
55917
  <h2>Thresholds</h2>
@@ -55324,9 +55925,14 @@ function renderThresholds(report) {
55324
55925
  </thead>
55325
55926
  <tbody>
55326
55927
  <tr>
55327
- <td>Repository Health (composite)</td>
55328
- <td>${available ? `${score.toFixed(1)} / ${limit}` : "\u2013"}</td>
55329
- <td><span class="status-badge ${thresholdStatusClass(failed)}">${available ? failed ? "fail" : "pass" : "n/a"}</span></td>
55928
+ <td>AI Slop Score (CI gate)</td>
55929
+ <td>${aiAvailable ? `${report.aiSlopScore.toFixed(1)} / ${meanSlop}` : "\u2013"}</td>
55930
+ <td><span class="status-badge ${thresholdStatusClass(!aiPassed)}">${aiAvailable ? aiPassed ? "pass" : "fail" : "n/a"}</span></td>
55931
+ </tr>
55932
+ <tr>
55933
+ <td>Repository Health (informational)</td>
55934
+ <td>${rhAvailable ? `${rh.toFixed(1)} / ${rhLimit}` : "\u2013"}</td>
55935
+ <td><span class="status-badge ${thresholdStatusClass(!rhPassed)}">${rhAvailable ? rhPassed ? "pass" : "fail" : "n/a"}</span></td>
55330
55936
  </tr>
55331
55937
  </tbody>
55332
55938
  </table>
@@ -56284,7 +56890,7 @@ var init_advice = __esm({
56284
56890
  });
56285
56891
 
56286
56892
  // src/fix/layout-token.ts
56287
- import { readFileSync as readFileSync22, writeFileSync as writeFileSync8 } from "fs";
56893
+ import { readFileSync as readFileSync23, writeFileSync as writeFileSync9 } from "fs";
56288
56894
  function escapeRegExp(value) {
56289
56895
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56290
56896
  }
@@ -56321,10 +56927,10 @@ function applyReplaceFixes(content, fixes) {
56321
56927
  return { content, applied, skipped, reasons };
56322
56928
  }
56323
56929
  function applyLayoutTokenFix(filePath, fixes) {
56324
- let content = readFileSync22(filePath, "utf-8");
56930
+ let content = readFileSync23(filePath, "utf-8");
56325
56931
  const { content: patched, applied, skipped, reasons } = applyReplaceFixes(content, fixes);
56326
56932
  if (patched !== content) {
56327
- writeFileSync8(filePath, patched);
56933
+ writeFileSync9(filePath, patched);
56328
56934
  }
56329
56935
  return { applied, skipped, reasons };
56330
56936
  }
@@ -56335,7 +56941,7 @@ var init_layout_token = __esm({
56335
56941
  });
56336
56942
 
56337
56943
  // src/report/unified-diff.ts
56338
- import { existsSync as existsSync18, readFileSync as readFileSync23 } from "fs";
56944
+ import { existsSync as existsSync19, readFileSync as readFileSync24 } from "fs";
56339
56945
  import { relative as relative13 } from "path";
56340
56946
  function collectAllFixes(issue) {
56341
56947
  return [...issue.fix ? [issue.fix] : [], ...issue.fixes ?? []];
@@ -56412,8 +57018,8 @@ function formatUnifiedDiff(report, cwd) {
56412
57018
  const parts = [];
56413
57019
  let hasHunk = false;
56414
57020
  for (const [filePath, fixes] of byFile) {
56415
- if (!existsSync18(filePath)) continue;
56416
- const original = readFileSync23(filePath, "utf-8");
57021
+ if (!existsSync19(filePath)) continue;
57022
+ const original = readFileSync24(filePath, "utf-8");
56417
57023
  const patched = applyFixesToString(original, fixes);
56418
57024
  if (patched === original) continue;
56419
57025
  if (!hasHunk) {
@@ -56519,7 +57125,7 @@ var init_heatmap = __esm({
56519
57125
 
56520
57126
  // src/cli/report/renderOutput.ts
56521
57127
  import { resolve as resolve10 } from "path";
56522
- import { writeFileSync as writeFileSync9 } from "fs";
57128
+ import { writeFileSync as writeFileSync10 } from "fs";
56523
57129
  function renderOutput(report, options, cwd) {
56524
57130
  if (options.format && !VALID_FORMATS.has(options.format)) {
56525
57131
  process.stderr.write(
@@ -56553,7 +57159,7 @@ function renderOutput(report, options, cwd) {
56553
57159
  if (options.html) {
56554
57160
  const html = formatHtml(report);
56555
57161
  if (typeof options.html === "string") {
56556
- writeFileSync9(resolve10(options.html), html);
57162
+ writeFileSync10(resolve10(options.html), html);
56557
57163
  if (!options.quiet) {
56558
57164
  logger.info(`Wrote HTML report to ${options.html}`);
56559
57165
  }
@@ -56565,7 +57171,7 @@ function renderOutput(report, options, cwd) {
56565
57171
  if (options.json) {
56566
57172
  const json = formatJson(report);
56567
57173
  if (typeof options.json === "string") {
56568
- writeFileSync9(resolve10(options.json), json);
57174
+ writeFileSync10(resolve10(options.json), json);
56569
57175
  if (!options.quiet) {
56570
57176
  logger.info(`Wrote JSON report to ${options.json}`);
56571
57177
  }
@@ -56796,7 +57402,7 @@ var init_watch = __esm({
56796
57402
  });
56797
57403
 
56798
57404
  // src/cli/scan.ts
56799
- import { existsSync as existsSync19, statSync as statSync7 } from "fs";
57405
+ import { existsSync as existsSync20, statSync as statSync7 } from "fs";
56800
57406
  import { resolve as resolve12, relative as relative15, extname as extname7, sep as sep3 } from "path";
56801
57407
  import { randomUUID } from "crypto";
56802
57408
  async function runScan(options, explicitPaths) {
@@ -56804,7 +57410,7 @@ async function runScan(options, explicitPaths) {
56804
57410
  const startTime = Date.now();
56805
57411
  const scanId = randomUUID();
56806
57412
  const cwd = resolve12(options.workspace ?? process.cwd());
56807
- if (!existsSync19(cwd)) {
57413
+ if (!existsSync20(cwd)) {
56808
57414
  throw new Error(`Workspace not found: ${cwd}`);
56809
57415
  }
56810
57416
  const cwdStat = statSync7(cwd);
@@ -56849,7 +57455,7 @@ async function runScan(options, explicitPaths) {
56849
57455
  const resolved = explicitPaths.map((p) => resolve12(cwd, p));
56850
57456
  const expanded = [];
56851
57457
  for (const p of resolved) {
56852
- if (existsSync19(p) && statSync7(p).isDirectory()) {
57458
+ if (existsSync20(p) && statSync7(p).isDirectory()) {
56853
57459
  const found = await globby4(`${p}/**/*`, { absolute: true, onlyFiles: true });
56854
57460
  for (const f of found) {
56855
57461
  if (!ALL_SOURCE_EXTENSIONS.has(extname7(f).toLowerCase())) continue;
@@ -56887,12 +57493,16 @@ async function runScan(options, explicitPaths) {
56887
57493
  files = intersectFiles(files, since, cwd);
56888
57494
  }
56889
57495
  let incrementalSummary;
57496
+ let cachePath3;
57497
+ let existingCache;
57498
+ let unchanged = [];
56890
57499
  if (options.incremental) {
56891
- const cachePath3 = options.cachePath ?? ".slopbrick-cache.json";
56892
- const existing = loadCache(cachePath3);
56893
- const { toScan, unchanged } = partitionByCache(files, existing);
56894
- files = toScan;
56895
- incrementalSummary = { skipped: unchanged.length, rescanned: toScan.length };
57500
+ cachePath3 = options.cachePath ?? ".slopbrick-cache.json";
57501
+ existingCache = loadCache(cachePath3);
57502
+ const partition = partitionByCache(files, existingCache);
57503
+ files = partition.toScan;
57504
+ unchanged = partition.unchanged;
57505
+ incrementalSummary = { skipped: unchanged.length, rescanned: partition.toScan.length };
56896
57506
  }
56897
57507
  const machineReadableStdout = !!options.json || !!options.html || options.format === "json" || options.format === "sarif" || options.format === "html";
56898
57508
  if (files.length === 0 && !options.quiet && !machineReadableStdout) {
@@ -56912,6 +57522,9 @@ async function runScan(options, explicitPaths) {
56912
57522
  logger.warn(
56913
57523
  `No source files matched in ${cwd}. Adjust --include or check your config at ${projectRoot}.`
56914
57524
  );
57525
+ logger.warn(
57526
+ "Tip: run from a directory with source files, or pass --workspace <path> (monorepos usually want `--workspace packages/<name>`)."
57527
+ );
56915
57528
  }
56916
57529
  }
56917
57530
  const configHash = hashConfig(config);
@@ -57003,7 +57616,7 @@ async function runScan(options, explicitPaths) {
57003
57616
  const scannedPaths = new Set(results.map((result) => result.filePath));
57004
57617
  for (const [filePath, cached] of Object.entries(baseline.scores)) {
57005
57618
  if (scannedPaths.has(filePath)) continue;
57006
- if (!existsSync19(filePath)) continue;
57619
+ if (!existsSync20(filePath)) continue;
57007
57620
  scores.push({
57008
57621
  filePath,
57009
57622
  rawScore: 0,
@@ -57042,7 +57655,12 @@ async function runScan(options, explicitPaths) {
57042
57655
  const { report, noIncreaseFailure } = await finalizeReport({
57043
57656
  cwd,
57044
57657
  config,
57045
- options,
57658
+ options: {
57659
+ ...options,
57660
+ // v0.42.0 (§3a.4): forward the opt-in flag through to
57661
+ // `persistRun`, which calls `refreshSnippets` post-scan.
57662
+ autoRefreshSnippets: Boolean(options.autoRefreshSnippets) || Boolean(config.autoRefreshSnippets)
57663
+ },
57046
57664
  results,
57047
57665
  aggregated,
57048
57666
  allIssues,
@@ -57056,6 +57674,38 @@ async function runScan(options, explicitPaths) {
57056
57674
  telemetryEnabled,
57057
57675
  machineReadableStdout
57058
57676
  });
57677
+ if (options.incremental && cachePath3 !== void 0) {
57678
+ const cachedFiles = {};
57679
+ for (const r of results) {
57680
+ try {
57681
+ cachedFiles[r.filePath] = {
57682
+ hash: computeFileHash(r.filePath),
57683
+ issueCount: r.issues.length,
57684
+ lastScannedAt: (/* @__PURE__ */ new Date()).toISOString()
57685
+ };
57686
+ } catch {
57687
+ }
57688
+ }
57689
+ for (const f of unchanged) {
57690
+ const cached = existingCache?.files[f];
57691
+ if (cached) {
57692
+ cachedFiles[f] = {
57693
+ hash: cached.hash,
57694
+ issueCount: cached.issueCount,
57695
+ lastScannedAt: cached.lastScannedAt
57696
+ };
57697
+ } else {
57698
+ }
57699
+ }
57700
+ try {
57701
+ saveCache(cachePath3, {
57702
+ version: VERSION,
57703
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
57704
+ files: cachedFiles
57705
+ });
57706
+ } catch {
57707
+ }
57708
+ }
57059
57709
  return {
57060
57710
  report,
57061
57711
  scores,
@@ -57110,13 +57760,57 @@ var init_scan2 = __esm({
57110
57760
  }
57111
57761
  });
57112
57762
 
57763
+ // src/rules/composite-seed.ts
57764
+ var composite_seed_exports = {};
57765
+ __export(composite_seed_exports, {
57766
+ HAND_CURATED_SEED: () => HAND_CURATED_SEED,
57767
+ maybeAppendSeed: () => maybeAppendSeed
57768
+ });
57769
+ function maybeAppendSeed(entries) {
57770
+ if (entries.length === 0) {
57771
+ return [...HAND_CURATED_SEED];
57772
+ }
57773
+ return entries;
57774
+ }
57775
+ var HAND_CURATED_SEED;
57776
+ var init_composite_seed = __esm({
57777
+ "src/rules/composite-seed.ts"() {
57778
+ "use strict";
57779
+ HAND_CURATED_SEED = [
57780
+ {
57781
+ id: "composite/ai-fingerprint-cluster",
57782
+ ruleIds: ["ai/compression-profile", "ai/comment-ratio", "ai/segment-surprisal-cv"],
57783
+ minMatch: 2,
57784
+ severity: "medium",
57785
+ defaultOff: true,
57786
+ 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.",
57787
+ calibration: {
57788
+ recall: 0,
57789
+ FP: 0,
57790
+ precision: 0,
57791
+ F1: 0,
57792
+ nFiles: 0
57793
+ },
57794
+ provenance: {
57795
+ seed: "hand-curated-by-brief",
57796
+ discoveredAt: "2026-07-05T00:00:00.000Z",
57797
+ nFiles: 0,
57798
+ members: 3,
57799
+ npmi: 0,
57800
+ fisherP: 1
57801
+ }
57802
+ }
57803
+ ];
57804
+ }
57805
+ });
57806
+
57113
57807
  // src/index.ts
57114
57808
  init_types();
57115
57809
  init_config2();
57116
57810
  init_dist2();
57117
57811
 
57118
57812
  // src/cli/program.ts
57119
- import { resolve as resolve41 } from "path";
57813
+ import { resolve as resolve42 } from "path";
57120
57814
  import { Command } from "commander";
57121
57815
 
57122
57816
  // src/cli/options.ts
@@ -57164,13 +57858,15 @@ init_discover();
57164
57858
  init_git();
57165
57859
  init_cache();
57166
57860
  init_logger();
57167
- import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync9, rmSync as rmSync2 } from "fs";
57168
- import { join as join21, dirname as dirname14 } from "path";
57861
+ init_types();
57862
+ import chalk2 from "chalk";
57863
+ import { writeFileSync as writeFileSync12, mkdirSync as mkdirSync9, rmSync as rmSync2 } from "fs";
57864
+ import { join as join23, dirname as dirname13 } from "path";
57169
57865
  import { createInterface } from "readline";
57170
57866
 
57171
57867
  // src/rules/registry-loader.ts
57172
- import { existsSync as existsSync20, mkdirSync as mkdirSync8, readFileSync as readFileSync24, writeFileSync as writeFileSync10 } from "fs";
57173
- import { dirname as dirname13, join as join20 } from "path";
57868
+ import { existsSync as existsSync21, mkdirSync as mkdirSync8, readFileSync as readFileSync25, writeFileSync as writeFileSync11 } from "fs";
57869
+ import { dirname as dirname12, join as join21 } from "path";
57174
57870
 
57175
57871
  // src/data/shadcn-registry.json
57176
57872
  var shadcn_registry_default = {
@@ -57304,11 +58000,11 @@ var shadcn_registry_default = {
57304
58000
  var REGISTRY_URL = "https://ui.shadcn.com/registry.json";
57305
58001
  var BUNDLED_REGISTRY_VERSION = shadcn_registry_default.version;
57306
58002
  function cachePath2(cwd) {
57307
- return join20(cwd, ".slopbrick", "cache", "registry-snapshot.json");
58003
+ return join21(cwd, ".slopbrick", "cache", "registry-snapshot.json");
57308
58004
  }
57309
58005
  function ensureCacheDir(cwd) {
57310
- const dir = dirname13(cachePath2(cwd));
57311
- if (!existsSync20(dir)) {
58006
+ const dir = dirname12(cachePath2(cwd));
58007
+ if (!existsSync21(dir)) {
57312
58008
  mkdirSync8(dir, { recursive: true });
57313
58009
  }
57314
58010
  }
@@ -57321,9 +58017,9 @@ function isValidSnapshot(value) {
57321
58017
  }
57322
58018
  function isRegistryFresh(cwd) {
57323
58019
  const cached = cachePath2(cwd);
57324
- if (!existsSync20(cached)) return false;
58020
+ if (!existsSync21(cached)) return false;
57325
58021
  try {
57326
- const parsed = JSON.parse(readFileSync24(cached, "utf8"));
58022
+ const parsed = JSON.parse(readFileSync25(cached, "utf8"));
57327
58023
  if (!isValidSnapshot(parsed)) return false;
57328
58024
  return parsed.version === BUNDLED_REGISTRY_VERSION;
57329
58025
  } catch {
@@ -57334,19 +58030,24 @@ async function refreshRegistrySnapshot(cwd, _url = REGISTRY_URL, _timeoutMs = 5e
57334
58030
  try {
57335
58031
  const cacheFile = cachePath2(cwd);
57336
58032
  ensureCacheDir(cwd);
57337
- writeFileSync10(cacheFile, JSON.stringify(shadcn_registry_default, null, 2), "utf-8");
58033
+ writeFileSync11(cacheFile, JSON.stringify(shadcn_registry_default, null, 2), "utf-8");
57338
58034
  } catch {
57339
58035
  }
57340
58036
  return {
57341
58037
  snapshot: shadcn_registry_default,
57342
58038
  ok: true,
57343
58039
  fresh: false,
57344
- message: "Using bundled snapshot (upstream URL returns 404)."
58040
+ // v0.42.0 (user-review fix): the previous message said
58041
+ // "upstream URL returns 404" which is alarming without context —
58042
+ // upstream has been dead since v0.39.0, and the bundled snapshot
58043
+ // IS the snapshot in use. Replaced with a neutral message that
58044
+ // doesn't suggest "something is wrong".
58045
+ message: "Using bundled shadcn/ui snapshot (built-in; no network fetch)."
57345
58046
  };
57346
58047
  }
57347
58048
  function copyBundledSnapshotToCache(cwd) {
57348
58049
  ensureCacheDir(cwd);
57349
- writeFileSync10(cachePath2(cwd), JSON.stringify(shadcn_registry_default, null, 2));
58050
+ writeFileSync11(cachePath2(cwd), JSON.stringify(shadcn_registry_default, null, 2));
57350
58051
  }
57351
58052
 
57352
58053
  // src/cli/init.ts
@@ -57375,17 +58076,17 @@ var UI_LIBRARY_OPTIONS = ["shadcn/ui", "mui", "chakra", "radix", "tamagui", "nat
57375
58076
  var STRICTNESS_OPTIONS = ["strict", "balanced", "permissive"];
57376
58077
  var STRUCTURE_OPTIONS = ["feature-based", "layer-based", "flat", "monorepo", "other"];
57377
58078
  function promptText(rl, question, detected) {
57378
- return new Promise((resolve42) => {
58079
+ return new Promise((resolve43) => {
57379
58080
  const lines = [
57380
58081
  `? ${question} (detected: ${detected || "none"}) \u2014 npm package name, or Enter to skip:`
57381
58082
  ];
57382
58083
  rl.question(lines.join("\n") + "\n", (answer) => {
57383
58084
  const trimmed = answer.trim();
57384
58085
  if (trimmed === "") {
57385
- resolve42(void 0);
58086
+ resolve43(void 0);
57386
58087
  return;
57387
58088
  }
57388
- resolve42(trimmed);
58089
+ resolve43(trimmed);
57389
58090
  });
57390
58091
  });
57391
58092
  }
@@ -57393,7 +58094,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57393
58094
  const defaultIndex = options.indexOf(defaultValue);
57394
58095
  const safeDefaultIndex = defaultIndex >= 0 ? defaultIndex : 0;
57395
58096
  const safeDefaultValue = options[safeDefaultIndex];
57396
- return new Promise((resolve42) => {
58097
+ return new Promise((resolve43) => {
57397
58098
  function ask() {
57398
58099
  const lines = [
57399
58100
  `? ${question} (detected: ${safeDefaultValue}):`,
@@ -57403,7 +58104,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57403
58104
  rl.question(lines.join("\n") + "\n", (answer) => {
57404
58105
  const trimmed = answer.trim();
57405
58106
  if (trimmed === "") {
57406
- resolve42(safeDefaultValue);
58107
+ resolve43(safeDefaultValue);
57407
58108
  return;
57408
58109
  }
57409
58110
  const num = parseInt(trimmed, 10);
@@ -57412,7 +58113,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57412
58113
  ask();
57413
58114
  return;
57414
58115
  }
57415
- resolve42(options[num - 1]);
58116
+ resolve43(options[num - 1]);
57416
58117
  });
57417
58118
  }
57418
58119
  ask();
@@ -57422,7 +58123,7 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57422
58123
  const defaultIndices = defaultValue.map((v) => options.indexOf(v)).filter((i) => i >= 0);
57423
58124
  const defaultDisplay = defaultValue.length > 0 ? defaultValue.join(", ") : "none";
57424
58125
  const defaultNumbers = defaultIndices.length > 0 ? defaultIndices.map((i) => i + 2).join(",") : "1";
57425
- return new Promise((resolve42) => {
58126
+ return new Promise((resolve43) => {
57426
58127
  function ask() {
57427
58128
  const lines = [
57428
58129
  `? ${question} (detected: ${defaultDisplay}):`,
@@ -57433,7 +58134,7 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57433
58134
  rl.question(lines.join("\n") + "\n", (answer) => {
57434
58135
  const trimmed = answer.trim();
57435
58136
  if (trimmed === "") {
57436
- resolve42(defaultValue.length > 0 ? defaultValue : []);
58137
+ resolve43(defaultValue.length > 0 ? defaultValue : []);
57437
58138
  return;
57438
58139
  }
57439
58140
  const numbers = trimmed.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n));
@@ -57443,11 +58144,11 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57443
58144
  return;
57444
58145
  }
57445
58146
  if (numbers.includes(1)) {
57446
- resolve42([]);
58147
+ resolve43([]);
57447
58148
  return;
57448
58149
  }
57449
58150
  const selected = [...new Set(numbers.map((n) => options[n - 2]).filter((v) => v !== void 0))];
57450
- resolve42(selected);
58151
+ resolve43(selected);
57451
58152
  });
57452
58153
  }
57453
58154
  ask();
@@ -57512,16 +58213,23 @@ async function runDoctor(cwd) {
57512
58213
  lines.push("slopbrick doctor\n");
57513
58214
  lines.push("Checking your setup:\n");
57514
58215
  const nodeMajor = parseInt(process.versions.node.split(".")[0] ?? "0", 10);
57515
- if (nodeMajor >= 20) ok(`Node ${process.versions.node}`);
58216
+ if (nodeMajor >= 20) ok(`Node ${process.versions.node}, slopbrick ${VERSION}`);
57516
58217
  else fail(`Node ${process.versions.node} \u2014 slopbrick needs Node 20 or newer. Run: nvm install 20`);
57517
58218
  lines.push(`
57518
58219
  Working in: ${cwd}`);
57519
58220
  try {
58221
+ const configPath = resolveConfigPath(cwd);
57520
58222
  const config = await loadConfig(cwd);
57521
- lines.push(`
57522
- \u2713 Config loaded from: ${resolveConfigPath(cwd) ?? "(default)"}`);
57523
- const ruleCount = Object.keys(config.rules ?? {}).length;
57524
- lines.push(` ${ruleCount} rules configured in your config file.`);
58223
+ if (configPath) {
58224
+ const ruleCount = Object.keys(config.rules ?? {}).length;
58225
+ lines.push(`
58226
+ \u2713 Config loaded from: ${configPath}`);
58227
+ lines.push(` ${ruleCount} rule${ruleCount === 1 ? "" : "s"} configured in your config file.`);
58228
+ } else {
58229
+ lines.push(`
58230
+ \u2713 Config: using built-in defaults (no config file found).`);
58231
+ lines.push(chalk2.dim(` Run \`slopbrick init\` to create ${cwd.endsWith("/") ? cwd : cwd + "/"}slopbrick.config.mjs and tune rule severity/rules to your project.`));
58232
+ }
57525
58233
  } catch (error) {
57526
58234
  fail(`Could not read your config: ${error.message}`);
57527
58235
  exitCode = 3;
@@ -57535,9 +58243,9 @@ async function runDoctor(cwd) {
57535
58243
  }
57536
58244
  try {
57537
58245
  const { parseFile: tryParse } = await Promise.resolve().then(() => (init_dist2(), dist_exports2));
57538
- const testFile = join21(cwd, ".slopbrick", ".doctor-test.ts");
57539
- mkdirSync9(dirname14(testFile), { recursive: true });
57540
- writeFileSync11(testFile, "export const x = 1;\n");
58246
+ const testFile = join23(cwd, ".slopbrick", ".doctor-test.ts");
58247
+ mkdirSync9(dirname13(testFile), { recursive: true });
58248
+ writeFileSync12(testFile, "export const x = 1;\n");
57541
58249
  await tryParse(testFile);
57542
58250
  rmSync2(testFile, { force: true });
57543
58251
  ok("Parser is working.");
@@ -57578,6 +58286,7 @@ async function runDoctor(cwd) {
57578
58286
  ok(`Found ${scannable.length} source files to scan.`);
57579
58287
  } else {
57580
58288
  warn("No source files (.ts/.tsx/.js/.jsx/.vue/.svelte/.astro/.mdx) found in this directory.");
58289
+ lines.push(chalk2.dim(" Run from a directory with source files, or pass --workspace <path>. Monorepos usually want `--workspace packages/<name>`."));
57581
58290
  }
57582
58291
  } catch {
57583
58292
  }
@@ -57642,7 +58351,7 @@ function registerBadge(program) {
57642
58351
  const health = loadHealth2(cwd);
57643
58352
  if (health) {
57644
58353
  const synthetic = {
57645
- slopIndex: 100 - health.repositoryHealth
58354
+ aiSlopScore: health.aiSlopScore ?? 0
57646
58355
  };
57647
58356
  logger.info(formatBadge(synthetic));
57648
58357
  process.exit(0);
@@ -57671,6 +58380,470 @@ function registerSuggest(program) {
57671
58380
  });
57672
58381
  }
57673
58382
 
58383
+ // src/cli/commands/composite.ts
58384
+ init_logger();
58385
+ init_telemetry();
58386
+ import { resolve as resolve15 } from "path";
58387
+
58388
+ // src/engine/cluster.ts
58389
+ import { createHash as createHash12 } from "crypto";
58390
+ var DEFAULT_CLUSTER_PARAMS = {
58391
+ minSupport: 0.05,
58392
+ // 5% of files
58393
+ minNPMI: 0.3,
58394
+ // Cohen's-Landis-and-Koch "fair" agreement
58395
+ fisherAlpha: 0.01,
58396
+ // Bonferroni-style floor for many-pair tests
58397
+ minClusterSize: 2
58398
+ };
58399
+ function adaptiveMinSupport(fileCount) {
58400
+ if (fileCount <= 0) return DEFAULT_CLUSTER_PARAMS.minSupport;
58401
+ return Math.max(DEFAULT_CLUSTER_PARAMS.minSupport, 5 / fileCount);
58402
+ }
58403
+ function computeRuleSupport(fireMatrix) {
58404
+ const support = /* @__PURE__ */ new Map();
58405
+ for (const ruleIds of fireMatrix.values()) {
58406
+ for (const ruleId of ruleIds) {
58407
+ support.set(ruleId, (support.get(ruleId) ?? 0) + 1);
58408
+ }
58409
+ }
58410
+ return support;
58411
+ }
58412
+ function prefilterBySupport(support, totalFiles, minSupport) {
58413
+ const kept = /* @__PURE__ */ new Set();
58414
+ const minCount = Math.max(1, Math.ceil(minSupport * totalFiles));
58415
+ for (const [ruleId, count] of support) {
58416
+ if (count >= minCount) kept.add(ruleId);
58417
+ }
58418
+ return kept;
58419
+ }
58420
+ function buildContingency(rA, rB, totalFiles) {
58421
+ let aAndB = 0;
58422
+ let aOnly = 0;
58423
+ let bOnly = 0;
58424
+ for (const file of rA) {
58425
+ if (rB.has(file)) aAndB++;
58426
+ else aOnly++;
58427
+ }
58428
+ for (const file of rB) {
58429
+ if (!rA.has(file)) bOnly++;
58430
+ }
58431
+ return {
58432
+ aAndB,
58433
+ aOnly,
58434
+ bOnly,
58435
+ neither: Math.max(0, totalFiles - aAndB - aOnly - bOnly)
58436
+ };
58437
+ }
58438
+ function fisherExactTwoSided(c) {
58439
+ const N = c.aAndB + c.aOnly + c.bOnly + c.neither;
58440
+ const K1 = c.aAndB + c.aOnly;
58441
+ const K2 = c.aAndB + c.bOnly;
58442
+ const n = K1;
58443
+ const kObs = c.aAndB;
58444
+ const logPmf = (k) => {
58445
+ if (k < Math.max(0, K1 + K2 - N) || k > Math.min(K1, K2)) return -Infinity;
58446
+ return lnChoose(K2, k) + lnChoose(N - K2, K1 - k) - lnChoose(N, K1);
58447
+ };
58448
+ const pObs = Math.exp(logPmf(kObs));
58449
+ const kMin = Math.max(0, K1 + K2 - N);
58450
+ const kMax = Math.min(K1, K2);
58451
+ let p = 0;
58452
+ for (let k = kMin; k <= kMax; k++) {
58453
+ const pk = Math.exp(logPmf(k));
58454
+ if (pk <= pObs + 1e-12) {
58455
+ p += pk;
58456
+ }
58457
+ }
58458
+ return Math.min(1, p);
58459
+ }
58460
+ function lnChoose(n, k) {
58461
+ if (k < 0 || k > n) return -Infinity;
58462
+ if (k === 0 || k === n) return 0;
58463
+ k = Math.min(k, n - k);
58464
+ let s = 0;
58465
+ for (let i = 1; i <= k; i++) {
58466
+ s += Math.log((n - k + i) / i);
58467
+ }
58468
+ return s;
58469
+ }
58470
+ function npmi(c, total) {
58471
+ if (total === 0 || c.aAndB === 0) return -1;
58472
+ const pXY = c.aAndB / total;
58473
+ const pX = (c.aAndB + c.aOnly) / total;
58474
+ const pY = (c.aAndB + c.bOnly) / total;
58475
+ if (pX === 0 || pY === 0 || pXY === 0) return -1;
58476
+ const pmi = Math.log2(pXY / (pX * pY));
58477
+ const norm = -Math.log2(pXY);
58478
+ return norm === 0 ? 1 : pmi / norm;
58479
+ }
58480
+ function computeEdges(fireMatrix, rules, total, params) {
58481
+ const ruleToFiles = /* @__PURE__ */ new Map();
58482
+ for (const ruleId of rules) ruleToFiles.set(ruleId, /* @__PURE__ */ new Set());
58483
+ for (const [file, fired] of fireMatrix) {
58484
+ for (const ruleId of fired) {
58485
+ if (rules.has(ruleId)) ruleToFiles.get(ruleId).add(file);
58486
+ }
58487
+ }
58488
+ const ruleList = Array.from(rules).sort();
58489
+ const edges = [];
58490
+ for (let i = 0; i < ruleList.length; i++) {
58491
+ for (let j = i + 1; j < ruleList.length; j++) {
58492
+ const a = ruleList[i];
58493
+ const b = ruleList[j];
58494
+ const rA = ruleToFiles.get(a);
58495
+ const rB = ruleToFiles.get(b);
58496
+ const c = buildContingency(rA, rB, total);
58497
+ const n = npmi(c, total);
58498
+ if (n < params.minNPMI) continue;
58499
+ const p = fisherExactTwoSided(c);
58500
+ if (p > params.fisherAlpha) continue;
58501
+ edges.push({ ruleIds: [a, b], weight: n, fisherP: p });
58502
+ }
58503
+ }
58504
+ return edges;
58505
+ }
58506
+ function singleLinkageCluster(edges, minClusterSize) {
58507
+ const parent = /* @__PURE__ */ new Map();
58508
+ const find = (x) => {
58509
+ if (!parent.has(x)) {
58510
+ parent.set(x, x);
58511
+ return x;
58512
+ }
58513
+ if (parent.get(x) === x) return x;
58514
+ const root = find(parent.get(x));
58515
+ parent.set(x, root);
58516
+ return root;
58517
+ };
58518
+ const union = (a, b) => {
58519
+ const ra = find(a);
58520
+ const rb = find(b);
58521
+ if (ra !== rb) parent.set(ra, rb);
58522
+ };
58523
+ for (const e of edges) {
58524
+ union(e.ruleIds[0], e.ruleIds[1]);
58525
+ }
58526
+ const groups = /* @__PURE__ */ new Map();
58527
+ for (const k of parent.keys()) {
58528
+ const root = find(k);
58529
+ if (!groups.has(root)) groups.set(root, []);
58530
+ groups.get(root).push(k);
58531
+ }
58532
+ const clusters = [];
58533
+ for (const members of groups.values()) {
58534
+ if (members.length < minClusterSize) continue;
58535
+ members.sort();
58536
+ clusters.push(members);
58537
+ }
58538
+ clusters.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
58539
+ return clusters;
58540
+ }
58541
+ function filesForComposite(fireMatrix, members, minMatch) {
58542
+ const fired = /* @__PURE__ */ new Set();
58543
+ for (const [file, ids] of fireMatrix) {
58544
+ let n = 0;
58545
+ for (const m of members) if (ids.has(m)) n++;
58546
+ if (n >= minMatch) fired.add(file);
58547
+ }
58548
+ return fired;
58549
+ }
58550
+ var DEFAULT_CALIBRATION = {
58551
+ // 1.5× is the documented floor.
58552
+ recallFpRatio: 1.5,
58553
+ // 0.05 absolute FP — the same `min` value most rules get via the
58554
+ // v0.18.x calibration contract.
58555
+ minFpFloor: 0.05
58556
+ };
58557
+ function calibrateCluster(cluster2, edges, fireMatrix, positiveFiles, totalFiles, thresholds = DEFAULT_CALIBRATION) {
58558
+ if (cluster2.length < 2) return null;
58559
+ const clusterSet = new Set(cluster2);
58560
+ const clusterEdges = edges.filter(
58561
+ (e) => clusterSet.has(e.ruleIds[0]) && clusterSet.has(e.ruleIds[1])
58562
+ );
58563
+ if (clusterEdges.length === 0) return null;
58564
+ const strongest = clusterEdges.reduce((a, b) => b.weight > a.weight ? b : a);
58565
+ let best = null;
58566
+ for (let k = 1; k <= cluster2.length; k++) {
58567
+ const firedFiles = filesForComposite(fireMatrix, cluster2, k);
58568
+ const nFiles = firedFiles.size;
58569
+ if (nFiles === 0) continue;
58570
+ let recall = 0;
58571
+ let fp = 0;
58572
+ let precision = 0;
58573
+ let canCalibrate = false;
58574
+ if (positiveFiles && positiveFiles.size > 0) {
58575
+ let tp = 0;
58576
+ let fpc = 0;
58577
+ for (const file of firedFiles) {
58578
+ if (positiveFiles.has(file)) tp++;
58579
+ else fpc++;
58580
+ }
58581
+ const totalPos = positiveFiles.size;
58582
+ const totalNeg = Math.max(1, totalFiles - totalPos);
58583
+ recall = totalPos > 0 ? tp / totalPos : 0;
58584
+ fp = fpc / totalNeg;
58585
+ precision = tp + fpc > 0 ? tp / (tp + fpc) : 0;
58586
+ canCalibrate = true;
58587
+ }
58588
+ const F1 = precision + recall > 0 ? 2 * precision * recall / (precision + recall) : 0;
58589
+ if (canCalibrate) {
58590
+ if (recall / Math.max(fp, 1e-9) < thresholds.recallFpRatio) continue;
58591
+ if (fp < thresholds.minFpFloor && recall < 0.5) continue;
58592
+ }
58593
+ if (best === null || F1 > best.F1 + 1e-9 || Math.abs(F1 - best.F1) < 1e-9 && k > best.minMatch) {
58594
+ best = {
58595
+ minMatch: k,
58596
+ npmi: strongest.weight,
58597
+ fisherP: strongest.fisherP,
58598
+ recall,
58599
+ FP: fp,
58600
+ precision,
58601
+ F1,
58602
+ nFiles
58603
+ };
58604
+ }
58605
+ }
58606
+ return best;
58607
+ }
58608
+ function compositeId(members) {
58609
+ const sorted = [...members].sort();
58610
+ return "composite/" + createHash12("sha1").update(sorted.join("\n")).digest("hex").slice(0, 12);
58611
+ }
58612
+ function worstSeverity(members, memberSeverities) {
58613
+ let worst = "low";
58614
+ const order = { low: 0, medium: 1, high: 2 };
58615
+ for (const m of members) {
58616
+ const s = memberSeverities?.get(m);
58617
+ if (s && order[s] > order[worst]) worst = s;
58618
+ }
58619
+ return worst;
58620
+ }
58621
+ function describeComposite(members, minMatch) {
58622
+ const clusterPart = members.length <= 4 ? members.join("+") : `${members.slice(0, 3).join("+")}+${members.length - 3} more`;
58623
+ return `Composite rule: at least ${minMatch} of {${clusterPart}} fire on the same file.`;
58624
+ }
58625
+ function runClusterer(input) {
58626
+ const total = input.fireMatrix.size;
58627
+ const minSupport = input.params?.minSupport ?? adaptiveMinSupport(total);
58628
+ const params = {
58629
+ minSupport,
58630
+ minNPMI: input.params?.minNPMI ?? DEFAULT_CLUSTER_PARAMS.minNPMI,
58631
+ fisherAlpha: input.params?.fisherAlpha ?? DEFAULT_CLUSTER_PARAMS.fisherAlpha,
58632
+ minClusterSize: input.params?.minClusterSize ?? DEFAULT_CLUSTER_PARAMS.minClusterSize
58633
+ };
58634
+ const support = computeRuleSupport(input.fireMatrix);
58635
+ const rulesKept = prefilterBySupport(support, total, params.minSupport);
58636
+ const edges = computeEdges(input.fireMatrix, rulesKept, total, params);
58637
+ const clusters = singleLinkageCluster(edges, params.minClusterSize);
58638
+ const entries = [];
58639
+ const clusterMaxNPMI = /* @__PURE__ */ new Map();
58640
+ for (const cluster2 of clusters) {
58641
+ const cal = calibrateCluster(
58642
+ cluster2,
58643
+ edges,
58644
+ input.fireMatrix,
58645
+ input.positiveFiles,
58646
+ total
58647
+ );
58648
+ if (!cal) continue;
58649
+ const id = compositeId(cluster2);
58650
+ const severity = worstSeverity(cluster2, input.memberSeverities);
58651
+ entries.push({
58652
+ id,
58653
+ ruleIds: [...cluster2].sort(),
58654
+ minMatch: cal.minMatch,
58655
+ severity,
58656
+ defaultOff: true,
58657
+ description: describeComposite(cluster2, cal.minMatch),
58658
+ calibration: {
58659
+ recall: round3(cal.recall),
58660
+ FP: round3(cal.FP),
58661
+ precision: round3(cal.precision),
58662
+ F1: round3(cal.F1),
58663
+ nFiles: cal.nFiles
58664
+ },
58665
+ provenance: {
58666
+ seed: "auto-cluster",
58667
+ discoveredAt: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
58668
+ nFiles: total,
58669
+ members: cluster2.length,
58670
+ npmi: round3(cal.npmi),
58671
+ fisherP: round3(cal.fisherP)
58672
+ }
58673
+ });
58674
+ clusterMaxNPMI.set(id, cal.npmi);
58675
+ }
58676
+ entries.sort((a, b) => b.calibration.F1 - a.calibration.F1 || a.id.localeCompare(b.id));
58677
+ return { entries, supportCounts: support, clusterMaxNPMI, filesScanned: total, params };
58678
+ }
58679
+ function round3(n) {
58680
+ return Math.round(n * 1e3) / 1e3;
58681
+ }
58682
+
58683
+ // src/rules/composite-loader.ts
58684
+ import { existsSync as existsSync22, readFileSync as readFileSync26, mkdirSync as mkdirSync10, writeFileSync as writeFileSync13 } from "fs";
58685
+ import { dirname as dirname14, join as join24 } from "path";
58686
+ var COMPOSITES_FILE = "composites.json";
58687
+ function compositesPath(cwd) {
58688
+ return join24(cwd, COMPOSITES_FILE);
58689
+ }
58690
+ function readComposites(cwd) {
58691
+ const path = compositesPath(cwd);
58692
+ if (!existsSync22(path)) return [];
58693
+ try {
58694
+ const raw = JSON.parse(readFileSync26(path, "utf-8"));
58695
+ if (!Array.isArray(raw)) return [];
58696
+ return raw.filter(isCompositeRuleEntry);
58697
+ } catch {
58698
+ return [];
58699
+ }
58700
+ }
58701
+ function isCompositeRuleEntry(value) {
58702
+ if (!value || typeof value !== "object") return false;
58703
+ const v = value;
58704
+ return typeof v.id === "string" && Array.isArray(v.ruleIds) && typeof v.minMatch === "number";
58705
+ }
58706
+ function writeComposites(cwd, entries) {
58707
+ const path = compositesPath(cwd);
58708
+ mkdirSync10(dirname14(path), { recursive: true });
58709
+ const tmp = `${path}.tmp`;
58710
+ writeFileSync13(tmp, JSON.stringify(entries, null, 2), "utf-8");
58711
+ const fs = __require("fs");
58712
+ fs.renameSync(tmp, path);
58713
+ }
58714
+
58715
+ // src/cli/commands/composite.ts
58716
+ function registerComposite(program) {
58717
+ const cmd = program.command("composite").description(
58718
+ "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."
58719
+ );
58720
+ cmd.command("discover").description(
58721
+ "run the empirical composite clusterer on .slopbrick/flywheel/scans.jsonl; writes composites.json"
58722
+ ).action(async (_cmdOptions, command) => {
58723
+ const options = command.optsWithGlobals();
58724
+ const cwd = resolve15(options.workspace ?? process.cwd());
58725
+ const payloads = readTelemetry(cwd);
58726
+ if (payloads.length === 0) {
58727
+ logger.error(
58728
+ "`composite discover` needs at least 2 scans in .slopbrick/flywheel/scans.jsonl. Run `slopbrick scan` a few times first."
58729
+ );
58730
+ process.exit(1);
58731
+ }
58732
+ const fireMatrix = /* @__PURE__ */ new Map();
58733
+ for (const payload of payloads) {
58734
+ for (const f of payload.files) {
58735
+ const set = fireMatrix.get(f.hash) ?? /* @__PURE__ */ new Set();
58736
+ for (const id of f.ruleIds) set.add(id);
58737
+ if (set.size > 0) fireMatrix.set(f.hash, set);
58738
+ }
58739
+ }
58740
+ const out = runClusterer({
58741
+ fireMatrix,
58742
+ // No labeled positive set; let the clusterer surface for audit.
58743
+ positiveFiles: void 0,
58744
+ now: (/* @__PURE__ */ new Date()).toISOString()
58745
+ });
58746
+ const finalEntries = out.entries.length === 0 ? [...(await Promise.resolve().then(() => (init_composite_seed(), composite_seed_exports))).HAND_CURATED_SEED] : out.entries;
58747
+ writeComposites(cwd, finalEntries);
58748
+ if (out.entries.length === 0) {
58749
+ logger.info("Composite discovery: no clusters passed the empirical gate.");
58750
+ logger.info(
58751
+ " (Try lowering --min-support, or run more scans to densify the ledger.)"
58752
+ );
58753
+ return;
58754
+ }
58755
+ logger.info(
58756
+ `Composite discovery: ${out.entries.length} candidate(s) written to composites.json`
58757
+ );
58758
+ const lines = [];
58759
+ for (const e of out.entries) {
58760
+ lines.push("");
58761
+ lines.push(
58762
+ ` ${e.id} minMatch=${e.minMatch} F1=${e.calibration.F1} (${e.ruleIds.length} members)`
58763
+ );
58764
+ lines.push(` ${describeComposite(e.ruleIds, e.minMatch)}`);
58765
+ lines.push(
58766
+ ` provenance: ${e.provenance.seed} npmi=${e.provenance.npmi} fisherP=${e.provenance.fisherP}`
58767
+ );
58768
+ }
58769
+ logger.info(lines.join("\n"));
58770
+ logger.info(
58771
+ "\nNext step: review each entry. Opt in with `slopbrick composite enable <id>`; opt out by removing the entry."
58772
+ );
58773
+ });
58774
+ cmd.command("enable <id>").description(
58775
+ "opt a specific composite into slopbrick.config.mjs#compositeRules (one composite per call)"
58776
+ ).action(async (id, _cmdOptions, command) => {
58777
+ const options = command.optsWithGlobals();
58778
+ const cwd = resolve15(options.workspace ?? process.cwd());
58779
+ const composites = readComposites(cwd);
58780
+ const target = composites.find((c) => c.id === id);
58781
+ if (!target) {
58782
+ logger.error(
58783
+ `Composite "${id}" not found in composites.json. Run \`slopbrick composite discover\` first to populate the ledger.`
58784
+ );
58785
+ process.exit(1);
58786
+ }
58787
+ const cfgPath = resolve15(cwd, "slopbrick.config.mjs");
58788
+ const fs = __require("fs");
58789
+ if (!fs.existsSync(cfgPath)) {
58790
+ logger.error(`No slopbrick.config.mjs found at ${cfgPath}.`);
58791
+ process.exit(1);
58792
+ }
58793
+ const src = fs.readFileSync(cfgPath, "utf-8");
58794
+ const compositeSnippet = JSON.stringify(
58795
+ {
58796
+ id: target.id,
58797
+ category: "ai",
58798
+ severity: target.severity,
58799
+ aiSpecific: true,
58800
+ description: target.description,
58801
+ defaultOff: true,
58802
+ ruleIds: target.ruleIds,
58803
+ minMatch: target.minMatch,
58804
+ create: "() => ({})",
58805
+ analyze: "() => []"
58806
+ },
58807
+ null,
58808
+ 2
58809
+ );
58810
+ if (src.includes(`id: '${target.id}'`) || src.includes(`id: "${target.id}"`)) {
58811
+ logger.info(`Composite "${id}" is already enabled in slopbrick.config.mjs (idempotent).`);
58812
+ return;
58813
+ }
58814
+ const updated = src.replace(
58815
+ /(export default\s*{[\s\S]*?)(}\s*;?\s*)$/,
58816
+ (_match, head, tail) => `${head} compositeRules: [
58817
+ ${compositeSnippet}
58818
+ ],
58819
+ ${tail}`
58820
+ );
58821
+ fs.writeFileSync(cfgPath, updated, "utf-8");
58822
+ logger.info(`Composite "${id}" enabled.`);
58823
+ logger.info("Next scan will apply it; rerun `slopbrick scan` to see the effect.");
58824
+ });
58825
+ cmd.command("list").description("list composites currently registered (composites.json + config-declared)").action(async (_cmdOptions, command) => {
58826
+ const options = command.optsWithGlobals();
58827
+ const cwd = resolve15(options.workspace ?? process.cwd());
58828
+ const composites = readComposites(cwd);
58829
+ if (composites.length === 0) {
58830
+ logger.info("No composites registered. Run `slopbrick composite discover` first.");
58831
+ return;
58832
+ }
58833
+ const lines = ["Registered composites:"];
58834
+ for (const e of composites) {
58835
+ lines.push(
58836
+ `
58837
+ ${e.id}
58838
+ ${describeComposite(e.ruleIds, e.minMatch)}
58839
+ members=[${e.ruleIds.join(", ")}] minMatch=${e.minMatch}
58840
+ F1=${e.calibration.F1} recall=${e.calibration.recall} FP=${e.calibration.FP}`
58841
+ );
58842
+ }
58843
+ logger.info(lines.join("\n"));
58844
+ });
58845
+ }
58846
+
57674
58847
  // src/cli/explain.ts
57675
58848
  var RULES_BASE_URL2 = "https://github.com/usebrick/platform/blob/main/packages/slopbrick/src/rules";
57676
58849
  function ruleIdToFilename2(ruleId) {
@@ -57720,154 +58893,13 @@ function formatExplain(result) {
57720
58893
  // src/cli/commands/explain.ts
57721
58894
  init_logger();
57722
58895
  init_builtins();
57723
-
57724
- // src/snippet/data.ts
57725
- var CATEGORY_DIRECTIVES = {
57726
- 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.',
57727
- 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.",
57728
- 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.',
57729
- 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".',
57730
- 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.",
57731
- typo: `Never leave TODO / placeholder / "change me" copy in shipped code. Use real i18n strings or the project's content map.`,
57732
- 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].",
57733
- component: "Don't build components > 200 lines. Extract shared subcomponents. Avoid circular prop drilling \u2014 use context.",
57734
- 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.",
57735
- 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'."
57736
- };
57737
- var RULE_HINTS = {
57738
- // v0.38.0 hygiene: 37 v10-DORMANT rule hints were removed. Every
57739
- // remaining key matches a rule in src/rules/builtins.ts. No orphans.
57740
- // (v0.16.0 previously moved 35 out-of-scope orphan hints to
57741
- // docs/research/backlog-rule-hints.md
57742
- // for future implementers to paste back when the corresponding rules ship.)
57743
- "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.",
57744
- "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.",
57745
- "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.",
57746
- "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.",
57747
- "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.",
57748
- "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.",
57749
- "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.',
57750
- "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.",
57751
- "visual/arbitrary-escape": "Never use bracket-notation values like text-[13px] or bg-[#7c3aed]. Use design tokens instead.",
57752
- "visual/spacing-scale-violation": "Use spacing scale tokens (p-2, gap-4, etc.) instead of arbitrary values like p-[13px] or gap-[1.75rem].",
57753
- "visual/radius-scale-violation": "Use radius scale tokens (rounded-md, rounded-lg, etc.) instead of arbitrary values like rounded-[7px].",
57754
- // v0.16.0 — in-scope orphans kept here (corresponding rule ships in v0.16.0).
57755
- "typo/placeholder-text": 'Never leave "TODO", "placeholder", "change me", "your text here" in shipped UI.',
57756
- "logic/key-prop-missing": "Always provide a stable `key` prop when rendering lists.",
57757
- "logic/boundary-violation": "Don't import data-layer / DB code into UI components. Server-side only.",
57758
- "wcag/missing-alt": 'Every <img> needs alt text. Decorative: alt="". Informative: describe the image.',
57759
- "security/localstorage-token": "Never store JWT / access token / refresh token in localStorage or sessionStorage. Issue as httpOnly cookie.",
57760
- "security/eval": "Never use eval() or new Function(). These are RCE vectors if the input is ever attacker-controlled.",
57761
- "security/target-blank-no-noopener": 'Always add rel="noopener" (or rel="noreferrer") to target="_blank" links.',
57762
- "component/giant-component": "Don't build components > 200 lines. Extract shared subcomponents.",
57763
- "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.",
57764
- "component/shadcn-prop-mismatch": "Select shadcn variants via the `variant` prop, not long `className` overrides. See the component registry for available variants.",
57765
- "context/import-path-mismatch": "Use only the canonical import paths declared in brick.config.json (e.g. @/components/ui/, @/lib/).",
57766
- "layout/gap-monopoly": "Mix gap-2 / gap-4 / gap-6 / gap-12 deliberately. Don't repeat the same gap value across the whole project.",
57767
- "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.",
57768
- "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.",
57769
- "layout/spacing-grid": "Use the configured spacing scale (4px or 8px grid). Avoid arbitrary values like p-[13px] that aren't on the scale.",
57770
- "logic/ghost-defensive": "Use optional chaining (?.) or early returns instead of deep && guards. If a defensive chain runs 3+ levels deep, refactor.",
57771
- "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.)",
57772
- "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.)",
57773
- "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.)",
57774
- "logic/math-any-density": "Replace `: any` with proper types. Start with the parameter/return types of the most-used functions.",
57775
- "logic/math-console-log-storm": "Replace debug logs with a proper debugger or logger.debug(). Remove all console.log before shipping.",
57776
- "logic/math-gini-class-usage": "Spread usage across more class tokens instead of repeating the same handful (p-4, p-8, rounded-lg, etc.).",
57777
- "logic/math-variable-name-entropy": "Use domain-specific identifier names (reservations, invoices, customers) instead of generic data/items/value.",
57778
- "logic/optimistic-no-rollback": "In optimistic updates, revert state in the catch block: `setX(prev => prev)`. Never leave stale UI on error.",
57779
- "logic/reactive-hook-soup": "Coordinate state via a single derived value (useMemo) or a state machine. Avoid chained useEffects that sync local state.",
57780
- "logic/zombie-state": "Remove unused useState or wire it into the component. Don't leave declared-but-never-read state bindings.",
57781
- "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.",
57782
- "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.",
57783
- "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.",
57784
- "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.",
57785
- "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.",
57786
- "perf/cls-image": "Add width/height attributes or an aspect-ratio utility to prevent layout shift.",
57787
- "perf/css-bloat": "Extract to a CSS variable (`--surface-card`) or a component prop when a class string repeats 5+ times.",
57788
- "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.',
57789
- "visual/inline-style-dominance": "Replace inline `style={{...}}` with className utilities (e.g. Tailwind `p-4 m-2 gap-3`) or a CSS module class.",
57790
- "visual/math-default-font": "Import a distinctive font (next/font/google, @font-face, or a CSS variable) instead of relying on the framework default.",
57791
- "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.",
57792
- "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.",
57793
- "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.",
57794
- "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%.",
57795
- "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.",
57796
- "wcag/focus-appearance": "Add a focus-visible:ring-* class, or remove outline-none. Keyboard users need a visible focus indicator.",
57797
- "wcag/focus-obscured": "Ensure focused elements are not hidden behind fixed or sticky wrappers.",
57798
- "test/weak-assertion": "Assert on a specific value or shape: `expect(x).toEqual(expectedValue)`. Avoid `.toBeDefined()` / `.toBeTruthy()` placeholders and tautological `expect(x).toBe(x)`.",
57799
- "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.",
57800
- "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`).",
57801
- "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.",
57802
- "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.",
57803
- // v0.13.0 — AI-specific rules (peer-reviewed signals).
57804
- "ai/markdown-leakage": "Delete stray `\\`\\`\\`<lang>\\`\\`\\`` markers; they are Markdown fences, not valid syntax in standalone source files (Yotkova et al. SemEval-2026).",
57805
- "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).",
57806
- "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).",
57807
- "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).",
57808
- "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).",
57809
- "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).",
57810
- "ai/renyi-profile": "The token distribution is mass-concentrated on a few high-frequency tokens. Verify authorship if unexpected (R\xE9nyi 1961, Moslonka 2025).",
57811
- "ai/segment-surprisal-cv": "The cross-entropy is suspiciously uniform across the file. Real codebases have varied registers (Binoculars, Hans 2024).",
57812
- "ai/compression-profile": "The file compresses unusually well and lines are highly repetitive \u2014 characteristic of AI-generated boilerplate (Cilibrasi 2005, Mahoney 1999).",
57813
- // v0.14.5b — 6 new AI tendency detection rules (DORMANT in v0.14.5b;
57814
- // reclassified post-v7 calibration in v0.14.5d)
57815
- "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.",
57816
- "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.",
57817
- "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.",
57818
- "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.",
57819
- "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.",
57820
- "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.",
57821
- // v0.17.0 — db/* rules (Postgres static analysis via pgsql-parser)
57822
- "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.',
57823
- // v0.17.0 — docs/* rules (markdown drift detection)
57824
- "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.",
57825
- "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.",
57826
- "docs/broken-link": "Create the file or fix the link target. On a public docs site, broken links erode trust more than stale copy.",
57827
- // v0.18.9 — Rust-specific rules (tree-sitter-backed)
57828
- "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.",
57829
- "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.",
57830
- "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.",
57831
- "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.',
57832
- // v0.19.0 — TypeScript-specific rules
57833
- "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.",
57834
- "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.",
57835
- "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.",
57836
- "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.",
57837
- // v0.19.0 — Go-specific rules
57838
- "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).',
57839
- "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.",
57840
- // v0.19.0 — Duplication detector
57841
- "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.",
57842
- // v0.23.0 — Type-2 near-duplicate detector (MinHash + LSH)
57843
- "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.)",
57844
- // v0.24.0 — Type-3 structural clone detector (two-stage MinHash)
57845
- "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.)",
57846
- // v0.24.0 — Kotlin rules (DORMANT until v9 Kotlin corpus calibration)
57847
- // v0.29.0 — non-AI Kotlin rules (DORMANT until larger pos arm)
57848
- // v0.30.0 — non-AI Java rules (Option C applied to v9 Java corpus, 92k files)
57849
- "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.)",
57850
- "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.)",
57851
- // v0.35.0 — content-based detection (CoCoNUTS-inspired)
57852
- "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.",
57853
- // v0.35.1 — Raidar-inspired content-based detection
57854
- "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)`.',
57855
- // v0.24.0 — Swift rules (DORMANT until v9 Swift corpus calibration)
57856
- "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.)",
57857
- "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.)",
57858
- "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.)",
57859
- "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.)",
57860
- "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.)",
57861
- // v0.24.0 — C++ rules (DORMANT until v9 C++ corpus calibration)
57862
- "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.)",
57863
- "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.)",
57864
- "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.)",
57865
- "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.)"
57866
- };
57867
-
57868
- // src/cli/commands/explain.ts
58896
+ init_data();
57869
58897
  function registerExplain(program) {
57870
- program.command("explain <ruleId>").description("Print rationale, pattern, and remediation for a single rule").action((ruleId) => {
58898
+ program.command("explain [ruleId]").description("Print rationale, pattern, and remediation for a single rule. With no ruleId, lists the rules.").action((ruleId) => {
58899
+ if (!ruleId) {
58900
+ logger.warn("Missing ruleId. Run `slopbrick rules` to see available rules, then `slopbrick explain <ruleId>`.");
58901
+ process.exit(2);
58902
+ }
57871
58903
  const result = explainRule(ruleId, builtinRules, RULE_HINTS);
57872
58904
  logger.info(formatExplain(result));
57873
58905
  if ("error" in result) process.exit(2);
@@ -61591,17 +62623,17 @@ function registerCalibration(program) {
61591
62623
  // src/cli/commands/install.ts
61592
62624
  init_logger();
61593
62625
  init_git();
61594
- import { resolve as resolve15 } from "path";
62626
+ import { resolve as resolve16 } from "path";
61595
62627
 
61596
62628
  // src/cli/installer.ts
61597
62629
  import {
61598
62630
  chmodSync,
61599
- existsSync as existsSync22,
61600
- mkdirSync as mkdirSync10,
61601
- readFileSync as readFileSync26,
61602
- writeFileSync as writeFileSync12
62631
+ existsSync as existsSync23,
62632
+ mkdirSync as mkdirSync11,
62633
+ readFileSync as readFileSync27,
62634
+ writeFileSync as writeFileSync14
61603
62635
  } from "fs";
61604
- import { dirname as dirname16, join as join24 } from "path";
62636
+ import { dirname as dirname16, join as join25 } from "path";
61605
62637
  var BEGIN_SENTINEL = "# slopbrick-hook-begin";
61606
62638
  var END_SENTINEL = "# slopbrick-hook-end";
61607
62639
  var SENTINEL_BLOCK = `${BEGIN_SENTINEL}
@@ -61609,14 +62641,14 @@ npx slopbrick --staged
61609
62641
  ${END_SENTINEL}
61610
62642
  `;
61611
62643
  function hookPath(gitRoot) {
61612
- const huskyDir = join24(gitRoot, ".husky");
61613
- if (existsSync22(huskyDir)) {
61614
- return join24(huskyDir, "pre-commit");
62644
+ const huskyDir = join25(gitRoot, ".husky");
62645
+ if (existsSync23(huskyDir)) {
62646
+ return join25(huskyDir, "pre-commit");
61615
62647
  }
61616
- return join24(gitRoot, ".git", "hooks", "pre-commit");
62648
+ return join25(gitRoot, ".git", "hooks", "pre-commit");
61617
62649
  }
61618
62650
  function readHookContent(path) {
61619
- return readFileSync26(path, "utf8");
62651
+ return readFileSync27(path, "utf8");
61620
62652
  }
61621
62653
  function sentinelsPresent(content) {
61622
62654
  const lines = content.split(/\r?\n/);
@@ -61638,7 +62670,7 @@ function replaceSentinelBlock(content) {
61638
62670
  }
61639
62671
  function installHook(gitRoot) {
61640
62672
  const path = hookPath(gitRoot);
61641
- if (existsSync22(path)) {
62673
+ if (existsSync23(path)) {
61642
62674
  const content = readHookContent(path);
61643
62675
  const { begin, end } = sentinelsPresent(content);
61644
62676
  if (begin && end) {
@@ -61650,7 +62682,7 @@ function installHook(gitRoot) {
61650
62682
  exitCode: 0
61651
62683
  };
61652
62684
  }
61653
- writeFileSync12(path, replaced.endsWith("\n") ? replaced : `${replaced}
62685
+ writeFileSync14(path, replaced.endsWith("\n") ? replaced : `${replaced}
61654
62686
  `);
61655
62687
  chmodSync(path, 493);
61656
62688
  return {
@@ -61670,7 +62702,7 @@ function installHook(gitRoot) {
61670
62702
  }
61671
62703
  const normalized = content.length > 0 && !content.endsWith("\n") ? `${content}
61672
62704
  ` : content;
61673
- writeFileSync12(path, `${normalized}${SENTINEL_BLOCK}`);
62705
+ writeFileSync14(path, `${normalized}${SENTINEL_BLOCK}`);
61674
62706
  chmodSync(path, 493);
61675
62707
  return {
61676
62708
  ok: true,
@@ -61678,8 +62710,8 @@ function installHook(gitRoot) {
61678
62710
  exitCode: 0
61679
62711
  };
61680
62712
  }
61681
- mkdirSync10(dirname16(path), { recursive: true });
61682
- writeFileSync12(path, SENTINEL_BLOCK, { mode: 493 });
62713
+ mkdirSync11(dirname16(path), { recursive: true });
62714
+ writeFileSync14(path, SENTINEL_BLOCK, { mode: 493 });
61683
62715
  chmodSync(path, 493);
61684
62716
  return {
61685
62717
  ok: true,
@@ -61689,7 +62721,7 @@ function installHook(gitRoot) {
61689
62721
  }
61690
62722
  function uninstallHook(gitRoot) {
61691
62723
  const path = hookPath(gitRoot);
61692
- if (!existsSync22(path)) {
62724
+ if (!existsSync23(path)) {
61693
62725
  return {
61694
62726
  ok: true,
61695
62727
  message: "Hook not installed",
@@ -61725,7 +62757,7 @@ function uninstallHook(gitRoot) {
61725
62757
  }
61726
62758
  const remaining = [...lines.slice(0, start), ...lines.slice(end + 1)];
61727
62759
  const result = remaining.join("\n");
61728
- writeFileSync12(path, result.endsWith("\n") ? result : `${result}
62760
+ writeFileSync14(path, result.endsWith("\n") ? result : `${result}
61729
62761
  `);
61730
62762
  return {
61731
62763
  ok: true,
@@ -61738,7 +62770,7 @@ function uninstallHook(gitRoot) {
61738
62770
  function registerInstall(program) {
61739
62771
  program.command("install").description("install the git pre-commit hook").action(async (_cmdOptions, command) => {
61740
62772
  const options = command.optsWithGlobals();
61741
- const cwd = resolve15(options.workspace ?? process.cwd());
62773
+ const cwd = resolve16(options.workspace ?? process.cwd());
61742
62774
  const root = getGitRoot(cwd);
61743
62775
  if (!root) {
61744
62776
  logger.error("Not a Git repository. Run `git init` first, or remove --staged from your command.");
@@ -61755,11 +62787,11 @@ function registerInstall(program) {
61755
62787
  // src/cli/commands/uninstall.ts
61756
62788
  init_logger();
61757
62789
  init_git();
61758
- import { resolve as resolve16 } from "path";
62790
+ import { resolve as resolve17 } from "path";
61759
62791
  function registerUninstall(program) {
61760
62792
  program.command("uninstall").description("uninstall the git pre-commit hook").action(async (_cmdOptions, command) => {
61761
62793
  const options = command.optsWithGlobals();
61762
- const cwd = resolve16(options.workspace ?? process.cwd());
62794
+ const cwd = resolve17(options.workspace ?? process.cwd());
61763
62795
  const root = getGitRoot(cwd);
61764
62796
  if (!root) {
61765
62797
  logger.error("Not a Git repository. Run `git init` first, or remove --staged from your command.");
@@ -61787,12 +62819,12 @@ init_patterns();
61787
62819
  init_architecture_score();
61788
62820
  init_business_logic();
61789
62821
  init_structure_md();
61790
- import { readFileSync as readFileSync27 } from "fs";
61791
- import { resolve as resolve17 } from "path";
62822
+ import { readFileSync as readFileSync28 } from "fs";
62823
+ import { resolve as resolve18 } from "path";
61792
62824
  var TOOL_DEFINITIONS = [
61793
62825
  {
61794
62826
  name: "slop_scan_file",
61795
- 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.",
62827
+ 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.',
61796
62828
  inputSchema: {
61797
62829
  type: "object",
61798
62830
  properties: {
@@ -61808,7 +62840,7 @@ var TOOL_DEFINITIONS = [
61808
62840
  },
61809
62841
  {
61810
62842
  name: "slop_explain_rule",
61811
- 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.",
62843
+ 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.",
61812
62844
  inputSchema: {
61813
62845
  type: "object",
61814
62846
  properties: {
@@ -61829,7 +62861,7 @@ var TOOL_DEFINITIONS = [
61829
62861
  },
61830
62862
  {
61831
62863
  name: "slop_suggest",
61832
- 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.",
62864
+ 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.",
61833
62865
  inputSchema: {
61834
62866
  type: "object",
61835
62867
  properties: {
@@ -61861,7 +62893,7 @@ var TOOL_DEFINITIONS = [
61861
62893
  // call slop_suggest and read repositoryHealth /
61862
62894
  // architectureConsistency / businessLogicCoherence.
61863
62895
  name: "slop_check_constitution",
61864
- 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.",
62896
+ 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.",
61865
62897
  inputSchema: {
61866
62898
  type: "object",
61867
62899
  properties: {
@@ -61878,7 +62910,7 @@ var TOOL_DEFINITIONS = [
61878
62910
  // (sha256 over sorted hooks ∪ props ∪ params) + Jaccard similarity
61879
62911
  // — no LLM, no embeddings, deterministic. Foundation for StackPick.
61880
62912
  name: "slop_find_similar",
61881
- 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].",
62913
+ 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].",
61882
62914
  inputSchema: {
61883
62915
  type: "object",
61884
62916
  properties: {
@@ -62029,10 +63061,10 @@ async function runSuggest(args, ctx, options = {}) {
62029
63061
  function runCheckConstitution(args, ctx) {
62030
63062
  const path = args.path;
62031
63063
  if (!path) return toolError("Missing required argument: path");
62032
- const absPath = resolve17(ctx.cwd, path);
63064
+ const absPath = resolve18(ctx.cwd, path);
62033
63065
  let source;
62034
63066
  try {
62035
- source = readFileSync27(absPath, "utf-8");
63067
+ source = readFileSync28(absPath, "utf-8");
62036
63068
  } catch (err) {
62037
63069
  return toolError(
62038
63070
  `Cannot read file ${absPath}: ${err instanceof Error ? err.message : String(err)}`
@@ -62109,13 +63141,6 @@ async function runFindSimilar(args, ctx) {
62109
63141
  }
62110
63142
  }
62111
63143
  async function handleToolCall(toolName, args, ctx) {
62112
- const deprecation = getDeprecation(toolName);
62113
- const deprecationNotice = deprecation ? {
62114
- tool: toolName,
62115
- replacedBy: deprecation.replacedBy,
62116
- removedIn: deprecation.removedIn ?? "next major",
62117
- reason: deprecation.reason
62118
- } : void 0;
62119
63144
  switch (toolName) {
62120
63145
  case "slop_scan_file":
62121
63146
  return runScanFile(args, ctx);
@@ -62215,7 +63240,7 @@ function errorResponse(id, code, message, data2) {
62215
63240
  return { jsonrpc: "2.0", id, error: { code, message, ...data2 !== void 0 ? { data: data2 } : {} } };
62216
63241
  }
62217
63242
  async function runMcpServer(input, output, cwd) {
62218
- return new Promise((resolve42) => {
63243
+ return new Promise((resolve43) => {
62219
63244
  let buffer = "";
62220
63245
  input.setEncoding("utf-8");
62221
63246
  input.on("data", (chunk) => {
@@ -62253,8 +63278,8 @@ async function runMcpServer(input, output, cwd) {
62253
63278
  nlIdx = buffer.indexOf("\n");
62254
63279
  }
62255
63280
  });
62256
- input.on("end", () => resolve42());
62257
- input.on("close", () => resolve42());
63281
+ input.on("end", () => resolve43());
63282
+ input.on("close", () => resolve43());
62258
63283
  });
62259
63284
  }
62260
63285
 
@@ -62278,7 +63303,7 @@ function registerDoctor(program) {
62278
63303
 
62279
63304
  // src/cli/commands/watch.ts
62280
63305
  init_watch();
62281
- import { resolve as resolve18 } from "path";
63306
+ import { resolve as resolve19 } from "path";
62282
63307
  function registerWatch(program, scanAction) {
62283
63308
  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) => {
62284
63309
  const rawGlobals = command.optsWithGlobals();
@@ -62286,7 +63311,7 @@ function registerWatch(program, scanAction) {
62286
63311
  ...rawGlobals,
62287
63312
  noIncrease: rawGlobals.increase === false
62288
63313
  };
62289
- const cwd = resolve18(options.workspace ?? process.cwd());
63314
+ const cwd = resolve19(options.workspace ?? process.cwd());
62290
63315
  await scanAction([], options, command);
62291
63316
  await watchProject(options, cwd, []);
62292
63317
  });
@@ -62294,11 +63319,11 @@ function registerWatch(program, scanAction) {
62294
63319
 
62295
63320
  // src/cli/commands/lock.ts
62296
63321
  init_logger();
62297
- import { resolve as resolve19 } from "path";
63322
+ import { resolve as resolve20 } from "path";
62298
63323
  function registerLock(program) {
62299
63324
  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(
62300
63325
  (cmdOptions) => {
62301
- const cwd = resolve19(cmdOptions.workspace ?? process.cwd());
63326
+ const cwd = resolve20(cmdOptions.workspace ?? process.cwd());
62302
63327
  if (cmdOptions.uninstall) {
62303
63328
  const result2 = uninstallHook(cwd);
62304
63329
  logger.info(result2.message);
@@ -62321,9 +63346,9 @@ function registerLock(program) {
62321
63346
  // src/cli/commands/ci.ts
62322
63347
  init_logger();
62323
63348
  init_dist();
62324
- import { resolve as resolve20 } from "path";
63349
+ import { resolve as resolve21 } from "path";
62325
63350
  function registerCi(program, scanAction) {
62326
- 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(
63351
+ 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(
62327
63352
  async (cmdOptions, command) => {
62328
63353
  const globals = command.optsWithGlobals();
62329
63354
  const options = {
@@ -62334,7 +63359,7 @@ function registerCi(program, scanAction) {
62334
63359
  // scan only changed files
62335
63360
  format: cmdOptions.format ?? "json"
62336
63361
  };
62337
- const cwd = resolve20(options.workspace ?? process.cwd());
63362
+ const cwd = resolve21(options.workspace ?? process.cwd());
62338
63363
  await scanAction([], options, command);
62339
63364
  const health = loadHealth(cwd);
62340
63365
  if (!health) {
@@ -62363,11 +63388,11 @@ function registerCi(program, scanAction) {
62363
63388
 
62364
63389
  // src/cli/commands/memory.ts
62365
63390
  init_logger();
62366
- import { resolve as resolve21 } from "path";
63391
+ import { resolve as resolve22 } from "path";
62367
63392
  function registerMemory(program) {
62368
63393
  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(
62369
63394
  async (cmdOptions) => {
62370
- const cwd = resolve21(cmdOptions.workspace ?? process.cwd());
63395
+ const cwd = resolve22(cmdOptions.workspace ?? process.cwd());
62371
63396
  const { renderStructureMarkdown: renderStructureMarkdown2, readStructureMarkdown: readStructureMarkdown2, writeStructureMarkdown: writeStructureMarkdown2 } = await Promise.resolve().then(() => (init_structure_md(), structure_md_exports));
62372
63397
  const { loadInventory: loadInventory2, loadConstitution: loadConstitution2, inventoryPath: invPath, constitutionPath: conPath } = await Promise.resolve().then(() => (init_dist(), dist_exports));
62373
63398
  if (cmdOptions.regenerate) {
@@ -62398,48 +63423,48 @@ function registerMemory(program) {
62398
63423
 
62399
63424
  // src/cli/commands/migrate.ts
62400
63425
  init_logger();
62401
- import { resolve as resolve22 } from "path";
63426
+ import { resolve as resolve23 } from "path";
62402
63427
 
62403
63428
  // src/cli/migrate.ts
62404
63429
  init_logger();
62405
- import { existsSync as existsSync23, readFileSync as readFileSync28, renameSync as renameSync4, writeFileSync as writeFileSync13 } from "fs";
62406
- import { join as join25 } from "path";
63430
+ import { existsSync as existsSync24, readFileSync as readFileSync29, renameSync as renameSync4, writeFileSync as writeFileSync15 } from "fs";
63431
+ import { join as join26 } from "path";
62407
63432
  function planMigration(workspaceDir) {
62408
63433
  const moves = [];
62409
63434
  const rewrites = [];
62410
63435
  const gitignoreEdits = [];
62411
- const oldDir = join25(workspaceDir, ".slop-audit");
62412
- const newDir = join25(workspaceDir, ".slopbrick");
62413
- if (existsSync23(oldDir)) {
63436
+ const oldDir = join26(workspaceDir, ".slop-audit");
63437
+ const newDir = join26(workspaceDir, ".slopbrick");
63438
+ if (existsSync24(oldDir)) {
62414
63439
  moves.push({ from: oldDir, to: newDir, kind: "dir" });
62415
63440
  rewrites.push({
62416
- path: join25(newDir, "inventory.json"),
63441
+ path: join26(newDir, "inventory.json"),
62417
63442
  field: "version",
62418
63443
  from: '"1"',
62419
63444
  to: '"2"'
62420
63445
  });
62421
63446
  rewrites.push({
62422
- path: join25(newDir, "constitution.json"),
63447
+ path: join26(newDir, "constitution.json"),
62423
63448
  field: "version",
62424
63449
  from: '"1"',
62425
63450
  to: '"2"'
62426
63451
  });
62427
63452
  }
62428
- const oldCache = join25(workspaceDir, ".slop-audit-cache.json");
62429
- const newCache = join25(workspaceDir, ".slopbrick-cache.json");
62430
- if (existsSync23(oldCache)) {
63453
+ const oldCache = join26(workspaceDir, ".slop-audit-cache.json");
63454
+ const newCache = join26(workspaceDir, ".slopbrick-cache.json");
63455
+ if (existsSync24(oldCache)) {
62431
63456
  moves.push({ from: oldCache, to: newCache, kind: "file" });
62432
63457
  }
62433
63458
  for (const ext of ["mjs", "cjs", "js"]) {
62434
- const oldCfg = join25(workspaceDir, `slop-audit.config.${ext}`);
62435
- const newCfg = join25(workspaceDir, `slopbrick.config.${ext}`);
62436
- if (existsSync23(oldCfg)) {
63459
+ const oldCfg = join26(workspaceDir, `slop-audit.config.${ext}`);
63460
+ const newCfg = join26(workspaceDir, `slopbrick.config.${ext}`);
63461
+ if (existsSync24(oldCfg)) {
62437
63462
  moves.push({ from: oldCfg, to: newCfg, kind: "config" });
62438
63463
  }
62439
63464
  }
62440
- const gi = join25(workspaceDir, ".gitignore");
62441
- if (existsSync23(gi)) {
62442
- const content = readFileSync28(gi, "utf-8");
63465
+ const gi = join26(workspaceDir, ".gitignore");
63466
+ if (existsSync24(gi)) {
63467
+ const content = readFileSync29(gi, "utf-8");
62443
63468
  if (content.includes(".slop-audit/")) {
62444
63469
  gitignoreEdits.push({
62445
63470
  path: gi,
@@ -62458,7 +63483,7 @@ function planMigration(workspaceDir) {
62458
63483
  return { moves, rewrites, gitignoreEdits };
62459
63484
  }
62460
63485
  function isAlreadyMigrated(workspaceDir) {
62461
- return existsSync23(join25(workspaceDir, ".slopbrick")) && !existsSync23(join25(workspaceDir, ".slop-audit"));
63486
+ return existsSync24(join26(workspaceDir, ".slopbrick")) && !existsSync24(join26(workspaceDir, ".slop-audit"));
62462
63487
  }
62463
63488
  function applyMigration(plan, options = {}) {
62464
63489
  if (options.dryRun) return;
@@ -62466,20 +63491,20 @@ function applyMigration(plan, options = {}) {
62466
63491
  renameSync4(m.from, m.to);
62467
63492
  }
62468
63493
  for (const r of plan.rewrites) {
62469
- if (!existsSync23(r.path)) continue;
62470
- const content = readFileSync28(r.path, "utf-8");
63494
+ if (!existsSync24(r.path)) continue;
63495
+ const content = readFileSync29(r.path, "utf-8");
62471
63496
  const next = content.replace(`"version": ${r.from}`, `"version": ${r.to}`);
62472
- writeFileSync13(r.path, next);
63497
+ writeFileSync15(r.path, next);
62473
63498
  }
62474
63499
  for (const g of plan.gitignoreEdits) {
62475
- const content = readFileSync28(g.path, "utf-8");
63500
+ const content = readFileSync29(g.path, "utf-8");
62476
63501
  const next = content.replaceAll(g.from, g.to);
62477
- writeFileSync13(g.path, next);
63502
+ writeFileSync15(g.path, next);
62478
63503
  }
62479
63504
  }
62480
63505
  function runMigrate(options) {
62481
63506
  const { workspace, dryRun = false, force = false } = options;
62482
- if (!existsSync23(workspace)) {
63507
+ if (!existsSync24(workspace)) {
62483
63508
  return {
62484
63509
  ok: false,
62485
63510
  alreadyMigrated: false,
@@ -62489,9 +63514,9 @@ function runMigrate(options) {
62489
63514
  };
62490
63515
  }
62491
63516
  const alreadyMigrated = isAlreadyMigrated(workspace);
62492
- const newDir = join25(workspace, ".slopbrick");
62493
- const oldDir = join25(workspace, ".slop-audit");
62494
- if (existsSync23(newDir) && existsSync23(oldDir) && !force) {
63517
+ const newDir = join26(workspace, ".slopbrick");
63518
+ const oldDir = join26(workspace, ".slop-audit");
63519
+ if (existsSync24(newDir) && existsSync24(oldDir) && !force) {
62495
63520
  return {
62496
63521
  ok: false,
62497
63522
  alreadyMigrated: false,
@@ -62567,7 +63592,7 @@ function registerMigrate(program) {
62567
63592
  (cmdOptions, command) => {
62568
63593
  const globals = command.optsWithGlobals();
62569
63594
  const format = (cmdOptions.format ?? globals.format) === "json" ? "json" : "pretty";
62570
- const cwd = resolve22(cmdOptions.workspace ?? process.cwd());
63595
+ const cwd = resolve23(cmdOptions.workspace ?? process.cwd());
62571
63596
  const result = runMigrate({
62572
63597
  workspace: cwd,
62573
63598
  dryRun: cmdOptions.dryRun,
@@ -62663,7 +63688,25 @@ function registerRules(program) {
62663
63688
  const sev = r.severity.padEnd(8);
62664
63689
  const tag = r.aiSpecific ? "[AI]" : " ";
62665
63690
  lines.push(` ${sev} ${tag} ${r.id}`);
62666
- if (r.description) lines.push(` ${r.description}`);
63691
+ if (r.description) {
63692
+ const cols = process.stdout.isTTY ? process.stdout.columns ?? 100 : 100;
63693
+ const indent = " ";
63694
+ const firstCol = indent.length + 1;
63695
+ const maxWidth = Math.max(40, cols - firstCol);
63696
+ const words = r.description.split(" ");
63697
+ let line = "";
63698
+ for (const word of words) {
63699
+ if (line === "") {
63700
+ line = word;
63701
+ } else if ((line + " " + word).length > maxWidth) {
63702
+ lines.push(indent + line);
63703
+ line = word;
63704
+ } else {
63705
+ line = line + " " + word;
63706
+ }
63707
+ }
63708
+ if (line) lines.push(indent + line);
63709
+ }
62667
63710
  }
62668
63711
  }
62669
63712
  logger.info(lines.join("\n"));
@@ -62673,12 +63716,12 @@ function registerRules(program) {
62673
63716
  // src/cli/commands/validate-config.ts
62674
63717
  init_logger();
62675
63718
  init_validation();
62676
- import { existsSync as existsSync24 } from "fs";
62677
- import { extname as extname8, resolve as resolve23 } from "path";
63719
+ import { existsSync as existsSync25 } from "fs";
63720
+ import { extname as extname8, resolve as resolve24 } from "path";
62678
63721
  function registerValidateConfig(program) {
62679
63722
  program.command("validate-config [path]").description("Statically validate a slopbrick.config.mjs without scanning").action(async (configPath) => {
62680
- const path = configPath ? resolve23(configPath) : resolve23(process.cwd(), "slopbrick.config.mjs");
62681
- if (!existsSync24(path)) {
63723
+ const path = configPath ? resolve24(configPath) : resolve24(process.cwd(), "slopbrick.config.mjs");
63724
+ if (!existsSync25(path)) {
62682
63725
  logger.error(`Error: config file not found: ${path}`);
62683
63726
  process.exit(2);
62684
63727
  }
@@ -62904,8 +63947,8 @@ ${formatMarkdown(result.report)}`);
62904
63947
 
62905
63948
  // src/cli/commands/calibrate.ts
62906
63949
  init_logger();
62907
- import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync16 } from "fs";
62908
- import { dirname as dirname18, resolve as resolve25 } from "path";
63950
+ import { mkdirSync as mkdirSync14, writeFileSync as writeFileSync18 } from "fs";
63951
+ import { dirname as dirname17, resolve as resolve26 } from "path";
62909
63952
 
62910
63953
  // src/research/providers/openai.ts
62911
63954
  var OpenAiProvider = class {
@@ -62960,8 +64003,8 @@ function createProvider(config) {
62960
64003
  }
62961
64004
 
62962
64005
  // src/research/generator.ts
62963
- import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync14 } from "fs";
62964
- import { join as join26 } from "path";
64006
+ import { mkdirSync as mkdirSync12, writeFileSync as writeFileSync16 } from "fs";
64007
+ import { join as join27 } from "path";
62965
64008
 
62966
64009
  // src/research/prompts.ts
62967
64010
  var DEFAULT_PROMPT_TEMPLATES = [
@@ -63024,14 +64067,14 @@ async function generateSamples(options) {
63024
64067
  }
63025
64068
  const samples = [];
63026
64069
  const ext = extForFramework(framework);
63027
- const dir = join26(outputDir, framework, componentType);
63028
- mkdirSync11(dir, { recursive: true });
64070
+ const dir = join27(outputDir, framework, componentType);
64071
+ mkdirSync12(dir, { recursive: true });
63029
64072
  for (let i = 1; i <= count; i += 1) {
63030
64073
  const raw = await provider.generateSample(renderPrompt(template), { temperature });
63031
64074
  const code = extractCodeFromMarkdown(raw);
63032
64075
  const fileName = `sample-${i}${ext}`;
63033
- const filePath = join26(dir, fileName);
63034
- writeFileSync14(filePath, code, "utf8");
64076
+ const filePath = join27(dir, fileName);
64077
+ writeFileSync16(filePath, code, "utf8");
63035
64078
  const sample = {
63036
64079
  filePath,
63037
64080
  framework,
@@ -63042,8 +64085,8 @@ async function generateSamples(options) {
63042
64085
  };
63043
64086
  samples.push(sample);
63044
64087
  }
63045
- const metadataPath = join26(dir, "metadata.json");
63046
- writeFileSync14(metadataPath, JSON.stringify(samples, null, 2), "utf8");
64088
+ const metadataPath = join27(dir, "metadata.json");
64089
+ writeFileSync16(metadataPath, JSON.stringify(samples, null, 2), "utf8");
63047
64090
  return samples;
63048
64091
  }
63049
64092
 
@@ -63249,46 +64292,46 @@ function slugify(value) {
63249
64292
 
63250
64293
  // src/research/calibrator.ts
63251
64294
  import { execFileSync as execFileSync2 } from "child_process";
63252
- import { existsSync as existsSync25, readFileSync as readFileSync29, writeFileSync as writeFileSync15, mkdirSync as mkdirSync12 } from "fs";
63253
- import { join as join28, resolve as resolve24 } from "path";
64295
+ import { existsSync as existsSync26, readFileSync as readFileSync30, writeFileSync as writeFileSync17, mkdirSync as mkdirSync13 } from "fs";
64296
+ import { join as join29, resolve as resolve25 } from "path";
63254
64297
 
63255
64298
  // src/corpus-paths.ts
63256
- import { join as join27 } from "path";
64299
+ import { join as join28 } from "path";
63257
64300
  var CORPUS_ROOT = process.env["SLOPBRICK_CORPUS_DIR"] ?? "/Users/cheng/corpus-expansion";
63258
- var POSITIVE_DIR = join27(CORPUS_ROOT, "positive");
63259
- var NEGATIVE_DIR = join27(CORPUS_ROOT, "negative");
63260
- var FILELISTS_DIR = join27(CORPUS_ROOT, "filelists");
64301
+ var POSITIVE_DIR = join28(CORPUS_ROOT, "positive");
64302
+ var NEGATIVE_DIR = join28(CORPUS_ROOT, "negative");
64303
+ var FILELISTS_DIR = join28(CORPUS_ROOT, "filelists");
63261
64304
 
63262
64305
  // src/research/calibrator.ts
63263
64306
  var DEFAULT_POSITIVE = POSITIVE_DIR;
63264
64307
  var DEFAULT_NEGATIVE = NEGATIVE_DIR;
63265
64308
  function buildFileList(dir, extensions) {
63266
- const tmpList = join28("/tmp", `cal-build-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
64309
+ const tmpList = join29("/tmp", `cal-build-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
63267
64310
  const expr = extensions.map((e) => `-name '*.${e}'`).join(" -o ");
63268
64311
  execFileSync2("bash", ["-c", `find ${dir} -maxdepth 8 -type f \\( ${expr} \\) -print0 | xargs -0 realpath > ${tmpList}`]);
63269
- const out = readFileSync29(tmpList, "utf8");
64312
+ const out = readFileSync30(tmpList, "utf8");
63270
64313
  execFileSync2("rm", ["-f", tmpList]);
63271
64314
  return out.trim().split("\n").filter(Boolean);
63272
64315
  }
63273
64316
  function runScan2(fileListPath) {
63274
- const files = readFileSync29(fileListPath, "utf8").trim().split("\n").filter(Boolean);
64317
+ const files = readFileSync30(fileListPath, "utf8").trim().split("\n").filter(Boolean);
63275
64318
  const CHUNK = 600;
63276
64319
  const ruleFires = /* @__PURE__ */ new Map();
63277
64320
  const uniqueFilesPerRule = /* @__PURE__ */ new Map();
63278
64321
  let fileCount = 0;
63279
- const tmpOut = join28("/tmp", `calibrate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
64322
+ const tmpOut = join29("/tmp", `calibrate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
63280
64323
  for (let i = 0; i < files.length; i += CHUNK) {
63281
64324
  const chunk = files.slice(i, i + CHUNK);
63282
64325
  try {
63283
64326
  execFileSync2(
63284
64327
  "node",
63285
- [join28(process.cwd(), "bin", "slopbrick.js"), "scan", ...chunk, "--json", tmpOut, "--no-telemetry", "--quiet"],
64328
+ [join29(process.cwd(), "bin", "slopbrick.js"), "scan", ...chunk, "--json", tmpOut, "--no-telemetry", "--quiet"],
63286
64329
  { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
63287
64330
  );
63288
64331
  } catch {
63289
64332
  }
63290
- if (!existsSync25(tmpOut)) continue;
63291
- const report = JSON.parse(readFileSync29(tmpOut, "utf8"));
64333
+ if (!existsSync26(tmpOut)) continue;
64334
+ const report = JSON.parse(readFileSync30(tmpOut, "utf8"));
63292
64335
  fileCount += report.fileCount;
63293
64336
  for (const issue of report.issues) {
63294
64337
  ruleFires.set(issue.ruleId, (ruleFires.get(issue.ruleId) ?? 0) + 1);
@@ -63316,16 +64359,16 @@ function classify(posFiles, negFiles) {
63316
64359
  async function calibrate(cwd, options = {}) {
63317
64360
  const positiveDir = options.positiveDir ?? DEFAULT_POSITIVE;
63318
64361
  const negativeDir = options.negativeDir ?? DEFAULT_NEGATIVE;
63319
- if (!existsSync25(positiveDir)) throw new Error(`Positive corpus not found: ${positiveDir}`);
63320
- if (!existsSync25(negativeDir)) throw new Error(`Negative corpus not found: ${negativeDir}`);
64362
+ if (!existsSync26(positiveDir)) throw new Error(`Positive corpus not found: ${positiveDir}`);
64363
+ if (!existsSync26(negativeDir)) throw new Error(`Negative corpus not found: ${negativeDir}`);
63321
64364
  const positiveFiles = buildFileList(positiveDir, ["tsx", "ts", "jsx", "js"]);
63322
64365
  const negativeFiles = buildFileList(negativeDir, ["tsx", "ts"]);
63323
64366
  const posSample = options.positiveLimit ? positiveFiles.slice(0, options.positiveLimit) : positiveFiles;
63324
64367
  const negSample = options.negativeLimit ? negativeFiles.slice(0, options.negativeLimit) : negativeFiles;
63325
- const posListPath = join28("/tmp", `cal-pos-${Date.now()}.txt`);
63326
- const negListPath = join28("/tmp", `cal-neg-${Date.now()}.txt`);
63327
- writeFileSync15(posListPath, posSample.join("\n"));
63328
- writeFileSync15(negListPath, negSample.join("\n"));
64368
+ const posListPath = join29("/tmp", `cal-pos-${Date.now()}.txt`);
64369
+ const negListPath = join29("/tmp", `cal-neg-${Date.now()}.txt`);
64370
+ writeFileSync17(posListPath, posSample.join("\n"));
64371
+ writeFileSync17(negListPath, negSample.join("\n"));
63329
64372
  const builtins = await Promise.resolve().then(() => (init_builtins(), builtins_exports));
63330
64373
  const builtinRules2 = builtins.builtinRules ?? [];
63331
64374
  const metaById = /* @__PURE__ */ new Map();
@@ -63439,9 +64482,9 @@ function registerCalibrate(program) {
63439
64482
  positiveLimit: cmdOptions.positiveLimit,
63440
64483
  negativeLimit: cmdOptions.negativeLimit
63441
64484
  });
63442
- const outputPath = cmdOptions.output ? resolve25(cwd, cmdOptions.output) : resolve25(cwd, "corpus", "calibration-empirical.md");
63443
- mkdirSync13(dirname18(outputPath), { recursive: true });
63444
- writeFileSync16(outputPath, reportToMarkdown(report), "utf8");
64485
+ const outputPath = cmdOptions.output ? resolve26(cwd, cmdOptions.output) : resolve26(cwd, "corpus", "calibration-empirical.md");
64486
+ mkdirSync14(dirname17(outputPath), { recursive: true });
64487
+ writeFileSync18(outputPath, reportToMarkdown(report), "utf8");
63445
64488
  logger.info(
63446
64489
  "Calibrated " + report.rules.length + " rules across " + report.positiveFileCount + " positive + " + report.negativeFileCount + " negative files."
63447
64490
  );
@@ -63508,19 +64551,20 @@ function trendToText(report) {
63508
64551
  }
63509
64552
  const slopValues = report.points.map((p) => p.slopIndex);
63510
64553
  const spark = sparkline(slopValues);
63511
- lines.push("Slop Index trend (last " + report.points.length + " scans, " + report.totalScans + " total):");
64554
+ lines.push("AI Slop Score trend (last " + report.points.length + " scans, " + report.totalScans + " total; lower = cleaner since v0.21):");
63512
64555
  lines.push("");
63513
64556
  lines.push(" " + spark + " " + slopValues[0].toFixed(1) + " -> " + slopValues[slopValues.length - 1].toFixed(1));
63514
64557
  lines.push("");
63515
64558
  if (report.first && report.latest) {
63516
64559
  const direction = report.delta < 0 ? "improved" : report.delta > 0 ? "regressed" : "unchanged";
63517
64560
  const arrow = report.delta < 0 ? "v" : report.delta > 0 ? "^" : "=";
63518
- lines.push(" " + arrow + " Slop Index " + direction + " by " + Math.abs(report.delta).toFixed(1) + " points");
64561
+ lines.push(" " + arrow + " AI Slop Score " + direction + " by " + Math.abs(report.delta).toFixed(1) + " points (lower = cleaner since v0.21)");
63519
64562
  lines.push(" " + report.first.timestamp + " -> " + report.latest.timestamp);
63520
64563
  }
63521
64564
  lines.push("");
63522
64565
  lines.push("Recent scans:");
63523
- lines.push(" Timestamp Slop Health Framework Issues");
64566
+ lines.push(" Timestamp AI Slop Health Framework Issues");
64567
+ lines.push(" (lower=cleaner)");
63524
64568
  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");
63525
64569
  for (const p of report.points.slice(-10)) {
63526
64570
  const ts = p.timestamp.slice(0, 19).replace("T", " ");
@@ -63532,14 +64576,14 @@ function trendToText(report) {
63532
64576
  }
63533
64577
  function trendToMarkdown(report) {
63534
64578
  const lines = [];
63535
- lines.push("# Slop Index Trend");
64579
+ lines.push("# AI Slop Score Trend (lower = cleaner since v0.21)");
63536
64580
  lines.push("");
63537
64581
  if (report.points.length === 0) {
63538
64582
  lines.push("No telemetry data yet. Run `slopbrick scan` to start tracking.");
63539
64583
  return lines.join("\n");
63540
64584
  }
63541
64585
  const slopValues = report.points.map((p) => p.slopIndex);
63542
- lines.push("Slop Index sparkline (last " + report.points.length + " scans):");
64586
+ lines.push("AI Slop Score sparkline (last " + report.points.length + " scans; lower = cleaner since v0.21):");
63543
64587
  lines.push("");
63544
64588
  lines.push("```");
63545
64589
  lines.push(sparkline(slopValues));
@@ -63547,10 +64591,10 @@ function trendToMarkdown(report) {
63547
64591
  lines.push("");
63548
64592
  if (report.first && report.latest) {
63549
64593
  const direction = report.delta < 0 ? "improved" : report.delta > 0 ? "regressed" : "unchanged";
63550
- lines.push("Slop Index " + direction + " by **" + Math.abs(report.delta).toFixed(1) + " points** between first and latest scan.");
64594
+ lines.push("AI Slop Score " + direction + " by **" + Math.abs(report.delta).toFixed(1) + " points** between first and latest scan (lower = cleaner since v0.21).");
63551
64595
  }
63552
64596
  lines.push("");
63553
- lines.push("| Timestamp | Slop Index | Health | Framework | Issues |");
64597
+ lines.push("| Timestamp | AI Slop Score (lower = cleaner) | Health | Framework | Issues |");
63554
64598
  lines.push("|-----------|-----------:|-------:|-----------|-------:|");
63555
64599
  for (const p of report.points) {
63556
64600
  lines.push("| " + p.timestamp + " | " + p.slopIndex.toFixed(1) + " | " + p.assemblyHealth.toFixed(1) + " | " + p.framework + " | " + p.totalIssues + " |");
@@ -63560,7 +64604,7 @@ function trendToMarkdown(report) {
63560
64604
 
63561
64605
  // src/cli/commands/trend.ts
63562
64606
  function registerTrend(program) {
63563
- 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) => {
64607
+ 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) => {
63564
64608
  try {
63565
64609
  const cwd = process.cwd();
63566
64610
  const report = buildTrend(cwd, cmdOptions.maxPoints);
@@ -63568,7 +64612,14 @@ function registerTrend(program) {
63568
64612
  logger.info(out);
63569
64613
  if (report.delta > 1) {
63570
64614
  logger.info("");
63571
- logger.info("Warning: Slop Index increased by " + report.delta.toFixed(1) + " points over this period.");
64615
+ logger.info(
64616
+ // v0.42.0: the legacy 'Slop Index increased' message implied higher
64617
+ // = worse, which is the v0.15-v0.20.1 inverted reading. Since v0.21,
64618
+ // AI Slop Score is lower-is-better; an INCREASE is worse, but
64619
+ // the warning should say "AI Slop Score got worse" or just
64620
+ // "delta went up" so users don't have to remember the direction.
64621
+ `Warning: AI Slop Score got worse by ${report.delta.toFixed(1)} points over this period (lower = cleaner since v0.21).`
64622
+ );
63572
64623
  }
63573
64624
  } catch (error) {
63574
64625
  logger.error(error instanceof Error ? error.message : String(error));
@@ -63580,13 +64631,13 @@ function registerTrend(program) {
63580
64631
  // src/cli/commands/drift.ts
63581
64632
  init_logger();
63582
64633
  init_scan2();
63583
- import { resolve as resolve26 } from "path";
64634
+ import { resolve as resolve27 } from "path";
63584
64635
 
63585
64636
  // src/cli/drift.ts
63586
64637
  init_discover();
63587
64638
  init_patterns();
63588
64639
  init_telemetry();
63589
- import { readFileSync as readFileSync30 } from "fs";
64640
+ import { readFileSync as readFileSync31 } from "fs";
63590
64641
  import { basename as basename4, relative as relative16 } from "path";
63591
64642
  async function runDrift(cwd, config, options = {}) {
63592
64643
  const maxFiles = options.maxFiles ?? 1e3;
@@ -63598,7 +64649,7 @@ async function runDrift(cwd, config, options = {}) {
63598
64649
  for (const absPath of limited) {
63599
64650
  let source;
63600
64651
  try {
63601
- source = readFileSync30(absPath, "utf-8");
64652
+ source = readFileSync31(absPath, "utf-8");
63602
64653
  } catch {
63603
64654
  continue;
63604
64655
  }
@@ -63885,7 +64936,7 @@ function registerDrift(program) {
63885
64936
  const options = command.optsWithGlobals();
63886
64937
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
63887
64938
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
63888
- const cwd = resolve26(options.workspace ?? process.cwd());
64939
+ const cwd = resolve27(options.workspace ?? process.cwd());
63889
64940
  if (cmdOptions.temporalSince !== void 0) {
63890
64941
  const temporalSince = cmdOptions.temporalSince.trim();
63891
64942
  if (temporalSince.length === 0) {
@@ -63913,7 +64964,7 @@ function registerDrift(program) {
63913
64964
  // src/cli/commands/pr.ts
63914
64965
  init_logger();
63915
64966
  init_scan2();
63916
- import { resolve as resolve28 } from "path";
64967
+ import { resolve as resolve29 } from "path";
63917
64968
 
63918
64969
  // src/cli/pr.ts
63919
64970
  init_git();
@@ -63921,8 +64972,8 @@ init_worker();
63921
64972
  init_metrics();
63922
64973
  init_patterns();
63923
64974
  init_discover();
63924
- import { readFileSync as readFileSync31 } from "fs";
63925
- import { extname as extname9, relative as relative17, resolve as resolve27 } from "path";
64975
+ import { readFileSync as readFileSync32 } from "fs";
64976
+ import { extname as extname9, relative as relative17, resolve as resolve28 } from "path";
63926
64977
  import { execFile as execFileCb } from "child_process";
63927
64978
  import { promisify as promisify2 } from "util";
63928
64979
  import { minimatch as minimatch3 } from "minimatch";
@@ -63963,7 +65014,7 @@ async function discoverPrFiles(cwd, config, base, head, maxFiles) {
63963
65014
  if (gitFiles.length === 0) return [];
63964
65015
  const sourceFiles = [];
63965
65016
  for (const relOrAbs of gitFiles) {
63966
- const abs = resolve27(cwd, relOrAbs);
65017
+ const abs = resolve28(cwd, relOrAbs);
63967
65018
  const ext = extname9(abs).toLowerCase();
63968
65019
  if (!PR_EXTENSIONS.has(ext)) continue;
63969
65020
  const rel = relative17(cwd, abs).split("\\").join("/");
@@ -63994,7 +65045,7 @@ async function runPrScan(cwd, config, options = {}) {
63994
65045
  for (const absPath of candidates) {
63995
65046
  let source;
63996
65047
  try {
63997
- source = readFileSync31(absPath, "utf-8");
65048
+ source = readFileSync32(absPath, "utf-8");
63998
65049
  } catch {
63999
65050
  continue;
64000
65051
  }
@@ -64169,20 +65220,135 @@ function formatPrMarkdown(result) {
64169
65220
  function prExitCode(result) {
64170
65221
  return result.totalScore > result.threshold ? 1 : 0;
64171
65222
  }
65223
+ function parseUnifiedDiffPaths(diff) {
65224
+ const paths = [];
65225
+ const seen = /* @__PURE__ */ new Set();
65226
+ for (const line of diff.split(/\r?\n/)) {
65227
+ const m = /^\+\+\+\s+(?:b\/)?(\S.*)$/.exec(line);
65228
+ if (!m) continue;
65229
+ const path = m[1];
65230
+ if (path === "/dev/null" || path === "dev/null") continue;
65231
+ if (seen.has(path)) continue;
65232
+ seen.add(path);
65233
+ paths.push(path);
65234
+ }
65235
+ return paths;
65236
+ }
65237
+ async function runPrScanFromDiffFile(cwd, config, diffPath, options = {}) {
65238
+ const threshold = options.threshold ?? config.prScoreThreshold ?? 20;
65239
+ const maxFiles = options.maxFiles ?? 500;
65240
+ const { existsSync: existsSync31, readFileSync: readFileSync41 } = await import("fs");
65241
+ const { isAbsolute: isAbsolute4, resolve: resolvePath } = await import("path");
65242
+ if (!existsSync31(diffPath)) {
65243
+ throw new Error(`Diff file not found: ${diffPath}`);
65244
+ }
65245
+ const diffAbs = isAbsolute4(diffPath) ? diffPath : resolvePath(cwd, diffPath);
65246
+ const diff = readFileSync41(diffAbs, "utf-8");
65247
+ const changedPaths = parseUnifiedDiffPaths(diff);
65248
+ if (changedPaths.length === 0) {
65249
+ return emptyPrResult(threshold);
65250
+ }
65251
+ return scoreChangedPaths(cwd, config, changedPaths, maxFiles, threshold);
65252
+ }
65253
+ async function scoreChangedPaths(cwd, config, paths, maxFiles, threshold) {
65254
+ const { existsSync: existsSync31, statSync: statSync8 } = await import("fs");
65255
+ const { resolve: resolvePath } = await import("path");
65256
+ const { readFile: readFile4 } = await import("fs/promises");
65257
+ const { scanFile: scanFile2 } = await Promise.resolve().then(() => (init_worker(), worker_exports));
65258
+ const limited = paths.slice(0, maxFiles);
65259
+ const files = [];
65260
+ let totalScore = 0;
65261
+ const byCategory = {};
65262
+ const bySeverity = { low: 0, medium: 0, high: 0 };
65263
+ let constitutionViolations = 0;
65264
+ for (const rel of limited) {
65265
+ const abs = resolvePath(cwd, rel);
65266
+ if (!existsSync31(abs) || !statSync8(abs).isFile()) continue;
65267
+ let source;
65268
+ try {
65269
+ source = await readFile4(abs, "utf-8");
65270
+ } catch {
65271
+ continue;
65272
+ }
65273
+ let result;
65274
+ try {
65275
+ result = await scanFile2(abs, config, void 0, cwd);
65276
+ } catch {
65277
+ continue;
65278
+ }
65279
+ if (result.parseError) continue;
65280
+ const issues = result.issues;
65281
+ let slopPoints = 0;
65282
+ const issueEntries = [];
65283
+ for (const issue of issues) {
65284
+ slopPoints += SEVERITY_WEIGHTS[issue.severity] ?? 0;
65285
+ byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
65286
+ bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
65287
+ issueEntries.push({
65288
+ ruleId: issue.ruleId,
65289
+ severity: issue.severity,
65290
+ line: issue.line,
65291
+ category: issue.category,
65292
+ message: issue.message
65293
+ });
65294
+ }
65295
+ totalScore += slopPoints;
65296
+ files.push({
65297
+ file: abs,
65298
+ relPath: rel,
65299
+ score: slopPoints,
65300
+ issueCount: issueEntries.length,
65301
+ constitutionViolationCount: 0,
65302
+ slopPoints,
65303
+ issues: issueEntries,
65304
+ constitutionViolations: []
65305
+ });
65306
+ }
65307
+ return {
65308
+ base: "diff",
65309
+ head: "diff",
65310
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65311
+ passed: totalScore <= threshold,
65312
+ threshold,
65313
+ totalScore,
65314
+ filesChanged: files.length,
65315
+ byCategory,
65316
+ bySeverity,
65317
+ files
65318
+ };
65319
+ }
65320
+ function emptyPrResult(threshold) {
65321
+ return {
65322
+ base: "diff",
65323
+ head: "diff",
65324
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65325
+ passed: true,
65326
+ threshold,
65327
+ totalScore: 0,
65328
+ filesChanged: 0,
65329
+ byCategory: {},
65330
+ bySeverity: { low: 0, medium: 0, high: 0 },
65331
+ files: []
65332
+ };
65333
+ }
64172
65334
 
64173
65335
  // src/cli/commands/pr.ts
64174
65336
  function registerPr(program) {
64175
65337
  program.command("pr").description(
64176
65338
  "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)."
64177
- ).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(
65339
+ ).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(
64178
65340
  async (cmdOptions, command) => {
64179
65341
  try {
64180
65342
  const options = command.optsWithGlobals();
64181
- const cwd = resolve28(options.workspace ?? process.cwd());
65343
+ const cwd = resolve29(options.workspace ?? process.cwd());
64182
65344
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64183
65345
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64184
65346
  const { config } = await runScan({ ...options, workspace: cwd });
64185
- const result = await runPrScan(cwd, config, {
65347
+ const result = cmdOptions.diff ? await runPrScanFromDiffFile(cwd, config, cmdOptions.diff, {
65348
+ format,
65349
+ threshold: cmdOptions.threshold,
65350
+ maxFiles: cmdOptions.maxFiles
65351
+ }) : await runPrScan(cwd, config, {
64186
65352
  base: cmdOptions.base,
64187
65353
  head: cmdOptions.head,
64188
65354
  format,
@@ -64203,7 +65369,7 @@ function registerPr(program) {
64203
65369
  init_logger();
64204
65370
  init_scan2();
64205
65371
  init_ai_security_risk();
64206
- import { resolve as resolve29 } from "path";
65372
+ import { resolve as resolve30 } from "path";
64207
65373
  function registerSecurity(program) {
64208
65374
  program.command("security").description(
64209
65375
  "AI Security Risk \u2014 categorical severity for security findings disproportionately introduced by AI-generated code"
@@ -64213,7 +65379,7 @@ function registerSecurity(program) {
64213
65379
  const options = command.optsWithGlobals();
64214
65380
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64215
65381
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64216
- const cwd = resolve29(options.workspace ?? process.cwd());
65382
+ const cwd = resolve30(options.workspace ?? process.cwd());
64217
65383
  const { report } = await runScan({ ...options, workspace: cwd });
64218
65384
  const securityIssues = report.issues.filter((i) => i.category === "security");
64219
65385
  const { risk, findings } = computeAiSecurityRisk(securityIssues);
@@ -64261,13 +65427,13 @@ function registerSecurity(program) {
64261
65427
  // src/cli/commands/test.ts
64262
65428
  init_logger();
64263
65429
  init_scan2();
64264
- import { resolve as resolve31 } from "path";
65430
+ import { resolve as resolve32 } from "path";
64265
65431
 
64266
65432
  // src/cli/test.ts
64267
65433
  init_scan2();
64268
65434
  init_test_quality();
64269
65435
  init_logger();
64270
- import { resolve as resolve30 } from "path";
65436
+ import { resolve as resolve31 } from "path";
64271
65437
  var TEST_INCLUDE_GLOBS = [
64272
65438
  "**/*.test.{ts,tsx,js,jsx}",
64273
65439
  "**/*.spec.{ts,tsx,js,jsx}",
@@ -64275,7 +65441,7 @@ var TEST_INCLUDE_GLOBS = [
64275
65441
  "**/__tests__/**/*.{ts,tsx,js,jsx}",
64276
65442
  "**/__fixtures__/**/*.{ts,tsx,js,jsx,json}"
64277
65443
  ];
64278
- async function runTestScan(cwd, config, options = {}) {
65444
+ async function runTestScan(cwd, _config, options = {}) {
64279
65445
  const include = TEST_INCLUDE_GLOBS;
64280
65446
  const cliOptions = {
64281
65447
  include,
@@ -64335,7 +65501,7 @@ function formatTestReport(result, opts = {}) {
64335
65501
  const sortedFiles = [...byFile.keys()].sort();
64336
65502
  for (const file of sortedFiles) {
64337
65503
  const issues = byFile.get(file) ?? [];
64338
- const rel = file.startsWith(resolve30(process.cwd())) ? file : file;
65504
+ const rel = file.startsWith(resolve31(process.cwd())) ? file : file;
64339
65505
  lines.push("");
64340
65506
  lines.push(` ${rel}`);
64341
65507
  for (const issue of issues.slice(0, 20)) {
@@ -64364,7 +65530,7 @@ function registerTest(program) {
64364
65530
  const options = command.optsWithGlobals();
64365
65531
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64366
65532
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64367
- const cwd = resolve31(options.workspace ?? process.cwd());
65533
+ const cwd = resolve32(options.workspace ?? process.cwd());
64368
65534
  const { config } = await runScan({ ...options, workspace: cwd });
64369
65535
  const { result } = await runTestScan(cwd, config, { strict: options.strict });
64370
65536
  logger.info(formatTestReport(result, { json: format === "json" }));
@@ -64381,7 +65547,7 @@ function registerTest(program) {
64381
65547
  init_logger();
64382
65548
  init_scan2();
64383
65549
  init_architecture_score();
64384
- import { resolve as resolve32 } from "path";
65550
+ import { resolve as resolve33 } from "path";
64385
65551
  function registerArchitecture(program) {
64386
65552
  program.command("architecture").description(
64387
65553
  "compute the Architecture Consistency Score (0-100) \u2014 one number for cross-file pattern duplication"
@@ -64391,7 +65557,7 @@ function registerArchitecture(program) {
64391
65557
  const options = command.optsWithGlobals();
64392
65558
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64393
65559
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64394
- const cwd = resolve32(options.workspace ?? process.cwd());
65560
+ const cwd = resolve33(options.workspace ?? process.cwd());
64395
65561
  const { config } = await runScan({ ...options, workspace: cwd });
64396
65562
  const score = await buildArchitectureScore(cwd, config, cmdOptions.maxFiles);
64397
65563
  const out = format === "json" ? JSON.stringify(score, null, 2) : formatArchitectureScore(score);
@@ -64408,12 +65574,12 @@ function registerArchitecture(program) {
64408
65574
  // src/cli/commands/business-logic.ts
64409
65575
  init_logger();
64410
65576
  init_scan2();
64411
- import { resolve as resolve33 } from "path";
65577
+ import { resolve as resolve34 } from "path";
64412
65578
 
64413
65579
  // src/cli/business-logic.ts
64414
65580
  init_discover();
64415
65581
  init_business_logic();
64416
- import { readFileSync as readFileSync32 } from "fs";
65582
+ import { readFileSync as readFileSync33 } from "fs";
64417
65583
  import { relative as relative18 } from "path";
64418
65584
  async function runBusinessLogicScan(cwd, config, options = {}) {
64419
65585
  const maxFiles = options.maxFiles ?? 500;
@@ -64424,7 +65590,7 @@ async function runBusinessLogicScan(cwd, config, options = {}) {
64424
65590
  for (const absPath of limited) {
64425
65591
  let source;
64426
65592
  try {
64427
- source = readFileSync32(absPath, "utf-8");
65593
+ source = readFileSync33(absPath, "utf-8");
64428
65594
  } catch {
64429
65595
  continue;
64430
65596
  }
@@ -64536,7 +65702,7 @@ function registerBusinessLogic(program) {
64536
65702
  const options = command.optsWithGlobals();
64537
65703
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64538
65704
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64539
- const cwd = resolve33(options.workspace ?? process.cwd());
65705
+ const cwd = resolve34(options.workspace ?? process.cwd());
64540
65706
  const { config } = await runScan({ ...options, workspace: cwd });
64541
65707
  const result = await runBusinessLogicScan(cwd, config, {
64542
65708
  maxFiles: cmdOptions.maxFiles
@@ -64554,7 +65720,7 @@ function registerBusinessLogic(program) {
64554
65720
  // src/cli/commands/maintenance-cost.ts
64555
65721
  init_logger();
64556
65722
  init_scan2();
64557
- import { resolve as resolve34 } from "path";
65723
+ import { resolve as resolve35 } from "path";
64558
65724
 
64559
65725
  // src/cli/maintenance-cost.ts
64560
65726
  init_scan2();
@@ -64678,7 +65844,7 @@ function registerMaintenanceCost(program) {
64678
65844
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64679
65845
  const format = rawFormat === "json" || rawFormat === "text" ? rawFormat : "text";
64680
65846
  const strict = options.strict ?? cmdOptions.strict ?? false;
64681
- const cwd = resolve34(options.workspace ?? process.cwd());
65847
+ const cwd = resolve35(options.workspace ?? process.cwd());
64682
65848
  const { config } = await runScan({ ...options, workspace: cwd });
64683
65849
  const result = await runMaintenanceCostScan(cwd, config, {
64684
65850
  maxFiles: cmdOptions.maxFiles,
@@ -64697,7 +65863,7 @@ function registerMaintenanceCost(program) {
64697
65863
  // src/cli/commands/docs.ts
64698
65864
  init_logger();
64699
65865
  init_scan2();
64700
- import { resolve as resolve35 } from "path";
65866
+ import { resolve as resolve36 } from "path";
64701
65867
 
64702
65868
  // src/cli/docs.ts
64703
65869
  init_scan2();
@@ -64822,7 +65988,7 @@ function registerDocs(program) {
64822
65988
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64823
65989
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64824
65990
  const strict = options.strict ?? cmdOptions.strict ?? false;
64825
- const cwd = resolve35(options.workspace ?? process.cwd());
65991
+ const cwd = resolve36(options.workspace ?? process.cwd());
64826
65992
  const { config } = await runScan({ ...options, workspace: cwd });
64827
65993
  const result = await runDocsScan(cwd, config, {
64828
65994
  maxDocFiles: cmdOptions.maxFiles,
@@ -64846,7 +66012,7 @@ function registerDocs(program) {
64846
66012
  // src/cli/commands/db.ts
64847
66013
  init_logger();
64848
66014
  init_scan2();
64849
- import { resolve as resolve36 } from "path";
66015
+ import { resolve as resolve37 } from "path";
64850
66016
 
64851
66017
  // src/cli/db.ts
64852
66018
  init_scan2();
@@ -64965,7 +66131,7 @@ function registerDb(program) {
64965
66131
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64966
66132
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64967
66133
  const strict = options.strict ?? cmdOptions.strict ?? false;
64968
- const cwd = resolve36(options.workspace ?? process.cwd());
66134
+ const cwd = resolve37(options.workspace ?? process.cwd());
64969
66135
  const { config } = await runScan({ ...options, workspace: cwd });
64970
66136
  const result = await runDbScan(cwd, config, {
64971
66137
  maxFiles: cmdOptions.maxFiles,
@@ -64989,12 +66155,12 @@ function registerDb(program) {
64989
66155
  // src/cli/commands/patterns.ts
64990
66156
  init_logger();
64991
66157
  init_scan2();
64992
- import { resolve as resolve37 } from "path";
66158
+ import { resolve as resolve38 } from "path";
64993
66159
 
64994
66160
  // src/engine/patterns.ts
64995
66161
  init_patterns();
64996
66162
  init_discover();
64997
- import { readFileSync as readFileSync33 } from "fs";
66163
+ import { readFileSync as readFileSync34 } from "fs";
64998
66164
  import { basename as basename5, relative as relative19 } from "path";
64999
66165
  var PATTERN_CATEGORIES = [
65000
66166
  "modal",
@@ -65097,7 +66263,7 @@ function detectFormsFromFiles(files) {
65097
66263
  for (const f of files) {
65098
66264
  let source;
65099
66265
  try {
65100
- source = readFileSync33(f, "utf-8");
66266
+ source = readFileSync34(f, "utf-8");
65101
66267
  } catch {
65102
66268
  continue;
65103
66269
  }
@@ -65224,7 +66390,7 @@ function formatText2(report) {
65224
66390
  const label = CATEGORY_LABELS2[cat].padEnd(20);
65225
66391
  const count = String(stats.count).padStart(2);
65226
66392
  const baseline = String(stats.baseline).padStart(1);
65227
- const excess = stats.excess > 0 ? `\u26A0 +${stats.excess} over` : "\u2713 clean";
66393
+ const excess = stats.excess > 0 ? `\u26A0 +${stats.excess} over` : stats.count === 0 ? "\u2713 none" : "\u2713 within baseline";
65228
66394
  lines.push(` ${label} ${count} implementations (baseline ${baseline}) ${excess}`);
65229
66395
  }
65230
66396
  if (report.doNotCreate.length > 0) {
@@ -65282,7 +66448,7 @@ function formatMarkdown3(report) {
65282
66448
  for (const cat of PATTERN_CATEGORIES) {
65283
66449
  const stats = report.byCategory[cat];
65284
66450
  const label = CATEGORY_LABELS2[cat];
65285
- const excess = stats.excess > 0 ? `+${stats.excess}` : "\u2014";
66451
+ const excess = stats.excess > 0 ? `+${stats.excess}` : stats.count === 0 ? "\u2014" : "\xB7";
65286
66452
  lines.push(`| ${label} | ${stats.count} | ${stats.baseline} | ${excess} |`);
65287
66453
  }
65288
66454
  if (report.doNotCreate.length > 0) {
@@ -65318,7 +66484,7 @@ function registerPatterns(program) {
65318
66484
  const options = command.optsWithGlobals();
65319
66485
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
65320
66486
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
65321
- const cwd = resolve37(options.workspace ?? process.cwd());
66487
+ const cwd = resolve38(options.workspace ?? process.cwd());
65322
66488
  const { config } = await runScan({ ...options, workspace: cwd });
65323
66489
  const result = await runPatternsScan(cwd, config, {
65324
66490
  maxFiles: cmdOptions.maxFiles,
@@ -65337,8 +66503,8 @@ function registerPatterns(program) {
65337
66503
  // src/cli/commands/research.ts
65338
66504
  init_logger();
65339
66505
  init_config2();
65340
- import { existsSync as existsSync26, mkdirSync as mkdirSync14, readFileSync as readFileSync34, writeFileSync as writeFileSync17 } from "fs";
65341
- import { dirname as dirname19, resolve as resolve38 } from "path";
66506
+ import { existsSync as existsSync27, mkdirSync as mkdirSync15, readFileSync as readFileSync35, writeFileSync as writeFileSync19 } from "fs";
66507
+ import { dirname as dirname18, resolve as resolve39 } from "path";
65342
66508
  function registerResearch(program) {
65343
66509
  const research = program.command("research").description("research commands for the AI UI learning loop");
65344
66510
  research.command("generate").description("generate synthetic UI samples").requiredOption("--count <n>", "number of samples", parseCount).requiredOption("--framework <name>", "target framework").requiredOption("--component-type <type>", "component type").requiredOption("--provider <name>", "AI provider (openai)").option("--api-key <key>", "API key for provider").option("--model <name>", "model name").option("--temperature <n>", "sampling temperature", parseFloat, 0.7).option("--output-dir <path>", "output directory", ".slopbrick/corpus/generated").action(async (cmdOptions) => {
@@ -65353,24 +66519,24 @@ function registerResearch(program) {
65353
66519
  framework: cmdOptions.framework,
65354
66520
  componentType: cmdOptions.componentType,
65355
66521
  provider,
65356
- outputDir: resolve38(cmdOptions.outputDir),
66522
+ outputDir: resolve39(cmdOptions.outputDir),
65357
66523
  temperature: cmdOptions.temperature
65358
66524
  });
65359
66525
  logger.info(`Generated ${samples.length} samples in ${cmdOptions.outputDir}`);
65360
66526
  });
65361
66527
  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) => {
65362
66528
  try {
65363
- const metadataPath = resolve38(cmdOptions.inputDir, "metadata.json");
65364
- if (!existsSync26(metadataPath)) {
66529
+ const metadataPath = resolve39(cmdOptions.inputDir, "metadata.json");
66530
+ if (!existsSync27(metadataPath)) {
65365
66531
  logger.error(`No metadata.json found in ${cmdOptions.inputDir}`);
65366
66532
  process.exit(2);
65367
66533
  }
65368
- const samples = JSON.parse(readFileSync34(metadataPath, "utf8"));
66534
+ const samples = JSON.parse(readFileSync35(metadataPath, "utf8"));
65369
66535
  const config = cmdOptions.config ? await loadConfig(cmdOptions.config) : { ...DEFAULT_CONFIG, framework: cmdOptions.framework };
65370
66536
  const analysis = await analyzeSamples(samples, config);
65371
- const outputPath = resolve38(cmdOptions.output);
65372
- mkdirSync14(dirname19(outputPath), { recursive: true });
65373
- writeFileSync17(outputPath, JSON.stringify(analysis, null, 2), "utf8");
66537
+ const outputPath = resolve39(cmdOptions.output);
66538
+ mkdirSync15(dirname18(outputPath), { recursive: true });
66539
+ writeFileSync19(outputPath, JSON.stringify(analysis, null, 2), "utf8");
65374
66540
  logger.info(`Analyzed ${analysis.summary.total} samples; coverage: ${analysis.summary.coverage}%`);
65375
66541
  logger.info(`Wrote analysis to ${outputPath}`);
65376
66542
  } catch (error) {
@@ -65380,12 +66546,12 @@ function registerResearch(program) {
65380
66546
  });
65381
66547
  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) => {
65382
66548
  try {
65383
- const metadataPath = resolve38(cmdOptions.inputDir, "metadata.json");
65384
- if (!existsSync26(metadataPath)) {
66549
+ const metadataPath = resolve39(cmdOptions.inputDir, "metadata.json");
66550
+ if (!existsSync27(metadataPath)) {
65385
66551
  logger.error(`No metadata.json found in ${cmdOptions.inputDir}`);
65386
66552
  process.exit(2);
65387
66553
  }
65388
- const samples = JSON.parse(readFileSync34(metadataPath, "utf8"));
66554
+ const samples = JSON.parse(readFileSync35(metadataPath, "utf8"));
65389
66555
  const config = cmdOptions.config ? await loadConfig(cmdOptions.config) : { ...DEFAULT_CONFIG, framework: cmdOptions.framework };
65390
66556
  const analysis = await analyzeSamples(samples, config);
65391
66557
  const extraction = extractAndCluster(analysis.samples, {
@@ -65395,8 +66561,8 @@ function registerResearch(program) {
65395
66561
  const candidates = clustersToCandidates(extraction.clusters, {
65396
66562
  minFrequency: cmdOptions.minFrequency
65397
66563
  });
65398
- const outputPath = resolve38(cmdOptions.output);
65399
- mkdirSync14(dirname19(outputPath), { recursive: true });
66564
+ const outputPath = resolve39(cmdOptions.output);
66565
+ mkdirSync15(dirname18(outputPath), { recursive: true });
65400
66566
  const payload = {
65401
66567
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65402
66568
  sampleCount: analysis.summary.total,
@@ -65404,7 +66570,7 @@ function registerResearch(program) {
65404
66570
  fingerprintCount: extraction.total,
65405
66571
  candidates
65406
66572
  };
65407
- writeFileSync17(outputPath, JSON.stringify(payload, null, 2), "utf8");
66573
+ writeFileSync19(outputPath, JSON.stringify(payload, null, 2), "utf8");
65408
66574
  logger.info(`Extracted ${extraction.total} fingerprints across ${analysis.summary.total} samples`);
65409
66575
  logger.info(`Wrote ${candidates.length} candidate rule(s) to ${outputPath}`);
65410
66576
  } catch (error) {
@@ -65417,8 +66583,8 @@ function registerResearch(program) {
65417
66583
  // src/cli/commands/init.ts
65418
66584
  init_logger();
65419
66585
  init_builtins();
65420
- import { existsSync as existsSync27, mkdirSync as mkdirSync15, readFileSync as readFileSync35, writeFileSync as writeFileSync18 } from "fs";
65421
- import { dirname as dirname20, join as join30, resolve as resolve39 } from "path";
66586
+ import { existsSync as existsSync28, mkdirSync as mkdirSync16, readFileSync as readFileSync36, writeFileSync as writeFileSync20 } from "fs";
66587
+ import { dirname as dirname19, join as join31, resolve as resolve40 } from "path";
65422
66588
  init_scan2();
65423
66589
  init_config2();
65424
66590
  init_threshold();
@@ -65426,145 +66592,9 @@ init_git();
65426
66592
  init_cache();
65427
66593
 
65428
66594
  // src/snippet/targets.ts
65429
- import { join as join29 } from "path";
65430
-
65431
- // src/snippet/render.ts
65432
- function aiSpecificRules(rules) {
65433
- return rules.filter((r) => r.aiSpecific).sort((a, b) => a.id.localeCompare(b.id));
65434
- }
65435
- function categorizeRules(rules) {
65436
- const byCategory = /* @__PURE__ */ new Map();
65437
- for (const r of rules) {
65438
- const list = byCategory.get(r.category) ?? [];
65439
- list.push(r);
65440
- byCategory.set(r.category, list);
65441
- }
65442
- for (const list of byCategory.values()) {
65443
- list.sort((a, b) => a.id.localeCompare(b.id));
65444
- }
65445
- return byCategory;
65446
- }
65447
- function baseContent(rules, toolLabel, intro) {
65448
- const lines = [];
65449
- lines.push("# " + toolLabel);
65450
- lines.push("");
65451
- lines.push(intro);
65452
- lines.push("");
65453
- const ai = aiSpecificRules(rules);
65454
- const byCategory = categorizeRules(ai);
65455
- lines.push("## Category-level directives");
65456
- lines.push("");
65457
- for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
65458
- lines.push("- **" + category + "**: " + directive);
65459
- }
65460
- lines.push("");
65461
- lines.push("## Per-rule directives");
65462
- lines.push("");
65463
- for (const [category, list] of byCategory) {
65464
- lines.push("### " + category);
65465
- lines.push("");
65466
- for (const rule of list) {
65467
- const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
65468
- lines.push("- `" + rule.id + "` (" + rule.severity + "): " + hint);
65469
- }
65470
- lines.push("");
65471
- }
65472
- return lines.join("\n");
65473
- }
65474
-
65475
- // src/snippet/generators.ts
65476
- function generateCursorSnippet(rules) {
65477
- const lines = [];
65478
- lines.push("# slopbrick cursor rules");
65479
- lines.push("");
65480
- lines.push("Generated by `slopbrick init --cursor`. These rules describe patterns that slopbrick");
65481
- lines.push("flags as AI-generated frontend slop. Avoid them when writing code in this project.");
65482
- lines.push("");
65483
- lines.push("## Category-level directives");
65484
- lines.push("");
65485
- for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
65486
- lines.push("- **" + category + "**: " + directive);
65487
- }
65488
- lines.push("");
65489
- lines.push("## Per-rule directives");
65490
- lines.push("");
65491
- const ai = aiSpecificRules(rules);
65492
- const byCategory = categorizeRules(ai);
65493
- for (const [category, list] of byCategory) {
65494
- lines.push("### " + category);
65495
- lines.push("");
65496
- for (const rule of list) {
65497
- const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
65498
- lines.push("- **`" + rule.id + "`** (" + rule.severity + "): " + hint);
65499
- }
65500
- lines.push("");
65501
- }
65502
- lines.push("## Verification");
65503
- lines.push("");
65504
- lines.push("After writing code, run `slopbrick scan` and check for issues in any of the categories above.");
65505
- lines.push("Use `slopbrick fix` for auto-fixable rules. Use `slopbrick mcp` from MCP-aware clients to query rules and scan files programmatically.");
65506
- lines.push("");
65507
- return lines.join("\n");
65508
- }
65509
- function generateAgentsMdSnippet(rules) {
65510
- return baseContent(
65511
- rules,
65512
- "AGENTS.md",
65513
- "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."
65514
- ) + "## How to verify\n\nAfter editing code, run `slopbrick scan`. Any issue with a category in this file is something to fix.\n";
65515
- }
65516
- function generateClaudeMdSnippet(rules) {
65517
- return baseContent(
65518
- rules,
65519
- "CLAUDE.md",
65520
- "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."
65521
- ) + "## 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";
65522
- }
65523
- function generateAiderSnippet(rules) {
65524
- return baseContent(
65525
- rules,
65526
- "CONVENTIONS.md",
65527
- "Generated by `slopbrick init --aider`. Aider reads CONVENTIONS.md (not AGENTS.md) for project conventions."
65528
- );
65529
- }
65530
- function generateWindsurfSnippet(rules) {
65531
- return baseContent(
65532
- rules,
65533
- ".windsurfrules",
65534
- "Generated by `slopbrick init --windsurf`. Windsurf Cascade reads .windsurfrules at project root."
65535
- );
65536
- }
65537
- function generateClineRules(rules) {
65538
- return baseContent(
65539
- rules,
65540
- "AGENTS.md",
65541
- "Generated by `slopbrick init --cline`. Cline reads .clinerules/AGENTS.md from the project root."
65542
- );
65543
- }
65544
- function generateCursorrulesLegacySnippet(rules) {
65545
- return baseContent(
65546
- rules,
65547
- ".cursorrules",
65548
- "Generated by `slopbrick init --cursorrules`. This is the legacy Cursor rule format. Prefer `.cursor/rules/slopbrick.mdc` (use --cursor) for new projects."
65549
- );
65550
- }
65551
- function generateGeminiSnippet(rules) {
65552
- return baseContent(
65553
- rules,
65554
- "GEMINI.md",
65555
- "Generated by `slopbrick init --gemini`. Gemini CLI reads .gemini/GEMINI.md from project root."
65556
- );
65557
- }
65558
- function generateCopilotSnippet(rules) {
65559
- return baseContent(
65560
- rules,
65561
- "copilot-instructions.md",
65562
- "Generated by `slopbrick init --copilot`. GitHub Copilot reads .github/copilot-instructions.md from project root."
65563
- );
65564
- }
65565
-
65566
- // src/snippet/targets.ts
65567
- var SNIPPET_TARGETS = [
66595
+ init_generators();
66596
+ import { join as join30 } from "path";
66597
+ var SNIPPET_TARGETS2 = [
65568
66598
  {
65569
66599
  flag: "cursor",
65570
66600
  cliName: "--cursor",
@@ -65657,7 +66687,7 @@ var SNIPPET_TARGETS = [
65657
66687
  }
65658
66688
  ];
65659
66689
  function resolveTargetPath(target) {
65660
- return target.isFolder ? join29(target.path, target.filename) : target.path;
66690
+ return target.isFolder ? join30(target.path, target.filename) : target.path;
65661
66691
  }
65662
66692
  function renderMatrix() {
65663
66693
  const lines = [];
@@ -65665,7 +66695,7 @@ function renderMatrix() {
65665
66695
  lines.push("");
65666
66696
  lines.push("| Flag | File | Agent |");
65667
66697
  lines.push("|------|------|-------|");
65668
- for (const t of SNIPPET_TARGETS) {
66698
+ for (const t of SNIPPET_TARGETS2) {
65669
66699
  lines.push("| `" + t.cliName + "` | `" + resolveTargetPath(t) + "` | " + t.agent + " |");
65670
66700
  }
65671
66701
  return lines.join("\n");
@@ -65679,13 +66709,13 @@ function registerInit(program) {
65679
66709
  logger.info(renderMatrix());
65680
66710
  process.exit(0);
65681
66711
  }
65682
- const cwd = resolve39(options.workspace ?? process.cwd());
65683
- const configPath = join30(cwd, "slopbrick.config.mjs");
66712
+ const cwd = resolve40(options.workspace ?? process.cwd());
66713
+ const configPath = join31(cwd, "slopbrick.config.mjs");
65684
66714
  const detected = detectStack(cwd);
65685
66715
  const fallbackConfig = { ...DEFAULT_CONFIG, ...detected };
65686
66716
  const proposed = serializeConfig(fallbackConfig);
65687
- if (existsSync27(configPath) && !cmdOptions.yes) {
65688
- const current = readFileSync35(configPath, "utf8");
66717
+ if (existsSync28(configPath) && !cmdOptions.yes) {
66718
+ const current = readFileSync36(configPath, "utf8");
65689
66719
  logger.error(`A config file already exists at ${configPath}.`);
65690
66720
  logger.error("To overwrite it with defaults, run `slopbrick init --yes`.");
65691
66721
  logger.error("");
@@ -65706,7 +66736,7 @@ function registerInit(program) {
65706
66736
  config = buildInitConfig(detected, answers);
65707
66737
  usedWizard = true;
65708
66738
  }
65709
- writeFileSync18(configPath, serializeConfig(config));
66739
+ writeFileSync20(configPath, serializeConfig(config));
65710
66740
  appendGitignore(cwd);
65711
66741
  const refresh = await refreshRegistrySnapshot(cwd);
65712
66742
  if (!refresh.ok) {
@@ -65730,27 +66760,27 @@ function registerInit(program) {
65730
66760
  }
65731
66761
  logger.info(refresh.message);
65732
66762
  }
65733
- const targetsToWrite = SNIPPET_TARGETS.filter((t) => {
66763
+ const targetsToWrite = SNIPPET_TARGETS2.filter((t) => {
65734
66764
  if (cmdOptions.all) return true;
65735
66765
  const opts = cmdOptions;
65736
66766
  return Boolean(opts[t.flag]);
65737
66767
  });
65738
66768
  for (const target of targetsToWrite) {
65739
- const snippetPath = join30(cwd, resolveTargetPath(target));
65740
- mkdirSync15(dirname20(snippetPath), { recursive: true });
66769
+ const snippetPath = join31(cwd, resolveTargetPath(target));
66770
+ mkdirSync16(dirname19(snippetPath), { recursive: true });
65741
66771
  const generated = target.generator(builtinRules);
65742
- if (!target.isFolder && existsSync27(snippetPath)) {
65743
- const existing = readFileSync35(snippetPath, "utf8");
66772
+ if (!target.isFolder && existsSync28(snippetPath)) {
66773
+ const existing = readFileSync36(snippetPath, "utf8");
65744
66774
  if (existing.includes("<!-- slopbrick:begin -->")) {
65745
66775
  const updated = existing.replace(
65746
66776
  /<!-- slopbrick:begin -->[\s\S]*?<!-- slopbrick:end -->/,
65747
66777
  "<!-- slopbrick:begin -->\n" + generated + "<!-- slopbrick:end -->"
65748
66778
  );
65749
- writeFileSync18(snippetPath, updated, "utf8");
66779
+ writeFileSync20(snippetPath, updated, "utf8");
65750
66780
  if (!options.quiet) logger.info(`Updated ${snippetPath}`);
65751
66781
  continue;
65752
66782
  }
65753
- writeFileSync18(
66783
+ writeFileSync20(
65754
66784
  snippetPath,
65755
66785
  existing + (existing.endsWith("\n") ? "\n" : "\n\n") + generated,
65756
66786
  "utf8"
@@ -65758,7 +66788,7 @@ function registerInit(program) {
65758
66788
  if (!options.quiet) logger.info(`Wrote ${snippetPath}`);
65759
66789
  continue;
65760
66790
  }
65761
- writeFileSync18(snippetPath, generated, "utf8");
66791
+ writeFileSync20(snippetPath, generated, "utf8");
65762
66792
  if (!options.quiet) logger.info(`Wrote ${snippetPath}`);
65763
66793
  }
65764
66794
  if (options.baseline) {
@@ -65778,12 +66808,12 @@ function registerInit(program) {
65778
66808
  // src/cli/commands/flywheel.ts
65779
66809
  init_logger();
65780
66810
  init_telemetry();
65781
- import { mkdirSync as mkdirSync16, writeFileSync as writeFileSync19 } from "fs";
65782
- import { dirname as dirname21, resolve as resolve40 } from "path";
66811
+ import { mkdirSync as mkdirSync17, writeFileSync as writeFileSync21 } from "fs";
66812
+ import { dirname as dirname20, resolve as resolve41 } from "path";
65783
66813
 
65784
66814
  // src/report/flywheel.ts
65785
- import { existsSync as existsSync28, readFileSync as readFileSync36 } from "fs";
65786
- import { join as join31 } from "path";
66815
+ import { existsSync as existsSync29, readFileSync as readFileSync37 } from "fs";
66816
+ import { join as join32 } from "path";
65787
66817
  function average(values) {
65788
66818
  if (values.length === 0) return 0;
65789
66819
  return values.reduce((a, b) => a + b, 0) / values.length;
@@ -65843,8 +66873,9 @@ function formatFlywheel(summary, options = {}) {
65843
66873
  if (summary.firstRunAt && summary.latestRunAt) {
65844
66874
  lines.push(` Window: ${summary.firstRunAt} \u2192 ${summary.latestRunAt}`);
65845
66875
  }
65846
- lines.push(` Average slop index: ${summary.averageSlopIndex.toFixed(2)}`);
65847
- lines.push(` Latest slop index: ${summary.latestSlopIndex.toFixed(2)}`);
66876
+ const directionNote = " (lower = cleaner since v0.21.0)";
66877
+ lines.push(` Average AI Slop Score: ${summary.averageSlopIndex.toFixed(2)}`);
66878
+ lines.push(` Latest AI Slop Score: ${summary.latestSlopIndex.toFixed(2)}${directionNote}`);
65848
66879
  lines.push(` Average assembly health: ${summary.averageAssemblyHealth.toFixed(2)}`);
65849
66880
  lines.push(` Latest assembly health: ${summary.latestAssemblyHealth.toFixed(2)}`);
65850
66881
  if (summary.topViolations.length > 0) {
@@ -65873,7 +66904,7 @@ function formatFlywheel(summary, options = {}) {
65873
66904
  function registerFlywheel(program) {
65874
66905
  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) => {
65875
66906
  const options = command.optsWithGlobals();
65876
- const cwd = resolve40(options.workspace ?? process.cwd());
66907
+ const cwd = resolve41(options.workspace ?? process.cwd());
65877
66908
  const payloads = readTelemetry(cwd);
65878
66909
  if (payloads.length === 0) {
65879
66910
  logger.info("No flywheel telemetry found. Run a scan first.");
@@ -65881,9 +66912,9 @@ function registerFlywheel(program) {
65881
66912
  }
65882
66913
  const summary = summarizeTelemetry(payloads);
65883
66914
  if (cmdOptions.export) {
65884
- const exportPath = resolve40(cmdOptions.export);
65885
- mkdirSync16(dirname21(exportPath), { recursive: true });
65886
- writeFileSync19(exportPath, JSON.stringify(summary, null, 2), "utf-8");
66915
+ const exportPath = resolve41(cmdOptions.export);
66916
+ mkdirSync17(dirname20(exportPath), { recursive: true });
66917
+ writeFileSync21(exportPath, JSON.stringify(summary, null, 2), "utf-8");
65887
66918
  logger.info(`Wrote flywheel summary to ${exportPath}`);
65888
66919
  process.exit(0);
65889
66920
  }
@@ -65910,8 +66941,8 @@ init_dist2();
65910
66941
  init_memory_io();
65911
66942
 
65912
66943
  // src/fix/focus-ring.ts
65913
- import { existsSync as existsSync29 } from "fs";
65914
- import { readFileSync as readFileSync37, writeFileSync as writeFileSync20 } from "fs";
66944
+ import { existsSync as existsSync30 } from "fs";
66945
+ import { readFileSync as readFileSync38, writeFileSync as writeFileSync22 } from "fs";
65915
66946
  var ANCHOR_START = "/* @slopbrick:v1.0.0:fix:focus-ring */";
65916
66947
  var CSS_BLOCK = `/* @slopbrick:v1.0.0:fix:focus-ring */
65917
66948
  :focus-visible {
@@ -65920,15 +66951,15 @@ var CSS_BLOCK = `/* @slopbrick:v1.0.0:fix:focus-ring */
65920
66951
  }
65921
66952
  /* @slopbrick:v1.0.0:fix:focus-ring-end */`;
65922
66953
  function applyFocusRingFix(targetFile) {
65923
- if (!existsSync29(targetFile)) {
66954
+ if (!existsSync30(targetFile)) {
65924
66955
  return { applied: false, reason: "missing-global-css-target" };
65925
66956
  }
65926
- const content = readFileSync37(targetFile, "utf-8");
66957
+ const content = readFileSync38(targetFile, "utf-8");
65927
66958
  if (content.includes(ANCHOR_START)) {
65928
66959
  return { applied: false, reason: "already-present" };
65929
66960
  }
65930
66961
  const separator = content.endsWith("\n") ? "" : "\n";
65931
- writeFileSync20(targetFile, `${content}${separator}${CSS_BLOCK}
66962
+ writeFileSync22(targetFile, `${content}${separator}${CSS_BLOCK}
65932
66963
  `);
65933
66964
  return { applied: true };
65934
66965
  }
@@ -65937,21 +66968,21 @@ function applyFocusRingFix(targetFile) {
65937
66968
  init_layout_token();
65938
66969
 
65939
66970
  // src/fix/use-client.ts
65940
- import { readFileSync as readFileSync38, writeFileSync as writeFileSync21 } from "fs";
66971
+ import { readFileSync as readFileSync39, writeFileSync as writeFileSync23 } from "fs";
65941
66972
  function applyUseClientFix(filePath) {
65942
- const content = readFileSync38(filePath, "utf-8");
66973
+ const content = readFileSync39(filePath, "utf-8");
65943
66974
  const trimmed = content.trimStart();
65944
66975
  if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) {
65945
66976
  return { applied: false, reason: "already-present" };
65946
66977
  }
65947
- writeFileSync21(filePath, `"use client";
66978
+ writeFileSync23(filePath, `"use client";
65948
66979
 
65949
66980
  ${content}`);
65950
66981
  return { applied: true };
65951
66982
  }
65952
66983
 
65953
66984
  // src/fix/visual-codemod.ts
65954
- import { readFileSync as readFileSync39, writeFileSync as writeFileSync22 } from "fs";
66985
+ import { readFileSync as readFileSync40, writeFileSync as writeFileSync24 } from "fs";
65955
66986
 
65956
66987
  // src/fix/visual-codemods/tailwind.ts
65957
66988
  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;
@@ -66160,7 +67191,7 @@ var ALL_CODEMODS = [
66160
67191
  { name: "aria-attr-typo", fn: applyAriaAttrTypoCodemod }
66161
67192
  ];
66162
67193
  function applyVisualCodemods(filePath) {
66163
- const original = readFileSync39(filePath, "utf-8");
67194
+ const original = readFileSync40(filePath, "utf-8");
66164
67195
  let content = original;
66165
67196
  const reasons = [];
66166
67197
  const seen = /* @__PURE__ */ new Set();
@@ -66176,7 +67207,7 @@ function applyVisualCodemods(filePath) {
66176
67207
  }
66177
67208
  }
66178
67209
  if (content !== original) {
66179
- writeFileSync22(filePath, content);
67210
+ writeFileSync24(filePath, content);
66180
67211
  }
66181
67212
  return {
66182
67213
  filePath,
@@ -66191,7 +67222,7 @@ function applyVisualCodemods(filePath) {
66191
67222
  function collectAllFixes2(issue) {
66192
67223
  return [...issue.fix ? [issue.fix] : [], ...issue.fixes ?? []];
66193
67224
  }
66194
- async function applyFixes(report, config, scannedFiles) {
67225
+ async function applyFixes(report, _config, scannedFiles) {
66195
67226
  const byFile = /* @__PURE__ */ new Map();
66196
67227
  for (const issue of report.issues) {
66197
67228
  const fixes = collectAllFixes2(issue);
@@ -66252,7 +67283,6 @@ async function applyFixes(report, config, scannedFiles) {
66252
67283
  const result = applyLayoutTokenFix(filePath, group.replaces);
66253
67284
  const total = group.replaceApps.length;
66254
67285
  const appliedCount = Math.min(result.applied, total);
66255
- const skippedCount = total - appliedCount;
66256
67286
  applied.push(...group.replaceApps.slice(0, appliedCount));
66257
67287
  skipped.push(...group.replaceApps.slice(appliedCount));
66258
67288
  } catch (err) {
@@ -66447,6 +67477,40 @@ function formatGroupedHelp(cmd) {
66447
67477
  }
66448
67478
  }
66449
67479
  lines.push("");
67480
+ lines.push(
67481
+ "Commands (run `<command> --help` for per-command details):\n" + [
67482
+ " scan score a workspace, with severity gating and report views",
67483
+ " pr score a PR (or a unified-diff file via `--diff <file>`)",
67484
+ " drift detect constitution / forbidden-package violations",
67485
+ " test test quality sub-score (CI gate)",
67486
+ " docs documentation freshness sub-score",
67487
+ " security AI security-risk sub-score",
67488
+ " rules list all built-in rules with categories and severities",
67489
+ " explain show rationale, pattern, and remediation for a single rule",
67490
+ " suggest print per-issue remediation advice",
67491
+ " badge print a shields.io slop-index badge",
67492
+ " doctor check setup, config, and environment for common problems",
67493
+ " init create a slopbrick config file and AGENTS.md/CLAUDE.md snippets",
67494
+ " flywheel summarize aggregated scan telemetry",
67495
+ " composite empirical-composite rule discover / enable (Sprint 3 \xA73b)",
67496
+ " calibrate empirical precision/recall/F1 calibration from held-out corpora",
67497
+ " patterns pattern-fragmentation sub-score",
67498
+ " business-logic business-logic coherence sub-score",
67499
+ " maintenance-cost AI maintenance-cost sub-score",
67500
+ " architecture architecture-consistency sub-score",
67501
+ " memory show patterns detected for `slop_suggest`",
67502
+ " lock install a git pre-commit hook (idempotent)",
67503
+ " migrate v0.10.x slop-audit/ \u2192 v0.11.0+ .slopbrick/ migration",
67504
+ " validate-config validate slopbrick.config.mjs",
67505
+ " tokens merge a design-tokens.json layout values into the allowlist",
67506
+ " report re-render a saved JSON report (from --json <path>)",
67507
+ " install / uninstall manage the git pre-commit hook",
67508
+ " mcp run the MCP server (JSON-RPC 2.0 over stdio)",
67509
+ " watch / --watch re-run scan on file change",
67510
+ " trend sparkline of the last n runs"
67511
+ ].join("\n")
67512
+ );
67513
+ lines.push("");
66450
67514
  lines.push(
66451
67515
  "Use `--help-flat` for the standard un-grouped list. See `https://usebrick.dev/docs/scan-options` for full docs."
66452
67516
  );
@@ -66458,12 +67522,12 @@ import * as http from "http";
66458
67522
  import * as https from "https";
66459
67523
  import { URL as URL2 } from "url";
66460
67524
  var sendBeacon = (url, payload, timeoutMs) => {
66461
- return new Promise((resolve42) => {
67525
+ return new Promise((resolve43) => {
66462
67526
  let parsed;
66463
67527
  try {
66464
67528
  parsed = new URL2(url);
66465
67529
  } catch {
66466
- resolve42();
67530
+ resolve43();
66467
67531
  return;
66468
67532
  }
66469
67533
  const isHttps = parsed.protocol === "https:";
@@ -66484,7 +67548,7 @@ var sendBeacon = (url, payload, timeoutMs) => {
66484
67548
  const settle = () => {
66485
67549
  if (settled) return;
66486
67550
  settled = true;
66487
- resolve42();
67551
+ resolve43();
66488
67552
  };
66489
67553
  let req;
66490
67554
  try {
@@ -66564,7 +67628,7 @@ process.on("uncaughtException", (err) => {
66564
67628
  });
66565
67629
  async function runCli({ start }) {
66566
67630
  try {
66567
- const program = new 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)");
67631
+ const program = new 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)");
66568
67632
  program.helpInformation = () => formatGroupedHelp(program);
66569
67633
  setExitOverride(program);
66570
67634
  registerInit(program);
@@ -66573,6 +67637,7 @@ async function runCli({ start }) {
66573
67637
  registerBadge(program);
66574
67638
  registerSuggest(program);
66575
67639
  registerExplain(program);
67640
+ registerComposite(program);
66576
67641
  registerCalibration(program);
66577
67642
  registerFlywheel(program);
66578
67643
  const scanAction = async (paths, _options, command) => {
@@ -66591,7 +67656,7 @@ async function runCli({ start }) {
66591
67656
  logger.error("--heatmap and --suggest can't be used together. Pick one: a heatmap of severity, or text advice.");
66592
67657
  process.exit(2);
66593
67658
  }
66594
- const cwd = resolve41(options.workspace ?? process.cwd());
67659
+ const cwd = resolve42(options.workspace ?? process.cwd());
66595
67660
  if (options.trend !== void 0) {
66596
67661
  const runs = await readRuns(cwd, fsMemoryIO);
66597
67662
  if (runs.length === 0) {
@@ -66639,25 +67704,25 @@ async function runCli({ start }) {
66639
67704
  });
66640
67705
  }
66641
67706
  if (options.baseline) {
66642
- const cwd2 = resolve41(options.workspace ?? process.cwd());
67707
+ const cwd2 = resolve42(options.workspace ?? process.cwd());
66643
67708
  const configHash = hashConfig(config);
66644
67709
  const gitHead = await getGitHead(cwd2) ?? "unknown";
66645
67710
  const cache = buildBaselineCache(report, configHash, gitHead, cwd2);
66646
67711
  saveBaseline(cwd2, cache);
66647
- if (!options.quiet) {
67712
+ if (!options.quiet && !machineReadableStdout) {
66648
67713
  logger.info(`Saved baseline to ${baselinePath(cwd2)}`);
66649
67714
  }
66650
67715
  }
66651
67716
  if (options.tighten && baseline) {
66652
67717
  saveBaseline(cwd, baseline);
66653
- if (!options.quiet) {
67718
+ if (!options.quiet && !machineReadableStdout) {
66654
67719
  logger.info(`Tightened baseline saved (revision ${baseline.baseline_revision}).`);
66655
67720
  }
66656
67721
  }
66657
- if (options.doctor && !options.quiet) {
67722
+ if (options.doctor && !options.quiet && !machineReadableStdout) {
66658
67723
  logger.info(`Doctor: bootstrap ${totalElapsed - scanElapsed}ms, scan ${scanElapsed}ms`);
66659
67724
  }
66660
- if (report.baseline && !options.quiet) {
67725
+ if (report.baseline && !options.quiet && !machineReadableStdout) {
66661
67726
  logger.info(baselineStatusMessage(report.baseline));
66662
67727
  }
66663
67728
  if (options.fix) {
@@ -66671,7 +67736,7 @@ async function runCli({ start }) {
66671
67736
  }
66672
67737
  const scannedFiles = results.map((r) => r.filePath);
66673
67738
  const fixResults = await applyFixes(report, config, scannedFiles);
66674
- const { totalApplied, totalSkipped, hasErrors } = printFixSummary(fixResults, options.quiet ?? false);
67739
+ const { hasErrors } = printFixSummary(fixResults, options.quiet ?? false);
66675
67740
  if (!options.showFixesDiff && !options.quiet) {
66676
67741
  logger.info("Tip: pass --show-fixes-diff to see the unified diff of proposed changes.");
66677
67742
  }
@@ -66711,8 +67776,18 @@ async function runCli({ start }) {
66711
67776
  if (options.staged && stagedGatingResult.reason) {
66712
67777
  logger.error(`Gating failure: ${stagedGatingResult.reason}`);
66713
67778
  } else {
66714
- const failed = failedThresholdCount(report, config);
66715
- logger.error(`${failed} threshold${failed === 1 ? "" : "s"} failed. See details above.`);
67779
+ const failedNames = failedThresholds(report, config);
67780
+ const detail = failedNames.map((name) => {
67781
+ if (name === "meanSlop") return `meanSlop (score ${report.aiSlopScore} > ${config.thresholds.meanSlop})`;
67782
+ if (name === "p90Slop") return `p90Slop (score ${report.p90Score} > ${config.thresholds.p90Slop})`;
67783
+ if (name === "individualSlopThreshold") return `individualSlopThreshold (peak ${report.peakScore} > ${config.thresholds.individualSlopThreshold})`;
67784
+ const cat = name.replace(/^category:/, "");
67785
+ const limit = config.thresholds.categoryThresholds?.[cat];
67786
+ const score = report.categoryScores[cat];
67787
+ return `${name} (score ${score} > ${limit})`;
67788
+ }).join(", ");
67789
+ const failed = failedNames.length;
67790
+ logger.error(`${failed} threshold${failed === 1 ? "" : "s"} failed: ${detail}`);
66716
67791
  }
66717
67792
  }
66718
67793
  if (!options.quiet && !machineReadableStdout) {
@@ -66809,6 +67884,7 @@ export {
66809
67884
  directoryScore,
66810
67885
  extractSignatures,
66811
67886
  failedThresholdCount,
67887
+ failedThresholds,
66812
67888
  filterByDisabledDirectives,
66813
67889
  filterIssues,
66814
67890
  findSimilarFunctions,