slopbrick 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -36,7 +36,7 @@ var VERSION;
36
36
  var init_header = __esm({
37
37
  "src/types/_header.ts"() {
38
38
  "use strict";
39
- VERSION = "0.40.0";
39
+ VERSION = "0.42.0";
40
40
  }
41
41
  });
42
42
 
@@ -45823,6 +45823,15 @@ function formatCompositeScore(report) {
45823
45823
  )
45824
45824
  );
45825
45825
  }
45826
+ const composite = report.compositeScore;
45827
+ if (composite !== void 0) {
45828
+ lines.push("");
45829
+ lines.push(
45830
+ import_chalk.default.dim(
45831
+ `composite=${composite.tier}@${composite.mean.toFixed(2)} \u2014 project-level Bayesian aggregate across ${composite.fileCount} file${composite.fileCount === 1 ? "" : "s"} (max ${composite.max.toFixed(2)}); informational, does not gate CI.`
45832
+ )
45833
+ );
45834
+ }
45826
45835
  return lines.join("\n");
45827
45836
  }
45828
45837
  function formatCoherenceScores(report) {
@@ -46875,11 +46884,11 @@ var init_pool = __esm({
46875
46884
  for (let i = 0; i < this.threadCount; i++) {
46876
46885
  spawnWorker();
46877
46886
  }
46878
- return new Promise((resolve42) => {
46887
+ return new Promise((resolve43) => {
46879
46888
  const check = setInterval(() => {
46880
46889
  if (resolved) {
46881
46890
  clearInterval(check);
46882
- resolve42(results);
46891
+ resolve43(results);
46883
46892
  }
46884
46893
  }, 10);
46885
46894
  });
@@ -46896,23 +46905,16 @@ var init_registry = __esm({
46896
46905
  init_builtins();
46897
46906
  RuleRegistry = class {
46898
46907
  rules = /* @__PURE__ */ new Map();
46899
- register(ruleOrId, factory) {
46900
- if (typeof ruleOrId === "string") {
46901
- if (!factory) {
46902
- throw new Error("Factory is required when registering by id");
46903
- }
46904
- const rule = {
46905
- id: ruleOrId,
46906
- category: factory.category,
46907
- severity: factory.severity,
46908
- aiSpecific: factory.aiSpecific,
46909
- create: factory.create,
46910
- analyze: factory.analyze
46911
- };
46912
- this.rules.set(ruleOrId, rule);
46913
- } else {
46914
- this.rules.set(ruleOrId.id, ruleOrId);
46915
- }
46908
+ /**
46909
+ * Register a rule. The 2-arg overload `register(id, factory)` was
46910
+ * deleted in v0.42.0 (architecture review F6): no callers used it,
46911
+ * every `Rule` carries its own `id`, and the per-id registration
46912
+ * was ergonomically redundant with the 1-arg form (`register(rule)`).
46913
+ * External consumers that needed `register-by-id` semantics can
46914
+ * construct a `Rule` directly and pass it in.
46915
+ */
46916
+ register(rule) {
46917
+ this.rules.set(rule.id, rule);
46916
46918
  }
46917
46919
  loadBuiltins(onlyRuleId) {
46918
46920
  for (const rule of builtinRules) {
@@ -46941,6 +46943,15 @@ var init_registry = __esm({
46941
46943
  all() {
46942
46944
  return Array.from(this.rules.values());
46943
46945
  }
46946
+ /** v0.42.0 (§3b.5): look up a registered rule by id.
46947
+ * Returns undefined if not registered. */
46948
+ get(id) {
46949
+ return this.rules.get(id);
46950
+ }
46951
+ /** v0.42.0 (§3b.5): check whether a rule id is registered. */
46952
+ has(id) {
46953
+ return this.rules.has(id);
46954
+ }
46944
46955
  createContexts(config, filePath, cwd, hotspotIssues = []) {
46945
46956
  const context = {
46946
46957
  config,
@@ -50592,6 +50603,10 @@ var init_signal_strength2 = __esm({
50592
50603
  });
50593
50604
 
50594
50605
  // src/engine/worker.ts
50606
+ var worker_exports = {};
50607
+ __export(worker_exports, {
50608
+ scanFile: () => scanFile
50609
+ });
50595
50610
  function buildParserCacheConfig(cwd) {
50596
50611
  const envVal = process.env.SLOP_AUDIT_CACHE;
50597
50612
  const enabled = envVal === "1" || envVal === "true";
@@ -50668,6 +50683,36 @@ async function scanFile(filePath, config, registry, cwd = process.cwd()) {
50668
50683
  const issues = applyRuleOverrides(rawIssues, config.rules);
50669
50684
  const triggeredRuleIds = Array.from(new Set(issues.map((i) => i.ruleId)));
50670
50685
  const compScore = compositeScore(triggeredRuleIds, loadSignalStrength());
50686
+ const fireSet = new Set(triggeredRuleIds);
50687
+ const factsWithFireSet = Object.assign(facts, {
50688
+ compositeFireSet: fireSet
50689
+ });
50690
+ for (const { rule } of rules) {
50691
+ if (!rule.id.startsWith("composite/")) continue;
50692
+ try {
50693
+ const compositeIssues = rule.analyze(
50694
+ rule.create({
50695
+ config,
50696
+ filePath,
50697
+ cwd,
50698
+ framework: config.framework,
50699
+ uiLibraries: config.uiLibraries,
50700
+ hasTailwind: config.hasTailwind,
50701
+ supportsRsc: config.supportsRsc,
50702
+ hotspotIssues: []
50703
+ }),
50704
+ factsWithFireSet
50705
+ );
50706
+ if (compositeIssues.length > 0) {
50707
+ issues.push(...compositeIssues);
50708
+ for (const ci of compositeIssues) fireSet.add(ci.ruleId);
50709
+ }
50710
+ } catch (err) {
50711
+ if (process.env.SLOP_AUDIT_DEBUG === "1") {
50712
+ console.error(`[worker] composite ${rule.id} threw: ${err.message}`);
50713
+ }
50714
+ }
50715
+ }
50671
50716
  const gapValues = collectGapValues(facts);
50672
50717
  const styleSources = collectStyleSources(facts);
50673
50718
  const elementTags = facts.v2.jsx.elements.map((e) => e.tag);
@@ -54238,6 +54283,21 @@ function safeRelative(cwd, filePath) {
54238
54283
  return filePath;
54239
54284
  }
54240
54285
  }
54286
+ function buildInventorySummary(inventory) {
54287
+ const patternCounts = {};
54288
+ const patternNames = {};
54289
+ for (const [category, matches] of Object.entries(inventory.patterns)) {
54290
+ if (matches.length === 0) continue;
54291
+ const names = Array.from(new Set(matches.map((m) => m.name))).sort();
54292
+ patternCounts[category] = names.length;
54293
+ patternNames[category] = names.slice(0, TELEMETRY_INVENTORY_NAME_CAP);
54294
+ }
54295
+ return {
54296
+ scannedFiles: inventory.scannedFiles,
54297
+ patternCounts,
54298
+ patternNames
54299
+ };
54300
+ }
54241
54301
  function aggregateViolations(report) {
54242
54302
  const counts = /* @__PURE__ */ new Map();
54243
54303
  for (const issue of report.issues) {
@@ -54317,7 +54377,7 @@ function readTelemetry(cwd) {
54317
54377
  }
54318
54378
  return payloads;
54319
54379
  }
54320
- function recordTelemetry(cwd, report, results, config) {
54380
+ function recordTelemetry(cwd, report, results, config, inventory) {
54321
54381
  if (config.telemetry === false) {
54322
54382
  return void 0;
54323
54383
  }
@@ -54338,7 +54398,14 @@ function recordTelemetry(cwd, report, results, config) {
54338
54398
  framework: config.framework
54339
54399
  },
54340
54400
  violations: aggregateViolations(report),
54341
- files: buildFileRecords(cwd, report, results)
54401
+ files: buildFileRecords(cwd, report, results),
54402
+ // v0.41.0 (Sprint 2, task 2a.1): the inventory summary is
54403
+ // additive — omitting it (legacy callers) keeps the JSONL line
54404
+ // shape identical to v0.40.x payloads, so old readers stay
54405
+ // green. New readers (`slopbrick drift --since <date>` in
54406
+ // Sprint 2a.2) treat the field as optional and fall back to
54407
+ // a re-scan when it's missing.
54408
+ ...inventory ? { inventory: buildInventorySummary(inventory) } : {}
54342
54409
  };
54343
54410
  const path = telemetryPath2(cwd);
54344
54411
  const dir = (0, import_node_path22.dirname)(path);
@@ -54349,7 +54416,7 @@ function recordTelemetry(cwd, report, results, config) {
54349
54416
  (0, import_node_fs20.appendFileSync)(path, JSON.stringify(payload) + "\n", "utf-8");
54350
54417
  return payload;
54351
54418
  }
54352
- var import_node_fs20, import_node_crypto8, import_node_path22, TELEMETRY_DIR, TELEMETRY_FILE2, MAX_TELEMETRY_BYTES, MAX_ROTATED_FILES;
54419
+ var import_node_fs20, import_node_crypto8, import_node_path22, TELEMETRY_DIR, TELEMETRY_FILE2, MAX_TELEMETRY_BYTES, MAX_ROTATED_FILES, TELEMETRY_INVENTORY_NAME_CAP;
54353
54420
  var init_telemetry = __esm({
54354
54421
  "src/engine/telemetry.ts"() {
54355
54422
  "use strict";
@@ -54360,6 +54427,7 @@ var init_telemetry = __esm({
54360
54427
  TELEMETRY_FILE2 = "scans.jsonl";
54361
54428
  MAX_TELEMETRY_BYTES = 10 * 1024 * 1024;
54362
54429
  MAX_ROTATED_FILES = 5;
54430
+ TELEMETRY_INVENTORY_NAME_CAP = 50;
54363
54431
  }
54364
54432
  });
54365
54433
 
@@ -54510,29 +54578,29 @@ function renderStructureMarkdown(inventory, constitution) {
54510
54578
  return lines.join("\n");
54511
54579
  }
54512
54580
  async function writeStructureMarkdown(workspaceDir, md) {
54513
- await new Promise((resolve42, reject) => {
54581
+ await new Promise((resolve43, reject) => {
54514
54582
  try {
54515
54583
  const path = (0, import_node_path23.join)(workspaceDir, STRUCTURE_MD_FILE);
54516
54584
  (0, import_node_fs21.mkdirSync)((0, import_node_path23.dirname)(path), { recursive: true });
54517
54585
  (0, import_node_fs21.writeFileSync)(path, md, "utf-8");
54518
- resolve42();
54586
+ resolve43();
54519
54587
  } catch (err) {
54520
54588
  reject(err instanceof Error ? err : new Error(String(err)));
54521
54589
  }
54522
54590
  });
54523
54591
  }
54524
54592
  async function readStructureMarkdown(workspaceDir) {
54525
- return new Promise((resolve42) => {
54593
+ return new Promise((resolve43) => {
54526
54594
  try {
54527
54595
  const path = (0, import_node_path23.join)(workspaceDir, STRUCTURE_MD_FILE);
54528
54596
  if (!(0, import_node_fs21.existsSync)(path)) {
54529
- resolve42(null);
54597
+ resolve43(null);
54530
54598
  return;
54531
54599
  }
54532
54600
  const content = (0, import_node_fs21.readFileSync)(path, "utf-8");
54533
- resolve42(content);
54601
+ resolve43(content);
54534
54602
  } catch {
54535
- resolve42(null);
54603
+ resolve43(null);
54536
54604
  }
54537
54605
  });
54538
54606
  }
@@ -54582,6 +54650,388 @@ var init_structure_md = __esm({
54582
54650
  }
54583
54651
  });
54584
54652
 
54653
+ // src/snippet/data.ts
54654
+ var CATEGORY_DIRECTIVES, RULE_HINTS;
54655
+ var init_data = __esm({
54656
+ "src/snippet/data.ts"() {
54657
+ "use strict";
54658
+ CATEGORY_DIRECTIVES = {
54659
+ 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.',
54660
+ 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.",
54661
+ 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.',
54662
+ 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".',
54663
+ 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.",
54664
+ typo: `Never leave TODO / placeholder / "change me" copy in shipped code. Use real i18n strings or the project's content map.`,
54665
+ 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].",
54666
+ component: "Don't build components > 200 lines. Extract shared subcomponents. Avoid circular prop drilling \u2014 use context.",
54667
+ 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.",
54668
+ 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'."
54669
+ };
54670
+ RULE_HINTS = {
54671
+ // v0.38.0 hygiene: 37 v10-DORMANT rule hints were removed. Every
54672
+ // remaining key matches a rule in src/rules/builtins.ts. No orphans.
54673
+ // (v0.16.0 previously moved 35 out-of-scope orphan hints to
54674
+ // docs/research/backlog-rule-hints.md
54675
+ // for future implementers to paste back when the corresponding rules ship.)
54676
+ "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.",
54677
+ "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.",
54678
+ "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.",
54679
+ "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.",
54680
+ "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.",
54681
+ "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.",
54682
+ "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.',
54683
+ "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.",
54684
+ "visual/arbitrary-escape": "Never use bracket-notation values like text-[13px] or bg-[#7c3aed]. Use design tokens instead.",
54685
+ "visual/spacing-scale-violation": "Use spacing scale tokens (p-2, gap-4, etc.) instead of arbitrary values like p-[13px] or gap-[1.75rem].",
54686
+ "visual/radius-scale-violation": "Use radius scale tokens (rounded-md, rounded-lg, etc.) instead of arbitrary values like rounded-[7px].",
54687
+ // v0.16.0 — in-scope orphans kept here (corresponding rule ships in v0.16.0).
54688
+ "typo/placeholder-text": 'Never leave "TODO", "placeholder", "change me", "your text here" in shipped UI.',
54689
+ "logic/key-prop-missing": "Always provide a stable `key` prop when rendering lists.",
54690
+ "logic/boundary-violation": "Don't import data-layer / DB code into UI components. Server-side only.",
54691
+ "wcag/missing-alt": 'Every <img> needs alt text. Decorative: alt="". Informative: describe the image.',
54692
+ "security/localstorage-token": "Never store JWT / access token / refresh token in localStorage or sessionStorage. Issue as httpOnly cookie.",
54693
+ "security/eval": "Never use eval() or new Function(). These are RCE vectors if the input is ever attacker-controlled.",
54694
+ "security/target-blank-no-noopener": 'Always add rel="noopener" (or rel="noreferrer") to target="_blank" links.',
54695
+ "component/giant-component": "Don't build components > 200 lines. Extract shared subcomponents.",
54696
+ "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.",
54697
+ "component/shadcn-prop-mismatch": "Select shadcn variants via the `variant` prop, not long `className` overrides. See the component registry for available variants.",
54698
+ "context/import-path-mismatch": "Use only the canonical import paths declared in brick.config.json (e.g. @/components/ui/, @/lib/).",
54699
+ "layout/gap-monopoly": "Mix gap-2 / gap-4 / gap-6 / gap-12 deliberately. Don't repeat the same gap value across the whole project.",
54700
+ "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.",
54701
+ "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.",
54702
+ "layout/spacing-grid": "Use the configured spacing scale (4px or 8px grid). Avoid arbitrary values like p-[13px] that aren't on the scale.",
54703
+ "logic/ghost-defensive": "Use optional chaining (?.) or early returns instead of deep && guards. If a defensive chain runs 3+ levels deep, refactor.",
54704
+ "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.)",
54705
+ "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.)",
54706
+ "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.)",
54707
+ "logic/math-any-density": "Replace `: any` with proper types. Start with the parameter/return types of the most-used functions.",
54708
+ "logic/math-console-log-storm": "Replace debug logs with a proper debugger or logger.debug(). Remove all console.log before shipping.",
54709
+ "logic/math-gini-class-usage": "Spread usage across more class tokens instead of repeating the same handful (p-4, p-8, rounded-lg, etc.).",
54710
+ "logic/math-variable-name-entropy": "Use domain-specific identifier names (reservations, invoices, customers) instead of generic data/items/value.",
54711
+ "logic/optimistic-no-rollback": "In optimistic updates, revert state in the catch block: `setX(prev => prev)`. Never leave stale UI on error.",
54712
+ "logic/reactive-hook-soup": "Coordinate state via a single derived value (useMemo) or a state machine. Avoid chained useEffects that sync local state.",
54713
+ "logic/zombie-state": "Remove unused useState or wire it into the component. Don't leave declared-but-never-read state bindings.",
54714
+ "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.",
54715
+ "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.",
54716
+ "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.",
54717
+ "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.",
54718
+ "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.",
54719
+ "perf/cls-image": "Add width/height attributes or an aspect-ratio utility to prevent layout shift.",
54720
+ "perf/css-bloat": "Extract to a CSS variable (`--surface-card`) or a component prop when a class string repeats 5+ times.",
54721
+ "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.',
54722
+ "visual/inline-style-dominance": "Replace inline `style={{...}}` with className utilities (e.g. Tailwind `p-4 m-2 gap-3`) or a CSS module class.",
54723
+ "visual/math-default-font": "Import a distinctive font (next/font/google, @font-face, or a CSS variable) instead of relying on the framework default.",
54724
+ "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.",
54725
+ "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.",
54726
+ "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.",
54727
+ "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%.",
54728
+ "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.",
54729
+ "wcag/focus-appearance": "Add a focus-visible:ring-* class, or remove outline-none. Keyboard users need a visible focus indicator.",
54730
+ "wcag/focus-obscured": "Ensure focused elements are not hidden behind fixed or sticky wrappers.",
54731
+ "test/weak-assertion": "Assert on a specific value or shape: `expect(x).toEqual(expectedValue)`. Avoid `.toBeDefined()` / `.toBeTruthy()` placeholders and tautological `expect(x).toBe(x)`.",
54732
+ "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.",
54733
+ "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`).",
54734
+ "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.",
54735
+ "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.",
54736
+ // v0.13.0 — AI-specific rules (peer-reviewed signals).
54737
+ "ai/markdown-leakage": "Delete stray `\\`\\`\\`<lang>\\`\\`\\`` markers; they are Markdown fences, not valid syntax in standalone source files (Yotkova et al. SemEval-2026).",
54738
+ "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).",
54739
+ "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).",
54740
+ "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).",
54741
+ "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).",
54742
+ "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).",
54743
+ "ai/renyi-profile": "The token distribution is mass-concentrated on a few high-frequency tokens. Verify authorship if unexpected (R\xE9nyi 1961, Moslonka 2025).",
54744
+ "ai/segment-surprisal-cv": "The cross-entropy is suspiciously uniform across the file. Real codebases have varied registers (Binoculars, Hans 2024).",
54745
+ "ai/compression-profile": "The file compresses unusually well and lines are highly repetitive \u2014 characteristic of AI-generated boilerplate (Cilibrasi 2005, Mahoney 1999).",
54746
+ // v0.14.5b — 6 new AI tendency detection rules (DORMANT in v0.14.5b;
54747
+ // reclassified post-v7 calibration in v0.14.5d)
54748
+ "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.",
54749
+ "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.",
54750
+ "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.",
54751
+ "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.",
54752
+ "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.",
54753
+ "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.",
54754
+ // v0.17.0 — db/* rules (Postgres static analysis via pgsql-parser)
54755
+ "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.',
54756
+ // v0.17.0 — docs/* rules (markdown drift detection)
54757
+ "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.",
54758
+ "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.",
54759
+ "docs/broken-link": "Create the file or fix the link target. On a public docs site, broken links erode trust more than stale copy.",
54760
+ // v0.18.9 — Rust-specific rules (tree-sitter-backed)
54761
+ "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.",
54762
+ "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.",
54763
+ "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.",
54764
+ "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.',
54765
+ // v0.19.0 — TypeScript-specific rules
54766
+ "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.",
54767
+ "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.",
54768
+ "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.",
54769
+ "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.",
54770
+ // v0.19.0 — Go-specific rules
54771
+ "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).',
54772
+ "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.",
54773
+ // v0.19.0 — Duplication detector
54774
+ "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.",
54775
+ // v0.23.0 — Type-2 near-duplicate detector (MinHash + LSH)
54776
+ "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.)",
54777
+ // v0.24.0 — Type-3 structural clone detector (two-stage MinHash)
54778
+ "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.)",
54779
+ // v0.24.0 — Kotlin rules (DORMANT until v9 Kotlin corpus calibration)
54780
+ // v0.29.0 — non-AI Kotlin rules (DORMANT until larger pos arm)
54781
+ // v0.30.0 — non-AI Java rules (Option C applied to v9 Java corpus, 92k files)
54782
+ "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.)",
54783
+ "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.)",
54784
+ // v0.35.0 — content-based detection (CoCoNUTS-inspired)
54785
+ "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.",
54786
+ // v0.35.1 — Raidar-inspired content-based detection
54787
+ "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)`.',
54788
+ // v0.24.0 — Swift rules (DORMANT until v9 Swift corpus calibration)
54789
+ "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.)",
54790
+ "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.)",
54791
+ "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.)",
54792
+ "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.)",
54793
+ "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.)",
54794
+ // v0.24.0 — C++ rules (DORMANT until v9 C++ corpus calibration)
54795
+ "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.)",
54796
+ "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.)",
54797
+ "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.)",
54798
+ "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.)"
54799
+ };
54800
+ }
54801
+ });
54802
+
54803
+ // src/snippet/generators.ts
54804
+ function wrapWithMarkers(level, body) {
54805
+ const head = level === 1 ? "<!-- slopbrick-managed-section -->\n" : "";
54806
+ return `${head}${MARKER_BEGIN}
54807
+ ${body}
54808
+ ${MARKER_END}
54809
+ `;
54810
+ }
54811
+ function generateCursorSnippet(rules) {
54812
+ const lines = [];
54813
+ lines.push("# slopbrick cursor rules");
54814
+ lines.push("");
54815
+ lines.push("Generated by `slopbrick init --cursor`. These rules describe patterns that slopbrick");
54816
+ lines.push("flags as AI-generated frontend slop. Avoid them when writing code in this project.");
54817
+ lines.push("");
54818
+ lines.push("## Category-level directives");
54819
+ lines.push("");
54820
+ for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
54821
+ lines.push("- **" + category + "**: " + directive);
54822
+ }
54823
+ lines.push("");
54824
+ lines.push("## Per-rule directives");
54825
+ lines.push("");
54826
+ const ai = aiSpecificRules(rules);
54827
+ const byCategory = categorizeRules(ai);
54828
+ for (const [category, list] of byCategory) {
54829
+ lines.push("### " + category);
54830
+ lines.push("");
54831
+ for (const rule of list) {
54832
+ const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
54833
+ lines.push("- **`" + rule.id + "`** (" + rule.severity + "): " + hint);
54834
+ }
54835
+ lines.push("");
54836
+ }
54837
+ lines.push("## Verification");
54838
+ lines.push("");
54839
+ lines.push("After writing code, run `slopbrick scan` and check for issues in any of the categories above.");
54840
+ lines.push("Use `slopbrick fix` for auto-fixable rules. Use `slopbrick mcp` from MCP-aware clients to query rules and scan files programmatically.");
54841
+ lines.push("");
54842
+ return lines.join("\n");
54843
+ }
54844
+ function generateAgentsMdSnippet(rules) {
54845
+ return wrapWithMarkers(
54846
+ 1,
54847
+ baseContent(
54848
+ rules,
54849
+ "AGENTS.md",
54850
+ "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."
54851
+ ) + "## How to verify\n\nAfter editing code, run `slopbrick scan`. Any issue with a category in this file is something to fix.\n"
54852
+ );
54853
+ }
54854
+ function generateClaudeMdSnippet(rules) {
54855
+ return wrapWithMarkers(
54856
+ 1,
54857
+ baseContent(
54858
+ rules,
54859
+ "CLAUDE.md",
54860
+ "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."
54861
+ ) + "## 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"
54862
+ );
54863
+ }
54864
+ function generateAiderSnippet(rules) {
54865
+ return baseContent(
54866
+ rules,
54867
+ "CONVENTIONS.md",
54868
+ "Generated by `slopbrick init --aider`. Aider reads CONVENTIONS.md (not AGENTS.md) for project conventions."
54869
+ );
54870
+ }
54871
+ function generateWindsurfSnippet(rules) {
54872
+ return baseContent(
54873
+ rules,
54874
+ ".windsurfrules",
54875
+ "Generated by `slopbrick init --windsurf`. Windsurf Cascade reads .windsurfrules at project root."
54876
+ );
54877
+ }
54878
+ function generateClineRules(rules) {
54879
+ return baseContent(
54880
+ rules,
54881
+ "AGENTS.md",
54882
+ "Generated by `slopbrick init --cline`. Cline reads .clinerules/AGENTS.md from the project root."
54883
+ );
54884
+ }
54885
+ function generateCursorrulesLegacySnippet(rules) {
54886
+ return baseContent(
54887
+ rules,
54888
+ ".cursorrules",
54889
+ "Generated by `slopbrick init --cursorrules`. This is the legacy Cursor rule format. Prefer `.cursor/rules/slopbrick.mdc` (use --cursor) for new projects."
54890
+ );
54891
+ }
54892
+ function generateGeminiSnippet(rules) {
54893
+ return baseContent(
54894
+ rules,
54895
+ "GEMINI.md",
54896
+ "Generated by `slopbrick init --gemini`. Gemini CLI reads .gemini/GEMINI.md from project root."
54897
+ );
54898
+ }
54899
+ function generateCopilotSnippet(rules) {
54900
+ return baseContent(
54901
+ rules,
54902
+ "copilot-instructions.md",
54903
+ "Generated by `slopbrick init --copilot`. GitHub Copilot reads .github/copilot-instructions.md from project root."
54904
+ );
54905
+ }
54906
+ var MARKER_BEGIN, MARKER_END;
54907
+ var init_generators = __esm({
54908
+ "src/snippet/generators.ts"() {
54909
+ "use strict";
54910
+ init_data();
54911
+ init_render2();
54912
+ MARKER_BEGIN = "<!-- slopbrick:begin:v3 -->";
54913
+ MARKER_END = "<!-- slopbrick:end:v3 -->";
54914
+ }
54915
+ });
54916
+
54917
+ // src/snippet/render.ts
54918
+ function aiSpecificRules(rules) {
54919
+ return rules.filter((r) => r.aiSpecific).sort((a, b) => a.id.localeCompare(b.id));
54920
+ }
54921
+ function categorizeRules(rules) {
54922
+ const byCategory = /* @__PURE__ */ new Map();
54923
+ for (const r of rules) {
54924
+ const list = byCategory.get(r.category) ?? [];
54925
+ list.push(r);
54926
+ byCategory.set(r.category, list);
54927
+ }
54928
+ for (const list of byCategory.values()) {
54929
+ list.sort((a, b) => a.id.localeCompare(b.id));
54930
+ }
54931
+ return byCategory;
54932
+ }
54933
+ function baseContent(rules, toolLabel, intro) {
54934
+ const lines = [];
54935
+ lines.push("# " + toolLabel);
54936
+ lines.push("");
54937
+ lines.push(intro);
54938
+ lines.push("");
54939
+ const ai = aiSpecificRules(rules);
54940
+ const byCategory = categorizeRules(ai);
54941
+ lines.push("## Category-level directives");
54942
+ lines.push("");
54943
+ for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
54944
+ lines.push("- **" + category + "**: " + directive);
54945
+ }
54946
+ lines.push("");
54947
+ lines.push("## Per-rule directives");
54948
+ lines.push("");
54949
+ for (const [category, list] of byCategory) {
54950
+ lines.push("### " + category);
54951
+ lines.push("");
54952
+ for (const rule of list) {
54953
+ const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
54954
+ lines.push("- `" + rule.id + "` (" + rule.severity + "): " + hint);
54955
+ }
54956
+ lines.push("");
54957
+ }
54958
+ return lines.join("\n");
54959
+ }
54960
+ function safeRewrite(existing, freshBlockBody) {
54961
+ const beginIdx = existing.indexOf(MARKER_BEGIN);
54962
+ const endIdx = existing.indexOf(MARKER_END);
54963
+ if (beginIdx === -1 && endIdx === -1) {
54964
+ return { content: existing, rewritten: false, failClosed: true, mismatched: false };
54965
+ }
54966
+ if (beginIdx === -1 || endIdx === -1) {
54967
+ return { content: existing, rewritten: false, failClosed: false, mismatched: true };
54968
+ }
54969
+ if (endIdx <= beginIdx) {
54970
+ return { content: existing, rewritten: false, failClosed: false, mismatched: true };
54971
+ }
54972
+ const before = existing.slice(0, beginIdx + MARKER_BEGIN.length);
54973
+ const after = existing.slice(endIdx);
54974
+ const next = `${before}
54975
+ ${freshBlockBody}
54976
+ ${after}`;
54977
+ return { content: next, rewritten: true, failClosed: false, mismatched: false };
54978
+ }
54979
+ var init_render2 = __esm({
54980
+ "src/snippet/render.ts"() {
54981
+ "use strict";
54982
+ init_data();
54983
+ init_generators();
54984
+ }
54985
+ });
54986
+
54987
+ // src/snippet/refresh.ts
54988
+ var refresh_exports = {};
54989
+ __export(refresh_exports, {
54990
+ SNIPPET_TARGETS: () => SNIPPET_TARGETS,
54991
+ refreshSnippets: () => refreshSnippets
54992
+ });
54993
+ function refreshSnippets(cwd, rules, targets = [...SNIPPET_TARGETS]) {
54994
+ const result = { rewritten: [], failClosed: [], mismatched: [], absent: [] };
54995
+ for (const filename of targets) {
54996
+ const path = (0, import_node_path24.join)(cwd, filename);
54997
+ if (!(0, import_node_fs22.existsSync)(path)) {
54998
+ result.absent.push(filename);
54999
+ continue;
55000
+ }
55001
+ const existing = (0, import_node_fs22.readFileSync)(path, "utf-8");
55002
+ const agentName = filename.replace(/\.md$/i, "").toLowerCase();
55003
+ const generator = agentName === "claude" ? generateAgentsMdSnippet : generateAgentsMdSnippet;
55004
+ const newBlock = generator([...rules]).trim();
55005
+ const outcome = safeRewrite(existing, newBlock);
55006
+ if (outcome.rewritten) {
55007
+ const tmp = `${path}.tmp`;
55008
+ (0, import_node_fs22.writeFileSync)(tmp, outcome.content, "utf-8");
55009
+ const fs = require("fs");
55010
+ fs.renameSync(tmp, path);
55011
+ result.rewritten.push(filename);
55012
+ } else if (outcome.failClosed) {
55013
+ result.failClosed.push(filename);
55014
+ } else if (outcome.mismatched) {
55015
+ result.mismatched.push(filename);
55016
+ }
55017
+ }
55018
+ return result;
55019
+ }
55020
+ var import_node_fs22, import_node_path24, SNIPPET_TARGETS;
55021
+ var init_refresh = __esm({
55022
+ "src/snippet/refresh.ts"() {
55023
+ "use strict";
55024
+ import_node_fs22 = require("fs");
55025
+ import_node_path24 = require("path");
55026
+ init_render2();
55027
+ init_generators();
55028
+ SNIPPET_TARGETS = [
55029
+ "AGENTS.md",
55030
+ "CLAUDE.md"
55031
+ ];
55032
+ }
55033
+ });
55034
+
54585
55035
  // src/cli/report/persistRun.ts
54586
55036
  async function persistRun(input) {
54587
55037
  const {
@@ -54644,7 +55094,7 @@ async function persistRun(input) {
54644
55094
  const recentTopHashes = telemetryPayloads.map(
54645
55095
  (payload) => [...payload.files].sort((a, b) => b.score - a.score).slice(0, 10).map((file) => file.hash)
54646
55096
  );
54647
- const currentTopFiles = [...report.components].sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, 10).map((c) => ({ filePath: c.filePath, hash: hashFile((0, import_node_path24.relative)(cwd, c.filePath)) }));
55097
+ const currentTopFiles = [...report.components].sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, 10).map((c) => ({ filePath: c.filePath, hash: hashFile((0, import_node_path25.relative)(cwd, c.filePath)) }));
54648
55098
  const unmatchedStringLiterals = results.flatMap((r) => r.unmatchedStringLiterals ?? []);
54649
55099
  const flywheelOutput = computeFlywheelOutput(
54650
55100
  runs,
@@ -54664,20 +55114,20 @@ async function persistRun(input) {
54664
55114
  report.research = state.research;
54665
55115
  }
54666
55116
  if (flywheelOutput.suggestions.length > 0) {
54667
- const suggestionsDir = (0, import_node_path24.join)(cwd, ".slopbrick", "flywheel");
54668
- if (!(0, import_node_fs22.existsSync)(suggestionsDir)) (0, import_node_fs22.mkdirSync)(suggestionsDir, { recursive: true });
54669
- (0, import_node_fs22.writeFileSync)(
54670
- (0, import_node_path24.join)(suggestionsDir, "rule-suggestions.json"),
55117
+ const suggestionsDir = (0, import_node_path25.join)(cwd, ".slopbrick", "flywheel");
55118
+ if (!(0, import_node_fs23.existsSync)(suggestionsDir)) (0, import_node_fs23.mkdirSync)(suggestionsDir, { recursive: true });
55119
+ (0, import_node_fs23.writeFileSync)(
55120
+ (0, import_node_path25.join)(suggestionsDir, "rule-suggestions.json"),
54671
55121
  JSON.stringify(flywheelOutput.suggestions, null, 2)
54672
55122
  );
54673
55123
  }
54674
55124
  report.issues.push(...flywheelOutput.hotspotIssues);
54675
55125
  }
54676
- recordTelemetry(cwd, report, results, config);
55126
+ let patternInventory;
54677
55127
  if (config.projectMemory !== false) {
54678
55128
  try {
54679
55129
  const durationMs = Date.now() - startTime;
54680
- const patternInventory = await buildPatternInventory(cwd, config);
55130
+ patternInventory = await buildPatternInventory(cwd, config);
54681
55131
  const inventory = buildInventoryFromScan(
54682
55132
  { cwd, results },
54683
55133
  patternInventory,
@@ -54711,13 +55161,29 @@ async function persistRun(input) {
54711
55161
  }
54712
55162
  }
54713
55163
  }
55164
+ recordTelemetry(cwd, report, results, config, patternInventory);
55165
+ if (input.autoRefreshSnippets) {
55166
+ try {
55167
+ const { refreshSnippets: refreshSnippets2 } = await Promise.resolve().then(() => (init_refresh(), refresh_exports));
55168
+ const outcome = refreshSnippets2(cwd, registry.getRules());
55169
+ if (outcome.rewritten.length > 0 && !options.quiet && !machineReadableStdout) {
55170
+ logger.info(
55171
+ `Snippet refresh: rewrote ${outcome.rewritten.length} file(s) (${outcome.rewritten.join(", ")}).`
55172
+ );
55173
+ }
55174
+ } catch (err) {
55175
+ if (options.quiet !== true && !machineReadableStdout) {
55176
+ logger.warn(`snippet refresh failed: ${err instanceof Error ? err.message : String(err)}`);
55177
+ }
55178
+ }
55179
+ }
54714
55180
  }
54715
- var import_node_fs22, import_node_path24;
55181
+ var import_node_fs23, import_node_path25;
54716
55182
  var init_persistRun = __esm({
54717
55183
  "src/cli/report/persistRun.ts"() {
54718
55184
  "use strict";
54719
- import_node_fs22 = require("fs");
54720
- import_node_path24 = require("path");
55185
+ import_node_fs23 = require("fs");
55186
+ import_node_path25 = require("path");
54721
55187
  init_logger();
54722
55188
  init_cache_incremental();
54723
55189
  init_flywheel();
@@ -54856,7 +55322,7 @@ var init_finalizeReport = __esm({
54856
55322
  function buildBaselineCache(report, configHash, gitHead, cwd) {
54857
55323
  const scores = {};
54858
55324
  for (const component of report.components) {
54859
- scores[(0, import_node_path25.relative)(cwd, component.filePath)] = {
55325
+ scores[(0, import_node_path26.relative)(cwd, component.filePath)] = {
54860
55326
  baselineScore: component.componentScore,
54861
55327
  componentCount: component.componentCount
54862
55328
  };
@@ -54871,11 +55337,11 @@ function buildBaselineCache(report, configHash, gitHead, cwd) {
54871
55337
  scores
54872
55338
  };
54873
55339
  }
54874
- var import_node_path25;
55340
+ var import_node_path26;
54875
55341
  var init_baseline_cache = __esm({
54876
55342
  "src/cli/report/baseline-cache.ts"() {
54877
55343
  "use strict";
54878
- import_node_path25 = require("path");
55344
+ import_node_path26 = require("path");
54879
55345
  init_types();
54880
55346
  }
54881
55347
  });
@@ -54939,24 +55405,24 @@ function buildArtifactUri(filePath, cwd) {
54939
55405
  return ".";
54940
55406
  }
54941
55407
  if (cwd) {
54942
- const absoluteFilePath = (0, import_node_path26.isAbsolute)(filePath) ? filePath : (0, import_node_path26.resolve)(cwd, filePath);
54943
- const rel = (0, import_node_path26.relative)(cwd, absoluteFilePath);
55408
+ const absoluteFilePath = (0, import_node_path27.isAbsolute)(filePath) ? filePath : (0, import_node_path27.resolve)(cwd, filePath);
55409
+ const rel = (0, import_node_path27.relative)(cwd, absoluteFilePath);
54944
55410
  if (rel.startsWith("..")) {
54945
- return (0, import_node_path26.basename)(filePath);
55411
+ return (0, import_node_path27.basename)(filePath);
54946
55412
  }
54947
55413
  return rel;
54948
55414
  }
54949
- if ((0, import_node_path26.isAbsolute)(filePath)) {
54950
- return (0, import_node_path26.basename)(filePath);
55415
+ if ((0, import_node_path27.isAbsolute)(filePath)) {
55416
+ return (0, import_node_path27.basename)(filePath);
54951
55417
  }
54952
55418
  return filePath;
54953
55419
  }
54954
55420
  function resolveSourcePath(filePath, cwd) {
54955
55421
  if (!filePath) return null;
54956
55422
  if (cwd) {
54957
- return (0, import_node_path26.isAbsolute)(filePath) ? filePath : (0, import_node_path26.resolve)(cwd, filePath);
55423
+ return (0, import_node_path27.isAbsolute)(filePath) ? filePath : (0, import_node_path27.resolve)(cwd, filePath);
54958
55424
  }
54959
- return (0, import_node_path26.isAbsolute)(filePath) ? filePath : null;
55425
+ return (0, import_node_path27.isAbsolute)(filePath) ? filePath : null;
54960
55426
  }
54961
55427
  function severityToSarifLevel(severity) {
54962
55428
  switch (severity) {
@@ -55039,7 +55505,7 @@ function buildResultFromIssue(issue, cwd, fileContentCache) {
55039
55505
  let content = fileContentCache.get(sourcePath);
55040
55506
  if (content === void 0) {
55041
55507
  try {
55042
- content = (0, import_node_fs23.readFileSync)(sourcePath, "utf-8");
55508
+ content = (0, import_node_fs24.readFileSync)(sourcePath, "utf-8");
55043
55509
  fileContentCache.set(sourcePath, content);
55044
55510
  } catch {
55045
55511
  fileContentCache.set(sourcePath, "");
@@ -55092,6 +55558,7 @@ function formatSarif(report, options) {
55092
55558
  const results = report.issues.map(
55093
55559
  (issue) => buildResultFromIssue(issue, options?.cwd, fileContentCache)
55094
55560
  );
55561
+ const driverProperties = report.compositeScore ? { compositeScore: report.compositeScore } : void 0;
55095
55562
  const log = {
55096
55563
  $schema: "https://json.schemastore.org/sarif-2.1.0.json",
55097
55564
  version: "2.1.0",
@@ -55102,7 +55569,8 @@ function formatSarif(report, options) {
55102
55569
  name: "slopbrick",
55103
55570
  version: report.version,
55104
55571
  informationUri: REPO_INFORMATION_URI,
55105
- rules
55572
+ rules,
55573
+ ...driverProperties ? { properties: driverProperties } : {}
55106
55574
  }
55107
55575
  },
55108
55576
  results
@@ -55111,13 +55579,13 @@ function formatSarif(report, options) {
55111
55579
  };
55112
55580
  return JSON.stringify(log, null, 2);
55113
55581
  }
55114
- var import_node_crypto9, import_node_fs23, import_node_path26, REPO_INFORMATION_URI, RULES_BASE_URL;
55582
+ var import_node_crypto9, import_node_fs24, import_node_path27, REPO_INFORMATION_URI, RULES_BASE_URL;
55115
55583
  var init_sarif = __esm({
55116
55584
  "src/report/sarif.ts"() {
55117
55585
  "use strict";
55118
55586
  import_node_crypto9 = require("crypto");
55119
- import_node_fs23 = require("fs");
55120
- import_node_path26 = require("path");
55587
+ import_node_fs24 = require("fs");
55588
+ import_node_path27 = require("path");
55121
55589
  REPO_INFORMATION_URI = "https://github.com/usebrick/platform";
55122
55590
  RULES_BASE_URL = "https://github.com/usebrick/platform/blob/main/packages/slopbrick/src/rules";
55123
55591
  }
@@ -56302,18 +56770,18 @@ function applyReplaceFixes(content, fixes) {
56302
56770
  return { content, applied, skipped, reasons };
56303
56771
  }
56304
56772
  function applyLayoutTokenFix(filePath, fixes) {
56305
- let content = (0, import_node_fs24.readFileSync)(filePath, "utf-8");
56773
+ let content = (0, import_node_fs25.readFileSync)(filePath, "utf-8");
56306
56774
  const { content: patched, applied, skipped, reasons } = applyReplaceFixes(content, fixes);
56307
56775
  if (patched !== content) {
56308
- (0, import_node_fs24.writeFileSync)(filePath, patched);
56776
+ (0, import_node_fs25.writeFileSync)(filePath, patched);
56309
56777
  }
56310
56778
  return { applied, skipped, reasons };
56311
56779
  }
56312
- var import_node_fs24;
56780
+ var import_node_fs25;
56313
56781
  var init_layout_token = __esm({
56314
56782
  "src/fix/layout-token.ts"() {
56315
56783
  "use strict";
56316
- import_node_fs24 = require("fs");
56784
+ import_node_fs25 = require("fs");
56317
56785
  }
56318
56786
  });
56319
56787
 
@@ -56393,8 +56861,8 @@ function formatUnifiedDiff(report, cwd) {
56393
56861
  const parts = [];
56394
56862
  let hasHunk = false;
56395
56863
  for (const [filePath, fixes] of byFile) {
56396
- if (!(0, import_node_fs25.existsSync)(filePath)) continue;
56397
- const original = (0, import_node_fs25.readFileSync)(filePath, "utf-8");
56864
+ if (!(0, import_node_fs26.existsSync)(filePath)) continue;
56865
+ const original = (0, import_node_fs26.readFileSync)(filePath, "utf-8");
56398
56866
  const patched = applyFixesToString(original, fixes);
56399
56867
  if (patched === original) continue;
56400
56868
  if (!hasHunk) {
@@ -56403,7 +56871,7 @@ function formatUnifiedDiff(report, cwd) {
56403
56871
  parts.push("");
56404
56872
  hasHunk = true;
56405
56873
  }
56406
- const rel = (0, import_node_path27.relative)(cwd, filePath);
56874
+ const rel = (0, import_node_path28.relative)(cwd, filePath);
56407
56875
  parts.push(`--- a/${rel}`);
56408
56876
  parts.push(`+++ b/${rel}`);
56409
56877
  parts.push(...formatHunk(original, patched));
@@ -56414,12 +56882,12 @@ function formatUnifiedDiff(report, cwd) {
56414
56882
  }
56415
56883
  return parts.join("\n");
56416
56884
  }
56417
- var import_node_fs25, import_node_path27;
56885
+ var import_node_fs26, import_node_path28;
56418
56886
  var init_unified_diff = __esm({
56419
56887
  "src/report/unified-diff.ts"() {
56420
56888
  "use strict";
56421
- import_node_fs25 = require("fs");
56422
- import_node_path27 = require("path");
56889
+ import_node_fs26 = require("fs");
56890
+ import_node_path28 = require("path");
56423
56891
  init_layout_token();
56424
56892
  }
56425
56893
  });
@@ -56445,7 +56913,7 @@ async function buildHeatmap(report, cwd, helpers = { getFileEditCount, getFileLa
56445
56913
  const reference = new Date(report.generatedAt);
56446
56914
  const entries = await Promise.all(
56447
56915
  report.components.map(async (component) => {
56448
- const relPath = (0, import_node_path28.relative)(cwd, component.filePath) || component.filePath;
56916
+ const relPath = (0, import_node_path29.relative)(cwd, component.filePath) || component.filePath;
56449
56917
  const [edits, lastModified] = await Promise.all([
56450
56918
  helpers.getFileEditCount(cwd, relPath, RECENCY_DAYS),
56451
56919
  helpers.getFileLastModifiedDate(cwd, relPath)
@@ -56490,11 +56958,11 @@ function formatHeatmap(entries, options = {}) {
56490
56958
  );
56491
56959
  return [header, ...rows].join("\n");
56492
56960
  }
56493
- var import_node_path28, RECENCY_DAYS, MAX_EDITS;
56961
+ var import_node_path29, RECENCY_DAYS, MAX_EDITS;
56494
56962
  var init_heatmap = __esm({
56495
56963
  "src/report/heatmap.ts"() {
56496
56964
  "use strict";
56497
- import_node_path28 = require("path");
56965
+ import_node_path29 = require("path");
56498
56966
  init_git();
56499
56967
  RECENCY_DAYS = 30;
56500
56968
  MAX_EDITS = 10;
@@ -56535,7 +57003,7 @@ function renderOutput(report, options, cwd) {
56535
57003
  if (options.html) {
56536
57004
  const html = formatHtml(report);
56537
57005
  if (typeof options.html === "string") {
56538
- (0, import_node_fs26.writeFileSync)((0, import_node_path29.resolve)(options.html), html);
57006
+ (0, import_node_fs27.writeFileSync)((0, import_node_path30.resolve)(options.html), html);
56539
57007
  if (!options.quiet) {
56540
57008
  logger.info(`Wrote HTML report to ${options.html}`);
56541
57009
  }
@@ -56547,7 +57015,7 @@ function renderOutput(report, options, cwd) {
56547
57015
  if (options.json) {
56548
57016
  const json = formatJson(report);
56549
57017
  if (typeof options.json === "string") {
56550
- (0, import_node_fs26.writeFileSync)((0, import_node_path29.resolve)(options.json), json);
57018
+ (0, import_node_fs27.writeFileSync)((0, import_node_path30.resolve)(options.json), json);
56551
57019
  if (!options.quiet) {
56552
57020
  logger.info(`Wrote JSON report to ${options.json}`);
56553
57021
  }
@@ -56561,7 +57029,7 @@ function renderOutput(report, options, cwd) {
56561
57029
  return;
56562
57030
  }
56563
57031
  if (options.format === "sarif") {
56564
- const cwdSarif = (0, import_node_path29.resolve)(options.workspace ?? process.cwd());
57032
+ const cwdSarif = (0, import_node_path30.resolve)(options.workspace ?? process.cwd());
56565
57033
  logger.info(formatSarif(report, { cwd: cwdSarif }));
56566
57034
  return;
56567
57035
  }
@@ -56582,12 +57050,12 @@ async function outputScanResults(report, options, cwd) {
56582
57050
  }
56583
57051
  renderOutput(report, options, cwd);
56584
57052
  }
56585
- var import_node_path29, import_node_fs26, VALID_FORMATS;
57053
+ var import_node_path30, import_node_fs27, VALID_FORMATS;
56586
57054
  var init_renderOutput = __esm({
56587
57055
  "src/cli/report/renderOutput.ts"() {
56588
57056
  "use strict";
56589
- import_node_path29 = require("path");
56590
- import_node_fs26 = require("fs");
57057
+ import_node_path30 = require("path");
57058
+ import_node_fs27 = require("fs");
56591
57059
  init_logger();
56592
57060
  init_json();
56593
57061
  init_pretty();
@@ -56612,7 +57080,7 @@ async function watchProject(options, cwd, paths) {
56612
57080
  let currentBaseline;
56613
57081
  function getBaselineMtime() {
56614
57082
  try {
56615
- return (0, import_node_fs27.statSync)(baselinePath(cwd)).mtimeMs;
57083
+ return (0, import_node_fs28.statSync)(baselinePath(cwd)).mtimeMs;
56616
57084
  } catch {
56617
57085
  return void 0;
56618
57086
  }
@@ -56720,12 +57188,12 @@ async function watchProject(options, cwd, paths) {
56720
57188
  await doScan(false);
56721
57189
  if (closed) return;
56722
57190
  const currentFiles = new Set(issueGroupsMap.keys());
56723
- watcher = (0, import_node_fs27.watch)(
57191
+ watcher = (0, import_node_fs28.watch)(
56724
57192
  cwd,
56725
57193
  { recursive: true },
56726
57194
  (_eventType, filename) => {
56727
57195
  if (closed || !filename) return;
56728
- const changedPath = (0, import_node_path30.resolve)(cwd, filename.toString());
57196
+ const changedPath = (0, import_node_path31.resolve)(cwd, filename.toString());
56729
57197
  if (debounceTimer) clearTimeout(debounceTimer);
56730
57198
  debounceTimer = setTimeout(() => {
56731
57199
  debounceTimer = void 0;
@@ -56760,12 +57228,12 @@ async function watchProject(options, cwd, paths) {
56760
57228
  }
56761
57229
  );
56762
57230
  }
56763
- var import_node_fs27, import_node_path30;
57231
+ var import_node_fs28, import_node_path31;
56764
57232
  var init_watch = __esm({
56765
57233
  "src/cli/watch.ts"() {
56766
57234
  "use strict";
56767
- import_node_fs27 = require("fs");
56768
- import_node_path30 = require("path");
57235
+ import_node_fs28 = require("fs");
57236
+ import_node_path31 = require("path");
56769
57237
  init_metrics();
56770
57238
  init_worker();
56771
57239
  init_threshold();
@@ -56785,11 +57253,11 @@ async function runScan(options, explicitPaths) {
56785
57253
  setLoggerQuiet(!!options.quiet);
56786
57254
  const startTime = Date.now();
56787
57255
  const scanId = (0, import_node_crypto10.randomUUID)();
56788
- const cwd = (0, import_node_path31.resolve)(options.workspace ?? process.cwd());
56789
- if (!(0, import_node_fs28.existsSync)(cwd)) {
57256
+ const cwd = (0, import_node_path32.resolve)(options.workspace ?? process.cwd());
57257
+ if (!(0, import_node_fs29.existsSync)(cwd)) {
56790
57258
  throw new Error(`Workspace not found: ${cwd}`);
56791
57259
  }
56792
- const cwdStat = (0, import_node_fs28.statSync)(cwd);
57260
+ const cwdStat = (0, import_node_fs29.statSync)(cwd);
56793
57261
  if (!cwdStat.isDirectory()) {
56794
57262
  throw new Error(`Workspace is not a directory: ${cwd}`);
56795
57263
  }
@@ -56816,7 +57284,7 @@ async function runScan(options, explicitPaths) {
56816
57284
  process.env.SLOP_AUDIT_CACHE = "1";
56817
57285
  }
56818
57286
  if (options.tokens) {
56819
- const tokenResult = readDtcgTokensFile((0, import_node_path31.resolve)(cwd, options.tokens));
57287
+ const tokenResult = readDtcgTokensFile((0, import_node_path32.resolve)(cwd, options.tokens));
56820
57288
  if (tokenResult.ok) {
56821
57289
  const extra = tokensToAllowlist(tokenResult.tree);
56822
57290
  config.arbitraryValueAllowlist = [...config.arbitraryValueAllowlist, ...extra];
@@ -56828,14 +57296,14 @@ async function runScan(options, explicitPaths) {
56828
57296
  if (explicitPaths && explicitPaths.length > 0) {
56829
57297
  const { globby: globby4 } = await import("globby");
56830
57298
  const { minimatch: minimatch4 } = await import("minimatch");
56831
- const resolved = explicitPaths.map((p) => (0, import_node_path31.resolve)(cwd, p));
57299
+ const resolved = explicitPaths.map((p) => (0, import_node_path32.resolve)(cwd, p));
56832
57300
  const expanded = [];
56833
57301
  for (const p of resolved) {
56834
- if ((0, import_node_fs28.existsSync)(p) && (0, import_node_fs28.statSync)(p).isDirectory()) {
57302
+ if ((0, import_node_fs29.existsSync)(p) && (0, import_node_fs29.statSync)(p).isDirectory()) {
56835
57303
  const found = await globby4(`${p}/**/*`, { absolute: true, onlyFiles: true });
56836
57304
  for (const f of found) {
56837
- if (!ALL_SOURCE_EXTENSIONS.has((0, import_node_path31.extname)(f).toLowerCase())) continue;
56838
- const rel = (0, import_node_path31.relative)(cwd, f).split(import_node_path31.sep).join("/");
57305
+ if (!ALL_SOURCE_EXTENSIONS.has((0, import_node_path32.extname)(f).toLowerCase())) continue;
57306
+ const rel = (0, import_node_path32.relative)(cwd, f).split(import_node_path32.sep).join("/");
56839
57307
  if (config.include.length > 0 && !config.include.some((pattern) => minimatch4(rel, pattern))) {
56840
57308
  continue;
56841
57309
  }
@@ -56985,7 +57453,7 @@ async function runScan(options, explicitPaths) {
56985
57453
  const scannedPaths = new Set(results.map((result) => result.filePath));
56986
57454
  for (const [filePath, cached] of Object.entries(baseline.scores)) {
56987
57455
  if (scannedPaths.has(filePath)) continue;
56988
- if (!(0, import_node_fs28.existsSync)(filePath)) continue;
57456
+ if (!(0, import_node_fs29.existsSync)(filePath)) continue;
56989
57457
  scores.push({
56990
57458
  filePath,
56991
57459
  rawScore: 0,
@@ -57024,7 +57492,12 @@ async function runScan(options, explicitPaths) {
57024
57492
  const { report, noIncreaseFailure } = await finalizeReport({
57025
57493
  cwd,
57026
57494
  config,
57027
- options,
57495
+ options: {
57496
+ ...options,
57497
+ // v0.42.0 (§3a.4): forward the opt-in flag through to
57498
+ // `persistRun`, which calls `refreshSnippets` post-scan.
57499
+ autoRefreshSnippets: Boolean(options.autoRefreshSnippets) || Boolean(config.autoRefreshSnippets)
57500
+ },
57028
57501
  results,
57029
57502
  aggregated,
57030
57503
  allIssues,
@@ -57063,12 +57536,12 @@ async function scanProject(options) {
57063
57536
  const { report } = await runScan({ ...options, workspace: options.cwd });
57064
57537
  return report;
57065
57538
  }
57066
- var import_node_fs28, import_node_path31, import_node_crypto10;
57539
+ var import_node_fs29, import_node_path32, import_node_crypto10;
57067
57540
  var init_scan2 = __esm({
57068
57541
  "src/cli/scan.ts"() {
57069
57542
  "use strict";
57070
- import_node_fs28 = require("fs");
57071
- import_node_path31 = require("path");
57543
+ import_node_fs29 = require("fs");
57544
+ import_node_path32 = require("path");
57072
57545
  import_node_crypto10 = require("crypto");
57073
57546
  init_render();
57074
57547
  init_threshold();
@@ -57096,396 +57569,44 @@ var init_scan2 = __esm({
57096
57569
  }
57097
57570
  });
57098
57571
 
57099
- // src/mcp/slop-suggest-structure.ts
57100
- async function runSuggestWithStructure(args, ctx) {
57101
- const cached = await readStructureMarkdown(ctx.cwd);
57102
- if (cached !== null) {
57103
- return {
57104
- content: [{ type: "text", text: cached }]
57105
- };
57106
- }
57107
- const { handleToolCall: handleToolCall2 } = await Promise.resolve().then(() => (init_tools(), tools_exports));
57108
- const result = await handleToolCall2("slop_suggest", args, ctx);
57109
- if (result.isError) return result;
57110
- try {
57111
- const parsed = JSON.parse(result.content[0].text);
57112
- if (parsed !== null && typeof parsed === "object") {
57113
- parsed.structureHint = STRUCTURE_NOT_FOUND_HINT;
57114
- return {
57115
- content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }]
57116
- };
57117
- }
57118
- } catch {
57119
- }
57120
- return result;
57121
- }
57122
- var STRUCTURE_NOT_FOUND_HINT;
57123
- var init_slop_suggest_structure = __esm({
57124
- "src/mcp/slop-suggest-structure.ts"() {
57125
- "use strict";
57126
- init_structure_md();
57127
- STRUCTURE_NOT_FOUND_HINT = "No .slopbrick/structure.md found. Run `slopbrick scan` to persist the pattern inventory, then call this tool again for the O(read file) fast path.";
57128
- }
57572
+ // src/rules/composite-seed.ts
57573
+ var composite_seed_exports = {};
57574
+ __export(composite_seed_exports, {
57575
+ HAND_CURATED_SEED: () => HAND_CURATED_SEED,
57576
+ maybeAppendSeed: () => maybeAppendSeed
57129
57577
  });
57130
-
57131
- // src/mcp/tools.ts
57132
- var tools_exports = {};
57133
- __export(tools_exports, {
57134
- TOOL_DEFINITIONS: () => TOOL_DEFINITIONS,
57135
- canonicalToolNames: () => canonicalToolNames,
57136
- getDeprecation: () => getDeprecation,
57137
- handleToolCall: () => handleToolCall
57138
- });
57139
- function toolError(message) {
57140
- return {
57141
- content: [{ type: "text", text: JSON.stringify({ error: message }) }],
57142
- isError: true
57143
- };
57144
- }
57145
- async function runScanFile(args, ctx) {
57146
- const path = args.path;
57147
- if (!path) return toolError("Missing required argument: path");
57148
- const result = await scanFile(path, ctx.config);
57149
- const simplified = {
57150
- filePath: result.filePath,
57151
- componentCount: result.componentCount,
57152
- parseError: result.parseError,
57153
- issues: result.issues.map((i) => ({
57154
- ruleId: i.ruleId,
57155
- category: i.category,
57156
- severity: i.severity,
57157
- line: i.line,
57158
- column: i.column,
57159
- message: i.message,
57160
- advice: i.advice
57161
- }))
57162
- };
57163
- return {
57164
- content: [{ type: "text", text: JSON.stringify(simplified, null, 2) }]
57165
- };
57166
- }
57167
- function explainRule2(args, ctx) {
57168
- const ruleId = args.ruleId;
57169
- if (!ruleId) return toolError("Missing required argument: ruleId");
57170
- const rule = ctx.rules.find((r) => r.id === ruleId);
57171
- if (!rule) return toolError("Unknown rule: " + ruleId);
57172
- const explanation = {
57173
- ruleId: rule.id,
57174
- category: rule.category,
57175
- severity: rule.severity,
57176
- aiSpecific: rule.aiSpecific,
57177
- rationale: "This rule flags " + rule.category + " patterns associated with AI-generated code. It is marked as " + (rule.aiSpecific ? "AI-specific" : "cross-cutting") + ". Severity: " + rule.severity + ".",
57178
- whereToLook: "src/rules/" + rule.category + "/" + rule.id.replace(/^[^/]+\//, "") + ".ts"
57179
- };
57180
- return {
57181
- content: [{ type: "text", text: JSON.stringify(explanation, null, 2) }]
57182
- };
57183
- }
57184
- function listRules(args, ctx) {
57185
- const category = args.category;
57186
- const filtered = category ? ctx.rules.filter((r) => r.category === category) : ctx.rules;
57187
- const rules = filtered.map((r) => ({
57188
- id: r.id,
57189
- category: r.category,
57190
- severity: r.severity,
57191
- aiSpecific: r.aiSpecific
57192
- }));
57193
- return {
57194
- content: [
57195
- {
57196
- type: "text",
57197
- text: JSON.stringify({ count: rules.length, rules }, null, 2)
57198
- }
57199
- ]
57200
- };
57201
- }
57202
- async function runSuggest(args, ctx) {
57203
- const maxFilesRaw = args.maxFiles;
57204
- const maxFiles = typeof maxFilesRaw === "number" && Number.isFinite(maxFilesRaw) && maxFilesRaw > 0 ? Math.min(2e3, Math.floor(maxFilesRaw)) : 200;
57205
- try {
57206
- const inventory = await buildPatternInventory(ctx.cwd, ctx.config, maxFiles);
57207
- const doNotCreate = [
57208
- ...ctx.config.constitution?.forbidden ?? []
57209
- ];
57210
- const declared = /* @__PURE__ */ new Set();
57211
- for (const list of [
57212
- ctx.config.constitution?.stateManagement ?? [],
57213
- ctx.config.constitution?.dataFetching ?? [],
57214
- ctx.config.constitution?.uiLibrary ?? [],
57215
- ctx.config.constitution?.forms ?? [],
57216
- ctx.config.constitution?.styling ?? [],
57217
- ctx.config.constitution?.routing ?? []
57218
- ]) {
57219
- for (const lib of list) declared.add(lib);
57220
- }
57221
- const doNotCreateCapped = doNotCreate.slice(0, 10);
57222
- return {
57223
- content: [
57224
- {
57225
- type: "text",
57226
- text: JSON.stringify(
57227
- {
57228
- hint: "Use these patterns instead of creating new ones. Pick the closest existing entry and import it. The `doNotCreate` list is the deny-list \u2014 never import any of these.",
57229
- doNotCreate: doNotCreateCapped,
57230
- declaredStack: Array.from(declared),
57231
- existingPatterns: inventory
57232
- },
57233
- null,
57234
- 2
57235
- )
57236
- }
57237
- ]
57238
- };
57239
- } catch (err) {
57240
- return toolError(err instanceof Error ? err.message : String(err));
57241
- }
57242
- }
57243
- function runCheckConstitution(args, ctx) {
57244
- const path = args.path;
57245
- if (!path) return toolError("Missing required argument: path");
57246
- const absPath = (0, import_node_path40.resolve)(ctx.cwd, path);
57247
- let source;
57248
- try {
57249
- source = (0, import_node_fs32.readFileSync)(absPath, "utf-8");
57250
- } catch (err) {
57251
- return toolError(
57252
- `Cannot read file ${absPath}: ${err instanceof Error ? err.message : String(err)}`
57253
- );
57254
- }
57255
- const result = checkFileConstitution(source, ctx.config.constitution);
57256
- return {
57257
- content: [
57258
- {
57259
- type: "text",
57260
- text: JSON.stringify(
57261
- {
57262
- file: absPath,
57263
- importCount: result.imports.length,
57264
- violationCount: result.violations.length,
57265
- imports: result.imports,
57266
- violations: result.violations,
57267
- // Field name kept stable for backward compatibility with
57268
- // older consumers; the value reflects whether the merged
57269
- // `config.constitution` was declared, detected, or absent.
57270
- conventionSource: ctx.config.constitution ? "declared-or-detected" : "none"
57271
- },
57272
- null,
57273
- 2
57274
- )
57275
- }
57276
- ]
57277
- };
57278
- }
57279
- async function runFindSimilar(args, ctx) {
57280
- const { findSimilarFunctions: findSimilarFunctions2 } = await Promise.resolve().then(() => (init_dist2(), dist_exports2));
57281
- const hooks = Array.isArray(args.hooks) ? args.hooks : [];
57282
- const props = Array.isArray(args.props) ? args.props : [];
57283
- const limitRaw = args.limit;
57284
- const limit = typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 10;
57285
- try {
57286
- const matches = await findSimilarFunctions2(
57287
- {
57288
- name: typeof args.name === "string" ? args.name : void 0,
57289
- hooks,
57290
- props,
57291
- limit,
57292
- workspaceDir: ctx.cwd
57293
- },
57294
- { cwd: ctx.cwd }
57295
- );
57296
- return {
57297
- content: [
57298
- {
57299
- type: "text",
57300
- text: JSON.stringify(
57301
- {
57302
- hint: "Each match is ranked by Jaccard similarity over (hooks \u222A props \u222A params). similarity=1 means the matched signature has an identical feature set. Agents should prefer the top match instead of writing a new implementation.",
57303
- count: matches.length,
57304
- matches: matches.map((m) => ({
57305
- name: m.signature.name,
57306
- file: m.signature.fileRel,
57307
- line: m.signature.line,
57308
- similarity: Number(m.similarity.toFixed(3)),
57309
- fingerprint: m.fingerprint,
57310
- hooks: m.signature.hooks,
57311
- props: m.signature.props,
57312
- params: m.signature.params
57313
- }))
57314
- },
57315
- null,
57316
- 2
57317
- )
57318
- }
57319
- ]
57320
- };
57321
- } catch (err) {
57322
- return toolError(err instanceof Error ? err.message : String(err));
57323
- }
57324
- }
57325
- async function handleToolCall(toolName, args, ctx) {
57326
- const deprecation = getDeprecation(toolName);
57327
- const deprecationNotice = deprecation ? {
57328
- tool: toolName,
57329
- replacedBy: deprecation.replacedBy,
57330
- removedIn: deprecation.removedIn ?? "next major",
57331
- reason: deprecation.reason
57332
- } : void 0;
57333
- switch (toolName) {
57334
- case "slop_scan_file":
57335
- return runScanFile(args, ctx);
57336
- case "slop_explain_rule":
57337
- return explainRule2(args, ctx);
57338
- case "slop_list_rules":
57339
- return listRules(args, ctx);
57340
- case "slop_suggest":
57341
- return runSuggest(args, ctx);
57342
- case "slop_suggest_with_structure":
57343
- return runSuggestWithStructure(args, ctx);
57344
- // v0.39.0: removed 3 deprecated tools (slop_governance,
57345
- // slop_architecture_score, slop_business_logic_score) that
57346
- // were marked for removal in v0.13.0 but never removed.
57347
- // Their runner functions (runGovernance, runArchitectureScore,
57348
- // runBusinessLogicScore) are kept in the file for now
57349
- // (marked @deprecated) to keep the diff small; they can be
57350
- // deleted in a follow-up. New clients will never see these
57351
- // tools listed in the MCP tools/list response.
57352
- case "slop_check_constitution":
57353
- return runCheckConstitution(args, ctx);
57354
- case "slop_find_similar":
57355
- return runFindSimilar(args, ctx);
57356
- default:
57357
- return toolError("Unknown tool: " + toolName);
57578
+ function maybeAppendSeed(entries) {
57579
+ if (entries.length === 0) {
57580
+ return [...HAND_CURATED_SEED];
57358
57581
  }
57582
+ return entries;
57359
57583
  }
57360
- function canonicalToolNames() {
57361
- return TOOL_DEFINITIONS.filter((t) => !t.deprecated).map((t) => t.name);
57362
- }
57363
- function getDeprecation(toolName) {
57364
- return TOOL_DEFINITIONS.find((t) => t.name === toolName)?.deprecated;
57365
- }
57366
- var import_node_fs32, import_node_path40, TOOL_DEFINITIONS;
57367
- var init_tools = __esm({
57368
- "src/mcp/tools.ts"() {
57584
+ var HAND_CURATED_SEED;
57585
+ var init_composite_seed = __esm({
57586
+ "src/rules/composite-seed.ts"() {
57369
57587
  "use strict";
57370
- import_node_fs32 = require("fs");
57371
- import_node_path40 = require("path");
57372
- init_worker();
57373
- init_patterns();
57374
- init_architecture_score();
57375
- init_business_logic();
57376
- init_slop_suggest_structure();
57377
- TOOL_DEFINITIONS = [
57378
- {
57379
- name: "slop_scan_file",
57380
- 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.",
57381
- inputSchema: {
57382
- type: "object",
57383
- properties: {
57384
- path: { type: "string", description: "Absolute or cwd-relative path to the source file." },
57385
- framework: {
57386
- type: "string",
57387
- enum: ["react", "vue", "svelte", "astro", "html"],
57388
- description: "Framework multiplier to apply. Defaults to the configured framework."
57389
- }
57390
- },
57391
- required: ["path"]
57392
- }
57393
- },
57394
- {
57395
- name: "slop_explain_rule",
57396
- 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.",
57397
- inputSchema: {
57398
- type: "object",
57399
- properties: {
57400
- ruleId: { type: "string", description: 'e.g. "visual/ai-default-palette".' }
57401
- },
57402
- required: ["ruleId"]
57403
- }
57404
- },
57588
+ HAND_CURATED_SEED = [
57405
57589
  {
57406
- name: "slop_list_rules",
57407
- description: "List all registered rules with their category, severity, and aiSpecific flag. Optional category filter (visual | logic | wcag | security | perf | typo | layout | component | arch).",
57408
- inputSchema: {
57409
- type: "object",
57410
- properties: {
57411
- category: { type: "string", description: "Optional category filter." }
57412
- }
57413
- }
57414
- },
57415
- {
57416
- name: "slop_suggest",
57417
- 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.",
57418
- inputSchema: {
57419
- type: "object",
57420
- properties: {
57421
- maxFiles: {
57422
- type: "number",
57423
- description: "Cap on files scanned to keep the inventory cheap. Defaults to 200."
57424
- }
57425
- }
57426
- }
57427
- },
57428
- {
57429
- name: "slop_suggest_with_structure",
57430
- description: "Fast-path variant of `slop_suggest` that reads `.slopbrick/structure.md` from disk instead of re-scanning the codebase. Requires a prior `slopbrick scan` to have persisted the inventory (100\u20131000\xD7 latency win on the agent integration). If `structure.md` is missing, falls back to `slop_suggest` and annotates the response with `structureHint` so the caller knows to run `slopbrick scan` first.",
57431
- inputSchema: {
57432
- type: "object",
57433
- properties: {
57434
- maxFiles: {
57435
- type: "number",
57436
- description: "Cap on files scanned for the slow-path fallback. Defaults to 200."
57437
- }
57438
- }
57439
- }
57440
- },
57441
- {
57442
- // v0.39.0: removed 3 deprecated tools (slop_governance,
57443
- // slop_architecture_score, slop_business_logic_score) that
57444
- // were marked for removal in v0.13.0 but never removed.
57445
- // They were strict subsets of slop_suggest; users should
57446
- // call slop_suggest and read repositoryHealth /
57447
- // architectureConsistency / businessLogicCoherence.
57448
- name: "slop_check_constitution",
57449
- 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.",
57450
- inputSchema: {
57451
- type: "object",
57452
- properties: {
57453
- path: { type: "string", description: "Absolute or cwd-relative path to the source file." }
57454
- },
57455
- required: ["path"]
57456
- }
57457
- },
57458
- {
57459
- // v0.10.1: find_similar_function. The GIR (Give-Implementation-
57460
- // Reference) primitive for slop_suggest. Given a function signature
57461
- // (name + hooks + props), find the most similar existing
57462
- // implementations across the codebase. Uses AST fingerprints
57463
- // (sha256 over sorted hooks ∪ props ∪ params) + Jaccard similarity
57464
- // — no LLM, no embeddings, deterministic. Foundation for StackPick.
57465
- name: "slop_find_similar",
57466
- 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].",
57467
- inputSchema: {
57468
- type: "object",
57469
- properties: {
57470
- name: {
57471
- type: "string",
57472
- description: "Function/component name to match. Omit to match by hooks+props only."
57473
- },
57474
- hooks: {
57475
- type: "array",
57476
- items: { type: "string" },
57477
- description: 'React hooks used by the target signature, e.g. ["useState", "useEffect"].'
57478
- },
57479
- props: {
57480
- type: "array",
57481
- items: { type: "string" },
57482
- description: 'Component props for the target signature, e.g. ["variant", "size", "children"].'
57483
- },
57484
- limit: {
57485
- type: "number",
57486
- description: "Top-k results to return. Default 10. Capped at 50."
57487
- }
57488
- }
57590
+ id: "composite/ai-fingerprint-cluster",
57591
+ ruleIds: ["ai/compression-profile", "ai/comment-ratio", "ai/segment-surprisal-cv"],
57592
+ minMatch: 2,
57593
+ severity: "medium",
57594
+ defaultOff: true,
57595
+ 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.",
57596
+ calibration: {
57597
+ recall: 0,
57598
+ FP: 0,
57599
+ precision: 0,
57600
+ F1: 0,
57601
+ nFiles: 0
57602
+ },
57603
+ provenance: {
57604
+ seed: "hand-curated-by-brief",
57605
+ discoveredAt: "2026-07-05T00:00:00.000Z",
57606
+ nFiles: 0,
57607
+ members: 3,
57608
+ npmi: 0,
57609
+ fisherP: 1
57489
57610
  }
57490
57611
  }
57491
57612
  ];
@@ -57578,8 +57699,8 @@ init_config2();
57578
57699
  init_dist2();
57579
57700
 
57580
57701
  // src/cli/program.ts
57581
- var import_node_path72 = require("path");
57582
- var import_commander2 = require("commander");
57702
+ var import_node_path75 = require("path");
57703
+ var import_commander3 = require("commander");
57583
57704
 
57584
57705
  // src/cli/options.ts
57585
57706
  var import_commander = require("commander");
@@ -57621,8 +57742,8 @@ init_scan2();
57621
57742
  init_scan2();
57622
57743
 
57623
57744
  // src/cli/init.ts
57624
- var import_node_fs30 = require("fs");
57625
- var import_node_path33 = require("path");
57745
+ var import_node_fs31 = require("fs");
57746
+ var import_node_path34 = require("path");
57626
57747
  var import_node_readline = require("readline");
57627
57748
  init_config2();
57628
57749
  init_discover();
@@ -57631,8 +57752,8 @@ init_cache();
57631
57752
  init_logger();
57632
57753
 
57633
57754
  // src/rules/registry-loader.ts
57634
- var import_node_fs29 = require("fs");
57635
- var import_node_path32 = require("path");
57755
+ var import_node_fs30 = require("fs");
57756
+ var import_node_path33 = require("path");
57636
57757
 
57637
57758
  // src/data/shadcn-registry.json
57638
57759
  var shadcn_registry_default = {
@@ -57766,12 +57887,12 @@ var shadcn_registry_default = {
57766
57887
  var REGISTRY_URL = "https://ui.shadcn.com/registry.json";
57767
57888
  var BUNDLED_REGISTRY_VERSION = shadcn_registry_default.version;
57768
57889
  function cachePath2(cwd) {
57769
- return (0, import_node_path32.join)(cwd, ".slopbrick", "cache", "registry-snapshot.json");
57890
+ return (0, import_node_path33.join)(cwd, ".slopbrick", "cache", "registry-snapshot.json");
57770
57891
  }
57771
57892
  function ensureCacheDir(cwd) {
57772
- const dir = (0, import_node_path32.dirname)(cachePath2(cwd));
57773
- if (!(0, import_node_fs29.existsSync)(dir)) {
57774
- (0, import_node_fs29.mkdirSync)(dir, { recursive: true });
57893
+ const dir = (0, import_node_path33.dirname)(cachePath2(cwd));
57894
+ if (!(0, import_node_fs30.existsSync)(dir)) {
57895
+ (0, import_node_fs30.mkdirSync)(dir, { recursive: true });
57775
57896
  }
57776
57897
  }
57777
57898
  function isValidSnapshot(value) {
@@ -57783,9 +57904,9 @@ function isValidSnapshot(value) {
57783
57904
  }
57784
57905
  function isRegistryFresh(cwd) {
57785
57906
  const cached = cachePath2(cwd);
57786
- if (!(0, import_node_fs29.existsSync)(cached)) return false;
57907
+ if (!(0, import_node_fs30.existsSync)(cached)) return false;
57787
57908
  try {
57788
- const parsed = JSON.parse((0, import_node_fs29.readFileSync)(cached, "utf8"));
57909
+ const parsed = JSON.parse((0, import_node_fs30.readFileSync)(cached, "utf8"));
57789
57910
  if (!isValidSnapshot(parsed)) return false;
57790
57911
  return parsed.version === BUNDLED_REGISTRY_VERSION;
57791
57912
  } catch {
@@ -57796,7 +57917,7 @@ async function refreshRegistrySnapshot(cwd, _url = REGISTRY_URL, _timeoutMs = 5e
57796
57917
  try {
57797
57918
  const cacheFile = cachePath2(cwd);
57798
57919
  ensureCacheDir(cwd);
57799
- (0, import_node_fs29.writeFileSync)(cacheFile, JSON.stringify(shadcn_registry_default, null, 2), "utf-8");
57920
+ (0, import_node_fs30.writeFileSync)(cacheFile, JSON.stringify(shadcn_registry_default, null, 2), "utf-8");
57800
57921
  } catch {
57801
57922
  }
57802
57923
  return {
@@ -57808,7 +57929,7 @@ async function refreshRegistrySnapshot(cwd, _url = REGISTRY_URL, _timeoutMs = 5e
57808
57929
  }
57809
57930
  function copyBundledSnapshotToCache(cwd) {
57810
57931
  ensureCacheDir(cwd);
57811
- (0, import_node_fs29.writeFileSync)(cachePath2(cwd), JSON.stringify(shadcn_registry_default, null, 2));
57932
+ (0, import_node_fs30.writeFileSync)(cachePath2(cwd), JSON.stringify(shadcn_registry_default, null, 2));
57812
57933
  }
57813
57934
 
57814
57935
  // src/cli/init.ts
@@ -57837,17 +57958,17 @@ var UI_LIBRARY_OPTIONS = ["shadcn/ui", "mui", "chakra", "radix", "tamagui", "nat
57837
57958
  var STRICTNESS_OPTIONS = ["strict", "balanced", "permissive"];
57838
57959
  var STRUCTURE_OPTIONS = ["feature-based", "layer-based", "flat", "monorepo", "other"];
57839
57960
  function promptText(rl, question, detected) {
57840
- return new Promise((resolve42) => {
57961
+ return new Promise((resolve43) => {
57841
57962
  const lines = [
57842
57963
  `? ${question} (detected: ${detected || "none"}) \u2014 npm package name, or Enter to skip:`
57843
57964
  ];
57844
57965
  rl.question(lines.join("\n") + "\n", (answer) => {
57845
57966
  const trimmed = answer.trim();
57846
57967
  if (trimmed === "") {
57847
- resolve42(void 0);
57968
+ resolve43(void 0);
57848
57969
  return;
57849
57970
  }
57850
- resolve42(trimmed);
57971
+ resolve43(trimmed);
57851
57972
  });
57852
57973
  });
57853
57974
  }
@@ -57855,7 +57976,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57855
57976
  const defaultIndex = options.indexOf(defaultValue);
57856
57977
  const safeDefaultIndex = defaultIndex >= 0 ? defaultIndex : 0;
57857
57978
  const safeDefaultValue = options[safeDefaultIndex];
57858
- return new Promise((resolve42) => {
57979
+ return new Promise((resolve43) => {
57859
57980
  function ask() {
57860
57981
  const lines = [
57861
57982
  `? ${question} (detected: ${safeDefaultValue}):`,
@@ -57865,7 +57986,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57865
57986
  rl.question(lines.join("\n") + "\n", (answer) => {
57866
57987
  const trimmed = answer.trim();
57867
57988
  if (trimmed === "") {
57868
- resolve42(safeDefaultValue);
57989
+ resolve43(safeDefaultValue);
57869
57990
  return;
57870
57991
  }
57871
57992
  const num = parseInt(trimmed, 10);
@@ -57874,7 +57995,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57874
57995
  ask();
57875
57996
  return;
57876
57997
  }
57877
- resolve42(options[num - 1]);
57998
+ resolve43(options[num - 1]);
57878
57999
  });
57879
58000
  }
57880
58001
  ask();
@@ -57884,7 +58005,7 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57884
58005
  const defaultIndices = defaultValue.map((v) => options.indexOf(v)).filter((i) => i >= 0);
57885
58006
  const defaultDisplay = defaultValue.length > 0 ? defaultValue.join(", ") : "none";
57886
58007
  const defaultNumbers = defaultIndices.length > 0 ? defaultIndices.map((i) => i + 2).join(",") : "1";
57887
- return new Promise((resolve42) => {
58008
+ return new Promise((resolve43) => {
57888
58009
  function ask() {
57889
58010
  const lines = [
57890
58011
  `? ${question} (detected: ${defaultDisplay}):`,
@@ -57895,7 +58016,7 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57895
58016
  rl.question(lines.join("\n") + "\n", (answer) => {
57896
58017
  const trimmed = answer.trim();
57897
58018
  if (trimmed === "") {
57898
- resolve42(defaultValue.length > 0 ? defaultValue : []);
58019
+ resolve43(defaultValue.length > 0 ? defaultValue : []);
57899
58020
  return;
57900
58021
  }
57901
58022
  const numbers = trimmed.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n));
@@ -57905,11 +58026,11 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57905
58026
  return;
57906
58027
  }
57907
58028
  if (numbers.includes(1)) {
57908
- resolve42([]);
58029
+ resolve43([]);
57909
58030
  return;
57910
58031
  }
57911
58032
  const selected = [...new Set(numbers.map((n) => options[n - 2]).filter((v) => v !== void 0))];
57912
- resolve42(selected);
58033
+ resolve43(selected);
57913
58034
  });
57914
58035
  }
57915
58036
  ask();
@@ -57997,11 +58118,11 @@ async function runDoctor(cwd) {
57997
58118
  }
57998
58119
  try {
57999
58120
  const { parseFile: tryParse } = await Promise.resolve().then(() => (init_dist2(), dist_exports2));
58000
- const testFile = (0, import_node_path33.join)(cwd, ".slopbrick", ".doctor-test.ts");
58001
- (0, import_node_fs30.mkdirSync)((0, import_node_path33.dirname)(testFile), { recursive: true });
58002
- (0, import_node_fs30.writeFileSync)(testFile, "export const x = 1;\n");
58121
+ const testFile = (0, import_node_path34.join)(cwd, ".slopbrick", ".doctor-test.ts");
58122
+ (0, import_node_fs31.mkdirSync)((0, import_node_path34.dirname)(testFile), { recursive: true });
58123
+ (0, import_node_fs31.writeFileSync)(testFile, "export const x = 1;\n");
58003
58124
  await tryParse(testFile);
58004
- (0, import_node_fs30.rmSync)(testFile, { force: true });
58125
+ (0, import_node_fs31.rmSync)(testFile, { force: true });
58005
58126
  ok("Parser is working.");
58006
58127
  } catch (err) {
58007
58128
  const message = err instanceof Error ? err.message : String(err);
@@ -58090,7 +58211,7 @@ async function runDoctor(cwd) {
58090
58211
  }
58091
58212
 
58092
58213
  // src/cli/commands/badge.ts
58093
- var import_node_path34 = require("path");
58214
+ var import_node_path35 = require("path");
58094
58215
  init_render();
58095
58216
  init_logger();
58096
58217
  init_scan2();
@@ -58099,7 +58220,7 @@ function registerBadge(program) {
58099
58220
  "print a shields.io slop-index badge. Reads .slopbrick/health.json if present (no re-scan); falls back to a fresh scan."
58100
58221
  ).action(async (_cmdOptions, command) => {
58101
58222
  const options = command.optsWithGlobals();
58102
- const cwd = (0, import_node_path34.resolve)(options.workspace ?? process.cwd());
58223
+ const cwd = (0, import_node_path35.resolve)(options.workspace ?? process.cwd());
58103
58224
  const { loadHealth: loadHealth2 } = await Promise.resolve().then(() => (init_dist(), dist_exports));
58104
58225
  const health = loadHealth2(cwd);
58105
58226
  if (health) {
@@ -58116,7 +58237,7 @@ function registerBadge(program) {
58116
58237
  }
58117
58238
 
58118
58239
  // src/cli/commands/suggest.ts
58119
- var import_node_path35 = require("path");
58240
+ var import_node_path36 = require("path");
58120
58241
  init_advice();
58121
58242
  init_unified_diff();
58122
58243
  init_logger();
@@ -58125,7 +58246,7 @@ function registerSuggest(program) {
58125
58246
  program.command("suggest").description("print remediation advice").action(async (_cmdOptions, command) => {
58126
58247
  const options = command.optsWithGlobals();
58127
58248
  const { report } = await runScan(options);
58128
- const cwd = (0, import_node_path35.resolve)(options.workspace ?? process.cwd());
58249
+ const cwd = (0, import_node_path36.resolve)(options.workspace ?? process.cwd());
58129
58250
  logger.info(formatAdvice(report));
58130
58251
  const diff = formatUnifiedDiff(report, cwd);
58131
58252
  if (diff) logger.info(diff);
@@ -58133,6 +58254,470 @@ function registerSuggest(program) {
58133
58254
  });
58134
58255
  }
58135
58256
 
58257
+ // src/cli/commands/composite.ts
58258
+ var import_node_path38 = require("path");
58259
+ init_logger();
58260
+ init_telemetry();
58261
+
58262
+ // src/engine/cluster.ts
58263
+ var import_node_crypto11 = require("crypto");
58264
+ var DEFAULT_CLUSTER_PARAMS = {
58265
+ minSupport: 0.05,
58266
+ // 5% of files
58267
+ minNPMI: 0.3,
58268
+ // Cohen's-Landis-and-Koch "fair" agreement
58269
+ fisherAlpha: 0.01,
58270
+ // Bonferroni-style floor for many-pair tests
58271
+ minClusterSize: 2
58272
+ };
58273
+ function adaptiveMinSupport(fileCount) {
58274
+ if (fileCount <= 0) return DEFAULT_CLUSTER_PARAMS.minSupport;
58275
+ return Math.max(DEFAULT_CLUSTER_PARAMS.minSupport, 5 / fileCount);
58276
+ }
58277
+ function computeRuleSupport(fireMatrix) {
58278
+ const support = /* @__PURE__ */ new Map();
58279
+ for (const ruleIds of fireMatrix.values()) {
58280
+ for (const ruleId of ruleIds) {
58281
+ support.set(ruleId, (support.get(ruleId) ?? 0) + 1);
58282
+ }
58283
+ }
58284
+ return support;
58285
+ }
58286
+ function prefilterBySupport(support, totalFiles, minSupport) {
58287
+ const kept = /* @__PURE__ */ new Set();
58288
+ const minCount = Math.max(1, Math.ceil(minSupport * totalFiles));
58289
+ for (const [ruleId, count] of support) {
58290
+ if (count >= minCount) kept.add(ruleId);
58291
+ }
58292
+ return kept;
58293
+ }
58294
+ function buildContingency(rA, rB, totalFiles) {
58295
+ let aAndB = 0;
58296
+ let aOnly = 0;
58297
+ let bOnly = 0;
58298
+ for (const file of rA) {
58299
+ if (rB.has(file)) aAndB++;
58300
+ else aOnly++;
58301
+ }
58302
+ for (const file of rB) {
58303
+ if (!rA.has(file)) bOnly++;
58304
+ }
58305
+ return {
58306
+ aAndB,
58307
+ aOnly,
58308
+ bOnly,
58309
+ neither: Math.max(0, totalFiles - aAndB - aOnly - bOnly)
58310
+ };
58311
+ }
58312
+ function fisherExactTwoSided(c) {
58313
+ const N = c.aAndB + c.aOnly + c.bOnly + c.neither;
58314
+ const K1 = c.aAndB + c.aOnly;
58315
+ const K2 = c.aAndB + c.bOnly;
58316
+ const n = K1;
58317
+ const kObs = c.aAndB;
58318
+ const logPmf = (k) => {
58319
+ if (k < Math.max(0, K1 + K2 - N) || k > Math.min(K1, K2)) return -Infinity;
58320
+ return lnChoose(K2, k) + lnChoose(N - K2, K1 - k) - lnChoose(N, K1);
58321
+ };
58322
+ const pObs = Math.exp(logPmf(kObs));
58323
+ const kMin = Math.max(0, K1 + K2 - N);
58324
+ const kMax = Math.min(K1, K2);
58325
+ let p = 0;
58326
+ for (let k = kMin; k <= kMax; k++) {
58327
+ const pk = Math.exp(logPmf(k));
58328
+ if (pk <= pObs + 1e-12) {
58329
+ p += pk;
58330
+ }
58331
+ }
58332
+ return Math.min(1, p);
58333
+ }
58334
+ function lnChoose(n, k) {
58335
+ if (k < 0 || k > n) return -Infinity;
58336
+ if (k === 0 || k === n) return 0;
58337
+ k = Math.min(k, n - k);
58338
+ let s = 0;
58339
+ for (let i = 1; i <= k; i++) {
58340
+ s += Math.log((n - k + i) / i);
58341
+ }
58342
+ return s;
58343
+ }
58344
+ function npmi(c, total) {
58345
+ if (total === 0 || c.aAndB === 0) return -1;
58346
+ const pXY = c.aAndB / total;
58347
+ const pX = (c.aAndB + c.aOnly) / total;
58348
+ const pY = (c.aAndB + c.bOnly) / total;
58349
+ if (pX === 0 || pY === 0 || pXY === 0) return -1;
58350
+ const pmi = Math.log2(pXY / (pX * pY));
58351
+ const norm = -Math.log2(pXY);
58352
+ return norm === 0 ? 1 : pmi / norm;
58353
+ }
58354
+ function computeEdges(fireMatrix, rules, total, params) {
58355
+ const ruleToFiles = /* @__PURE__ */ new Map();
58356
+ for (const ruleId of rules) ruleToFiles.set(ruleId, /* @__PURE__ */ new Set());
58357
+ for (const [file, fired] of fireMatrix) {
58358
+ for (const ruleId of fired) {
58359
+ if (rules.has(ruleId)) ruleToFiles.get(ruleId).add(file);
58360
+ }
58361
+ }
58362
+ const ruleList = Array.from(rules).sort();
58363
+ const edges = [];
58364
+ for (let i = 0; i < ruleList.length; i++) {
58365
+ for (let j = i + 1; j < ruleList.length; j++) {
58366
+ const a = ruleList[i];
58367
+ const b = ruleList[j];
58368
+ const rA = ruleToFiles.get(a);
58369
+ const rB = ruleToFiles.get(b);
58370
+ const c = buildContingency(rA, rB, total);
58371
+ const n = npmi(c, total);
58372
+ if (n < params.minNPMI) continue;
58373
+ const p = fisherExactTwoSided(c);
58374
+ if (p > params.fisherAlpha) continue;
58375
+ edges.push({ ruleIds: [a, b], weight: n, fisherP: p });
58376
+ }
58377
+ }
58378
+ return edges;
58379
+ }
58380
+ function singleLinkageCluster(edges, minClusterSize) {
58381
+ const parent = /* @__PURE__ */ new Map();
58382
+ const find = (x) => {
58383
+ if (!parent.has(x)) {
58384
+ parent.set(x, x);
58385
+ return x;
58386
+ }
58387
+ if (parent.get(x) === x) return x;
58388
+ const root = find(parent.get(x));
58389
+ parent.set(x, root);
58390
+ return root;
58391
+ };
58392
+ const union = (a, b) => {
58393
+ const ra = find(a);
58394
+ const rb = find(b);
58395
+ if (ra !== rb) parent.set(ra, rb);
58396
+ };
58397
+ for (const e of edges) {
58398
+ union(e.ruleIds[0], e.ruleIds[1]);
58399
+ }
58400
+ const groups = /* @__PURE__ */ new Map();
58401
+ for (const k of parent.keys()) {
58402
+ const root = find(k);
58403
+ if (!groups.has(root)) groups.set(root, []);
58404
+ groups.get(root).push(k);
58405
+ }
58406
+ const clusters = [];
58407
+ for (const members of groups.values()) {
58408
+ if (members.length < minClusterSize) continue;
58409
+ members.sort();
58410
+ clusters.push(members);
58411
+ }
58412
+ clusters.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
58413
+ return clusters;
58414
+ }
58415
+ function filesForComposite(fireMatrix, members, minMatch) {
58416
+ const fired = /* @__PURE__ */ new Set();
58417
+ for (const [file, ids] of fireMatrix) {
58418
+ let n = 0;
58419
+ for (const m of members) if (ids.has(m)) n++;
58420
+ if (n >= minMatch) fired.add(file);
58421
+ }
58422
+ return fired;
58423
+ }
58424
+ var DEFAULT_CALIBRATION = {
58425
+ // 1.5× is the documented floor.
58426
+ recallFpRatio: 1.5,
58427
+ // 0.05 absolute FP — the same `min` value most rules get via the
58428
+ // v0.18.x calibration contract.
58429
+ minFpFloor: 0.05
58430
+ };
58431
+ function calibrateCluster(cluster2, edges, fireMatrix, positiveFiles, totalFiles, thresholds = DEFAULT_CALIBRATION) {
58432
+ if (cluster2.length < 2) return null;
58433
+ const clusterSet = new Set(cluster2);
58434
+ const clusterEdges = edges.filter(
58435
+ (e) => clusterSet.has(e.ruleIds[0]) && clusterSet.has(e.ruleIds[1])
58436
+ );
58437
+ if (clusterEdges.length === 0) return null;
58438
+ const strongest = clusterEdges.reduce((a, b) => b.weight > a.weight ? b : a);
58439
+ let best = null;
58440
+ for (let k = 1; k <= cluster2.length; k++) {
58441
+ const firedFiles = filesForComposite(fireMatrix, cluster2, k);
58442
+ const nFiles = firedFiles.size;
58443
+ if (nFiles === 0) continue;
58444
+ let recall = 0;
58445
+ let fp = 0;
58446
+ let precision = 0;
58447
+ let canCalibrate = false;
58448
+ if (positiveFiles && positiveFiles.size > 0) {
58449
+ let tp = 0;
58450
+ let fpc = 0;
58451
+ for (const file of firedFiles) {
58452
+ if (positiveFiles.has(file)) tp++;
58453
+ else fpc++;
58454
+ }
58455
+ const totalPos = positiveFiles.size;
58456
+ const totalNeg = Math.max(1, totalFiles - totalPos);
58457
+ recall = totalPos > 0 ? tp / totalPos : 0;
58458
+ fp = fpc / totalNeg;
58459
+ precision = tp + fpc > 0 ? tp / (tp + fpc) : 0;
58460
+ canCalibrate = true;
58461
+ }
58462
+ const F1 = precision + recall > 0 ? 2 * precision * recall / (precision + recall) : 0;
58463
+ if (canCalibrate) {
58464
+ if (recall / Math.max(fp, 1e-9) < thresholds.recallFpRatio) continue;
58465
+ if (fp < thresholds.minFpFloor && recall < 0.5) continue;
58466
+ }
58467
+ if (best === null || F1 > best.F1 + 1e-9 || Math.abs(F1 - best.F1) < 1e-9 && k > best.minMatch) {
58468
+ best = {
58469
+ minMatch: k,
58470
+ npmi: strongest.weight,
58471
+ fisherP: strongest.fisherP,
58472
+ recall,
58473
+ FP: fp,
58474
+ precision,
58475
+ F1,
58476
+ nFiles
58477
+ };
58478
+ }
58479
+ }
58480
+ return best;
58481
+ }
58482
+ function compositeId(members) {
58483
+ const sorted = [...members].sort();
58484
+ return "composite/" + (0, import_node_crypto11.createHash)("sha1").update(sorted.join("\n")).digest("hex").slice(0, 12);
58485
+ }
58486
+ function worstSeverity(members, memberSeverities) {
58487
+ let worst = "low";
58488
+ const order = { low: 0, medium: 1, high: 2 };
58489
+ for (const m of members) {
58490
+ const s = memberSeverities?.get(m);
58491
+ if (s && order[s] > order[worst]) worst = s;
58492
+ }
58493
+ return worst;
58494
+ }
58495
+ function describeComposite(members, minMatch) {
58496
+ const clusterPart = members.length <= 4 ? members.join("+") : `${members.slice(0, 3).join("+")}+${members.length - 3} more`;
58497
+ return `Composite rule: at least ${minMatch} of {${clusterPart}} fire on the same file.`;
58498
+ }
58499
+ function runClusterer(input) {
58500
+ const total = input.fireMatrix.size;
58501
+ const minSupport = input.params?.minSupport ?? adaptiveMinSupport(total);
58502
+ const params = {
58503
+ minSupport,
58504
+ minNPMI: input.params?.minNPMI ?? DEFAULT_CLUSTER_PARAMS.minNPMI,
58505
+ fisherAlpha: input.params?.fisherAlpha ?? DEFAULT_CLUSTER_PARAMS.fisherAlpha,
58506
+ minClusterSize: input.params?.minClusterSize ?? DEFAULT_CLUSTER_PARAMS.minClusterSize
58507
+ };
58508
+ const support = computeRuleSupport(input.fireMatrix);
58509
+ const rulesKept = prefilterBySupport(support, total, params.minSupport);
58510
+ const edges = computeEdges(input.fireMatrix, rulesKept, total, params);
58511
+ const clusters = singleLinkageCluster(edges, params.minClusterSize);
58512
+ const entries = [];
58513
+ const clusterMaxNPMI = /* @__PURE__ */ new Map();
58514
+ for (const cluster2 of clusters) {
58515
+ const cal = calibrateCluster(
58516
+ cluster2,
58517
+ edges,
58518
+ input.fireMatrix,
58519
+ input.positiveFiles,
58520
+ total
58521
+ );
58522
+ if (!cal) continue;
58523
+ const id = compositeId(cluster2);
58524
+ const severity = worstSeverity(cluster2, input.memberSeverities);
58525
+ entries.push({
58526
+ id,
58527
+ ruleIds: [...cluster2].sort(),
58528
+ minMatch: cal.minMatch,
58529
+ severity,
58530
+ defaultOff: true,
58531
+ description: describeComposite(cluster2, cal.minMatch),
58532
+ calibration: {
58533
+ recall: round3(cal.recall),
58534
+ FP: round3(cal.FP),
58535
+ precision: round3(cal.precision),
58536
+ F1: round3(cal.F1),
58537
+ nFiles: cal.nFiles
58538
+ },
58539
+ provenance: {
58540
+ seed: "auto-cluster",
58541
+ discoveredAt: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
58542
+ nFiles: total,
58543
+ members: cluster2.length,
58544
+ npmi: round3(cal.npmi),
58545
+ fisherP: round3(cal.fisherP)
58546
+ }
58547
+ });
58548
+ clusterMaxNPMI.set(id, cal.npmi);
58549
+ }
58550
+ entries.sort((a, b) => b.calibration.F1 - a.calibration.F1 || a.id.localeCompare(b.id));
58551
+ return { entries, supportCounts: support, clusterMaxNPMI, filesScanned: total, params };
58552
+ }
58553
+ function round3(n) {
58554
+ return Math.round(n * 1e3) / 1e3;
58555
+ }
58556
+
58557
+ // src/rules/composite-loader.ts
58558
+ var import_node_fs32 = require("fs");
58559
+ var import_node_path37 = require("path");
58560
+ var COMPOSITES_FILE = "composites.json";
58561
+ function compositesPath(cwd) {
58562
+ return (0, import_node_path37.join)(cwd, COMPOSITES_FILE);
58563
+ }
58564
+ function readComposites(cwd) {
58565
+ const path = compositesPath(cwd);
58566
+ if (!(0, import_node_fs32.existsSync)(path)) return [];
58567
+ try {
58568
+ const raw = JSON.parse((0, import_node_fs32.readFileSync)(path, "utf-8"));
58569
+ if (!Array.isArray(raw)) return [];
58570
+ return raw.filter(isCompositeRuleEntry);
58571
+ } catch {
58572
+ return [];
58573
+ }
58574
+ }
58575
+ function isCompositeRuleEntry(value) {
58576
+ if (!value || typeof value !== "object") return false;
58577
+ const v = value;
58578
+ return typeof v.id === "string" && Array.isArray(v.ruleIds) && typeof v.minMatch === "number";
58579
+ }
58580
+ function writeComposites(cwd, entries) {
58581
+ const path = compositesPath(cwd);
58582
+ (0, import_node_fs32.mkdirSync)((0, import_node_path37.dirname)(path), { recursive: true });
58583
+ const tmp = `${path}.tmp`;
58584
+ (0, import_node_fs32.writeFileSync)(tmp, JSON.stringify(entries, null, 2), "utf-8");
58585
+ const fs = require("fs");
58586
+ fs.renameSync(tmp, path);
58587
+ }
58588
+
58589
+ // src/cli/commands/composite.ts
58590
+ function registerComposite(program) {
58591
+ const cmd = program.command("composite").description(
58592
+ "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."
58593
+ );
58594
+ cmd.command("discover").description(
58595
+ "run the empirical composite clusterer on .slopbrick/flywheel/scans.jsonl; writes composites.json"
58596
+ ).action(async (_cmdOptions, command) => {
58597
+ const options = command.optsWithGlobals();
58598
+ const cwd = (0, import_node_path38.resolve)(options.workspace ?? process.cwd());
58599
+ const payloads = readTelemetry(cwd);
58600
+ if (payloads.length === 0) {
58601
+ logger.error(
58602
+ "`composite discover` needs at least 2 scans in .slopbrick/flywheel/scans.jsonl. Run `slopbrick scan` a few times first."
58603
+ );
58604
+ process.exit(1);
58605
+ }
58606
+ const fireMatrix = /* @__PURE__ */ new Map();
58607
+ for (const payload of payloads) {
58608
+ for (const f of payload.files) {
58609
+ const set = fireMatrix.get(f.hash) ?? /* @__PURE__ */ new Set();
58610
+ for (const id of f.ruleIds) set.add(id);
58611
+ if (set.size > 0) fireMatrix.set(f.hash, set);
58612
+ }
58613
+ }
58614
+ const out = runClusterer({
58615
+ fireMatrix,
58616
+ // No labeled positive set; let the clusterer surface for audit.
58617
+ positiveFiles: void 0,
58618
+ now: (/* @__PURE__ */ new Date()).toISOString()
58619
+ });
58620
+ const finalEntries = out.entries.length === 0 ? [...(await Promise.resolve().then(() => (init_composite_seed(), composite_seed_exports))).HAND_CURATED_SEED] : out.entries;
58621
+ writeComposites(cwd, finalEntries);
58622
+ if (out.entries.length === 0) {
58623
+ logger.info("Composite discovery: no clusters passed the empirical gate.");
58624
+ logger.info(
58625
+ " (Try lowering --min-support, or run more scans to densify the ledger.)"
58626
+ );
58627
+ return;
58628
+ }
58629
+ logger.info(
58630
+ `Composite discovery: ${out.entries.length} candidate(s) written to composites.json`
58631
+ );
58632
+ const lines = [];
58633
+ for (const e of out.entries) {
58634
+ lines.push("");
58635
+ lines.push(
58636
+ ` ${e.id} minMatch=${e.minMatch} F1=${e.calibration.F1} (${e.ruleIds.length} members)`
58637
+ );
58638
+ lines.push(` ${describeComposite(e.ruleIds, e.minMatch)}`);
58639
+ lines.push(
58640
+ ` provenance: ${e.provenance.seed} npmi=${e.provenance.npmi} fisherP=${e.provenance.fisherP}`
58641
+ );
58642
+ }
58643
+ logger.info(lines.join("\n"));
58644
+ logger.info(
58645
+ "\nNext step: review each entry. Opt in with `slopbrick composite enable <id>`; opt out by removing the entry."
58646
+ );
58647
+ });
58648
+ cmd.command("enable <id>").description(
58649
+ "opt a specific composite into slopbrick.config.mjs#compositeRules (one composite per call)"
58650
+ ).action(async (id, _cmdOptions, command) => {
58651
+ const options = command.optsWithGlobals();
58652
+ const cwd = (0, import_node_path38.resolve)(options.workspace ?? process.cwd());
58653
+ const composites = readComposites(cwd);
58654
+ const target = composites.find((c) => c.id === id);
58655
+ if (!target) {
58656
+ logger.error(
58657
+ `Composite "${id}" not found in composites.json. Run \`slopbrick composite discover\` first to populate the ledger.`
58658
+ );
58659
+ process.exit(1);
58660
+ }
58661
+ const cfgPath = (0, import_node_path38.resolve)(cwd, "slopbrick.config.mjs");
58662
+ const fs = require("fs");
58663
+ if (!fs.existsSync(cfgPath)) {
58664
+ logger.error(`No slopbrick.config.mjs found at ${cfgPath}.`);
58665
+ process.exit(1);
58666
+ }
58667
+ const src = fs.readFileSync(cfgPath, "utf-8");
58668
+ const compositeSnippet = JSON.stringify(
58669
+ {
58670
+ id: target.id,
58671
+ category: "ai",
58672
+ severity: target.severity,
58673
+ aiSpecific: true,
58674
+ description: target.description,
58675
+ defaultOff: true,
58676
+ ruleIds: target.ruleIds,
58677
+ minMatch: target.minMatch,
58678
+ create: "() => ({})",
58679
+ analyze: "() => []"
58680
+ },
58681
+ null,
58682
+ 2
58683
+ );
58684
+ if (src.includes(`id: '${target.id}'`) || src.includes(`id: "${target.id}"`)) {
58685
+ logger.info(`Composite "${id}" is already enabled in slopbrick.config.mjs (idempotent).`);
58686
+ return;
58687
+ }
58688
+ const updated = src.replace(
58689
+ /(export default\s*{[\s\S]*?)(}\s*;?\s*)$/,
58690
+ (_match, head, tail) => `${head} compositeRules: [
58691
+ ${compositeSnippet}
58692
+ ],
58693
+ ${tail}`
58694
+ );
58695
+ fs.writeFileSync(cfgPath, updated, "utf-8");
58696
+ logger.info(`Composite "${id}" enabled.`);
58697
+ logger.info("Next scan will apply it; rerun `slopbrick scan` to see the effect.");
58698
+ });
58699
+ cmd.command("list").description("list composites currently registered (composites.json + config-declared)").action(async (_cmdOptions, command) => {
58700
+ const options = command.optsWithGlobals();
58701
+ const cwd = (0, import_node_path38.resolve)(options.workspace ?? process.cwd());
58702
+ const composites = readComposites(cwd);
58703
+ if (composites.length === 0) {
58704
+ logger.info("No composites registered. Run `slopbrick composite discover` first.");
58705
+ return;
58706
+ }
58707
+ const lines = ["Registered composites:"];
58708
+ for (const e of composites) {
58709
+ lines.push(
58710
+ `
58711
+ ${e.id}
58712
+ ${describeComposite(e.ruleIds, e.minMatch)}
58713
+ members=[${e.ruleIds.join(", ")}] minMatch=${e.minMatch}
58714
+ F1=${e.calibration.F1} recall=${e.calibration.recall} FP=${e.calibration.FP}`
58715
+ );
58716
+ }
58717
+ logger.info(lines.join("\n"));
58718
+ });
58719
+ }
58720
+
58136
58721
  // src/cli/explain.ts
58137
58722
  var RULES_BASE_URL2 = "https://github.com/usebrick/platform/blob/main/packages/slopbrick/src/rules";
58138
58723
  function ruleIdToFilename2(ruleId) {
@@ -58182,152 +58767,7 @@ function formatExplain(result) {
58182
58767
  // src/cli/commands/explain.ts
58183
58768
  init_logger();
58184
58769
  init_builtins();
58185
-
58186
- // src/snippet/data.ts
58187
- var CATEGORY_DIRECTIVES = {
58188
- 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.',
58189
- 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.",
58190
- 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.',
58191
- 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".',
58192
- 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.",
58193
- typo: `Never leave TODO / placeholder / "change me" copy in shipped code. Use real i18n strings or the project's content map.`,
58194
- 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].",
58195
- component: "Don't build components > 200 lines. Extract shared subcomponents. Avoid circular prop drilling \u2014 use context.",
58196
- 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.",
58197
- 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'."
58198
- };
58199
- var RULE_HINTS = {
58200
- // v0.38.0 hygiene: 37 v10-DORMANT rule hints were removed. Every
58201
- // remaining key matches a rule in src/rules/builtins.ts. No orphans.
58202
- // (v0.16.0 previously moved 35 out-of-scope orphan hints to
58203
- // docs/research/backlog-rule-hints.md
58204
- // for future implementers to paste back when the corresponding rules ship.)
58205
- "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.",
58206
- "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.",
58207
- "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.",
58208
- "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.",
58209
- "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.",
58210
- "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.",
58211
- "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.',
58212
- "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.",
58213
- "visual/arbitrary-escape": "Never use bracket-notation values like text-[13px] or bg-[#7c3aed]. Use design tokens instead.",
58214
- "visual/spacing-scale-violation": "Use spacing scale tokens (p-2, gap-4, etc.) instead of arbitrary values like p-[13px] or gap-[1.75rem].",
58215
- "visual/radius-scale-violation": "Use radius scale tokens (rounded-md, rounded-lg, etc.) instead of arbitrary values like rounded-[7px].",
58216
- // v0.16.0 — in-scope orphans kept here (corresponding rule ships in v0.16.0).
58217
- "typo/placeholder-text": 'Never leave "TODO", "placeholder", "change me", "your text here" in shipped UI.',
58218
- "logic/key-prop-missing": "Always provide a stable `key` prop when rendering lists.",
58219
- "logic/boundary-violation": "Don't import data-layer / DB code into UI components. Server-side only.",
58220
- "wcag/missing-alt": 'Every <img> needs alt text. Decorative: alt="". Informative: describe the image.',
58221
- "security/localstorage-token": "Never store JWT / access token / refresh token in localStorage or sessionStorage. Issue as httpOnly cookie.",
58222
- "security/eval": "Never use eval() or new Function(). These are RCE vectors if the input is ever attacker-controlled.",
58223
- "security/target-blank-no-noopener": 'Always add rel="noopener" (or rel="noreferrer") to target="_blank" links.',
58224
- "component/giant-component": "Don't build components > 200 lines. Extract shared subcomponents.",
58225
- "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.",
58226
- "component/shadcn-prop-mismatch": "Select shadcn variants via the `variant` prop, not long `className` overrides. See the component registry for available variants.",
58227
- "context/import-path-mismatch": "Use only the canonical import paths declared in brick.config.json (e.g. @/components/ui/, @/lib/).",
58228
- "layout/gap-monopoly": "Mix gap-2 / gap-4 / gap-6 / gap-12 deliberately. Don't repeat the same gap value across the whole project.",
58229
- "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.",
58230
- "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.",
58231
- "layout/spacing-grid": "Use the configured spacing scale (4px or 8px grid). Avoid arbitrary values like p-[13px] that aren't on the scale.",
58232
- "logic/ghost-defensive": "Use optional chaining (?.) or early returns instead of deep && guards. If a defensive chain runs 3+ levels deep, refactor.",
58233
- "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.)",
58234
- "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.)",
58235
- "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.)",
58236
- "logic/math-any-density": "Replace `: any` with proper types. Start with the parameter/return types of the most-used functions.",
58237
- "logic/math-console-log-storm": "Replace debug logs with a proper debugger or logger.debug(). Remove all console.log before shipping.",
58238
- "logic/math-gini-class-usage": "Spread usage across more class tokens instead of repeating the same handful (p-4, p-8, rounded-lg, etc.).",
58239
- "logic/math-variable-name-entropy": "Use domain-specific identifier names (reservations, invoices, customers) instead of generic data/items/value.",
58240
- "logic/optimistic-no-rollback": "In optimistic updates, revert state in the catch block: `setX(prev => prev)`. Never leave stale UI on error.",
58241
- "logic/reactive-hook-soup": "Coordinate state via a single derived value (useMemo) or a state machine. Avoid chained useEffects that sync local state.",
58242
- "logic/zombie-state": "Remove unused useState or wire it into the component. Don't leave declared-but-never-read state bindings.",
58243
- "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.",
58244
- "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.",
58245
- "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.",
58246
- "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.",
58247
- "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.",
58248
- "perf/cls-image": "Add width/height attributes or an aspect-ratio utility to prevent layout shift.",
58249
- "perf/css-bloat": "Extract to a CSS variable (`--surface-card`) or a component prop when a class string repeats 5+ times.",
58250
- "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.',
58251
- "visual/inline-style-dominance": "Replace inline `style={{...}}` with className utilities (e.g. Tailwind `p-4 m-2 gap-3`) or a CSS module class.",
58252
- "visual/math-default-font": "Import a distinctive font (next/font/google, @font-face, or a CSS variable) instead of relying on the framework default.",
58253
- "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.",
58254
- "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.",
58255
- "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.",
58256
- "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%.",
58257
- "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.",
58258
- "wcag/focus-appearance": "Add a focus-visible:ring-* class, or remove outline-none. Keyboard users need a visible focus indicator.",
58259
- "wcag/focus-obscured": "Ensure focused elements are not hidden behind fixed or sticky wrappers.",
58260
- "test/weak-assertion": "Assert on a specific value or shape: `expect(x).toEqual(expectedValue)`. Avoid `.toBeDefined()` / `.toBeTruthy()` placeholders and tautological `expect(x).toBe(x)`.",
58261
- "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.",
58262
- "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`).",
58263
- "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.",
58264
- "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.",
58265
- // v0.13.0 — AI-specific rules (peer-reviewed signals).
58266
- "ai/markdown-leakage": "Delete stray `\\`\\`\\`<lang>\\`\\`\\`` markers; they are Markdown fences, not valid syntax in standalone source files (Yotkova et al. SemEval-2026).",
58267
- "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).",
58268
- "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).",
58269
- "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).",
58270
- "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).",
58271
- "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).",
58272
- "ai/renyi-profile": "The token distribution is mass-concentrated on a few high-frequency tokens. Verify authorship if unexpected (R\xE9nyi 1961, Moslonka 2025).",
58273
- "ai/segment-surprisal-cv": "The cross-entropy is suspiciously uniform across the file. Real codebases have varied registers (Binoculars, Hans 2024).",
58274
- "ai/compression-profile": "The file compresses unusually well and lines are highly repetitive \u2014 characteristic of AI-generated boilerplate (Cilibrasi 2005, Mahoney 1999).",
58275
- // v0.14.5b — 6 new AI tendency detection rules (DORMANT in v0.14.5b;
58276
- // reclassified post-v7 calibration in v0.14.5d)
58277
- "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.",
58278
- "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.",
58279
- "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.",
58280
- "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.",
58281
- "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.",
58282
- "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.",
58283
- // v0.17.0 — db/* rules (Postgres static analysis via pgsql-parser)
58284
- "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.',
58285
- // v0.17.0 — docs/* rules (markdown drift detection)
58286
- "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.",
58287
- "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.",
58288
- "docs/broken-link": "Create the file or fix the link target. On a public docs site, broken links erode trust more than stale copy.",
58289
- // v0.18.9 — Rust-specific rules (tree-sitter-backed)
58290
- "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.",
58291
- "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.",
58292
- "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.",
58293
- "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.',
58294
- // v0.19.0 — TypeScript-specific rules
58295
- "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.",
58296
- "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.",
58297
- "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.",
58298
- "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.",
58299
- // v0.19.0 — Go-specific rules
58300
- "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).',
58301
- "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.",
58302
- // v0.19.0 — Duplication detector
58303
- "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.",
58304
- // v0.23.0 — Type-2 near-duplicate detector (MinHash + LSH)
58305
- "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.)",
58306
- // v0.24.0 — Type-3 structural clone detector (two-stage MinHash)
58307
- "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.)",
58308
- // v0.24.0 — Kotlin rules (DORMANT until v9 Kotlin corpus calibration)
58309
- // v0.29.0 — non-AI Kotlin rules (DORMANT until larger pos arm)
58310
- // v0.30.0 — non-AI Java rules (Option C applied to v9 Java corpus, 92k files)
58311
- "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.)",
58312
- "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.)",
58313
- // v0.35.0 — content-based detection (CoCoNUTS-inspired)
58314
- "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.",
58315
- // v0.35.1 — Raidar-inspired content-based detection
58316
- "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)`.',
58317
- // v0.24.0 — Swift rules (DORMANT until v9 Swift corpus calibration)
58318
- "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.)",
58319
- "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.)",
58320
- "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.)",
58321
- "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.)",
58322
- "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.)",
58323
- // v0.24.0 — C++ rules (DORMANT until v9 C++ corpus calibration)
58324
- "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.)",
58325
- "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.)",
58326
- "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.)",
58327
- "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.)"
58328
- };
58329
-
58330
- // src/cli/commands/explain.ts
58770
+ init_data();
58331
58771
  function registerExplain(program) {
58332
58772
  program.command("explain <ruleId>").description("Print rationale, pattern, and remediation for a single rule").action((ruleId) => {
58333
58773
  const result = explainRule(ruleId, builtinRules, RULE_HINTS);
@@ -58337,7 +58777,7 @@ function registerExplain(program) {
58337
58777
  }
58338
58778
 
58339
58779
  // src/cli/commands/calibration.ts
58340
- var import_node_path36 = require("path");
58780
+ var import_node_path39 = require("path");
58341
58781
  var import_node_url = require("url");
58342
58782
 
58343
58783
  // src/rules/signal-strength.json
@@ -61930,7 +62370,7 @@ var signal_strength_default2 = {
61930
62370
  };
61931
62371
 
61932
62372
  // src/cli/commands/calibration.ts
61933
- var __dirname = (0, import_node_path36.dirname)((0, import_node_url.fileURLToPath)(__importMetaUrl));
62373
+ var __dirname = (0, import_node_path39.dirname)((0, import_node_url.fileURLToPath)(__importMetaUrl));
61934
62374
  function loadData() {
61935
62375
  return signal_strength_default2;
61936
62376
  }
@@ -62051,13 +62491,13 @@ function registerCalibration(program) {
62051
62491
  }
62052
62492
 
62053
62493
  // src/cli/commands/install.ts
62054
- var import_node_path38 = require("path");
62494
+ var import_node_path41 = require("path");
62055
62495
  init_logger();
62056
62496
  init_git();
62057
62497
 
62058
62498
  // src/cli/installer.ts
62059
- var import_node_fs31 = require("fs");
62060
- var import_node_path37 = require("path");
62499
+ var import_node_fs33 = require("fs");
62500
+ var import_node_path40 = require("path");
62061
62501
  var BEGIN_SENTINEL = "# slopbrick-hook-begin";
62062
62502
  var END_SENTINEL = "# slopbrick-hook-end";
62063
62503
  var SENTINEL_BLOCK = `${BEGIN_SENTINEL}
@@ -62065,14 +62505,14 @@ npx slopbrick --staged
62065
62505
  ${END_SENTINEL}
62066
62506
  `;
62067
62507
  function hookPath(gitRoot) {
62068
- const huskyDir = (0, import_node_path37.join)(gitRoot, ".husky");
62069
- if ((0, import_node_fs31.existsSync)(huskyDir)) {
62070
- return (0, import_node_path37.join)(huskyDir, "pre-commit");
62508
+ const huskyDir = (0, import_node_path40.join)(gitRoot, ".husky");
62509
+ if ((0, import_node_fs33.existsSync)(huskyDir)) {
62510
+ return (0, import_node_path40.join)(huskyDir, "pre-commit");
62071
62511
  }
62072
- return (0, import_node_path37.join)(gitRoot, ".git", "hooks", "pre-commit");
62512
+ return (0, import_node_path40.join)(gitRoot, ".git", "hooks", "pre-commit");
62073
62513
  }
62074
62514
  function readHookContent(path) {
62075
- return (0, import_node_fs31.readFileSync)(path, "utf8");
62515
+ return (0, import_node_fs33.readFileSync)(path, "utf8");
62076
62516
  }
62077
62517
  function sentinelsPresent(content) {
62078
62518
  const lines = content.split(/\r?\n/);
@@ -62094,7 +62534,7 @@ function replaceSentinelBlock(content) {
62094
62534
  }
62095
62535
  function installHook(gitRoot) {
62096
62536
  const path = hookPath(gitRoot);
62097
- if ((0, import_node_fs31.existsSync)(path)) {
62537
+ if ((0, import_node_fs33.existsSync)(path)) {
62098
62538
  const content = readHookContent(path);
62099
62539
  const { begin, end } = sentinelsPresent(content);
62100
62540
  if (begin && end) {
@@ -62106,9 +62546,9 @@ function installHook(gitRoot) {
62106
62546
  exitCode: 0
62107
62547
  };
62108
62548
  }
62109
- (0, import_node_fs31.writeFileSync)(path, replaced.endsWith("\n") ? replaced : `${replaced}
62549
+ (0, import_node_fs33.writeFileSync)(path, replaced.endsWith("\n") ? replaced : `${replaced}
62110
62550
  `);
62111
- (0, import_node_fs31.chmodSync)(path, 493);
62551
+ (0, import_node_fs33.chmodSync)(path, 493);
62112
62552
  return {
62113
62553
  ok: true,
62114
62554
  message: "Replaced pre-commit hook block",
@@ -62126,17 +62566,17 @@ function installHook(gitRoot) {
62126
62566
  }
62127
62567
  const normalized = content.length > 0 && !content.endsWith("\n") ? `${content}
62128
62568
  ` : content;
62129
- (0, import_node_fs31.writeFileSync)(path, `${normalized}${SENTINEL_BLOCK}`);
62130
- (0, import_node_fs31.chmodSync)(path, 493);
62569
+ (0, import_node_fs33.writeFileSync)(path, `${normalized}${SENTINEL_BLOCK}`);
62570
+ (0, import_node_fs33.chmodSync)(path, 493);
62131
62571
  return {
62132
62572
  ok: true,
62133
62573
  message: "Installed pre-commit hook",
62134
62574
  exitCode: 0
62135
62575
  };
62136
62576
  }
62137
- (0, import_node_fs31.mkdirSync)((0, import_node_path37.dirname)(path), { recursive: true });
62138
- (0, import_node_fs31.writeFileSync)(path, SENTINEL_BLOCK, { mode: 493 });
62139
- (0, import_node_fs31.chmodSync)(path, 493);
62577
+ (0, import_node_fs33.mkdirSync)((0, import_node_path40.dirname)(path), { recursive: true });
62578
+ (0, import_node_fs33.writeFileSync)(path, SENTINEL_BLOCK, { mode: 493 });
62579
+ (0, import_node_fs33.chmodSync)(path, 493);
62140
62580
  return {
62141
62581
  ok: true,
62142
62582
  message: "Installed pre-commit hook",
@@ -62145,7 +62585,7 @@ function installHook(gitRoot) {
62145
62585
  }
62146
62586
  function uninstallHook(gitRoot) {
62147
62587
  const path = hookPath(gitRoot);
62148
- if (!(0, import_node_fs31.existsSync)(path)) {
62588
+ if (!(0, import_node_fs33.existsSync)(path)) {
62149
62589
  return {
62150
62590
  ok: true,
62151
62591
  message: "Hook not installed",
@@ -62181,7 +62621,7 @@ function uninstallHook(gitRoot) {
62181
62621
  }
62182
62622
  const remaining = [...lines.slice(0, start), ...lines.slice(end + 1)];
62183
62623
  const result = remaining.join("\n");
62184
- (0, import_node_fs31.writeFileSync)(path, result.endsWith("\n") ? result : `${result}
62624
+ (0, import_node_fs33.writeFileSync)(path, result.endsWith("\n") ? result : `${result}
62185
62625
  `);
62186
62626
  return {
62187
62627
  ok: true,
@@ -62194,7 +62634,7 @@ function uninstallHook(gitRoot) {
62194
62634
  function registerInstall(program) {
62195
62635
  program.command("install").description("install the git pre-commit hook").action(async (_cmdOptions, command) => {
62196
62636
  const options = command.optsWithGlobals();
62197
- const cwd = (0, import_node_path38.resolve)(options.workspace ?? process.cwd());
62637
+ const cwd = (0, import_node_path41.resolve)(options.workspace ?? process.cwd());
62198
62638
  const root = getGitRoot(cwd);
62199
62639
  if (!root) {
62200
62640
  logger.error("Not a Git repository. Run `git init` first, or remove --staged from your command.");
@@ -62209,13 +62649,13 @@ function registerInstall(program) {
62209
62649
  }
62210
62650
 
62211
62651
  // src/cli/commands/uninstall.ts
62212
- var import_node_path39 = require("path");
62652
+ var import_node_path42 = require("path");
62213
62653
  init_logger();
62214
62654
  init_git();
62215
62655
  function registerUninstall(program) {
62216
62656
  program.command("uninstall").description("uninstall the git pre-commit hook").action(async (_cmdOptions, command) => {
62217
62657
  const options = command.optsWithGlobals();
62218
- const cwd = (0, import_node_path39.resolve)(options.workspace ?? process.cwd());
62658
+ const cwd = (0, import_node_path42.resolve)(options.workspace ?? process.cwd());
62219
62659
  const root = getGitRoot(cwd);
62220
62660
  if (!root) {
62221
62661
  logger.error("Not a Git repository. Run `git init` first, or remove --staged from your command.");
@@ -62236,7 +62676,374 @@ init_logger();
62236
62676
  init_builtins();
62237
62677
  init_config2();
62238
62678
  init_header();
62239
- init_tools();
62679
+
62680
+ // src/mcp/tools.ts
62681
+ var import_node_fs34 = require("fs");
62682
+ var import_node_path43 = require("path");
62683
+ init_worker();
62684
+ init_patterns();
62685
+ init_architecture_score();
62686
+ init_business_logic();
62687
+ init_structure_md();
62688
+ var TOOL_DEFINITIONS = [
62689
+ {
62690
+ name: "slop_scan_file",
62691
+ description: "Scan a single TypeScript/JavaScript file for AI-generated frontend slop. Returns issues (ruleId, severity, line, column, message, advice) and the file-level Slop Index.",
62692
+ inputSchema: {
62693
+ type: "object",
62694
+ properties: {
62695
+ path: { type: "string", description: "Absolute or cwd-relative path to the source file." },
62696
+ framework: {
62697
+ type: "string",
62698
+ enum: ["react", "vue", "svelte", "astro", "html"],
62699
+ description: "Framework multiplier to apply. Defaults to the configured framework."
62700
+ }
62701
+ },
62702
+ required: ["path"]
62703
+ }
62704
+ },
62705
+ {
62706
+ name: "slop_explain_rule",
62707
+ description: "Return metadata for a single rule (id, category, severity, aiSpecific) plus a rationale and the recommended fix. Use this before auto-applying --fix to understand what the rule catches.",
62708
+ inputSchema: {
62709
+ type: "object",
62710
+ properties: {
62711
+ ruleId: { type: "string", description: 'e.g. "visual/ai-default-palette".' }
62712
+ },
62713
+ required: ["ruleId"]
62714
+ }
62715
+ },
62716
+ {
62717
+ name: "slop_list_rules",
62718
+ description: "List all registered rules with their category, severity, and aiSpecific flag. Optional category filter (visual | logic | wcag | security | perf | typo | layout | component | arch).",
62719
+ inputSchema: {
62720
+ type: "object",
62721
+ properties: {
62722
+ category: { type: "string", description: "Optional category filter." }
62723
+ }
62724
+ }
62725
+ },
62726
+ {
62727
+ name: "slop_suggest",
62728
+ description: "**Primary entry point for AI agents.** Returns the project's existing patterns (modals, buttons, api clients, state libs, data-fetching libs), the do-not-create list (forbidden imports + canonical patterns not to duplicate), top issues by rule, hot files by issue count, and the composite Repository Health score. Call this BEFORE writing new code so the agent reuses existing patterns instead of duplicating them.",
62729
+ inputSchema: {
62730
+ type: "object",
62731
+ properties: {
62732
+ maxFiles: {
62733
+ type: "number",
62734
+ description: "Cap on files scanned to keep the inventory cheap. Defaults to 200."
62735
+ }
62736
+ }
62737
+ }
62738
+ },
62739
+ {
62740
+ name: "slop_suggest_with_structure",
62741
+ description: "Fast-path variant of `slop_suggest` that reads `.slopbrick/structure.md` from disk instead of re-scanning the codebase. Requires a prior `slopbrick scan` to have persisted the inventory (100\u20131000\xD7 latency win on the agent integration). If `structure.md` is missing, falls back to `slop_suggest` and annotates the response with `structureHint` so the caller knows to run `slopbrick scan` first.",
62742
+ inputSchema: {
62743
+ type: "object",
62744
+ properties: {
62745
+ maxFiles: {
62746
+ type: "number",
62747
+ description: "Cap on files scanned for the slow-path fallback. Defaults to 200."
62748
+ }
62749
+ }
62750
+ }
62751
+ },
62752
+ {
62753
+ // v0.39.0: removed 3 deprecated tools (slop_governance,
62754
+ // slop_architecture_score, slop_business_logic_score) that
62755
+ // were marked for removal in v0.13.0 but never removed.
62756
+ // They were strict subsets of slop_suggest; users should
62757
+ // call slop_suggest and read repositoryHealth /
62758
+ // architectureConsistency / businessLogicCoherence.
62759
+ name: "slop_check_constitution",
62760
+ description: "Check a single file against the project's declared constitution (stateManagement, dataFetching, uiLibrary, forms, styling, routing, plus a forbidden deny-list in slopbrick.config.mjs). Returns a list of imports that violate declared values or hit the deny-list. Use this on a newly-written or modified file before suggesting a PR.",
62761
+ inputSchema: {
62762
+ type: "object",
62763
+ properties: {
62764
+ path: { type: "string", description: "Absolute or cwd-relative path to the source file." }
62765
+ },
62766
+ required: ["path"]
62767
+ }
62768
+ },
62769
+ {
62770
+ // v0.10.1: find_similar_function. The GIR (Give-Implementation-
62771
+ // Reference) primitive for slop_suggest. Given a function signature
62772
+ // (name + hooks + props), find the most similar existing
62773
+ // implementations across the codebase. Uses AST fingerprints
62774
+ // (sha256 over sorted hooks ∪ props ∪ params) + Jaccard similarity
62775
+ // — no LLM, no embeddings, deterministic. Foundation for StackPick.
62776
+ name: "slop_find_similar",
62777
+ description: "Find the most similar existing function/component implementations across the codebase, ranked by Jaccard similarity over the union of (hooks \u222A props \u222A params). Use this BEFORE writing new code so the agent reuses an existing pattern instead of inventing a new one. Returns top-k matches with name, file, line, fingerprint, and similarity score in [0, 1].",
62778
+ inputSchema: {
62779
+ type: "object",
62780
+ properties: {
62781
+ name: {
62782
+ type: "string",
62783
+ description: "Function/component name to match. Omit to match by hooks+props only."
62784
+ },
62785
+ hooks: {
62786
+ type: "array",
62787
+ items: { type: "string" },
62788
+ description: 'React hooks used by the target signature, e.g. ["useState", "useEffect"].'
62789
+ },
62790
+ props: {
62791
+ type: "array",
62792
+ items: { type: "string" },
62793
+ description: 'Component props for the target signature, e.g. ["variant", "size", "children"].'
62794
+ },
62795
+ limit: {
62796
+ type: "number",
62797
+ description: "Top-k results to return. Default 10. Capped at 50."
62798
+ }
62799
+ }
62800
+ }
62801
+ }
62802
+ ];
62803
+ function toolError(message) {
62804
+ return {
62805
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
62806
+ isError: true
62807
+ };
62808
+ }
62809
+ async function runScanFile(args, ctx) {
62810
+ const path = args.path;
62811
+ if (!path) return toolError("Missing required argument: path");
62812
+ const result = await scanFile(path, ctx.config);
62813
+ const simplified = {
62814
+ filePath: result.filePath,
62815
+ componentCount: result.componentCount,
62816
+ parseError: result.parseError,
62817
+ issues: result.issues.map((i) => ({
62818
+ ruleId: i.ruleId,
62819
+ category: i.category,
62820
+ severity: i.severity,
62821
+ line: i.line,
62822
+ column: i.column,
62823
+ message: i.message,
62824
+ advice: i.advice
62825
+ }))
62826
+ };
62827
+ return {
62828
+ content: [{ type: "text", text: JSON.stringify(simplified, null, 2) }]
62829
+ };
62830
+ }
62831
+ function explainRule2(args, ctx) {
62832
+ const ruleId = args.ruleId;
62833
+ if (!ruleId) return toolError("Missing required argument: ruleId");
62834
+ const rule = ctx.rules.find((r) => r.id === ruleId);
62835
+ if (!rule) return toolError("Unknown rule: " + ruleId);
62836
+ const explanation = {
62837
+ ruleId: rule.id,
62838
+ category: rule.category,
62839
+ severity: rule.severity,
62840
+ aiSpecific: rule.aiSpecific,
62841
+ rationale: "This rule flags " + rule.category + " patterns associated with AI-generated code. It is marked as " + (rule.aiSpecific ? "AI-specific" : "cross-cutting") + ". Severity: " + rule.severity + ".",
62842
+ whereToLook: "src/rules/" + rule.category + "/" + rule.id.replace(/^[^/]+\//, "") + ".ts"
62843
+ };
62844
+ return {
62845
+ content: [{ type: "text", text: JSON.stringify(explanation, null, 2) }]
62846
+ };
62847
+ }
62848
+ function listRules(args, ctx) {
62849
+ const category = args.category;
62850
+ const filtered = category ? ctx.rules.filter((r) => r.category === category) : ctx.rules;
62851
+ const rules = filtered.map((r) => ({
62852
+ id: r.id,
62853
+ category: r.category,
62854
+ severity: r.severity,
62855
+ aiSpecific: r.aiSpecific
62856
+ }));
62857
+ return {
62858
+ content: [
62859
+ {
62860
+ type: "text",
62861
+ text: JSON.stringify({ count: rules.length, rules }, null, 2)
62862
+ }
62863
+ ]
62864
+ };
62865
+ }
62866
+ var STRUCTURE_NOT_FOUND_HINT = "No .slopbrick/structure.md found. Run `slopbrick scan` to persist the pattern inventory, then call this tool again for the O(read file) fast path.";
62867
+ async function runSuggest(args, ctx, options = {}) {
62868
+ const { includeStructure = false } = options;
62869
+ if (includeStructure) {
62870
+ const cached = await readStructureMarkdown(ctx.cwd);
62871
+ if (cached !== null) {
62872
+ return {
62873
+ content: [{ type: "text", text: cached }]
62874
+ };
62875
+ }
62876
+ }
62877
+ const maxFilesRaw = args.maxFiles;
62878
+ const maxFiles = typeof maxFilesRaw === "number" && Number.isFinite(maxFilesRaw) && maxFilesRaw > 0 ? Math.min(2e3, Math.floor(maxFilesRaw)) : 200;
62879
+ try {
62880
+ const inventory = await buildPatternInventory(ctx.cwd, ctx.config, maxFiles);
62881
+ const doNotCreate = [
62882
+ ...ctx.config.constitution?.forbidden ?? []
62883
+ ];
62884
+ const declared = /* @__PURE__ */ new Set();
62885
+ for (const list of [
62886
+ ctx.config.constitution?.stateManagement ?? [],
62887
+ ctx.config.constitution?.dataFetching ?? [],
62888
+ ctx.config.constitution?.uiLibrary ?? [],
62889
+ ctx.config.constitution?.forms ?? [],
62890
+ ctx.config.constitution?.styling ?? [],
62891
+ ctx.config.constitution?.routing ?? []
62892
+ ]) {
62893
+ for (const lib of list) declared.add(lib);
62894
+ }
62895
+ const doNotCreateCapped = doNotCreate.slice(0, 10);
62896
+ const payload = {
62897
+ hint: "Use these patterns instead of creating new ones. Pick the closest existing entry and import it. The `doNotCreate` list is the deny-list \u2014 never import any of these.",
62898
+ doNotCreate: doNotCreateCapped,
62899
+ declaredStack: Array.from(declared),
62900
+ existingPatterns: inventory
62901
+ };
62902
+ if (includeStructure) {
62903
+ payload.structureHint = STRUCTURE_NOT_FOUND_HINT;
62904
+ }
62905
+ try {
62906
+ const { loadHealth: loadHealth2 } = await Promise.resolve().then(() => (init_dist(), dist_exports));
62907
+ const health = loadHealth2(ctx.cwd);
62908
+ if (health?.compositeScore) {
62909
+ payload.compositeScore = health.compositeScore;
62910
+ }
62911
+ } catch {
62912
+ }
62913
+ return {
62914
+ content: [
62915
+ {
62916
+ type: "text",
62917
+ text: JSON.stringify(payload, null, 2)
62918
+ }
62919
+ ]
62920
+ };
62921
+ } catch (err) {
62922
+ return toolError(err instanceof Error ? err.message : String(err));
62923
+ }
62924
+ }
62925
+ function runCheckConstitution(args, ctx) {
62926
+ const path = args.path;
62927
+ if (!path) return toolError("Missing required argument: path");
62928
+ const absPath = (0, import_node_path43.resolve)(ctx.cwd, path);
62929
+ let source;
62930
+ try {
62931
+ source = (0, import_node_fs34.readFileSync)(absPath, "utf-8");
62932
+ } catch (err) {
62933
+ return toolError(
62934
+ `Cannot read file ${absPath}: ${err instanceof Error ? err.message : String(err)}`
62935
+ );
62936
+ }
62937
+ const result = checkFileConstitution(source, ctx.config.constitution);
62938
+ return {
62939
+ content: [
62940
+ {
62941
+ type: "text",
62942
+ text: JSON.stringify(
62943
+ {
62944
+ file: absPath,
62945
+ importCount: result.imports.length,
62946
+ violationCount: result.violations.length,
62947
+ imports: result.imports,
62948
+ violations: result.violations,
62949
+ // Field name kept stable for backward compatibility with
62950
+ // older consumers; the value reflects whether the merged
62951
+ // `config.constitution` was declared, detected, or absent.
62952
+ conventionSource: ctx.config.constitution ? "declared-or-detected" : "none"
62953
+ },
62954
+ null,
62955
+ 2
62956
+ )
62957
+ }
62958
+ ]
62959
+ };
62960
+ }
62961
+ async function runFindSimilar(args, ctx) {
62962
+ const { findSimilarFunctions: findSimilarFunctions2 } = await Promise.resolve().then(() => (init_dist2(), dist_exports2));
62963
+ const hooks = Array.isArray(args.hooks) ? args.hooks : [];
62964
+ const props = Array.isArray(args.props) ? args.props : [];
62965
+ const limitRaw = args.limit;
62966
+ const limit = typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 10;
62967
+ try {
62968
+ const matches = await findSimilarFunctions2(
62969
+ {
62970
+ name: typeof args.name === "string" ? args.name : void 0,
62971
+ hooks,
62972
+ props,
62973
+ limit,
62974
+ workspaceDir: ctx.cwd
62975
+ },
62976
+ { cwd: ctx.cwd }
62977
+ );
62978
+ return {
62979
+ content: [
62980
+ {
62981
+ type: "text",
62982
+ text: JSON.stringify(
62983
+ {
62984
+ hint: "Each match is ranked by Jaccard similarity over (hooks \u222A props \u222A params). similarity=1 means the matched signature has an identical feature set. Agents should prefer the top match instead of writing a new implementation.",
62985
+ count: matches.length,
62986
+ matches: matches.map((m) => ({
62987
+ name: m.signature.name,
62988
+ file: m.signature.fileRel,
62989
+ line: m.signature.line,
62990
+ similarity: Number(m.similarity.toFixed(3)),
62991
+ fingerprint: m.fingerprint,
62992
+ hooks: m.signature.hooks,
62993
+ props: m.signature.props,
62994
+ params: m.signature.params
62995
+ }))
62996
+ },
62997
+ null,
62998
+ 2
62999
+ )
63000
+ }
63001
+ ]
63002
+ };
63003
+ } catch (err) {
63004
+ return toolError(err instanceof Error ? err.message : String(err));
63005
+ }
63006
+ }
63007
+ async function handleToolCall(toolName, args, ctx) {
63008
+ const deprecation = getDeprecation(toolName);
63009
+ const deprecationNotice = deprecation ? {
63010
+ tool: toolName,
63011
+ replacedBy: deprecation.replacedBy,
63012
+ removedIn: deprecation.removedIn ?? "next major",
63013
+ reason: deprecation.reason
63014
+ } : void 0;
63015
+ switch (toolName) {
63016
+ case "slop_scan_file":
63017
+ return runScanFile(args, ctx);
63018
+ case "slop_explain_rule":
63019
+ return explainRule2(args, ctx);
63020
+ case "slop_list_rules":
63021
+ return listRules(args, ctx);
63022
+ case "slop_suggest":
63023
+ return runSuggest(args, ctx);
63024
+ case "slop_suggest_with_structure":
63025
+ return runSuggest(args, ctx, { includeStructure: true });
63026
+ // v0.39.0: removed 3 deprecated tools (slop_governance,
63027
+ // slop_architecture_score, slop_business_logic_score) that
63028
+ // were marked for removal in v0.13.0 but never removed.
63029
+ // Their runner functions (runGovernance, runArchitectureScore,
63030
+ // runBusinessLogicScore) are kept in the file for now
63031
+ // (marked @deprecated) to keep the diff small; they can be
63032
+ // deleted in a follow-up. New clients will never see these
63033
+ // tools listed in the MCP tools/list response.
63034
+ case "slop_check_constitution":
63035
+ return runCheckConstitution(args, ctx);
63036
+ case "slop_find_similar":
63037
+ return runFindSimilar(args, ctx);
63038
+ default:
63039
+ return toolError("Unknown tool: " + toolName);
63040
+ }
63041
+ }
63042
+ function getDeprecation(toolName) {
63043
+ return TOOL_DEFINITIONS.find((t) => t.name === toolName)?.deprecated;
63044
+ }
63045
+
63046
+ // src/mcp/server.ts
62240
63047
  var SERVER_INFO = {
62241
63048
  name: "slopbrick",
62242
63049
  // v0.39.0: use the VERSION constant (same source as the CLI)
@@ -62304,7 +63111,7 @@ function errorResponse(id, code, message, data2) {
62304
63111
  return { jsonrpc: "2.0", id, error: { code, message, ...data2 !== void 0 ? { data: data2 } : {} } };
62305
63112
  }
62306
63113
  async function runMcpServer(input, output, cwd) {
62307
- return new Promise((resolve42) => {
63114
+ return new Promise((resolve43) => {
62308
63115
  let buffer = "";
62309
63116
  input.setEncoding("utf-8");
62310
63117
  input.on("data", (chunk) => {
@@ -62342,8 +63149,8 @@ async function runMcpServer(input, output, cwd) {
62342
63149
  nlIdx = buffer.indexOf("\n");
62343
63150
  }
62344
63151
  });
62345
- input.on("end", () => resolve42());
62346
- input.on("close", () => resolve42());
63152
+ input.on("end", () => resolve43());
63153
+ input.on("close", () => resolve43());
62347
63154
  });
62348
63155
  }
62349
63156
 
@@ -62366,7 +63173,7 @@ function registerDoctor(program) {
62366
63173
  }
62367
63174
 
62368
63175
  // src/cli/commands/watch.ts
62369
- var import_node_path41 = require("path");
63176
+ var import_node_path44 = require("path");
62370
63177
  init_watch();
62371
63178
  function registerWatch(program, scanAction) {
62372
63179
  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) => {
@@ -62375,19 +63182,19 @@ function registerWatch(program, scanAction) {
62375
63182
  ...rawGlobals,
62376
63183
  noIncrease: rawGlobals.increase === false
62377
63184
  };
62378
- const cwd = (0, import_node_path41.resolve)(options.workspace ?? process.cwd());
63185
+ const cwd = (0, import_node_path44.resolve)(options.workspace ?? process.cwd());
62379
63186
  await scanAction([], options, command);
62380
63187
  await watchProject(options, cwd, []);
62381
63188
  });
62382
63189
  }
62383
63190
 
62384
63191
  // src/cli/commands/lock.ts
62385
- var import_node_path42 = require("path");
63192
+ var import_node_path45 = require("path");
62386
63193
  init_logger();
62387
63194
  function registerLock(program) {
62388
63195
  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(
62389
63196
  (cmdOptions) => {
62390
- const cwd = (0, import_node_path42.resolve)(cmdOptions.workspace ?? process.cwd());
63197
+ const cwd = (0, import_node_path45.resolve)(cmdOptions.workspace ?? process.cwd());
62391
63198
  if (cmdOptions.uninstall) {
62392
63199
  const result2 = uninstallHook(cwd);
62393
63200
  logger.info(result2.message);
@@ -62408,7 +63215,7 @@ function registerLock(program) {
62408
63215
  }
62409
63216
 
62410
63217
  // src/cli/commands/ci.ts
62411
- var import_node_path43 = require("path");
63218
+ var import_node_path46 = require("path");
62412
63219
  init_logger();
62413
63220
  init_dist();
62414
63221
  function registerCi(program, scanAction) {
@@ -62423,7 +63230,7 @@ function registerCi(program, scanAction) {
62423
63230
  // scan only changed files
62424
63231
  format: cmdOptions.format ?? "json"
62425
63232
  };
62426
- const cwd = (0, import_node_path43.resolve)(options.workspace ?? process.cwd());
63233
+ const cwd = (0, import_node_path46.resolve)(options.workspace ?? process.cwd());
62427
63234
  await scanAction([], options, command);
62428
63235
  const health = loadHealth(cwd);
62429
63236
  if (!health) {
@@ -62451,12 +63258,12 @@ function registerCi(program, scanAction) {
62451
63258
  }
62452
63259
 
62453
63260
  // src/cli/commands/memory.ts
62454
- var import_node_path44 = require("path");
63261
+ var import_node_path47 = require("path");
62455
63262
  init_logger();
62456
63263
  function registerMemory(program) {
62457
63264
  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(
62458
63265
  async (cmdOptions) => {
62459
- const cwd = (0, import_node_path44.resolve)(cmdOptions.workspace ?? process.cwd());
63266
+ const cwd = (0, import_node_path47.resolve)(cmdOptions.workspace ?? process.cwd());
62460
63267
  const { renderStructureMarkdown: renderStructureMarkdown2, readStructureMarkdown: readStructureMarkdown2, writeStructureMarkdown: writeStructureMarkdown2 } = await Promise.resolve().then(() => (init_structure_md(), structure_md_exports));
62461
63268
  const { loadInventory: loadInventory2, loadConstitution: loadConstitution2, inventoryPath: invPath, constitutionPath: conPath } = await Promise.resolve().then(() => (init_dist(), dist_exports));
62462
63269
  if (cmdOptions.regenerate) {
@@ -62486,49 +63293,49 @@ function registerMemory(program) {
62486
63293
  }
62487
63294
 
62488
63295
  // src/cli/commands/migrate.ts
62489
- var import_node_path46 = require("path");
63296
+ var import_node_path49 = require("path");
62490
63297
  init_logger();
62491
63298
 
62492
63299
  // src/cli/migrate.ts
62493
- var import_node_fs33 = require("fs");
62494
- var import_node_path45 = require("path");
63300
+ var import_node_fs35 = require("fs");
63301
+ var import_node_path48 = require("path");
62495
63302
  init_logger();
62496
63303
  function planMigration(workspaceDir) {
62497
63304
  const moves = [];
62498
63305
  const rewrites = [];
62499
63306
  const gitignoreEdits = [];
62500
- const oldDir = (0, import_node_path45.join)(workspaceDir, ".slop-audit");
62501
- const newDir = (0, import_node_path45.join)(workspaceDir, ".slopbrick");
62502
- if ((0, import_node_fs33.existsSync)(oldDir)) {
63307
+ const oldDir = (0, import_node_path48.join)(workspaceDir, ".slop-audit");
63308
+ const newDir = (0, import_node_path48.join)(workspaceDir, ".slopbrick");
63309
+ if ((0, import_node_fs35.existsSync)(oldDir)) {
62503
63310
  moves.push({ from: oldDir, to: newDir, kind: "dir" });
62504
63311
  rewrites.push({
62505
- path: (0, import_node_path45.join)(newDir, "inventory.json"),
63312
+ path: (0, import_node_path48.join)(newDir, "inventory.json"),
62506
63313
  field: "version",
62507
63314
  from: '"1"',
62508
63315
  to: '"2"'
62509
63316
  });
62510
63317
  rewrites.push({
62511
- path: (0, import_node_path45.join)(newDir, "constitution.json"),
63318
+ path: (0, import_node_path48.join)(newDir, "constitution.json"),
62512
63319
  field: "version",
62513
63320
  from: '"1"',
62514
63321
  to: '"2"'
62515
63322
  });
62516
63323
  }
62517
- const oldCache = (0, import_node_path45.join)(workspaceDir, ".slop-audit-cache.json");
62518
- const newCache = (0, import_node_path45.join)(workspaceDir, ".slopbrick-cache.json");
62519
- if ((0, import_node_fs33.existsSync)(oldCache)) {
63324
+ const oldCache = (0, import_node_path48.join)(workspaceDir, ".slop-audit-cache.json");
63325
+ const newCache = (0, import_node_path48.join)(workspaceDir, ".slopbrick-cache.json");
63326
+ if ((0, import_node_fs35.existsSync)(oldCache)) {
62520
63327
  moves.push({ from: oldCache, to: newCache, kind: "file" });
62521
63328
  }
62522
63329
  for (const ext of ["mjs", "cjs", "js"]) {
62523
- const oldCfg = (0, import_node_path45.join)(workspaceDir, `slop-audit.config.${ext}`);
62524
- const newCfg = (0, import_node_path45.join)(workspaceDir, `slopbrick.config.${ext}`);
62525
- if ((0, import_node_fs33.existsSync)(oldCfg)) {
63330
+ const oldCfg = (0, import_node_path48.join)(workspaceDir, `slop-audit.config.${ext}`);
63331
+ const newCfg = (0, import_node_path48.join)(workspaceDir, `slopbrick.config.${ext}`);
63332
+ if ((0, import_node_fs35.existsSync)(oldCfg)) {
62526
63333
  moves.push({ from: oldCfg, to: newCfg, kind: "config" });
62527
63334
  }
62528
63335
  }
62529
- const gi = (0, import_node_path45.join)(workspaceDir, ".gitignore");
62530
- if ((0, import_node_fs33.existsSync)(gi)) {
62531
- const content = (0, import_node_fs33.readFileSync)(gi, "utf-8");
63336
+ const gi = (0, import_node_path48.join)(workspaceDir, ".gitignore");
63337
+ if ((0, import_node_fs35.existsSync)(gi)) {
63338
+ const content = (0, import_node_fs35.readFileSync)(gi, "utf-8");
62532
63339
  if (content.includes(".slop-audit/")) {
62533
63340
  gitignoreEdits.push({
62534
63341
  path: gi,
@@ -62547,28 +63354,28 @@ function planMigration(workspaceDir) {
62547
63354
  return { moves, rewrites, gitignoreEdits };
62548
63355
  }
62549
63356
  function isAlreadyMigrated(workspaceDir) {
62550
- return (0, import_node_fs33.existsSync)((0, import_node_path45.join)(workspaceDir, ".slopbrick")) && !(0, import_node_fs33.existsSync)((0, import_node_path45.join)(workspaceDir, ".slop-audit"));
63357
+ return (0, import_node_fs35.existsSync)((0, import_node_path48.join)(workspaceDir, ".slopbrick")) && !(0, import_node_fs35.existsSync)((0, import_node_path48.join)(workspaceDir, ".slop-audit"));
62551
63358
  }
62552
63359
  function applyMigration(plan, options = {}) {
62553
63360
  if (options.dryRun) return;
62554
63361
  for (const m of plan.moves) {
62555
- (0, import_node_fs33.renameSync)(m.from, m.to);
63362
+ (0, import_node_fs35.renameSync)(m.from, m.to);
62556
63363
  }
62557
63364
  for (const r of plan.rewrites) {
62558
- if (!(0, import_node_fs33.existsSync)(r.path)) continue;
62559
- const content = (0, import_node_fs33.readFileSync)(r.path, "utf-8");
63365
+ if (!(0, import_node_fs35.existsSync)(r.path)) continue;
63366
+ const content = (0, import_node_fs35.readFileSync)(r.path, "utf-8");
62560
63367
  const next = content.replace(`"version": ${r.from}`, `"version": ${r.to}`);
62561
- (0, import_node_fs33.writeFileSync)(r.path, next);
63368
+ (0, import_node_fs35.writeFileSync)(r.path, next);
62562
63369
  }
62563
63370
  for (const g of plan.gitignoreEdits) {
62564
- const content = (0, import_node_fs33.readFileSync)(g.path, "utf-8");
63371
+ const content = (0, import_node_fs35.readFileSync)(g.path, "utf-8");
62565
63372
  const next = content.replaceAll(g.from, g.to);
62566
- (0, import_node_fs33.writeFileSync)(g.path, next);
63373
+ (0, import_node_fs35.writeFileSync)(g.path, next);
62567
63374
  }
62568
63375
  }
62569
63376
  function runMigrate(options) {
62570
63377
  const { workspace, dryRun = false, force = false } = options;
62571
- if (!(0, import_node_fs33.existsSync)(workspace)) {
63378
+ if (!(0, import_node_fs35.existsSync)(workspace)) {
62572
63379
  return {
62573
63380
  ok: false,
62574
63381
  alreadyMigrated: false,
@@ -62578,9 +63385,9 @@ function runMigrate(options) {
62578
63385
  };
62579
63386
  }
62580
63387
  const alreadyMigrated = isAlreadyMigrated(workspace);
62581
- const newDir = (0, import_node_path45.join)(workspace, ".slopbrick");
62582
- const oldDir = (0, import_node_path45.join)(workspace, ".slop-audit");
62583
- if ((0, import_node_fs33.existsSync)(newDir) && (0, import_node_fs33.existsSync)(oldDir) && !force) {
63388
+ const newDir = (0, import_node_path48.join)(workspace, ".slopbrick");
63389
+ const oldDir = (0, import_node_path48.join)(workspace, ".slop-audit");
63390
+ if ((0, import_node_fs35.existsSync)(newDir) && (0, import_node_fs35.existsSync)(oldDir) && !force) {
62584
63391
  return {
62585
63392
  ok: false,
62586
63393
  alreadyMigrated: false,
@@ -62656,7 +63463,7 @@ function registerMigrate(program) {
62656
63463
  (cmdOptions, command) => {
62657
63464
  const globals = command.optsWithGlobals();
62658
63465
  const format = (cmdOptions.format ?? globals.format) === "json" ? "json" : "pretty";
62659
- const cwd = (0, import_node_path46.resolve)(cmdOptions.workspace ?? process.cwd());
63466
+ const cwd = (0, import_node_path49.resolve)(cmdOptions.workspace ?? process.cwd());
62660
63467
  const result = runMigrate({
62661
63468
  workspace: cwd,
62662
63469
  dryRun: cmdOptions.dryRun,
@@ -62760,19 +63567,19 @@ function registerRules(program) {
62760
63567
  }
62761
63568
 
62762
63569
  // src/cli/commands/validate-config.ts
62763
- var import_node_fs34 = require("fs");
62764
- var import_node_path47 = require("path");
63570
+ var import_node_fs36 = require("fs");
63571
+ var import_node_path50 = require("path");
62765
63572
  init_logger();
62766
63573
  init_validation();
62767
63574
  function registerValidateConfig(program) {
62768
63575
  program.command("validate-config [path]").description("Statically validate a slopbrick.config.mjs without scanning").action(async (configPath) => {
62769
- const path = configPath ? (0, import_node_path47.resolve)(configPath) : (0, import_node_path47.resolve)(process.cwd(), "slopbrick.config.mjs");
62770
- if (!(0, import_node_fs34.existsSync)(path)) {
63576
+ const path = configPath ? (0, import_node_path50.resolve)(configPath) : (0, import_node_path50.resolve)(process.cwd(), "slopbrick.config.mjs");
63577
+ if (!(0, import_node_fs36.existsSync)(path)) {
62771
63578
  logger.error(`Error: config file not found: ${path}`);
62772
63579
  process.exit(2);
62773
63580
  }
62774
63581
  try {
62775
- const mod = (0, import_node_path47.extname)(path) === ".cjs" ? require(path) : await import(path);
63582
+ const mod = (0, import_node_path50.extname)(path) === ".cjs" ? require(path) : await import(path);
62776
63583
  const userConfig = mod.default ?? mod;
62777
63584
  const result = validateConfig(userConfig);
62778
63585
  if (result.errors.length === 0) {
@@ -62992,8 +63799,8 @@ ${formatMarkdown(result.report)}`);
62992
63799
  }
62993
63800
 
62994
63801
  // src/cli/commands/calibrate.ts
62995
- var import_node_fs37 = require("fs");
62996
- var import_node_path51 = require("path");
63802
+ var import_node_fs39 = require("fs");
63803
+ var import_node_path54 = require("path");
62997
63804
  init_logger();
62998
63805
 
62999
63806
  // src/research/providers/openai.ts
@@ -63049,8 +63856,8 @@ function createProvider(config) {
63049
63856
  }
63050
63857
 
63051
63858
  // src/research/generator.ts
63052
- var import_node_fs35 = require("fs");
63053
- var import_node_path48 = require("path");
63859
+ var import_node_fs37 = require("fs");
63860
+ var import_node_path51 = require("path");
63054
63861
 
63055
63862
  // src/research/prompts.ts
63056
63863
  var DEFAULT_PROMPT_TEMPLATES = [
@@ -63113,14 +63920,14 @@ async function generateSamples(options) {
63113
63920
  }
63114
63921
  const samples = [];
63115
63922
  const ext = extForFramework(framework);
63116
- const dir = (0, import_node_path48.join)(outputDir, framework, componentType);
63117
- (0, import_node_fs35.mkdirSync)(dir, { recursive: true });
63923
+ const dir = (0, import_node_path51.join)(outputDir, framework, componentType);
63924
+ (0, import_node_fs37.mkdirSync)(dir, { recursive: true });
63118
63925
  for (let i = 1; i <= count; i += 1) {
63119
63926
  const raw = await provider.generateSample(renderPrompt(template), { temperature });
63120
63927
  const code = extractCodeFromMarkdown(raw);
63121
63928
  const fileName = `sample-${i}${ext}`;
63122
- const filePath = (0, import_node_path48.join)(dir, fileName);
63123
- (0, import_node_fs35.writeFileSync)(filePath, code, "utf8");
63929
+ const filePath = (0, import_node_path51.join)(dir, fileName);
63930
+ (0, import_node_fs37.writeFileSync)(filePath, code, "utf8");
63124
63931
  const sample = {
63125
63932
  filePath,
63126
63933
  framework,
@@ -63131,8 +63938,8 @@ async function generateSamples(options) {
63131
63938
  };
63132
63939
  samples.push(sample);
63133
63940
  }
63134
- const metadataPath = (0, import_node_path48.join)(dir, "metadata.json");
63135
- (0, import_node_fs35.writeFileSync)(metadataPath, JSON.stringify(samples, null, 2), "utf8");
63941
+ const metadataPath = (0, import_node_path51.join)(dir, "metadata.json");
63942
+ (0, import_node_fs37.writeFileSync)(metadataPath, JSON.stringify(samples, null, 2), "utf8");
63136
63943
  return samples;
63137
63944
  }
63138
63945
 
@@ -63338,46 +64145,46 @@ function slugify(value) {
63338
64145
 
63339
64146
  // src/research/calibrator.ts
63340
64147
  var import_node_child_process2 = require("child_process");
63341
- var import_node_fs36 = require("fs");
63342
- var import_node_path50 = require("path");
64148
+ var import_node_fs38 = require("fs");
64149
+ var import_node_path53 = require("path");
63343
64150
 
63344
64151
  // src/corpus-paths.ts
63345
- var import_node_path49 = require("path");
64152
+ var import_node_path52 = require("path");
63346
64153
  var CORPUS_ROOT = process.env["SLOPBRICK_CORPUS_DIR"] ?? "/Users/cheng/corpus-expansion";
63347
- var POSITIVE_DIR = (0, import_node_path49.join)(CORPUS_ROOT, "positive");
63348
- var NEGATIVE_DIR = (0, import_node_path49.join)(CORPUS_ROOT, "negative");
63349
- var FILELISTS_DIR = (0, import_node_path49.join)(CORPUS_ROOT, "filelists");
64154
+ var POSITIVE_DIR = (0, import_node_path52.join)(CORPUS_ROOT, "positive");
64155
+ var NEGATIVE_DIR = (0, import_node_path52.join)(CORPUS_ROOT, "negative");
64156
+ var FILELISTS_DIR = (0, import_node_path52.join)(CORPUS_ROOT, "filelists");
63350
64157
 
63351
64158
  // src/research/calibrator.ts
63352
64159
  var DEFAULT_POSITIVE = POSITIVE_DIR;
63353
64160
  var DEFAULT_NEGATIVE = NEGATIVE_DIR;
63354
64161
  function buildFileList(dir, extensions) {
63355
- const tmpList = (0, import_node_path50.join)("/tmp", `cal-build-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
64162
+ const tmpList = (0, import_node_path53.join)("/tmp", `cal-build-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
63356
64163
  const expr = extensions.map((e) => `-name '*.${e}'`).join(" -o ");
63357
64164
  (0, import_node_child_process2.execFileSync)("bash", ["-c", `find ${dir} -maxdepth 8 -type f \\( ${expr} \\) -print0 | xargs -0 realpath > ${tmpList}`]);
63358
- const out = (0, import_node_fs36.readFileSync)(tmpList, "utf8");
64165
+ const out = (0, import_node_fs38.readFileSync)(tmpList, "utf8");
63359
64166
  (0, import_node_child_process2.execFileSync)("rm", ["-f", tmpList]);
63360
64167
  return out.trim().split("\n").filter(Boolean);
63361
64168
  }
63362
64169
  function runScan2(fileListPath) {
63363
- const files = (0, import_node_fs36.readFileSync)(fileListPath, "utf8").trim().split("\n").filter(Boolean);
64170
+ const files = (0, import_node_fs38.readFileSync)(fileListPath, "utf8").trim().split("\n").filter(Boolean);
63364
64171
  const CHUNK = 600;
63365
64172
  const ruleFires = /* @__PURE__ */ new Map();
63366
64173
  const uniqueFilesPerRule = /* @__PURE__ */ new Map();
63367
64174
  let fileCount = 0;
63368
- const tmpOut = (0, import_node_path50.join)("/tmp", `calibrate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
64175
+ const tmpOut = (0, import_node_path53.join)("/tmp", `calibrate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
63369
64176
  for (let i = 0; i < files.length; i += CHUNK) {
63370
64177
  const chunk = files.slice(i, i + CHUNK);
63371
64178
  try {
63372
64179
  (0, import_node_child_process2.execFileSync)(
63373
64180
  "node",
63374
- [(0, import_node_path50.join)(process.cwd(), "bin", "slopbrick.js"), "scan", ...chunk, "--json", tmpOut, "--no-telemetry", "--quiet"],
64181
+ [(0, import_node_path53.join)(process.cwd(), "bin", "slopbrick.js"), "scan", ...chunk, "--json", tmpOut, "--no-telemetry", "--quiet"],
63375
64182
  { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
63376
64183
  );
63377
64184
  } catch {
63378
64185
  }
63379
- if (!(0, import_node_fs36.existsSync)(tmpOut)) continue;
63380
- const report = JSON.parse((0, import_node_fs36.readFileSync)(tmpOut, "utf8"));
64186
+ if (!(0, import_node_fs38.existsSync)(tmpOut)) continue;
64187
+ const report = JSON.parse((0, import_node_fs38.readFileSync)(tmpOut, "utf8"));
63381
64188
  fileCount += report.fileCount;
63382
64189
  for (const issue of report.issues) {
63383
64190
  ruleFires.set(issue.ruleId, (ruleFires.get(issue.ruleId) ?? 0) + 1);
@@ -63405,16 +64212,16 @@ function classify(posFiles, negFiles) {
63405
64212
  async function calibrate(cwd, options = {}) {
63406
64213
  const positiveDir = options.positiveDir ?? DEFAULT_POSITIVE;
63407
64214
  const negativeDir = options.negativeDir ?? DEFAULT_NEGATIVE;
63408
- if (!(0, import_node_fs36.existsSync)(positiveDir)) throw new Error(`Positive corpus not found: ${positiveDir}`);
63409
- if (!(0, import_node_fs36.existsSync)(negativeDir)) throw new Error(`Negative corpus not found: ${negativeDir}`);
64215
+ if (!(0, import_node_fs38.existsSync)(positiveDir)) throw new Error(`Positive corpus not found: ${positiveDir}`);
64216
+ if (!(0, import_node_fs38.existsSync)(negativeDir)) throw new Error(`Negative corpus not found: ${negativeDir}`);
63410
64217
  const positiveFiles = buildFileList(positiveDir, ["tsx", "ts", "jsx", "js"]);
63411
64218
  const negativeFiles = buildFileList(negativeDir, ["tsx", "ts"]);
63412
64219
  const posSample = options.positiveLimit ? positiveFiles.slice(0, options.positiveLimit) : positiveFiles;
63413
64220
  const negSample = options.negativeLimit ? negativeFiles.slice(0, options.negativeLimit) : negativeFiles;
63414
- const posListPath = (0, import_node_path50.join)("/tmp", `cal-pos-${Date.now()}.txt`);
63415
- const negListPath = (0, import_node_path50.join)("/tmp", `cal-neg-${Date.now()}.txt`);
63416
- (0, import_node_fs36.writeFileSync)(posListPath, posSample.join("\n"));
63417
- (0, import_node_fs36.writeFileSync)(negListPath, negSample.join("\n"));
64221
+ const posListPath = (0, import_node_path53.join)("/tmp", `cal-pos-${Date.now()}.txt`);
64222
+ const negListPath = (0, import_node_path53.join)("/tmp", `cal-neg-${Date.now()}.txt`);
64223
+ (0, import_node_fs38.writeFileSync)(posListPath, posSample.join("\n"));
64224
+ (0, import_node_fs38.writeFileSync)(negListPath, negSample.join("\n"));
63418
64225
  const builtins = await Promise.resolve().then(() => (init_builtins(), builtins_exports));
63419
64226
  const builtinRules2 = builtins.builtinRules ?? [];
63420
64227
  const metaById = /* @__PURE__ */ new Map();
@@ -63528,9 +64335,9 @@ function registerCalibrate(program) {
63528
64335
  positiveLimit: cmdOptions.positiveLimit,
63529
64336
  negativeLimit: cmdOptions.negativeLimit
63530
64337
  });
63531
- const outputPath = cmdOptions.output ? (0, import_node_path51.resolve)(cwd, cmdOptions.output) : (0, import_node_path51.resolve)(cwd, "corpus", "calibration-empirical.md");
63532
- (0, import_node_fs37.mkdirSync)((0, import_node_path51.dirname)(outputPath), { recursive: true });
63533
- (0, import_node_fs37.writeFileSync)(outputPath, reportToMarkdown(report), "utf8");
64338
+ const outputPath = cmdOptions.output ? (0, import_node_path54.resolve)(cwd, cmdOptions.output) : (0, import_node_path54.resolve)(cwd, "corpus", "calibration-empirical.md");
64339
+ (0, import_node_fs39.mkdirSync)((0, import_node_path54.dirname)(outputPath), { recursive: true });
64340
+ (0, import_node_fs39.writeFileSync)(outputPath, reportToMarkdown(report), "utf8");
63534
64341
  logger.info(
63535
64342
  "Calibrated " + report.rules.length + " rules across " + report.positiveFileCount + " positive + " + report.negativeFileCount + " negative files."
63536
64343
  );
@@ -63667,15 +64474,16 @@ function registerTrend(program) {
63667
64474
  }
63668
64475
 
63669
64476
  // src/cli/commands/drift.ts
63670
- var import_node_path53 = require("path");
64477
+ var import_node_path56 = require("path");
63671
64478
  init_logger();
63672
64479
  init_scan2();
63673
64480
 
63674
64481
  // src/cli/drift.ts
63675
- var import_node_fs38 = require("fs");
63676
- var import_node_path52 = require("path");
64482
+ var import_node_fs40 = require("fs");
64483
+ var import_node_path55 = require("path");
63677
64484
  init_discover();
63678
64485
  init_patterns();
64486
+ init_telemetry();
63679
64487
  async function runDrift(cwd, config, options = {}) {
63680
64488
  const maxFiles = options.maxFiles ?? 1e3;
63681
64489
  const allFiles = await discoverFiles(cwd, config);
@@ -63686,7 +64494,7 @@ async function runDrift(cwd, config, options = {}) {
63686
64494
  for (const absPath of limited) {
63687
64495
  let source;
63688
64496
  try {
63689
- source = (0, import_node_fs38.readFileSync)(absPath, "utf-8");
64497
+ source = (0, import_node_fs40.readFileSync)(absPath, "utf-8");
63690
64498
  } catch {
63691
64499
  continue;
63692
64500
  }
@@ -63697,7 +64505,7 @@ async function runDrift(cwd, config, options = {}) {
63697
64505
  byCategory[v.category] = (byCategory[v.category] ?? 0) + 1;
63698
64506
  byFile.push({
63699
64507
  file: absPath,
63700
- relPath: (0, import_node_path52.relative)(cwd, absPath),
64508
+ relPath: (0, import_node_path55.relative)(cwd, absPath),
63701
64509
  category: v.category,
63702
64510
  import: v.import,
63703
64511
  declared: v.declared,
@@ -63783,38 +64591,229 @@ function formatDrift(result, opts = {}) {
63783
64591
  function driftExitCode(result) {
63784
64592
  return result.totalViolations > 0 ? 1 : 0;
63785
64593
  }
64594
+ async function runDriftOverTime(cwd, config, options) {
64595
+ const payloads = readTelemetry(cwd);
64596
+ const sortedAsc = [...payloads].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
64597
+ const withInventory = sortedAsc.filter((p) => p.inventory !== void 0);
64598
+ const baselineSource = options.since === "baseline" ? "baseline" : "since";
64599
+ let baseline;
64600
+ if (baselineSource === "baseline") {
64601
+ baseline = withInventory[0];
64602
+ } else {
64603
+ baseline = withInventory.find((p) => p.timestamp >= options.since);
64604
+ }
64605
+ const current = withInventory.at(-1);
64606
+ if (!baseline || !current) {
64607
+ return {
64608
+ scannedFiles: 0,
64609
+ filesWithViolations: 0,
64610
+ totalViolations: 0,
64611
+ byCategory: {},
64612
+ byFile: [],
64613
+ conventionSource: deriveSource(config.constitution),
64614
+ constitution: config.constitution,
64615
+ introduced: [],
64616
+ removed: [],
64617
+ introducedUndeclared: [],
64618
+ snapshotsConsidered: withInventory.length,
64619
+ driftScore: 0,
64620
+ baselineAt: baseline?.timestamp ?? "",
64621
+ currentAt: current?.timestamp ?? "",
64622
+ baselineSource
64623
+ };
64624
+ }
64625
+ const baselineNames = flattenPatternNames(baseline.inventory.patternNames);
64626
+ const currentNames = flattenPatternNames(current.inventory.patternNames);
64627
+ const baselineSet = new Set(baselineNames.map((p) => `${p.category}\0${p.name}`));
64628
+ const currentSet = new Set(currentNames.map((p) => `${p.category}\0${p.name}`));
64629
+ const introduced = [];
64630
+ const removed = [];
64631
+ for (const p of currentNames) {
64632
+ const key = `${p.category}\0${p.name}`;
64633
+ if (!baselineSet.has(key)) introduced.push(p);
64634
+ }
64635
+ for (const p of baselineNames) {
64636
+ const key = `${p.category}\0${p.name}`;
64637
+ if (!currentSet.has(key)) removed.push(p);
64638
+ }
64639
+ introduced.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
64640
+ removed.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
64641
+ const declared = collectDeclaredNames(config.constitution);
64642
+ const introducedUndeclared = introduced.filter((p) => !declared.has(p.name.toLowerCase()));
64643
+ const baselineTotal = Math.max(baselineSet.size, 1);
64644
+ const driftScore = Math.min(
64645
+ 100,
64646
+ Math.round((introduced.length + removed.length) / baselineTotal * 100)
64647
+ );
64648
+ return {
64649
+ scannedFiles: current.inventory.scannedFiles,
64650
+ filesWithViolations: 0,
64651
+ totalViolations: 0,
64652
+ byCategory: {},
64653
+ byFile: [],
64654
+ conventionSource: deriveSource(config.constitution),
64655
+ constitution: config.constitution,
64656
+ introduced,
64657
+ removed,
64658
+ introducedUndeclared,
64659
+ snapshotsConsidered: withInventory.length,
64660
+ driftScore,
64661
+ baselineAt: baseline.timestamp,
64662
+ currentAt: current.timestamp,
64663
+ baselineSource
64664
+ };
64665
+ }
64666
+ function flattenPatternNames(patternNames) {
64667
+ const out = [];
64668
+ for (const [category, names] of Object.entries(patternNames)) {
64669
+ for (const name of names) {
64670
+ out.push({ category, name });
64671
+ }
64672
+ }
64673
+ out.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
64674
+ return out;
64675
+ }
64676
+ function collectDeclaredNames(constitution) {
64677
+ const out = /* @__PURE__ */ new Set();
64678
+ if (!constitution) return out;
64679
+ const fields = ["stateManagement", "dataFetching", "uiLibrary", "forms", "styling", "routing"];
64680
+ for (const f of fields) {
64681
+ for (const v of constitution[f] ?? []) {
64682
+ out.add(v.toLowerCase());
64683
+ }
64684
+ }
64685
+ for (const list of Object.values(constitution.custom ?? {})) {
64686
+ for (const v of list) {
64687
+ out.add(v.toLowerCase());
64688
+ }
64689
+ }
64690
+ for (const v of constitution.forbidden ?? []) {
64691
+ out.add(v.toLowerCase());
64692
+ }
64693
+ return out;
64694
+ }
64695
+ function formatDriftOverTime(result) {
64696
+ const lines = [];
64697
+ lines.push("Temporal drift report");
64698
+ lines.push("");
64699
+ if (result.snapshotsConsidered === 0 || !result.baselineAt || !result.currentAt) {
64700
+ lines.push(" No historical telemetry found at .slopbrick/flywheel/scans.jsonl.");
64701
+ lines.push(" Run a few scans with `slopbrick scan` first; the temporal drift");
64702
+ lines.push(" detector needs \u2265 2 scan payloads to compute a baseline window.");
64703
+ return lines.join("\n");
64704
+ }
64705
+ const sinceLabel = result.baselineSource === "baseline" ? `baseline (oldest scan)` : `since ${result.baselineAt}`;
64706
+ lines.push(` Window: ${sinceLabel} \u2192 ${result.currentAt}`);
64707
+ lines.push(` Snapshots considered: ${result.snapshotsConsidered}`);
64708
+ lines.push(` Drift score: ${result.driftScore} / 100 (informational)`);
64709
+ lines.push("");
64710
+ lines.push(` Patterns introduced: ${result.introduced.length}`);
64711
+ for (const p of result.introduced) {
64712
+ lines.push(` + ${p.category}/${p.name}`);
64713
+ }
64714
+ if (result.introducedUndeclared.length > 0) {
64715
+ lines.push("");
64716
+ lines.push(
64717
+ ` Patterns introduced but not in declared constitution: ${result.introducedUndeclared.length}`
64718
+ );
64719
+ for (const p of result.introducedUndeclared) {
64720
+ lines.push(` ! ${p.category}/${p.name}`);
64721
+ }
64722
+ }
64723
+ lines.push("");
64724
+ lines.push(` Patterns removed: ${result.removed.length}`);
64725
+ for (const p of result.removed) {
64726
+ lines.push(` - ${p.category}/${p.name}`);
64727
+ }
64728
+ lines.push("");
64729
+ if (result.introducedUndeclared.length > 0) {
64730
+ lines.push(
64731
+ " Tip: add undeclared patterns to slopbrick.config.mjs#constitution to"
64732
+ );
64733
+ lines.push(" promote them into the declared set on the next scan.");
64734
+ } else if (result.introduced.length === 0 && result.removed.length === 0) {
64735
+ lines.push(" \u2713 No pattern churn since the baseline window.");
64736
+ }
64737
+ return lines.join("\n");
64738
+ }
64739
+ function driftOverTimeExitCode(result) {
64740
+ return result.introducedUndeclared.length > 0 ? 1 : 0;
64741
+ }
64742
+
64743
+ // src/cli/commands/drift.ts
64744
+ init_load();
64745
+
64746
+ // src/cli/commands/_shared.ts
64747
+ var import_commander2 = require("commander");
64748
+ init_logger();
64749
+ function setExitOverride(program) {
64750
+ program.exitOverride();
64751
+ }
64752
+ async function dispatch(program, runFn) {
64753
+ try {
64754
+ await runFn();
64755
+ } catch (err) {
64756
+ if (err instanceof import_commander2.CommanderError) {
64757
+ if (err.code !== "commander.helpDisplayed" && err.code !== "commander.help") {
64758
+ logger.error(err.message);
64759
+ }
64760
+ process.exit(err.exitCode);
64761
+ return;
64762
+ }
64763
+ throw err;
64764
+ }
64765
+ }
64766
+ function withExitCode(result, compute, message) {
64767
+ const code = compute(result);
64768
+ if (code === 0) return;
64769
+ throw new import_commander2.CommanderError(code, "slopbrick.exit", message);
64770
+ }
63786
64771
 
63787
64772
  // src/cli/commands/drift.ts
63788
64773
  function registerDrift(program) {
63789
64774
  program.command("drift").description(
63790
64775
  "detect imports that violate declared constitution (state, data-fetching, UI, forms, styling, routing) or import forbidden packages"
63791
- ).option("--format <pretty|json>", "output format", "pretty").option("--max-files <n>", "cap on files scanned", parseCount, 1e3).action(
64776
+ ).option("--format <pretty|json>", "output format", "pretty").option("--max-files <n>", "cap on files scanned", parseCount, 1e3).option(
64777
+ "--temporal-since <date>",
64778
+ `temporal drift since the given date (ISO-8601, or the literal string \`baseline\` to use the oldest scan in scans.jsonl)`
64779
+ ).action(
63792
64780
  async (cmdOptions, command) => {
63793
- try {
63794
- const options = command.optsWithGlobals();
63795
- const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
63796
- const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
63797
- const cwd = (0, import_node_path53.resolve)(options.workspace ?? process.cwd());
63798
- const { config } = await runScan({ ...options, workspace: cwd });
63799
- const result = await runDrift(cwd, config, { maxFiles: cmdOptions.maxFiles });
63800
- logger.info(formatDrift(result, { json: format === "json" }));
63801
- process.exit(driftExitCode(result));
63802
- } catch (err) {
63803
- logger.error(err instanceof Error ? err.message : String(err));
63804
- process.exit(2);
64781
+ const options = command.optsWithGlobals();
64782
+ const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64783
+ const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64784
+ const cwd = (0, import_node_path56.resolve)(options.workspace ?? process.cwd());
64785
+ if (cmdOptions.temporalSince !== void 0) {
64786
+ const temporalSince = cmdOptions.temporalSince.trim();
64787
+ if (temporalSince.length === 0) {
64788
+ throw new Error("--temporal-since expects a non-empty ISO date or `baseline`");
64789
+ }
64790
+ const sinceArg = temporalSince === "baseline" ? "baseline" : temporalSince;
64791
+ const config2 = await loadConfig(cwd);
64792
+ const result2 = await runDriftOverTime(cwd, config2, { since: sinceArg });
64793
+ logger.info(formatDriftOverTime(result2));
64794
+ withExitCode(
64795
+ result2,
64796
+ driftOverTimeExitCode,
64797
+ `drift --temporal-since: ${result2.introducedUndeclared.length} undeclared patterns introduced`
64798
+ );
64799
+ return;
63805
64800
  }
64801
+ const { config } = await runScan({ ...options, workspace: cwd });
64802
+ const result = await runDrift(cwd, config, { maxFiles: cmdOptions.maxFiles });
64803
+ logger.info(formatDrift(result, { json: format === "json" }));
64804
+ withExitCode(result, driftExitCode, `drift: ${result.totalViolations} violations`);
63806
64805
  }
63807
64806
  );
63808
64807
  }
63809
64808
 
63810
64809
  // src/cli/commands/pr.ts
63811
- var import_node_path55 = require("path");
64810
+ var import_node_path58 = require("path");
63812
64811
  init_logger();
63813
64812
  init_scan2();
63814
64813
 
63815
64814
  // src/cli/pr.ts
63816
- var import_node_fs39 = require("fs");
63817
- var import_node_path54 = require("path");
64815
+ var import_node_fs41 = require("fs");
64816
+ var import_node_path57 = require("path");
63818
64817
  var import_node_child_process3 = require("child_process");
63819
64818
  var import_node_util2 = require("util");
63820
64819
  var import_minimatch3 = require("minimatch");
@@ -63860,10 +64859,10 @@ async function discoverPrFiles(cwd, config, base, head, maxFiles) {
63860
64859
  if (gitFiles.length === 0) return [];
63861
64860
  const sourceFiles = [];
63862
64861
  for (const relOrAbs of gitFiles) {
63863
- const abs = (0, import_node_path54.resolve)(cwd, relOrAbs);
63864
- const ext = (0, import_node_path54.extname)(abs).toLowerCase();
64862
+ const abs = (0, import_node_path57.resolve)(cwd, relOrAbs);
64863
+ const ext = (0, import_node_path57.extname)(abs).toLowerCase();
63865
64864
  if (!PR_EXTENSIONS.has(ext)) continue;
63866
- const rel = (0, import_node_path54.relative)(cwd, abs).split("\\").join("/");
64865
+ const rel = (0, import_node_path57.relative)(cwd, abs).split("\\").join("/");
63867
64866
  if (config.include.length > 0 && !config.include.some((pattern) => (0, import_minimatch3.minimatch)(rel, pattern))) {
63868
64867
  continue;
63869
64868
  }
@@ -63891,7 +64890,7 @@ async function runPrScan(cwd, config, options = {}) {
63891
64890
  for (const absPath of candidates) {
63892
64891
  let source;
63893
64892
  try {
63894
- source = (0, import_node_fs39.readFileSync)(absPath, "utf-8");
64893
+ source = (0, import_node_fs41.readFileSync)(absPath, "utf-8");
63895
64894
  } catch {
63896
64895
  continue;
63897
64896
  }
@@ -63934,7 +64933,7 @@ async function runPrScan(cwd, config, options = {}) {
63934
64933
  totalScore += fileScore;
63935
64934
  files.push({
63936
64935
  file: absPath,
63937
- relPath: (0, import_node_path54.relative)(cwd, absPath).split("\\").join("/"),
64936
+ relPath: (0, import_node_path57.relative)(cwd, absPath).split("\\").join("/"),
63938
64937
  score: fileScore,
63939
64938
  issueCount: issues.length,
63940
64939
  constitutionViolationCount: constitutionViolations.length,
@@ -64066,20 +65065,135 @@ function formatPrMarkdown(result) {
64066
65065
  function prExitCode(result) {
64067
65066
  return result.totalScore > result.threshold ? 1 : 0;
64068
65067
  }
65068
+ function parseUnifiedDiffPaths(diff) {
65069
+ const paths = [];
65070
+ const seen = /* @__PURE__ */ new Set();
65071
+ for (const line of diff.split(/\r?\n/)) {
65072
+ const m = /^\+\+\+\s+(?:b\/)?(\S.*)$/.exec(line);
65073
+ if (!m) continue;
65074
+ const path = m[1];
65075
+ if (path === "/dev/null" || path === "dev/null") continue;
65076
+ if (seen.has(path)) continue;
65077
+ seen.add(path);
65078
+ paths.push(path);
65079
+ }
65080
+ return paths;
65081
+ }
65082
+ async function runPrScanFromDiffFile(cwd, config, diffPath, options = {}) {
65083
+ const threshold = options.threshold ?? config.prScoreThreshold ?? 20;
65084
+ const maxFiles = options.maxFiles ?? 500;
65085
+ const { existsSync: existsSync32, readFileSync: readFileSync42 } = await import("fs");
65086
+ const { isAbsolute: isAbsolute4, resolve: resolvePath } = await import("path");
65087
+ if (!existsSync32(diffPath)) {
65088
+ throw new Error(`Diff file not found: ${diffPath}`);
65089
+ }
65090
+ const diffAbs = isAbsolute4(diffPath) ? diffPath : resolvePath(cwd, diffPath);
65091
+ const diff = readFileSync42(diffAbs, "utf-8");
65092
+ const changedPaths = parseUnifiedDiffPaths(diff);
65093
+ if (changedPaths.length === 0) {
65094
+ return emptyPrResult(threshold);
65095
+ }
65096
+ return scoreChangedPaths(cwd, config, changedPaths, maxFiles, threshold);
65097
+ }
65098
+ async function scoreChangedPaths(cwd, config, paths, maxFiles, threshold) {
65099
+ const { existsSync: existsSync32, statSync: statSync8 } = await import("fs");
65100
+ const { resolve: resolvePath } = await import("path");
65101
+ const { readFile: readFile4 } = await import("fs/promises");
65102
+ const { scanFile: scanFile2 } = await Promise.resolve().then(() => (init_worker(), worker_exports));
65103
+ const limited = paths.slice(0, maxFiles);
65104
+ const files = [];
65105
+ let totalScore = 0;
65106
+ const byCategory = {};
65107
+ const bySeverity = { low: 0, medium: 0, high: 0 };
65108
+ let constitutionViolations = 0;
65109
+ for (const rel of limited) {
65110
+ const abs = resolvePath(cwd, rel);
65111
+ if (!existsSync32(abs) || !statSync8(abs).isFile()) continue;
65112
+ let source;
65113
+ try {
65114
+ source = await readFile4(abs, "utf-8");
65115
+ } catch {
65116
+ continue;
65117
+ }
65118
+ let result;
65119
+ try {
65120
+ result = await scanFile2(abs, config, void 0, cwd);
65121
+ } catch {
65122
+ continue;
65123
+ }
65124
+ if (result.parseError) continue;
65125
+ const issues = result.issues;
65126
+ let slopPoints = 0;
65127
+ const issueEntries = [];
65128
+ for (const issue of issues) {
65129
+ slopPoints += SEVERITY_WEIGHTS[issue.severity] ?? 0;
65130
+ byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
65131
+ bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
65132
+ issueEntries.push({
65133
+ ruleId: issue.ruleId,
65134
+ severity: issue.severity,
65135
+ line: issue.line,
65136
+ category: issue.category,
65137
+ message: issue.message
65138
+ });
65139
+ }
65140
+ totalScore += slopPoints;
65141
+ files.push({
65142
+ file: abs,
65143
+ relPath: rel,
65144
+ score: slopPoints,
65145
+ issueCount: issueEntries.length,
65146
+ constitutionViolationCount: 0,
65147
+ slopPoints,
65148
+ issues: issueEntries,
65149
+ constitutionViolations: []
65150
+ });
65151
+ }
65152
+ return {
65153
+ base: "diff",
65154
+ head: "diff",
65155
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65156
+ passed: totalScore <= threshold,
65157
+ threshold,
65158
+ totalScore,
65159
+ filesChanged: files.length,
65160
+ byCategory,
65161
+ bySeverity,
65162
+ files
65163
+ };
65164
+ }
65165
+ function emptyPrResult(threshold) {
65166
+ return {
65167
+ base: "diff",
65168
+ head: "diff",
65169
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65170
+ passed: true,
65171
+ threshold,
65172
+ totalScore: 0,
65173
+ filesChanged: 0,
65174
+ byCategory: {},
65175
+ bySeverity: { low: 0, medium: 0, high: 0 },
65176
+ files: []
65177
+ };
65178
+ }
64069
65179
 
64070
65180
  // src/cli/commands/pr.ts
64071
65181
  function registerPr(program) {
64072
65182
  program.command("pr").description(
64073
65183
  "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)."
64074
- ).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(
65184
+ ).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(
64075
65185
  async (cmdOptions, command) => {
64076
65186
  try {
64077
65187
  const options = command.optsWithGlobals();
64078
- const cwd = (0, import_node_path55.resolve)(options.workspace ?? process.cwd());
65188
+ const cwd = (0, import_node_path58.resolve)(options.workspace ?? process.cwd());
64079
65189
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64080
65190
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64081
65191
  const { config } = await runScan({ ...options, workspace: cwd });
64082
- const result = await runPrScan(cwd, config, {
65192
+ const result = cmdOptions.diff ? await runPrScanFromDiffFile(cwd, config, cmdOptions.diff, {
65193
+ format,
65194
+ threshold: cmdOptions.threshold,
65195
+ maxFiles: cmdOptions.maxFiles
65196
+ }) : await runPrScan(cwd, config, {
64083
65197
  base: cmdOptions.base,
64084
65198
  head: cmdOptions.head,
64085
65199
  format,
@@ -64097,7 +65211,7 @@ function registerPr(program) {
64097
65211
  }
64098
65212
 
64099
65213
  // src/cli/commands/security.ts
64100
- var import_node_path56 = require("path");
65214
+ var import_node_path59 = require("path");
64101
65215
  init_logger();
64102
65216
  init_scan2();
64103
65217
  init_ai_security_risk();
@@ -64110,7 +65224,7 @@ function registerSecurity(program) {
64110
65224
  const options = command.optsWithGlobals();
64111
65225
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64112
65226
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64113
- const cwd = (0, import_node_path56.resolve)(options.workspace ?? process.cwd());
65227
+ const cwd = (0, import_node_path59.resolve)(options.workspace ?? process.cwd());
64114
65228
  const { report } = await runScan({ ...options, workspace: cwd });
64115
65229
  const securityIssues = report.issues.filter((i) => i.category === "security");
64116
65230
  const { risk, findings } = computeAiSecurityRisk(securityIssues);
@@ -64156,12 +65270,12 @@ function registerSecurity(program) {
64156
65270
  }
64157
65271
 
64158
65272
  // src/cli/commands/test.ts
64159
- var import_node_path58 = require("path");
65273
+ var import_node_path61 = require("path");
64160
65274
  init_logger();
64161
65275
  init_scan2();
64162
65276
 
64163
65277
  // src/cli/test.ts
64164
- var import_node_path57 = require("path");
65278
+ var import_node_path60 = require("path");
64165
65279
  init_scan2();
64166
65280
  init_test_quality();
64167
65281
  init_logger();
@@ -64232,7 +65346,7 @@ function formatTestReport(result, opts = {}) {
64232
65346
  const sortedFiles = [...byFile.keys()].sort();
64233
65347
  for (const file of sortedFiles) {
64234
65348
  const issues = byFile.get(file) ?? [];
64235
- const rel = file.startsWith((0, import_node_path57.resolve)(process.cwd())) ? file : file;
65349
+ const rel = file.startsWith((0, import_node_path60.resolve)(process.cwd())) ? file : file;
64236
65350
  lines.push("");
64237
65351
  lines.push(` ${rel}`);
64238
65352
  for (const issue of issues.slice(0, 20)) {
@@ -64261,7 +65375,7 @@ function registerTest(program) {
64261
65375
  const options = command.optsWithGlobals();
64262
65376
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64263
65377
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64264
- const cwd = (0, import_node_path58.resolve)(options.workspace ?? process.cwd());
65378
+ const cwd = (0, import_node_path61.resolve)(options.workspace ?? process.cwd());
64265
65379
  const { config } = await runScan({ ...options, workspace: cwd });
64266
65380
  const { result } = await runTestScan(cwd, config, { strict: options.strict });
64267
65381
  logger.info(formatTestReport(result, { json: format === "json" }));
@@ -64275,7 +65389,7 @@ function registerTest(program) {
64275
65389
  }
64276
65390
 
64277
65391
  // src/cli/commands/architecture.ts
64278
- var import_node_path59 = require("path");
65392
+ var import_node_path62 = require("path");
64279
65393
  init_logger();
64280
65394
  init_scan2();
64281
65395
  init_architecture_score();
@@ -64288,7 +65402,7 @@ function registerArchitecture(program) {
64288
65402
  const options = command.optsWithGlobals();
64289
65403
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64290
65404
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64291
- const cwd = (0, import_node_path59.resolve)(options.workspace ?? process.cwd());
65405
+ const cwd = (0, import_node_path62.resolve)(options.workspace ?? process.cwd());
64292
65406
  const { config } = await runScan({ ...options, workspace: cwd });
64293
65407
  const score = await buildArchitectureScore(cwd, config, cmdOptions.maxFiles);
64294
65408
  const out = format === "json" ? JSON.stringify(score, null, 2) : formatArchitectureScore(score);
@@ -64303,13 +65417,13 @@ function registerArchitecture(program) {
64303
65417
  }
64304
65418
 
64305
65419
  // src/cli/commands/business-logic.ts
64306
- var import_node_path61 = require("path");
65420
+ var import_node_path64 = require("path");
64307
65421
  init_logger();
64308
65422
  init_scan2();
64309
65423
 
64310
65424
  // src/cli/business-logic.ts
64311
- var import_node_fs40 = require("fs");
64312
- var import_node_path60 = require("path");
65425
+ var import_node_fs42 = require("fs");
65426
+ var import_node_path63 = require("path");
64313
65427
  init_discover();
64314
65428
  init_business_logic();
64315
65429
  async function runBusinessLogicScan(cwd, config, options = {}) {
@@ -64321,7 +65435,7 @@ async function runBusinessLogicScan(cwd, config, options = {}) {
64321
65435
  for (const absPath of limited) {
64322
65436
  let source;
64323
65437
  try {
64324
- source = (0, import_node_fs40.readFileSync)(absPath, "utf-8");
65438
+ source = (0, import_node_fs42.readFileSync)(absPath, "utf-8");
64325
65439
  } catch {
64326
65440
  continue;
64327
65441
  }
@@ -64330,7 +65444,7 @@ async function runBusinessLogicScan(cwd, config, options = {}) {
64330
65444
  for (const issue of fileIssues) {
64331
65445
  issues.push({
64332
65446
  ...issue,
64333
- filePath: (0, import_node_path60.relative)(cwd, absPath) || absPath
65447
+ filePath: (0, import_node_path63.relative)(cwd, absPath) || absPath
64334
65448
  });
64335
65449
  }
64336
65450
  }
@@ -64433,7 +65547,7 @@ function registerBusinessLogic(program) {
64433
65547
  const options = command.optsWithGlobals();
64434
65548
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64435
65549
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64436
- const cwd = (0, import_node_path61.resolve)(options.workspace ?? process.cwd());
65550
+ const cwd = (0, import_node_path64.resolve)(options.workspace ?? process.cwd());
64437
65551
  const { config } = await runScan({ ...options, workspace: cwd });
64438
65552
  const result = await runBusinessLogicScan(cwd, config, {
64439
65553
  maxFiles: cmdOptions.maxFiles
@@ -64449,7 +65563,7 @@ function registerBusinessLogic(program) {
64449
65563
  }
64450
65564
 
64451
65565
  // src/cli/commands/maintenance-cost.ts
64452
- var import_node_path62 = require("path");
65566
+ var import_node_path65 = require("path");
64453
65567
  init_logger();
64454
65568
  init_scan2();
64455
65569
 
@@ -64575,7 +65689,7 @@ function registerMaintenanceCost(program) {
64575
65689
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64576
65690
  const format = rawFormat === "json" || rawFormat === "text" ? rawFormat : "text";
64577
65691
  const strict = options.strict ?? cmdOptions.strict ?? false;
64578
- const cwd = (0, import_node_path62.resolve)(options.workspace ?? process.cwd());
65692
+ const cwd = (0, import_node_path65.resolve)(options.workspace ?? process.cwd());
64579
65693
  const { config } = await runScan({ ...options, workspace: cwd });
64580
65694
  const result = await runMaintenanceCostScan(cwd, config, {
64581
65695
  maxFiles: cmdOptions.maxFiles,
@@ -64592,7 +65706,7 @@ function registerMaintenanceCost(program) {
64592
65706
  }
64593
65707
 
64594
65708
  // src/cli/commands/docs.ts
64595
- var import_node_path63 = require("path");
65709
+ var import_node_path66 = require("path");
64596
65710
  init_logger();
64597
65711
  init_scan2();
64598
65712
 
@@ -64719,7 +65833,7 @@ function registerDocs(program) {
64719
65833
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64720
65834
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64721
65835
  const strict = options.strict ?? cmdOptions.strict ?? false;
64722
- const cwd = (0, import_node_path63.resolve)(options.workspace ?? process.cwd());
65836
+ const cwd = (0, import_node_path66.resolve)(options.workspace ?? process.cwd());
64723
65837
  const { config } = await runScan({ ...options, workspace: cwd });
64724
65838
  const result = await runDocsScan(cwd, config, {
64725
65839
  maxDocFiles: cmdOptions.maxFiles,
@@ -64741,7 +65855,7 @@ function registerDocs(program) {
64741
65855
  }
64742
65856
 
64743
65857
  // src/cli/commands/db.ts
64744
- var import_node_path64 = require("path");
65858
+ var import_node_path67 = require("path");
64745
65859
  init_logger();
64746
65860
  init_scan2();
64747
65861
 
@@ -64862,7 +65976,7 @@ function registerDb(program) {
64862
65976
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64863
65977
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64864
65978
  const strict = options.strict ?? cmdOptions.strict ?? false;
64865
- const cwd = (0, import_node_path64.resolve)(options.workspace ?? process.cwd());
65979
+ const cwd = (0, import_node_path67.resolve)(options.workspace ?? process.cwd());
64866
65980
  const { config } = await runScan({ ...options, workspace: cwd });
64867
65981
  const result = await runDbScan(cwd, config, {
64868
65982
  maxFiles: cmdOptions.maxFiles,
@@ -64884,13 +65998,13 @@ function registerDb(program) {
64884
65998
  }
64885
65999
 
64886
66000
  // src/cli/commands/patterns.ts
64887
- var import_node_path66 = require("path");
66001
+ var import_node_path69 = require("path");
64888
66002
  init_logger();
64889
66003
  init_scan2();
64890
66004
 
64891
66005
  // src/engine/patterns.ts
64892
- var import_node_fs41 = require("fs");
64893
- var import_node_path65 = require("path");
66006
+ var import_node_fs43 = require("fs");
66007
+ var import_node_path68 = require("path");
64894
66008
  init_patterns();
64895
66009
  init_discover();
64896
66010
  var PATTERN_CATEGORIES = [
@@ -64925,7 +66039,7 @@ var TOAST_NAME_RE = /^(?:[A-Z][a-zA-Z0-9]+)?(Toast|Notification|Snackbar|Alert|B
64925
66039
  var CARD_NAME_RE = /^(?:[A-Z][a-zA-Z0-9]+)?(Card|Tile|Chip|Badge)$/;
64926
66040
  var API_PATH_RE2 = /(?:^|\/)(?:lib\/api|services|api-client|clients)\//;
64927
66041
  function baseName(filePath) {
64928
- return (0, import_node_path65.basename)(filePath).replace(/\.(tsx|ts|jsx|js|vue|svelte|astro)$/i, "");
66042
+ return (0, import_node_path68.basename)(filePath).replace(/\.(tsx|ts|jsx|js|vue|svelte|astro)$/i, "");
64929
66043
  }
64930
66044
  function makeStats(patterns) {
64931
66045
  const seen = /* @__PURE__ */ new Set();
@@ -64984,7 +66098,7 @@ function detectApiFromFiles(files, cwd) {
64984
66098
  const out = [];
64985
66099
  for (const f of files) {
64986
66100
  if (API_PATH_RE2.test(f)) {
64987
- out.push((0, import_node_path65.relative)(cwd, f).split("\\").join("/"));
66101
+ out.push((0, import_node_path68.relative)(cwd, f).split("\\").join("/"));
64988
66102
  }
64989
66103
  }
64990
66104
  return out;
@@ -64994,7 +66108,7 @@ function detectFormsFromFiles(files) {
64994
66108
  for (const f of files) {
64995
66109
  let source;
64996
66110
  try {
64997
- source = (0, import_node_fs41.readFileSync)(f, "utf-8");
66111
+ source = (0, import_node_fs43.readFileSync)(f, "utf-8");
64998
66112
  } catch {
64999
66113
  continue;
65000
66114
  }
@@ -65215,7 +66329,7 @@ function registerPatterns(program) {
65215
66329
  const options = command.optsWithGlobals();
65216
66330
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
65217
66331
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
65218
- const cwd = (0, import_node_path66.resolve)(options.workspace ?? process.cwd());
66332
+ const cwd = (0, import_node_path69.resolve)(options.workspace ?? process.cwd());
65219
66333
  const { config } = await runScan({ ...options, workspace: cwd });
65220
66334
  const result = await runPatternsScan(cwd, config, {
65221
66335
  maxFiles: cmdOptions.maxFiles,
@@ -65232,8 +66346,8 @@ function registerPatterns(program) {
65232
66346
  }
65233
66347
 
65234
66348
  // src/cli/commands/research.ts
65235
- var import_node_fs42 = require("fs");
65236
- var import_node_path67 = require("path");
66349
+ var import_node_fs44 = require("fs");
66350
+ var import_node_path70 = require("path");
65237
66351
  init_logger();
65238
66352
  init_config2();
65239
66353
  function registerResearch(program) {
@@ -65250,24 +66364,24 @@ function registerResearch(program) {
65250
66364
  framework: cmdOptions.framework,
65251
66365
  componentType: cmdOptions.componentType,
65252
66366
  provider,
65253
- outputDir: (0, import_node_path67.resolve)(cmdOptions.outputDir),
66367
+ outputDir: (0, import_node_path70.resolve)(cmdOptions.outputDir),
65254
66368
  temperature: cmdOptions.temperature
65255
66369
  });
65256
66370
  logger.info(`Generated ${samples.length} samples in ${cmdOptions.outputDir}`);
65257
66371
  });
65258
66372
  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) => {
65259
66373
  try {
65260
- const metadataPath = (0, import_node_path67.resolve)(cmdOptions.inputDir, "metadata.json");
65261
- if (!(0, import_node_fs42.existsSync)(metadataPath)) {
66374
+ const metadataPath = (0, import_node_path70.resolve)(cmdOptions.inputDir, "metadata.json");
66375
+ if (!(0, import_node_fs44.existsSync)(metadataPath)) {
65262
66376
  logger.error(`No metadata.json found in ${cmdOptions.inputDir}`);
65263
66377
  process.exit(2);
65264
66378
  }
65265
- const samples = JSON.parse((0, import_node_fs42.readFileSync)(metadataPath, "utf8"));
66379
+ const samples = JSON.parse((0, import_node_fs44.readFileSync)(metadataPath, "utf8"));
65266
66380
  const config = cmdOptions.config ? await loadConfig(cmdOptions.config) : { ...DEFAULT_CONFIG, framework: cmdOptions.framework };
65267
66381
  const analysis = await analyzeSamples(samples, config);
65268
- const outputPath = (0, import_node_path67.resolve)(cmdOptions.output);
65269
- (0, import_node_fs42.mkdirSync)((0, import_node_path67.dirname)(outputPath), { recursive: true });
65270
- (0, import_node_fs42.writeFileSync)(outputPath, JSON.stringify(analysis, null, 2), "utf8");
66382
+ const outputPath = (0, import_node_path70.resolve)(cmdOptions.output);
66383
+ (0, import_node_fs44.mkdirSync)((0, import_node_path70.dirname)(outputPath), { recursive: true });
66384
+ (0, import_node_fs44.writeFileSync)(outputPath, JSON.stringify(analysis, null, 2), "utf8");
65271
66385
  logger.info(`Analyzed ${analysis.summary.total} samples; coverage: ${analysis.summary.coverage}%`);
65272
66386
  logger.info(`Wrote analysis to ${outputPath}`);
65273
66387
  } catch (error) {
@@ -65277,12 +66391,12 @@ function registerResearch(program) {
65277
66391
  });
65278
66392
  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) => {
65279
66393
  try {
65280
- const metadataPath = (0, import_node_path67.resolve)(cmdOptions.inputDir, "metadata.json");
65281
- if (!(0, import_node_fs42.existsSync)(metadataPath)) {
66394
+ const metadataPath = (0, import_node_path70.resolve)(cmdOptions.inputDir, "metadata.json");
66395
+ if (!(0, import_node_fs44.existsSync)(metadataPath)) {
65282
66396
  logger.error(`No metadata.json found in ${cmdOptions.inputDir}`);
65283
66397
  process.exit(2);
65284
66398
  }
65285
- const samples = JSON.parse((0, import_node_fs42.readFileSync)(metadataPath, "utf8"));
66399
+ const samples = JSON.parse((0, import_node_fs44.readFileSync)(metadataPath, "utf8"));
65286
66400
  const config = cmdOptions.config ? await loadConfig(cmdOptions.config) : { ...DEFAULT_CONFIG, framework: cmdOptions.framework };
65287
66401
  const analysis = await analyzeSamples(samples, config);
65288
66402
  const extraction = extractAndCluster(analysis.samples, {
@@ -65292,8 +66406,8 @@ function registerResearch(program) {
65292
66406
  const candidates = clustersToCandidates(extraction.clusters, {
65293
66407
  minFrequency: cmdOptions.minFrequency
65294
66408
  });
65295
- const outputPath = (0, import_node_path67.resolve)(cmdOptions.output);
65296
- (0, import_node_fs42.mkdirSync)((0, import_node_path67.dirname)(outputPath), { recursive: true });
66409
+ const outputPath = (0, import_node_path70.resolve)(cmdOptions.output);
66410
+ (0, import_node_fs44.mkdirSync)((0, import_node_path70.dirname)(outputPath), { recursive: true });
65297
66411
  const payload = {
65298
66412
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65299
66413
  sampleCount: analysis.summary.total,
@@ -65301,7 +66415,7 @@ function registerResearch(program) {
65301
66415
  fingerprintCount: extraction.total,
65302
66416
  candidates
65303
66417
  };
65304
- (0, import_node_fs42.writeFileSync)(outputPath, JSON.stringify(payload, null, 2), "utf8");
66418
+ (0, import_node_fs44.writeFileSync)(outputPath, JSON.stringify(payload, null, 2), "utf8");
65305
66419
  logger.info(`Extracted ${extraction.total} fingerprints across ${analysis.summary.total} samples`);
65306
66420
  logger.info(`Wrote ${candidates.length} candidate rule(s) to ${outputPath}`);
65307
66421
  } catch (error) {
@@ -65312,8 +66426,8 @@ function registerResearch(program) {
65312
66426
  }
65313
66427
 
65314
66428
  // src/cli/commands/init.ts
65315
- var import_node_fs43 = require("fs");
65316
- var import_node_path69 = require("path");
66429
+ var import_node_fs45 = require("fs");
66430
+ var import_node_path72 = require("path");
65317
66431
  init_logger();
65318
66432
  init_builtins();
65319
66433
  init_scan2();
@@ -65323,145 +66437,9 @@ init_git();
65323
66437
  init_cache();
65324
66438
 
65325
66439
  // src/snippet/targets.ts
65326
- var import_node_path68 = require("path");
65327
-
65328
- // src/snippet/render.ts
65329
- function aiSpecificRules(rules) {
65330
- return rules.filter((r) => r.aiSpecific).sort((a, b) => a.id.localeCompare(b.id));
65331
- }
65332
- function categorizeRules(rules) {
65333
- const byCategory = /* @__PURE__ */ new Map();
65334
- for (const r of rules) {
65335
- const list = byCategory.get(r.category) ?? [];
65336
- list.push(r);
65337
- byCategory.set(r.category, list);
65338
- }
65339
- for (const list of byCategory.values()) {
65340
- list.sort((a, b) => a.id.localeCompare(b.id));
65341
- }
65342
- return byCategory;
65343
- }
65344
- function baseContent(rules, toolLabel, intro) {
65345
- const lines = [];
65346
- lines.push("# " + toolLabel);
65347
- lines.push("");
65348
- lines.push(intro);
65349
- lines.push("");
65350
- const ai = aiSpecificRules(rules);
65351
- const byCategory = categorizeRules(ai);
65352
- lines.push("## Category-level directives");
65353
- lines.push("");
65354
- for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
65355
- lines.push("- **" + category + "**: " + directive);
65356
- }
65357
- lines.push("");
65358
- lines.push("## Per-rule directives");
65359
- lines.push("");
65360
- for (const [category, list] of byCategory) {
65361
- lines.push("### " + category);
65362
- lines.push("");
65363
- for (const rule of list) {
65364
- const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
65365
- lines.push("- `" + rule.id + "` (" + rule.severity + "): " + hint);
65366
- }
65367
- lines.push("");
65368
- }
65369
- return lines.join("\n");
65370
- }
65371
-
65372
- // src/snippet/generators.ts
65373
- function generateCursorSnippet(rules) {
65374
- const lines = [];
65375
- lines.push("# slopbrick cursor rules");
65376
- lines.push("");
65377
- lines.push("Generated by `slopbrick init --cursor`. These rules describe patterns that slopbrick");
65378
- lines.push("flags as AI-generated frontend slop. Avoid them when writing code in this project.");
65379
- lines.push("");
65380
- lines.push("## Category-level directives");
65381
- lines.push("");
65382
- for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
65383
- lines.push("- **" + category + "**: " + directive);
65384
- }
65385
- lines.push("");
65386
- lines.push("## Per-rule directives");
65387
- lines.push("");
65388
- const ai = aiSpecificRules(rules);
65389
- const byCategory = categorizeRules(ai);
65390
- for (const [category, list] of byCategory) {
65391
- lines.push("### " + category);
65392
- lines.push("");
65393
- for (const rule of list) {
65394
- const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
65395
- lines.push("- **`" + rule.id + "`** (" + rule.severity + "): " + hint);
65396
- }
65397
- lines.push("");
65398
- }
65399
- lines.push("## Verification");
65400
- lines.push("");
65401
- lines.push("After writing code, run `slopbrick scan` and check for issues in any of the categories above.");
65402
- lines.push("Use `slopbrick fix` for auto-fixable rules. Use `slopbrick mcp` from MCP-aware clients to query rules and scan files programmatically.");
65403
- lines.push("");
65404
- return lines.join("\n");
65405
- }
65406
- function generateAgentsMdSnippet(rules) {
65407
- return baseContent(
65408
- rules,
65409
- "AGENTS.md",
65410
- "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."
65411
- ) + "## How to verify\n\nAfter editing code, run `slopbrick scan`. Any issue with a category in this file is something to fix.\n";
65412
- }
65413
- function generateClaudeMdSnippet(rules) {
65414
- return baseContent(
65415
- rules,
65416
- "CLAUDE.md",
65417
- "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."
65418
- ) + "## 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";
65419
- }
65420
- function generateAiderSnippet(rules) {
65421
- return baseContent(
65422
- rules,
65423
- "CONVENTIONS.md",
65424
- "Generated by `slopbrick init --aider`. Aider reads CONVENTIONS.md (not AGENTS.md) for project conventions."
65425
- );
65426
- }
65427
- function generateWindsurfSnippet(rules) {
65428
- return baseContent(
65429
- rules,
65430
- ".windsurfrules",
65431
- "Generated by `slopbrick init --windsurf`. Windsurf Cascade reads .windsurfrules at project root."
65432
- );
65433
- }
65434
- function generateClineRules(rules) {
65435
- return baseContent(
65436
- rules,
65437
- "AGENTS.md",
65438
- "Generated by `slopbrick init --cline`. Cline reads .clinerules/AGENTS.md from the project root."
65439
- );
65440
- }
65441
- function generateCursorrulesLegacySnippet(rules) {
65442
- return baseContent(
65443
- rules,
65444
- ".cursorrules",
65445
- "Generated by `slopbrick init --cursorrules`. This is the legacy Cursor rule format. Prefer `.cursor/rules/slopbrick.mdc` (use --cursor) for new projects."
65446
- );
65447
- }
65448
- function generateGeminiSnippet(rules) {
65449
- return baseContent(
65450
- rules,
65451
- "GEMINI.md",
65452
- "Generated by `slopbrick init --gemini`. Gemini CLI reads .gemini/GEMINI.md from project root."
65453
- );
65454
- }
65455
- function generateCopilotSnippet(rules) {
65456
- return baseContent(
65457
- rules,
65458
- "copilot-instructions.md",
65459
- "Generated by `slopbrick init --copilot`. GitHub Copilot reads .github/copilot-instructions.md from project root."
65460
- );
65461
- }
65462
-
65463
- // src/snippet/targets.ts
65464
- var SNIPPET_TARGETS = [
66440
+ var import_node_path71 = require("path");
66441
+ init_generators();
66442
+ var SNIPPET_TARGETS2 = [
65465
66443
  {
65466
66444
  flag: "cursor",
65467
66445
  cliName: "--cursor",
@@ -65554,7 +66532,7 @@ var SNIPPET_TARGETS = [
65554
66532
  }
65555
66533
  ];
65556
66534
  function resolveTargetPath(target) {
65557
- return target.isFolder ? (0, import_node_path68.join)(target.path, target.filename) : target.path;
66535
+ return target.isFolder ? (0, import_node_path71.join)(target.path, target.filename) : target.path;
65558
66536
  }
65559
66537
  function renderMatrix() {
65560
66538
  const lines = [];
@@ -65562,7 +66540,7 @@ function renderMatrix() {
65562
66540
  lines.push("");
65563
66541
  lines.push("| Flag | File | Agent |");
65564
66542
  lines.push("|------|------|-------|");
65565
- for (const t of SNIPPET_TARGETS) {
66543
+ for (const t of SNIPPET_TARGETS2) {
65566
66544
  lines.push("| `" + t.cliName + "` | `" + resolveTargetPath(t) + "` | " + t.agent + " |");
65567
66545
  }
65568
66546
  return lines.join("\n");
@@ -65576,13 +66554,13 @@ function registerInit(program) {
65576
66554
  logger.info(renderMatrix());
65577
66555
  process.exit(0);
65578
66556
  }
65579
- const cwd = (0, import_node_path69.resolve)(options.workspace ?? process.cwd());
65580
- const configPath = (0, import_node_path69.join)(cwd, "slopbrick.config.mjs");
66557
+ const cwd = (0, import_node_path72.resolve)(options.workspace ?? process.cwd());
66558
+ const configPath = (0, import_node_path72.join)(cwd, "slopbrick.config.mjs");
65581
66559
  const detected = detectStack(cwd);
65582
66560
  const fallbackConfig = { ...DEFAULT_CONFIG, ...detected };
65583
66561
  const proposed = serializeConfig(fallbackConfig);
65584
- if ((0, import_node_fs43.existsSync)(configPath) && !cmdOptions.yes) {
65585
- const current = (0, import_node_fs43.readFileSync)(configPath, "utf8");
66562
+ if ((0, import_node_fs45.existsSync)(configPath) && !cmdOptions.yes) {
66563
+ const current = (0, import_node_fs45.readFileSync)(configPath, "utf8");
65586
66564
  logger.error(`A config file already exists at ${configPath}.`);
65587
66565
  logger.error("To overwrite it with defaults, run `slopbrick init --yes`.");
65588
66566
  logger.error("");
@@ -65603,7 +66581,7 @@ function registerInit(program) {
65603
66581
  config = buildInitConfig(detected, answers);
65604
66582
  usedWizard = true;
65605
66583
  }
65606
- (0, import_node_fs43.writeFileSync)(configPath, serializeConfig(config));
66584
+ (0, import_node_fs45.writeFileSync)(configPath, serializeConfig(config));
65607
66585
  appendGitignore(cwd);
65608
66586
  const refresh = await refreshRegistrySnapshot(cwd);
65609
66587
  if (!refresh.ok) {
@@ -65627,27 +66605,27 @@ function registerInit(program) {
65627
66605
  }
65628
66606
  logger.info(refresh.message);
65629
66607
  }
65630
- const targetsToWrite = SNIPPET_TARGETS.filter((t) => {
66608
+ const targetsToWrite = SNIPPET_TARGETS2.filter((t) => {
65631
66609
  if (cmdOptions.all) return true;
65632
66610
  const opts = cmdOptions;
65633
66611
  return Boolean(opts[t.flag]);
65634
66612
  });
65635
66613
  for (const target of targetsToWrite) {
65636
- const snippetPath = (0, import_node_path69.join)(cwd, resolveTargetPath(target));
65637
- (0, import_node_fs43.mkdirSync)((0, import_node_path69.dirname)(snippetPath), { recursive: true });
66614
+ const snippetPath = (0, import_node_path72.join)(cwd, resolveTargetPath(target));
66615
+ (0, import_node_fs45.mkdirSync)((0, import_node_path72.dirname)(snippetPath), { recursive: true });
65638
66616
  const generated = target.generator(builtinRules);
65639
- if (!target.isFolder && (0, import_node_fs43.existsSync)(snippetPath)) {
65640
- const existing = (0, import_node_fs43.readFileSync)(snippetPath, "utf8");
66617
+ if (!target.isFolder && (0, import_node_fs45.existsSync)(snippetPath)) {
66618
+ const existing = (0, import_node_fs45.readFileSync)(snippetPath, "utf8");
65641
66619
  if (existing.includes("<!-- slopbrick:begin -->")) {
65642
66620
  const updated = existing.replace(
65643
66621
  /<!-- slopbrick:begin -->[\s\S]*?<!-- slopbrick:end -->/,
65644
66622
  "<!-- slopbrick:begin -->\n" + generated + "<!-- slopbrick:end -->"
65645
66623
  );
65646
- (0, import_node_fs43.writeFileSync)(snippetPath, updated, "utf8");
66624
+ (0, import_node_fs45.writeFileSync)(snippetPath, updated, "utf8");
65647
66625
  if (!options.quiet) logger.info(`Updated ${snippetPath}`);
65648
66626
  continue;
65649
66627
  }
65650
- (0, import_node_fs43.writeFileSync)(
66628
+ (0, import_node_fs45.writeFileSync)(
65651
66629
  snippetPath,
65652
66630
  existing + (existing.endsWith("\n") ? "\n" : "\n\n") + generated,
65653
66631
  "utf8"
@@ -65655,7 +66633,7 @@ function registerInit(program) {
65655
66633
  if (!options.quiet) logger.info(`Wrote ${snippetPath}`);
65656
66634
  continue;
65657
66635
  }
65658
- (0, import_node_fs43.writeFileSync)(snippetPath, generated, "utf8");
66636
+ (0, import_node_fs45.writeFileSync)(snippetPath, generated, "utf8");
65659
66637
  if (!options.quiet) logger.info(`Wrote ${snippetPath}`);
65660
66638
  }
65661
66639
  if (options.baseline) {
@@ -65673,14 +66651,14 @@ function registerInit(program) {
65673
66651
  }
65674
66652
 
65675
66653
  // src/cli/commands/flywheel.ts
65676
- var import_node_fs45 = require("fs");
65677
- var import_node_path71 = require("path");
66654
+ var import_node_fs47 = require("fs");
66655
+ var import_node_path74 = require("path");
65678
66656
  init_logger();
65679
66657
  init_telemetry();
65680
66658
 
65681
66659
  // src/report/flywheel.ts
65682
- var import_node_fs44 = require("fs");
65683
- var import_node_path70 = require("path");
66660
+ var import_node_fs46 = require("fs");
66661
+ var import_node_path73 = require("path");
65684
66662
  function average(values) {
65685
66663
  if (values.length === 0) return 0;
65686
66664
  return values.reduce((a, b) => a + b, 0) / values.length;
@@ -65770,7 +66748,7 @@ function formatFlywheel(summary, options = {}) {
65770
66748
  function registerFlywheel(program) {
65771
66749
  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) => {
65772
66750
  const options = command.optsWithGlobals();
65773
- const cwd = (0, import_node_path71.resolve)(options.workspace ?? process.cwd());
66751
+ const cwd = (0, import_node_path74.resolve)(options.workspace ?? process.cwd());
65774
66752
  const payloads = readTelemetry(cwd);
65775
66753
  if (payloads.length === 0) {
65776
66754
  logger.info("No flywheel telemetry found. Run a scan first.");
@@ -65778,9 +66756,9 @@ function registerFlywheel(program) {
65778
66756
  }
65779
66757
  const summary = summarizeTelemetry(payloads);
65780
66758
  if (cmdOptions.export) {
65781
- const exportPath = (0, import_node_path71.resolve)(cmdOptions.export);
65782
- (0, import_node_fs45.mkdirSync)((0, import_node_path71.dirname)(exportPath), { recursive: true });
65783
- (0, import_node_fs45.writeFileSync)(exportPath, JSON.stringify(summary, null, 2), "utf-8");
66759
+ const exportPath = (0, import_node_path74.resolve)(cmdOptions.export);
66760
+ (0, import_node_fs47.mkdirSync)((0, import_node_path74.dirname)(exportPath), { recursive: true });
66761
+ (0, import_node_fs47.writeFileSync)(exportPath, JSON.stringify(summary, null, 2), "utf-8");
65784
66762
  logger.info(`Wrote flywheel summary to ${exportPath}`);
65785
66763
  process.exit(0);
65786
66764
  }
@@ -65807,8 +66785,8 @@ init_dist2();
65807
66785
  init_memory_io();
65808
66786
 
65809
66787
  // src/fix/focus-ring.ts
65810
- var import_node_fs46 = require("fs");
65811
- var import_node_fs47 = require("fs");
66788
+ var import_node_fs48 = require("fs");
66789
+ var import_node_fs49 = require("fs");
65812
66790
  var ANCHOR_START = "/* @slopbrick:v1.0.0:fix:focus-ring */";
65813
66791
  var CSS_BLOCK = `/* @slopbrick:v1.0.0:fix:focus-ring */
65814
66792
  :focus-visible {
@@ -65817,15 +66795,15 @@ var CSS_BLOCK = `/* @slopbrick:v1.0.0:fix:focus-ring */
65817
66795
  }
65818
66796
  /* @slopbrick:v1.0.0:fix:focus-ring-end */`;
65819
66797
  function applyFocusRingFix(targetFile) {
65820
- if (!(0, import_node_fs46.existsSync)(targetFile)) {
66798
+ if (!(0, import_node_fs48.existsSync)(targetFile)) {
65821
66799
  return { applied: false, reason: "missing-global-css-target" };
65822
66800
  }
65823
- const content = (0, import_node_fs47.readFileSync)(targetFile, "utf-8");
66801
+ const content = (0, import_node_fs49.readFileSync)(targetFile, "utf-8");
65824
66802
  if (content.includes(ANCHOR_START)) {
65825
66803
  return { applied: false, reason: "already-present" };
65826
66804
  }
65827
66805
  const separator = content.endsWith("\n") ? "" : "\n";
65828
- (0, import_node_fs47.writeFileSync)(targetFile, `${content}${separator}${CSS_BLOCK}
66806
+ (0, import_node_fs49.writeFileSync)(targetFile, `${content}${separator}${CSS_BLOCK}
65829
66807
  `);
65830
66808
  return { applied: true };
65831
66809
  }
@@ -65834,21 +66812,21 @@ function applyFocusRingFix(targetFile) {
65834
66812
  init_layout_token();
65835
66813
 
65836
66814
  // src/fix/use-client.ts
65837
- var import_node_fs48 = require("fs");
66815
+ var import_node_fs50 = require("fs");
65838
66816
  function applyUseClientFix(filePath) {
65839
- const content = (0, import_node_fs48.readFileSync)(filePath, "utf-8");
66817
+ const content = (0, import_node_fs50.readFileSync)(filePath, "utf-8");
65840
66818
  const trimmed = content.trimStart();
65841
66819
  if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) {
65842
66820
  return { applied: false, reason: "already-present" };
65843
66821
  }
65844
- (0, import_node_fs48.writeFileSync)(filePath, `"use client";
66822
+ (0, import_node_fs50.writeFileSync)(filePath, `"use client";
65845
66823
 
65846
66824
  ${content}`);
65847
66825
  return { applied: true };
65848
66826
  }
65849
66827
 
65850
66828
  // src/fix/visual-codemod.ts
65851
- var import_node_fs49 = require("fs");
66829
+ var import_node_fs51 = require("fs");
65852
66830
 
65853
66831
  // src/fix/visual-codemods/tailwind.ts
65854
66832
  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;
@@ -66057,7 +67035,7 @@ var ALL_CODEMODS = [
66057
67035
  { name: "aria-attr-typo", fn: applyAriaAttrTypoCodemod }
66058
67036
  ];
66059
67037
  function applyVisualCodemods(filePath) {
66060
- const original = (0, import_node_fs49.readFileSync)(filePath, "utf-8");
67038
+ const original = (0, import_node_fs51.readFileSync)(filePath, "utf-8");
66061
67039
  let content = original;
66062
67040
  const reasons = [];
66063
67041
  const seen = /* @__PURE__ */ new Set();
@@ -66073,7 +67051,7 @@ function applyVisualCodemods(filePath) {
66073
67051
  }
66074
67052
  }
66075
67053
  if (content !== original) {
66076
- (0, import_node_fs49.writeFileSync)(filePath, content);
67054
+ (0, import_node_fs51.writeFileSync)(filePath, content);
66077
67055
  }
66078
67056
  return {
66079
67057
  filePath,
@@ -66355,12 +67333,12 @@ var http = __toESM(require("http"), 1);
66355
67333
  var https = __toESM(require("https"), 1);
66356
67334
  var import_node_url2 = require("url");
66357
67335
  var sendBeacon = (url, payload, timeoutMs) => {
66358
- return new Promise((resolve42) => {
67336
+ return new Promise((resolve43) => {
66359
67337
  let parsed;
66360
67338
  try {
66361
67339
  parsed = new import_node_url2.URL(url);
66362
67340
  } catch {
66363
- resolve42();
67341
+ resolve43();
66364
67342
  return;
66365
67343
  }
66366
67344
  const isHttps = parsed.protocol === "https:";
@@ -66381,7 +67359,7 @@ var sendBeacon = (url, payload, timeoutMs) => {
66381
67359
  const settle = () => {
66382
67360
  if (settled) return;
66383
67361
  settled = true;
66384
- resolve42();
67362
+ resolve43();
66385
67363
  };
66386
67364
  let req;
66387
67365
  try {
@@ -66461,14 +67439,16 @@ process.on("uncaughtException", (err) => {
66461
67439
  });
66462
67440
  async function runCli({ start }) {
66463
67441
  try {
66464
- const program = new import_commander2.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)");
67442
+ const program = new import_commander3.Command().name("slopbrick").description("Repository Coherence Scanner \u2014 surface AI-induced pattern drift, secret leaks, and design-token violations").version(VERSION).option("--framework <name>", "framework multiplier to apply").option("--include <glob>", "include pattern (repeatable)", collectGlob, []).option("--exclude <glob>", "exclude pattern (repeatable)", collectGlob, []).option("--ai-only", "only report AI-specific issues").option("--human-only", "only report human-facing issues").option("--ignore-wcag22", "ignore WCAG 2.2 related issues").option("--format <pretty|json|sarif|html>", "output format", "pretty").option("--threads <n>", "number of worker threads", parseThreads).option("--since <ref>", "only scan files changed since git ref").option("--diff <ref>", "alias for --since <ref>; also adds PR Slop Score to the report").option("--workspace <path>", "workspace/project path", process.cwd()).option("--tighten", "tighten baseline allowances").option("--fix", "apply auto-fixes").option("--dry-run", "with --fix: print what would change without writing").option("--show-fixes-diff", "print unified diff of proposed auto-fixes").option("--doctor", "run diagnostics").option("--watch", "watch files and re-run").option("--suggest", "print remediation advice").option("--why-failing", "print the top 5 rules dragging the score down").option("--brief", "terse output (verdict + headline + threshold + delta only)").option("--heatmap", "print migration ROI heatmap").option("--quiet", "suppress non-error output").option("--verbose", "enable debug logging (file paths, timings, rule-fire counts)").option("--strict", "exit 2 if any high-severity issue remains").option("--no-increase", "exit 2 if slop index increased since last run").option("--refresh-snippets", "rewrite the managed slopbrick block in AGENTS.md and CLAUDE.md after a scan").option("--baseline", "save a baseline after this scan").option("--trend [n]", "print a sparkline of the last n runs", parseTrend).option("--json [path]", "write JSON report to path or stdout").option("--html [path]", "write HTML report to path or stdout").option("--staged", "scan only changed files (staged and unstaged)").option("--changed", "scan working-tree changes (staged + unstaged + untracked)").option("--incremental", "skip unchanged files using the persisted hash cache").option("--cache-path <path>", "path to the incremental-scan cache (default: .slopbrick-cache.json)").option("--tokens <path>", "merge tokens.json layout values into the arbitrary-value allowlist").option("--cache", "cache parsed AST results locally").option("--no-color", "suppress ANSI color codes in output").option("--security-only", "run only the security/* rules").option("--full", "show the complete report (all issues, all categories)").option("--report-usage", "opt in to a one-shot usage ping to SLOPBRICK_TELEMETRY_ENDPOINT (no PII)");
66465
67443
  program.helpInformation = () => formatGroupedHelp(program);
67444
+ setExitOverride(program);
66466
67445
  registerInit(program);
66467
67446
  registerInstall(program);
66468
67447
  registerUninstall(program);
66469
67448
  registerBadge(program);
66470
67449
  registerSuggest(program);
66471
67450
  registerExplain(program);
67451
+ registerComposite(program);
66472
67452
  registerCalibration(program);
66473
67453
  registerFlywheel(program);
66474
67454
  const scanAction = async (paths, _options, command) => {
@@ -66487,7 +67467,7 @@ async function runCli({ start }) {
66487
67467
  logger.error("--heatmap and --suggest can't be used together. Pick one: a heatmap of severity, or text advice.");
66488
67468
  process.exit(2);
66489
67469
  }
66490
- const cwd = (0, import_node_path72.resolve)(options.workspace ?? process.cwd());
67470
+ const cwd = (0, import_node_path75.resolve)(options.workspace ?? process.cwd());
66491
67471
  if (options.trend !== void 0) {
66492
67472
  const runs = await readRuns(cwd, fsMemoryIO);
66493
67473
  if (runs.length === 0) {
@@ -66535,7 +67515,7 @@ async function runCli({ start }) {
66535
67515
  });
66536
67516
  }
66537
67517
  if (options.baseline) {
66538
- const cwd2 = (0, import_node_path72.resolve)(options.workspace ?? process.cwd());
67518
+ const cwd2 = (0, import_node_path75.resolve)(options.workspace ?? process.cwd());
66539
67519
  const configHash = hashConfig(config);
66540
67520
  const gitHead = await getGitHead(cwd2) ?? "unknown";
66541
67521
  const cache = buildBaselineCache(report, configHash, gitHead, cwd2);
@@ -66647,7 +67627,9 @@ async function runCli({ start }) {
66647
67627
  program.outputHelp();
66648
67628
  process.exit(0);
66649
67629
  }
66650
- await program.parseAsync(process.argv);
67630
+ await dispatch(program, async () => {
67631
+ await program.parseAsync(process.argv);
67632
+ });
66651
67633
  } catch (err) {
66652
67634
  if (err instanceof ConfigValidationError) {
66653
67635
  logger.error(err.message);