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.js CHANGED
@@ -19,7 +19,7 @@ var VERSION;
19
19
  var init_header = __esm({
20
20
  "src/types/_header.ts"() {
21
21
  "use strict";
22
- VERSION = "0.40.0";
22
+ VERSION = "0.42.0";
23
23
  }
24
24
  });
25
25
 
@@ -45804,6 +45804,15 @@ function formatCompositeScore(report) {
45804
45804
  )
45805
45805
  );
45806
45806
  }
45807
+ const composite = report.compositeScore;
45808
+ if (composite !== void 0) {
45809
+ lines.push("");
45810
+ lines.push(
45811
+ chalk.dim(
45812
+ `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.`
45813
+ )
45814
+ );
45815
+ }
45807
45816
  return lines.join("\n");
45808
45817
  }
45809
45818
  function formatCoherenceScores(report) {
@@ -46853,11 +46862,11 @@ var init_pool = __esm({
46853
46862
  for (let i = 0; i < this.threadCount; i++) {
46854
46863
  spawnWorker();
46855
46864
  }
46856
- return new Promise((resolve42) => {
46865
+ return new Promise((resolve43) => {
46857
46866
  const check = setInterval(() => {
46858
46867
  if (resolved) {
46859
46868
  clearInterval(check);
46860
- resolve42(results);
46869
+ resolve43(results);
46861
46870
  }
46862
46871
  }, 10);
46863
46872
  });
@@ -46874,23 +46883,16 @@ var init_registry = __esm({
46874
46883
  init_builtins();
46875
46884
  RuleRegistry = class {
46876
46885
  rules = /* @__PURE__ */ new Map();
46877
- register(ruleOrId, factory) {
46878
- if (typeof ruleOrId === "string") {
46879
- if (!factory) {
46880
- throw new Error("Factory is required when registering by id");
46881
- }
46882
- const rule = {
46883
- id: ruleOrId,
46884
- category: factory.category,
46885
- severity: factory.severity,
46886
- aiSpecific: factory.aiSpecific,
46887
- create: factory.create,
46888
- analyze: factory.analyze
46889
- };
46890
- this.rules.set(ruleOrId, rule);
46891
- } else {
46892
- this.rules.set(ruleOrId.id, ruleOrId);
46893
- }
46886
+ /**
46887
+ * Register a rule. The 2-arg overload `register(id, factory)` was
46888
+ * deleted in v0.42.0 (architecture review F6): no callers used it,
46889
+ * every `Rule` carries its own `id`, and the per-id registration
46890
+ * was ergonomically redundant with the 1-arg form (`register(rule)`).
46891
+ * External consumers that needed `register-by-id` semantics can
46892
+ * construct a `Rule` directly and pass it in.
46893
+ */
46894
+ register(rule) {
46895
+ this.rules.set(rule.id, rule);
46894
46896
  }
46895
46897
  loadBuiltins(onlyRuleId) {
46896
46898
  for (const rule of builtinRules) {
@@ -46919,6 +46921,15 @@ var init_registry = __esm({
46919
46921
  all() {
46920
46922
  return Array.from(this.rules.values());
46921
46923
  }
46924
+ /** v0.42.0 (§3b.5): look up a registered rule by id.
46925
+ * Returns undefined if not registered. */
46926
+ get(id) {
46927
+ return this.rules.get(id);
46928
+ }
46929
+ /** v0.42.0 (§3b.5): check whether a rule id is registered. */
46930
+ has(id) {
46931
+ return this.rules.has(id);
46932
+ }
46922
46933
  createContexts(config, filePath, cwd, hotspotIssues = []) {
46923
46934
  const context = {
46924
46935
  config,
@@ -50570,6 +50581,10 @@ var init_signal_strength2 = __esm({
50570
50581
  });
50571
50582
 
50572
50583
  // src/engine/worker.ts
50584
+ var worker_exports = {};
50585
+ __export(worker_exports, {
50586
+ scanFile: () => scanFile
50587
+ });
50573
50588
  import { isMainThread, parentPort, workerData } from "worker_threads";
50574
50589
  import { extname as extname5 } from "path";
50575
50590
  import { join as join14, relative as relative5, sep as sep2 } from "path";
@@ -50650,6 +50665,36 @@ async function scanFile(filePath, config, registry, cwd = process.cwd()) {
50650
50665
  const issues = applyRuleOverrides(rawIssues, config.rules);
50651
50666
  const triggeredRuleIds = Array.from(new Set(issues.map((i) => i.ruleId)));
50652
50667
  const compScore = compositeScore(triggeredRuleIds, loadSignalStrength());
50668
+ const fireSet = new Set(triggeredRuleIds);
50669
+ const factsWithFireSet = Object.assign(facts, {
50670
+ compositeFireSet: fireSet
50671
+ });
50672
+ for (const { rule } of rules) {
50673
+ if (!rule.id.startsWith("composite/")) continue;
50674
+ try {
50675
+ const compositeIssues = rule.analyze(
50676
+ rule.create({
50677
+ config,
50678
+ filePath,
50679
+ cwd,
50680
+ framework: config.framework,
50681
+ uiLibraries: config.uiLibraries,
50682
+ hasTailwind: config.hasTailwind,
50683
+ supportsRsc: config.supportsRsc,
50684
+ hotspotIssues: []
50685
+ }),
50686
+ factsWithFireSet
50687
+ );
50688
+ if (compositeIssues.length > 0) {
50689
+ issues.push(...compositeIssues);
50690
+ for (const ci of compositeIssues) fireSet.add(ci.ruleId);
50691
+ }
50692
+ } catch (err) {
50693
+ if (process.env.SLOP_AUDIT_DEBUG === "1") {
50694
+ console.error(`[worker] composite ${rule.id} threw: ${err.message}`);
50695
+ }
50696
+ }
50697
+ }
50653
50698
  const gapValues = collectGapValues(facts);
50654
50699
  const styleSources = collectStyleSources(facts);
50655
50700
  const elementTags = facts.v2.jsx.elements.map((e) => e.tag);
@@ -54226,6 +54271,21 @@ function safeRelative(cwd, filePath) {
54226
54271
  return filePath;
54227
54272
  }
54228
54273
  }
54274
+ function buildInventorySummary(inventory) {
54275
+ const patternCounts = {};
54276
+ const patternNames = {};
54277
+ for (const [category, matches] of Object.entries(inventory.patterns)) {
54278
+ if (matches.length === 0) continue;
54279
+ const names = Array.from(new Set(matches.map((m) => m.name))).sort();
54280
+ patternCounts[category] = names.length;
54281
+ patternNames[category] = names.slice(0, TELEMETRY_INVENTORY_NAME_CAP);
54282
+ }
54283
+ return {
54284
+ scannedFiles: inventory.scannedFiles,
54285
+ patternCounts,
54286
+ patternNames
54287
+ };
54288
+ }
54229
54289
  function aggregateViolations(report) {
54230
54290
  const counts = /* @__PURE__ */ new Map();
54231
54291
  for (const issue of report.issues) {
@@ -54305,7 +54365,7 @@ function readTelemetry(cwd) {
54305
54365
  }
54306
54366
  return payloads;
54307
54367
  }
54308
- function recordTelemetry(cwd, report, results, config) {
54368
+ function recordTelemetry(cwd, report, results, config, inventory) {
54309
54369
  if (config.telemetry === false) {
54310
54370
  return void 0;
54311
54371
  }
@@ -54326,7 +54386,14 @@ function recordTelemetry(cwd, report, results, config) {
54326
54386
  framework: config.framework
54327
54387
  },
54328
54388
  violations: aggregateViolations(report),
54329
- files: buildFileRecords(cwd, report, results)
54389
+ files: buildFileRecords(cwd, report, results),
54390
+ // v0.41.0 (Sprint 2, task 2a.1): the inventory summary is
54391
+ // additive — omitting it (legacy callers) keeps the JSONL line
54392
+ // shape identical to v0.40.x payloads, so old readers stay
54393
+ // green. New readers (`slopbrick drift --since <date>` in
54394
+ // Sprint 2a.2) treat the field as optional and fall back to
54395
+ // a re-scan when it's missing.
54396
+ ...inventory ? { inventory: buildInventorySummary(inventory) } : {}
54330
54397
  };
54331
54398
  const path = telemetryPath2(cwd);
54332
54399
  const dir = dirname11(path);
@@ -54337,7 +54404,7 @@ function recordTelemetry(cwd, report, results, config) {
54337
54404
  appendFileSync(path, JSON.stringify(payload) + "\n", "utf-8");
54338
54405
  return payload;
54339
54406
  }
54340
- var TELEMETRY_DIR, TELEMETRY_FILE2, MAX_TELEMETRY_BYTES, MAX_ROTATED_FILES;
54407
+ var TELEMETRY_DIR, TELEMETRY_FILE2, MAX_TELEMETRY_BYTES, MAX_ROTATED_FILES, TELEMETRY_INVENTORY_NAME_CAP;
54341
54408
  var init_telemetry = __esm({
54342
54409
  "src/engine/telemetry.ts"() {
54343
54410
  "use strict";
@@ -54345,6 +54412,7 @@ var init_telemetry = __esm({
54345
54412
  TELEMETRY_FILE2 = "scans.jsonl";
54346
54413
  MAX_TELEMETRY_BYTES = 10 * 1024 * 1024;
54347
54414
  MAX_ROTATED_FILES = 5;
54415
+ TELEMETRY_INVENTORY_NAME_CAP = 50;
54348
54416
  }
54349
54417
  });
54350
54418
 
@@ -54497,29 +54565,29 @@ function renderStructureMarkdown(inventory, constitution) {
54497
54565
  return lines.join("\n");
54498
54566
  }
54499
54567
  async function writeStructureMarkdown(workspaceDir, md) {
54500
- await new Promise((resolve42, reject) => {
54568
+ await new Promise((resolve43, reject) => {
54501
54569
  try {
54502
54570
  const path = join18(workspaceDir, STRUCTURE_MD_FILE);
54503
54571
  mkdirSync6(dirname12(path), { recursive: true });
54504
54572
  writeFileSync6(path, md, "utf-8");
54505
- resolve42();
54573
+ resolve43();
54506
54574
  } catch (err) {
54507
54575
  reject(err instanceof Error ? err : new Error(String(err)));
54508
54576
  }
54509
54577
  });
54510
54578
  }
54511
54579
  async function readStructureMarkdown(workspaceDir) {
54512
- return new Promise((resolve42) => {
54580
+ return new Promise((resolve43) => {
54513
54581
  try {
54514
54582
  const path = join18(workspaceDir, STRUCTURE_MD_FILE);
54515
54583
  if (!existsSync16(path)) {
54516
- resolve42(null);
54584
+ resolve43(null);
54517
54585
  return;
54518
54586
  }
54519
54587
  const content = readFileSync20(path, "utf-8");
54520
- resolve42(content);
54588
+ resolve43(content);
54521
54589
  } catch {
54522
- resolve42(null);
54590
+ resolve43(null);
54523
54591
  }
54524
54592
  });
54525
54593
  }
@@ -54567,9 +54635,391 @@ var init_structure_md = __esm({
54567
54635
  }
54568
54636
  });
54569
54637
 
54638
+ // src/snippet/data.ts
54639
+ var CATEGORY_DIRECTIVES, RULE_HINTS;
54640
+ var init_data = __esm({
54641
+ "src/snippet/data.ts"() {
54642
+ "use strict";
54643
+ CATEGORY_DIRECTIVES = {
54644
+ 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.',
54645
+ 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.",
54646
+ 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.',
54647
+ 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".',
54648
+ 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.",
54649
+ typo: `Never leave TODO / placeholder / "change me" copy in shipped code. Use real i18n strings or the project's content map.`,
54650
+ 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].",
54651
+ component: "Don't build components > 200 lines. Extract shared subcomponents. Avoid circular prop drilling \u2014 use context.",
54652
+ 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.",
54653
+ 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'."
54654
+ };
54655
+ RULE_HINTS = {
54656
+ // v0.38.0 hygiene: 37 v10-DORMANT rule hints were removed. Every
54657
+ // remaining key matches a rule in src/rules/builtins.ts. No orphans.
54658
+ // (v0.16.0 previously moved 35 out-of-scope orphan hints to
54659
+ // docs/research/backlog-rule-hints.md
54660
+ // for future implementers to paste back when the corresponding rules ship.)
54661
+ "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.",
54662
+ "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.",
54663
+ "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.",
54664
+ "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.",
54665
+ "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.",
54666
+ "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.",
54667
+ "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.',
54668
+ "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.",
54669
+ "visual/arbitrary-escape": "Never use bracket-notation values like text-[13px] or bg-[#7c3aed]. Use design tokens instead.",
54670
+ "visual/spacing-scale-violation": "Use spacing scale tokens (p-2, gap-4, etc.) instead of arbitrary values like p-[13px] or gap-[1.75rem].",
54671
+ "visual/radius-scale-violation": "Use radius scale tokens (rounded-md, rounded-lg, etc.) instead of arbitrary values like rounded-[7px].",
54672
+ // v0.16.0 — in-scope orphans kept here (corresponding rule ships in v0.16.0).
54673
+ "typo/placeholder-text": 'Never leave "TODO", "placeholder", "change me", "your text here" in shipped UI.',
54674
+ "logic/key-prop-missing": "Always provide a stable `key` prop when rendering lists.",
54675
+ "logic/boundary-violation": "Don't import data-layer / DB code into UI components. Server-side only.",
54676
+ "wcag/missing-alt": 'Every <img> needs alt text. Decorative: alt="". Informative: describe the image.',
54677
+ "security/localstorage-token": "Never store JWT / access token / refresh token in localStorage or sessionStorage. Issue as httpOnly cookie.",
54678
+ "security/eval": "Never use eval() or new Function(). These are RCE vectors if the input is ever attacker-controlled.",
54679
+ "security/target-blank-no-noopener": 'Always add rel="noopener" (or rel="noreferrer") to target="_blank" links.',
54680
+ "component/giant-component": "Don't build components > 200 lines. Extract shared subcomponents.",
54681
+ "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.",
54682
+ "component/shadcn-prop-mismatch": "Select shadcn variants via the `variant` prop, not long `className` overrides. See the component registry for available variants.",
54683
+ "context/import-path-mismatch": "Use only the canonical import paths declared in brick.config.json (e.g. @/components/ui/, @/lib/).",
54684
+ "layout/gap-monopoly": "Mix gap-2 / gap-4 / gap-6 / gap-12 deliberately. Don't repeat the same gap value across the whole project.",
54685
+ "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.",
54686
+ "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.",
54687
+ "layout/spacing-grid": "Use the configured spacing scale (4px or 8px grid). Avoid arbitrary values like p-[13px] that aren't on the scale.",
54688
+ "logic/ghost-defensive": "Use optional chaining (?.) or early returns instead of deep && guards. If a defensive chain runs 3+ levels deep, refactor.",
54689
+ "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.)",
54690
+ "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.)",
54691
+ "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.)",
54692
+ "logic/math-any-density": "Replace `: any` with proper types. Start with the parameter/return types of the most-used functions.",
54693
+ "logic/math-console-log-storm": "Replace debug logs with a proper debugger or logger.debug(). Remove all console.log before shipping.",
54694
+ "logic/math-gini-class-usage": "Spread usage across more class tokens instead of repeating the same handful (p-4, p-8, rounded-lg, etc.).",
54695
+ "logic/math-variable-name-entropy": "Use domain-specific identifier names (reservations, invoices, customers) instead of generic data/items/value.",
54696
+ "logic/optimistic-no-rollback": "In optimistic updates, revert state in the catch block: `setX(prev => prev)`. Never leave stale UI on error.",
54697
+ "logic/reactive-hook-soup": "Coordinate state via a single derived value (useMemo) or a state machine. Avoid chained useEffects that sync local state.",
54698
+ "logic/zombie-state": "Remove unused useState or wire it into the component. Don't leave declared-but-never-read state bindings.",
54699
+ "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.",
54700
+ "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.",
54701
+ "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.",
54702
+ "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.",
54703
+ "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.",
54704
+ "perf/cls-image": "Add width/height attributes or an aspect-ratio utility to prevent layout shift.",
54705
+ "perf/css-bloat": "Extract to a CSS variable (`--surface-card`) or a component prop when a class string repeats 5+ times.",
54706
+ "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.',
54707
+ "visual/inline-style-dominance": "Replace inline `style={{...}}` with className utilities (e.g. Tailwind `p-4 m-2 gap-3`) or a CSS module class.",
54708
+ "visual/math-default-font": "Import a distinctive font (next/font/google, @font-face, or a CSS variable) instead of relying on the framework default.",
54709
+ "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.",
54710
+ "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.",
54711
+ "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.",
54712
+ "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%.",
54713
+ "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.",
54714
+ "wcag/focus-appearance": "Add a focus-visible:ring-* class, or remove outline-none. Keyboard users need a visible focus indicator.",
54715
+ "wcag/focus-obscured": "Ensure focused elements are not hidden behind fixed or sticky wrappers.",
54716
+ "test/weak-assertion": "Assert on a specific value or shape: `expect(x).toEqual(expectedValue)`. Avoid `.toBeDefined()` / `.toBeTruthy()` placeholders and tautological `expect(x).toBe(x)`.",
54717
+ "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.",
54718
+ "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`).",
54719
+ "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.",
54720
+ "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.",
54721
+ // v0.13.0 — AI-specific rules (peer-reviewed signals).
54722
+ "ai/markdown-leakage": "Delete stray `\\`\\`\\`<lang>\\`\\`\\`` markers; they are Markdown fences, not valid syntax in standalone source files (Yotkova et al. SemEval-2026).",
54723
+ "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).",
54724
+ "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).",
54725
+ "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).",
54726
+ "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).",
54727
+ "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).",
54728
+ "ai/renyi-profile": "The token distribution is mass-concentrated on a few high-frequency tokens. Verify authorship if unexpected (R\xE9nyi 1961, Moslonka 2025).",
54729
+ "ai/segment-surprisal-cv": "The cross-entropy is suspiciously uniform across the file. Real codebases have varied registers (Binoculars, Hans 2024).",
54730
+ "ai/compression-profile": "The file compresses unusually well and lines are highly repetitive \u2014 characteristic of AI-generated boilerplate (Cilibrasi 2005, Mahoney 1999).",
54731
+ // v0.14.5b — 6 new AI tendency detection rules (DORMANT in v0.14.5b;
54732
+ // reclassified post-v7 calibration in v0.14.5d)
54733
+ "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.",
54734
+ "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.",
54735
+ "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.",
54736
+ "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.",
54737
+ "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.",
54738
+ "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.",
54739
+ // v0.17.0 — db/* rules (Postgres static analysis via pgsql-parser)
54740
+ "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.',
54741
+ // v0.17.0 — docs/* rules (markdown drift detection)
54742
+ "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.",
54743
+ "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.",
54744
+ "docs/broken-link": "Create the file or fix the link target. On a public docs site, broken links erode trust more than stale copy.",
54745
+ // v0.18.9 — Rust-specific rules (tree-sitter-backed)
54746
+ "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.",
54747
+ "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.",
54748
+ "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.",
54749
+ "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.',
54750
+ // v0.19.0 — TypeScript-specific rules
54751
+ "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.",
54752
+ "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.",
54753
+ "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.",
54754
+ "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.",
54755
+ // v0.19.0 — Go-specific rules
54756
+ "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).',
54757
+ "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.",
54758
+ // v0.19.0 — Duplication detector
54759
+ "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.",
54760
+ // v0.23.0 — Type-2 near-duplicate detector (MinHash + LSH)
54761
+ "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.)",
54762
+ // v0.24.0 — Type-3 structural clone detector (two-stage MinHash)
54763
+ "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.)",
54764
+ // v0.24.0 — Kotlin rules (DORMANT until v9 Kotlin corpus calibration)
54765
+ // v0.29.0 — non-AI Kotlin rules (DORMANT until larger pos arm)
54766
+ // v0.30.0 — non-AI Java rules (Option C applied to v9 Java corpus, 92k files)
54767
+ "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.)",
54768
+ "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.)",
54769
+ // v0.35.0 — content-based detection (CoCoNUTS-inspired)
54770
+ "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.",
54771
+ // v0.35.1 — Raidar-inspired content-based detection
54772
+ "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)`.',
54773
+ // v0.24.0 — Swift rules (DORMANT until v9 Swift corpus calibration)
54774
+ "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.)",
54775
+ "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.)",
54776
+ "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.)",
54777
+ "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.)",
54778
+ "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.)",
54779
+ // v0.24.0 — C++ rules (DORMANT until v9 C++ corpus calibration)
54780
+ "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.)",
54781
+ "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.)",
54782
+ "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.)",
54783
+ "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.)"
54784
+ };
54785
+ }
54786
+ });
54787
+
54788
+ // src/snippet/generators.ts
54789
+ function wrapWithMarkers(level, body) {
54790
+ const head = level === 1 ? "<!-- slopbrick-managed-section -->\n" : "";
54791
+ return `${head}${MARKER_BEGIN}
54792
+ ${body}
54793
+ ${MARKER_END}
54794
+ `;
54795
+ }
54796
+ function generateCursorSnippet(rules) {
54797
+ const lines = [];
54798
+ lines.push("# slopbrick cursor rules");
54799
+ lines.push("");
54800
+ lines.push("Generated by `slopbrick init --cursor`. These rules describe patterns that slopbrick");
54801
+ lines.push("flags as AI-generated frontend slop. Avoid them when writing code in this project.");
54802
+ lines.push("");
54803
+ lines.push("## Category-level directives");
54804
+ lines.push("");
54805
+ for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
54806
+ lines.push("- **" + category + "**: " + directive);
54807
+ }
54808
+ lines.push("");
54809
+ lines.push("## Per-rule directives");
54810
+ lines.push("");
54811
+ const ai = aiSpecificRules(rules);
54812
+ const byCategory = categorizeRules(ai);
54813
+ for (const [category, list] of byCategory) {
54814
+ lines.push("### " + category);
54815
+ lines.push("");
54816
+ for (const rule of list) {
54817
+ const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
54818
+ lines.push("- **`" + rule.id + "`** (" + rule.severity + "): " + hint);
54819
+ }
54820
+ lines.push("");
54821
+ }
54822
+ lines.push("## Verification");
54823
+ lines.push("");
54824
+ lines.push("After writing code, run `slopbrick scan` and check for issues in any of the categories above.");
54825
+ lines.push("Use `slopbrick fix` for auto-fixable rules. Use `slopbrick mcp` from MCP-aware clients to query rules and scan files programmatically.");
54826
+ lines.push("");
54827
+ return lines.join("\n");
54828
+ }
54829
+ function generateAgentsMdSnippet(rules) {
54830
+ return wrapWithMarkers(
54831
+ 1,
54832
+ baseContent(
54833
+ rules,
54834
+ "AGENTS.md",
54835
+ "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."
54836
+ ) + "## How to verify\n\nAfter editing code, run `slopbrick scan`. Any issue with a category in this file is something to fix.\n"
54837
+ );
54838
+ }
54839
+ function generateClaudeMdSnippet(rules) {
54840
+ return wrapWithMarkers(
54841
+ 1,
54842
+ baseContent(
54843
+ rules,
54844
+ "CLAUDE.md",
54845
+ "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."
54846
+ ) + "## 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"
54847
+ );
54848
+ }
54849
+ function generateAiderSnippet(rules) {
54850
+ return baseContent(
54851
+ rules,
54852
+ "CONVENTIONS.md",
54853
+ "Generated by `slopbrick init --aider`. Aider reads CONVENTIONS.md (not AGENTS.md) for project conventions."
54854
+ );
54855
+ }
54856
+ function generateWindsurfSnippet(rules) {
54857
+ return baseContent(
54858
+ rules,
54859
+ ".windsurfrules",
54860
+ "Generated by `slopbrick init --windsurf`. Windsurf Cascade reads .windsurfrules at project root."
54861
+ );
54862
+ }
54863
+ function generateClineRules(rules) {
54864
+ return baseContent(
54865
+ rules,
54866
+ "AGENTS.md",
54867
+ "Generated by `slopbrick init --cline`. Cline reads .clinerules/AGENTS.md from the project root."
54868
+ );
54869
+ }
54870
+ function generateCursorrulesLegacySnippet(rules) {
54871
+ return baseContent(
54872
+ rules,
54873
+ ".cursorrules",
54874
+ "Generated by `slopbrick init --cursorrules`. This is the legacy Cursor rule format. Prefer `.cursor/rules/slopbrick.mdc` (use --cursor) for new projects."
54875
+ );
54876
+ }
54877
+ function generateGeminiSnippet(rules) {
54878
+ return baseContent(
54879
+ rules,
54880
+ "GEMINI.md",
54881
+ "Generated by `slopbrick init --gemini`. Gemini CLI reads .gemini/GEMINI.md from project root."
54882
+ );
54883
+ }
54884
+ function generateCopilotSnippet(rules) {
54885
+ return baseContent(
54886
+ rules,
54887
+ "copilot-instructions.md",
54888
+ "Generated by `slopbrick init --copilot`. GitHub Copilot reads .github/copilot-instructions.md from project root."
54889
+ );
54890
+ }
54891
+ var MARKER_BEGIN, MARKER_END;
54892
+ var init_generators = __esm({
54893
+ "src/snippet/generators.ts"() {
54894
+ "use strict";
54895
+ init_data();
54896
+ init_render2();
54897
+ MARKER_BEGIN = "<!-- slopbrick:begin:v3 -->";
54898
+ MARKER_END = "<!-- slopbrick:end:v3 -->";
54899
+ }
54900
+ });
54901
+
54902
+ // src/snippet/render.ts
54903
+ function aiSpecificRules(rules) {
54904
+ return rules.filter((r) => r.aiSpecific).sort((a, b) => a.id.localeCompare(b.id));
54905
+ }
54906
+ function categorizeRules(rules) {
54907
+ const byCategory = /* @__PURE__ */ new Map();
54908
+ for (const r of rules) {
54909
+ const list = byCategory.get(r.category) ?? [];
54910
+ list.push(r);
54911
+ byCategory.set(r.category, list);
54912
+ }
54913
+ for (const list of byCategory.values()) {
54914
+ list.sort((a, b) => a.id.localeCompare(b.id));
54915
+ }
54916
+ return byCategory;
54917
+ }
54918
+ function baseContent(rules, toolLabel, intro) {
54919
+ const lines = [];
54920
+ lines.push("# " + toolLabel);
54921
+ lines.push("");
54922
+ lines.push(intro);
54923
+ lines.push("");
54924
+ const ai = aiSpecificRules(rules);
54925
+ const byCategory = categorizeRules(ai);
54926
+ lines.push("## Category-level directives");
54927
+ lines.push("");
54928
+ for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
54929
+ lines.push("- **" + category + "**: " + directive);
54930
+ }
54931
+ lines.push("");
54932
+ lines.push("## Per-rule directives");
54933
+ lines.push("");
54934
+ for (const [category, list] of byCategory) {
54935
+ lines.push("### " + category);
54936
+ lines.push("");
54937
+ for (const rule of list) {
54938
+ const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
54939
+ lines.push("- `" + rule.id + "` (" + rule.severity + "): " + hint);
54940
+ }
54941
+ lines.push("");
54942
+ }
54943
+ return lines.join("\n");
54944
+ }
54945
+ function safeRewrite(existing, freshBlockBody) {
54946
+ const beginIdx = existing.indexOf(MARKER_BEGIN);
54947
+ const endIdx = existing.indexOf(MARKER_END);
54948
+ if (beginIdx === -1 && endIdx === -1) {
54949
+ return { content: existing, rewritten: false, failClosed: true, mismatched: false };
54950
+ }
54951
+ if (beginIdx === -1 || endIdx === -1) {
54952
+ return { content: existing, rewritten: false, failClosed: false, mismatched: true };
54953
+ }
54954
+ if (endIdx <= beginIdx) {
54955
+ return { content: existing, rewritten: false, failClosed: false, mismatched: true };
54956
+ }
54957
+ const before = existing.slice(0, beginIdx + MARKER_BEGIN.length);
54958
+ const after = existing.slice(endIdx);
54959
+ const next = `${before}
54960
+ ${freshBlockBody}
54961
+ ${after}`;
54962
+ return { content: next, rewritten: true, failClosed: false, mismatched: false };
54963
+ }
54964
+ var init_render2 = __esm({
54965
+ "src/snippet/render.ts"() {
54966
+ "use strict";
54967
+ init_data();
54968
+ init_generators();
54969
+ }
54970
+ });
54971
+
54972
+ // src/snippet/refresh.ts
54973
+ var refresh_exports = {};
54974
+ __export(refresh_exports, {
54975
+ SNIPPET_TARGETS: () => SNIPPET_TARGETS,
54976
+ refreshSnippets: () => refreshSnippets
54977
+ });
54978
+ import { existsSync as existsSync17, readFileSync as readFileSync21, writeFileSync as writeFileSync7 } from "fs";
54979
+ import { join as join19 } from "path";
54980
+ function refreshSnippets(cwd, rules, targets = [...SNIPPET_TARGETS]) {
54981
+ const result = { rewritten: [], failClosed: [], mismatched: [], absent: [] };
54982
+ for (const filename of targets) {
54983
+ const path = join19(cwd, filename);
54984
+ if (!existsSync17(path)) {
54985
+ result.absent.push(filename);
54986
+ continue;
54987
+ }
54988
+ const existing = readFileSync21(path, "utf-8");
54989
+ const agentName = filename.replace(/\.md$/i, "").toLowerCase();
54990
+ const generator = agentName === "claude" ? generateAgentsMdSnippet : generateAgentsMdSnippet;
54991
+ const newBlock = generator([...rules]).trim();
54992
+ const outcome = safeRewrite(existing, newBlock);
54993
+ if (outcome.rewritten) {
54994
+ const tmp = `${path}.tmp`;
54995
+ writeFileSync7(tmp, outcome.content, "utf-8");
54996
+ const fs = __require("fs");
54997
+ fs.renameSync(tmp, path);
54998
+ result.rewritten.push(filename);
54999
+ } else if (outcome.failClosed) {
55000
+ result.failClosed.push(filename);
55001
+ } else if (outcome.mismatched) {
55002
+ result.mismatched.push(filename);
55003
+ }
55004
+ }
55005
+ return result;
55006
+ }
55007
+ var SNIPPET_TARGETS;
55008
+ var init_refresh = __esm({
55009
+ "src/snippet/refresh.ts"() {
55010
+ "use strict";
55011
+ init_render2();
55012
+ init_generators();
55013
+ SNIPPET_TARGETS = [
55014
+ "AGENTS.md",
55015
+ "CLAUDE.md"
55016
+ ];
55017
+ }
55018
+ });
55019
+
54570
55020
  // src/cli/report/persistRun.ts
54571
- import { existsSync as existsSync17, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7 } from "fs";
54572
- import { join as join19, relative as relative10 } from "path";
55021
+ import { existsSync as existsSync18, writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
55022
+ import { join as join20, relative as relative10 } from "path";
54573
55023
  async function persistRun(input) {
54574
55024
  const {
54575
55025
  cwd,
@@ -54651,20 +55101,20 @@ async function persistRun(input) {
54651
55101
  report.research = state.research;
54652
55102
  }
54653
55103
  if (flywheelOutput.suggestions.length > 0) {
54654
- const suggestionsDir = join19(cwd, ".slopbrick", "flywheel");
54655
- if (!existsSync17(suggestionsDir)) mkdirSync7(suggestionsDir, { recursive: true });
54656
- writeFileSync7(
54657
- join19(suggestionsDir, "rule-suggestions.json"),
55104
+ const suggestionsDir = join20(cwd, ".slopbrick", "flywheel");
55105
+ if (!existsSync18(suggestionsDir)) mkdirSync7(suggestionsDir, { recursive: true });
55106
+ writeFileSync8(
55107
+ join20(suggestionsDir, "rule-suggestions.json"),
54658
55108
  JSON.stringify(flywheelOutput.suggestions, null, 2)
54659
55109
  );
54660
55110
  }
54661
55111
  report.issues.push(...flywheelOutput.hotspotIssues);
54662
55112
  }
54663
- recordTelemetry(cwd, report, results, config);
55113
+ let patternInventory;
54664
55114
  if (config.projectMemory !== false) {
54665
55115
  try {
54666
55116
  const durationMs = Date.now() - startTime;
54667
- const patternInventory = await buildPatternInventory(cwd, config);
55117
+ patternInventory = await buildPatternInventory(cwd, config);
54668
55118
  const inventory = buildInventoryFromScan(
54669
55119
  { cwd, results },
54670
55120
  patternInventory,
@@ -54698,6 +55148,22 @@ async function persistRun(input) {
54698
55148
  }
54699
55149
  }
54700
55150
  }
55151
+ recordTelemetry(cwd, report, results, config, patternInventory);
55152
+ if (input.autoRefreshSnippets) {
55153
+ try {
55154
+ const { refreshSnippets: refreshSnippets2 } = await Promise.resolve().then(() => (init_refresh(), refresh_exports));
55155
+ const outcome = refreshSnippets2(cwd, registry.getRules());
55156
+ if (outcome.rewritten.length > 0 && !options.quiet && !machineReadableStdout) {
55157
+ logger.info(
55158
+ `Snippet refresh: rewrote ${outcome.rewritten.length} file(s) (${outcome.rewritten.join(", ")}).`
55159
+ );
55160
+ }
55161
+ } catch (err) {
55162
+ if (options.quiet !== true && !machineReadableStdout) {
55163
+ logger.warn(`snippet refresh failed: ${err instanceof Error ? err.message : String(err)}`);
55164
+ }
55165
+ }
55166
+ }
54701
55167
  }
54702
55168
  var init_persistRun = __esm({
54703
55169
  "src/cli/report/persistRun.ts"() {
@@ -54918,7 +55384,7 @@ var init_json = __esm({
54918
55384
 
54919
55385
  // src/report/sarif.ts
54920
55386
  import { createHash as createHash11 } from "crypto";
54921
- import { readFileSync as readFileSync21 } from "fs";
55387
+ import { readFileSync as readFileSync22 } from "fs";
54922
55388
  import { basename as basename3, isAbsolute as isAbsolute3, relative as relative12, resolve as resolve9 } from "path";
54923
55389
  function buildArtifactUri(filePath, cwd) {
54924
55390
  if (!filePath) {
@@ -55025,7 +55491,7 @@ function buildResultFromIssue(issue, cwd, fileContentCache) {
55025
55491
  let content = fileContentCache.get(sourcePath);
55026
55492
  if (content === void 0) {
55027
55493
  try {
55028
- content = readFileSync21(sourcePath, "utf-8");
55494
+ content = readFileSync22(sourcePath, "utf-8");
55029
55495
  fileContentCache.set(sourcePath, content);
55030
55496
  } catch {
55031
55497
  fileContentCache.set(sourcePath, "");
@@ -55078,6 +55544,7 @@ function formatSarif(report, options) {
55078
55544
  const results = report.issues.map(
55079
55545
  (issue) => buildResultFromIssue(issue, options?.cwd, fileContentCache)
55080
55546
  );
55547
+ const driverProperties = report.compositeScore ? { compositeScore: report.compositeScore } : void 0;
55081
55548
  const log = {
55082
55549
  $schema: "https://json.schemastore.org/sarif-2.1.0.json",
55083
55550
  version: "2.1.0",
@@ -55088,7 +55555,8 @@ function formatSarif(report, options) {
55088
55555
  name: "slopbrick",
55089
55556
  version: report.version,
55090
55557
  informationUri: REPO_INFORMATION_URI,
55091
- rules
55558
+ rules,
55559
+ ...driverProperties ? { properties: driverProperties } : {}
55092
55560
  }
55093
55561
  },
55094
55562
  results
@@ -56249,7 +56717,7 @@ var init_advice = __esm({
56249
56717
  });
56250
56718
 
56251
56719
  // src/fix/layout-token.ts
56252
- import { readFileSync as readFileSync22, writeFileSync as writeFileSync8 } from "fs";
56720
+ import { readFileSync as readFileSync23, writeFileSync as writeFileSync9 } from "fs";
56253
56721
  function escapeRegExp(value) {
56254
56722
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56255
56723
  }
@@ -56286,10 +56754,10 @@ function applyReplaceFixes(content, fixes) {
56286
56754
  return { content, applied, skipped, reasons };
56287
56755
  }
56288
56756
  function applyLayoutTokenFix(filePath, fixes) {
56289
- let content = readFileSync22(filePath, "utf-8");
56757
+ let content = readFileSync23(filePath, "utf-8");
56290
56758
  const { content: patched, applied, skipped, reasons } = applyReplaceFixes(content, fixes);
56291
56759
  if (patched !== content) {
56292
- writeFileSync8(filePath, patched);
56760
+ writeFileSync9(filePath, patched);
56293
56761
  }
56294
56762
  return { applied, skipped, reasons };
56295
56763
  }
@@ -56300,7 +56768,7 @@ var init_layout_token = __esm({
56300
56768
  });
56301
56769
 
56302
56770
  // src/report/unified-diff.ts
56303
- import { existsSync as existsSync18, readFileSync as readFileSync23 } from "fs";
56771
+ import { existsSync as existsSync19, readFileSync as readFileSync24 } from "fs";
56304
56772
  import { relative as relative13 } from "path";
56305
56773
  function collectAllFixes(issue) {
56306
56774
  return [...issue.fix ? [issue.fix] : [], ...issue.fixes ?? []];
@@ -56377,8 +56845,8 @@ function formatUnifiedDiff(report, cwd) {
56377
56845
  const parts = [];
56378
56846
  let hasHunk = false;
56379
56847
  for (const [filePath, fixes] of byFile) {
56380
- if (!existsSync18(filePath)) continue;
56381
- const original = readFileSync23(filePath, "utf-8");
56848
+ if (!existsSync19(filePath)) continue;
56849
+ const original = readFileSync24(filePath, "utf-8");
56382
56850
  const patched = applyFixesToString(original, fixes);
56383
56851
  if (patched === original) continue;
56384
56852
  if (!hasHunk) {
@@ -56484,7 +56952,7 @@ var init_heatmap = __esm({
56484
56952
 
56485
56953
  // src/cli/report/renderOutput.ts
56486
56954
  import { resolve as resolve10 } from "path";
56487
- import { writeFileSync as writeFileSync9 } from "fs";
56955
+ import { writeFileSync as writeFileSync10 } from "fs";
56488
56956
  function renderOutput(report, options, cwd) {
56489
56957
  if (options.format && !VALID_FORMATS.has(options.format)) {
56490
56958
  process.stderr.write(
@@ -56518,7 +56986,7 @@ function renderOutput(report, options, cwd) {
56518
56986
  if (options.html) {
56519
56987
  const html = formatHtml(report);
56520
56988
  if (typeof options.html === "string") {
56521
- writeFileSync9(resolve10(options.html), html);
56989
+ writeFileSync10(resolve10(options.html), html);
56522
56990
  if (!options.quiet) {
56523
56991
  logger.info(`Wrote HTML report to ${options.html}`);
56524
56992
  }
@@ -56530,7 +56998,7 @@ function renderOutput(report, options, cwd) {
56530
56998
  if (options.json) {
56531
56999
  const json = formatJson(report);
56532
57000
  if (typeof options.json === "string") {
56533
- writeFileSync9(resolve10(options.json), json);
57001
+ writeFileSync10(resolve10(options.json), json);
56534
57002
  if (!options.quiet) {
56535
57003
  logger.info(`Wrote JSON report to ${options.json}`);
56536
57004
  }
@@ -56761,7 +57229,7 @@ var init_watch = __esm({
56761
57229
  });
56762
57230
 
56763
57231
  // src/cli/scan.ts
56764
- import { existsSync as existsSync19, statSync as statSync7 } from "fs";
57232
+ import { existsSync as existsSync20, statSync as statSync7 } from "fs";
56765
57233
  import { resolve as resolve12, relative as relative15, extname as extname7, sep as sep3 } from "path";
56766
57234
  import { randomUUID } from "crypto";
56767
57235
  async function runScan(options, explicitPaths) {
@@ -56769,7 +57237,7 @@ async function runScan(options, explicitPaths) {
56769
57237
  const startTime = Date.now();
56770
57238
  const scanId = randomUUID();
56771
57239
  const cwd = resolve12(options.workspace ?? process.cwd());
56772
- if (!existsSync19(cwd)) {
57240
+ if (!existsSync20(cwd)) {
56773
57241
  throw new Error(`Workspace not found: ${cwd}`);
56774
57242
  }
56775
57243
  const cwdStat = statSync7(cwd);
@@ -56814,7 +57282,7 @@ async function runScan(options, explicitPaths) {
56814
57282
  const resolved = explicitPaths.map((p) => resolve12(cwd, p));
56815
57283
  const expanded = [];
56816
57284
  for (const p of resolved) {
56817
- if (existsSync19(p) && statSync7(p).isDirectory()) {
57285
+ if (existsSync20(p) && statSync7(p).isDirectory()) {
56818
57286
  const found = await globby4(`${p}/**/*`, { absolute: true, onlyFiles: true });
56819
57287
  for (const f of found) {
56820
57288
  if (!ALL_SOURCE_EXTENSIONS.has(extname7(f).toLowerCase())) continue;
@@ -56968,7 +57436,7 @@ async function runScan(options, explicitPaths) {
56968
57436
  const scannedPaths = new Set(results.map((result) => result.filePath));
56969
57437
  for (const [filePath, cached] of Object.entries(baseline.scores)) {
56970
57438
  if (scannedPaths.has(filePath)) continue;
56971
- if (!existsSync19(filePath)) continue;
57439
+ if (!existsSync20(filePath)) continue;
56972
57440
  scores.push({
56973
57441
  filePath,
56974
57442
  rawScore: 0,
@@ -57007,7 +57475,12 @@ async function runScan(options, explicitPaths) {
57007
57475
  const { report, noIncreaseFailure } = await finalizeReport({
57008
57476
  cwd,
57009
57477
  config,
57010
- options,
57478
+ options: {
57479
+ ...options,
57480
+ // v0.42.0 (§3a.4): forward the opt-in flag through to
57481
+ // `persistRun`, which calls `refreshSnippets` post-scan.
57482
+ autoRefreshSnippets: Boolean(options.autoRefreshSnippets) || Boolean(config.autoRefreshSnippets)
57483
+ },
57011
57484
  results,
57012
57485
  aggregated,
57013
57486
  allIssues,
@@ -57075,396 +57548,44 @@ var init_scan2 = __esm({
57075
57548
  }
57076
57549
  });
57077
57550
 
57078
- // src/mcp/slop-suggest-structure.ts
57079
- async function runSuggestWithStructure(args, ctx) {
57080
- const cached = await readStructureMarkdown(ctx.cwd);
57081
- if (cached !== null) {
57082
- return {
57083
- content: [{ type: "text", text: cached }]
57084
- };
57085
- }
57086
- const { handleToolCall: handleToolCall2 } = await Promise.resolve().then(() => (init_tools(), tools_exports));
57087
- const result = await handleToolCall2("slop_suggest", args, ctx);
57088
- if (result.isError) return result;
57089
- try {
57090
- const parsed = JSON.parse(result.content[0].text);
57091
- if (parsed !== null && typeof parsed === "object") {
57092
- parsed.structureHint = STRUCTURE_NOT_FOUND_HINT;
57093
- return {
57094
- content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }]
57095
- };
57096
- }
57097
- } catch {
57098
- }
57099
- return result;
57100
- }
57101
- var STRUCTURE_NOT_FOUND_HINT;
57102
- var init_slop_suggest_structure = __esm({
57103
- "src/mcp/slop-suggest-structure.ts"() {
57104
- "use strict";
57105
- init_structure_md();
57106
- 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.";
57107
- }
57108
- });
57109
-
57110
- // src/mcp/tools.ts
57111
- var tools_exports = {};
57112
- __export(tools_exports, {
57113
- TOOL_DEFINITIONS: () => TOOL_DEFINITIONS,
57114
- canonicalToolNames: () => canonicalToolNames,
57115
- getDeprecation: () => getDeprecation,
57116
- handleToolCall: () => handleToolCall
57551
+ // src/rules/composite-seed.ts
57552
+ var composite_seed_exports = {};
57553
+ __export(composite_seed_exports, {
57554
+ HAND_CURATED_SEED: () => HAND_CURATED_SEED,
57555
+ maybeAppendSeed: () => maybeAppendSeed
57117
57556
  });
57118
- import { readFileSync as readFileSync27 } from "fs";
57119
- import { resolve as resolve17 } from "path";
57120
- function toolError(message) {
57121
- return {
57122
- content: [{ type: "text", text: JSON.stringify({ error: message }) }],
57123
- isError: true
57124
- };
57125
- }
57126
- async function runScanFile(args, ctx) {
57127
- const path = args.path;
57128
- if (!path) return toolError("Missing required argument: path");
57129
- const result = await scanFile(path, ctx.config);
57130
- const simplified = {
57131
- filePath: result.filePath,
57132
- componentCount: result.componentCount,
57133
- parseError: result.parseError,
57134
- issues: result.issues.map((i) => ({
57135
- ruleId: i.ruleId,
57136
- category: i.category,
57137
- severity: i.severity,
57138
- line: i.line,
57139
- column: i.column,
57140
- message: i.message,
57141
- advice: i.advice
57142
- }))
57143
- };
57144
- return {
57145
- content: [{ type: "text", text: JSON.stringify(simplified, null, 2) }]
57146
- };
57147
- }
57148
- function explainRule2(args, ctx) {
57149
- const ruleId = args.ruleId;
57150
- if (!ruleId) return toolError("Missing required argument: ruleId");
57151
- const rule = ctx.rules.find((r) => r.id === ruleId);
57152
- if (!rule) return toolError("Unknown rule: " + ruleId);
57153
- const explanation = {
57154
- ruleId: rule.id,
57155
- category: rule.category,
57156
- severity: rule.severity,
57157
- aiSpecific: rule.aiSpecific,
57158
- 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 + ".",
57159
- whereToLook: "src/rules/" + rule.category + "/" + rule.id.replace(/^[^/]+\//, "") + ".ts"
57160
- };
57161
- return {
57162
- content: [{ type: "text", text: JSON.stringify(explanation, null, 2) }]
57163
- };
57164
- }
57165
- function listRules(args, ctx) {
57166
- const category = args.category;
57167
- const filtered = category ? ctx.rules.filter((r) => r.category === category) : ctx.rules;
57168
- const rules = filtered.map((r) => ({
57169
- id: r.id,
57170
- category: r.category,
57171
- severity: r.severity,
57172
- aiSpecific: r.aiSpecific
57173
- }));
57174
- return {
57175
- content: [
57176
- {
57177
- type: "text",
57178
- text: JSON.stringify({ count: rules.length, rules }, null, 2)
57179
- }
57180
- ]
57181
- };
57182
- }
57183
- async function runSuggest(args, ctx) {
57184
- const maxFilesRaw = args.maxFiles;
57185
- const maxFiles = typeof maxFilesRaw === "number" && Number.isFinite(maxFilesRaw) && maxFilesRaw > 0 ? Math.min(2e3, Math.floor(maxFilesRaw)) : 200;
57186
- try {
57187
- const inventory = await buildPatternInventory(ctx.cwd, ctx.config, maxFiles);
57188
- const doNotCreate = [
57189
- ...ctx.config.constitution?.forbidden ?? []
57190
- ];
57191
- const declared = /* @__PURE__ */ new Set();
57192
- for (const list of [
57193
- ctx.config.constitution?.stateManagement ?? [],
57194
- ctx.config.constitution?.dataFetching ?? [],
57195
- ctx.config.constitution?.uiLibrary ?? [],
57196
- ctx.config.constitution?.forms ?? [],
57197
- ctx.config.constitution?.styling ?? [],
57198
- ctx.config.constitution?.routing ?? []
57199
- ]) {
57200
- for (const lib of list) declared.add(lib);
57201
- }
57202
- const doNotCreateCapped = doNotCreate.slice(0, 10);
57203
- return {
57204
- content: [
57205
- {
57206
- type: "text",
57207
- text: JSON.stringify(
57208
- {
57209
- 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.",
57210
- doNotCreate: doNotCreateCapped,
57211
- declaredStack: Array.from(declared),
57212
- existingPatterns: inventory
57213
- },
57214
- null,
57215
- 2
57216
- )
57217
- }
57218
- ]
57219
- };
57220
- } catch (err) {
57221
- return toolError(err instanceof Error ? err.message : String(err));
57222
- }
57223
- }
57224
- function runCheckConstitution(args, ctx) {
57225
- const path = args.path;
57226
- if (!path) return toolError("Missing required argument: path");
57227
- const absPath = resolve17(ctx.cwd, path);
57228
- let source;
57229
- try {
57230
- source = readFileSync27(absPath, "utf-8");
57231
- } catch (err) {
57232
- return toolError(
57233
- `Cannot read file ${absPath}: ${err instanceof Error ? err.message : String(err)}`
57234
- );
57235
- }
57236
- const result = checkFileConstitution(source, ctx.config.constitution);
57237
- return {
57238
- content: [
57239
- {
57240
- type: "text",
57241
- text: JSON.stringify(
57242
- {
57243
- file: absPath,
57244
- importCount: result.imports.length,
57245
- violationCount: result.violations.length,
57246
- imports: result.imports,
57247
- violations: result.violations,
57248
- // Field name kept stable for backward compatibility with
57249
- // older consumers; the value reflects whether the merged
57250
- // `config.constitution` was declared, detected, or absent.
57251
- conventionSource: ctx.config.constitution ? "declared-or-detected" : "none"
57252
- },
57253
- null,
57254
- 2
57255
- )
57256
- }
57257
- ]
57258
- };
57259
- }
57260
- async function runFindSimilar(args, ctx) {
57261
- const { findSimilarFunctions: findSimilarFunctions2 } = await Promise.resolve().then(() => (init_dist2(), dist_exports2));
57262
- const hooks = Array.isArray(args.hooks) ? args.hooks : [];
57263
- const props = Array.isArray(args.props) ? args.props : [];
57264
- const limitRaw = args.limit;
57265
- const limit = typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 10;
57266
- try {
57267
- const matches = await findSimilarFunctions2(
57268
- {
57269
- name: typeof args.name === "string" ? args.name : void 0,
57270
- hooks,
57271
- props,
57272
- limit,
57273
- workspaceDir: ctx.cwd
57274
- },
57275
- { cwd: ctx.cwd }
57276
- );
57277
- return {
57278
- content: [
57279
- {
57280
- type: "text",
57281
- text: JSON.stringify(
57282
- {
57283
- 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.",
57284
- count: matches.length,
57285
- matches: matches.map((m) => ({
57286
- name: m.signature.name,
57287
- file: m.signature.fileRel,
57288
- line: m.signature.line,
57289
- similarity: Number(m.similarity.toFixed(3)),
57290
- fingerprint: m.fingerprint,
57291
- hooks: m.signature.hooks,
57292
- props: m.signature.props,
57293
- params: m.signature.params
57294
- }))
57295
- },
57296
- null,
57297
- 2
57298
- )
57299
- }
57300
- ]
57301
- };
57302
- } catch (err) {
57303
- return toolError(err instanceof Error ? err.message : String(err));
57304
- }
57305
- }
57306
- async function handleToolCall(toolName, args, ctx) {
57307
- const deprecation = getDeprecation(toolName);
57308
- const deprecationNotice = deprecation ? {
57309
- tool: toolName,
57310
- replacedBy: deprecation.replacedBy,
57311
- removedIn: deprecation.removedIn ?? "next major",
57312
- reason: deprecation.reason
57313
- } : void 0;
57314
- switch (toolName) {
57315
- case "slop_scan_file":
57316
- return runScanFile(args, ctx);
57317
- case "slop_explain_rule":
57318
- return explainRule2(args, ctx);
57319
- case "slop_list_rules":
57320
- return listRules(args, ctx);
57321
- case "slop_suggest":
57322
- return runSuggest(args, ctx);
57323
- case "slop_suggest_with_structure":
57324
- return runSuggestWithStructure(args, ctx);
57325
- // v0.39.0: removed 3 deprecated tools (slop_governance,
57326
- // slop_architecture_score, slop_business_logic_score) that
57327
- // were marked for removal in v0.13.0 but never removed.
57328
- // Their runner functions (runGovernance, runArchitectureScore,
57329
- // runBusinessLogicScore) are kept in the file for now
57330
- // (marked @deprecated) to keep the diff small; they can be
57331
- // deleted in a follow-up. New clients will never see these
57332
- // tools listed in the MCP tools/list response.
57333
- case "slop_check_constitution":
57334
- return runCheckConstitution(args, ctx);
57335
- case "slop_find_similar":
57336
- return runFindSimilar(args, ctx);
57337
- default:
57338
- return toolError("Unknown tool: " + toolName);
57557
+ function maybeAppendSeed(entries) {
57558
+ if (entries.length === 0) {
57559
+ return [...HAND_CURATED_SEED];
57339
57560
  }
57561
+ return entries;
57340
57562
  }
57341
- function canonicalToolNames() {
57342
- return TOOL_DEFINITIONS.filter((t) => !t.deprecated).map((t) => t.name);
57343
- }
57344
- function getDeprecation(toolName) {
57345
- return TOOL_DEFINITIONS.find((t) => t.name === toolName)?.deprecated;
57346
- }
57347
- var TOOL_DEFINITIONS;
57348
- var init_tools = __esm({
57349
- "src/mcp/tools.ts"() {
57563
+ var HAND_CURATED_SEED;
57564
+ var init_composite_seed = __esm({
57565
+ "src/rules/composite-seed.ts"() {
57350
57566
  "use strict";
57351
- init_worker();
57352
- init_patterns();
57353
- init_architecture_score();
57354
- init_business_logic();
57355
- init_slop_suggest_structure();
57356
- TOOL_DEFINITIONS = [
57357
- {
57358
- name: "slop_scan_file",
57359
- 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.",
57360
- inputSchema: {
57361
- type: "object",
57362
- properties: {
57363
- path: { type: "string", description: "Absolute or cwd-relative path to the source file." },
57364
- framework: {
57365
- type: "string",
57366
- enum: ["react", "vue", "svelte", "astro", "html"],
57367
- description: "Framework multiplier to apply. Defaults to the configured framework."
57368
- }
57369
- },
57370
- required: ["path"]
57371
- }
57372
- },
57373
- {
57374
- name: "slop_explain_rule",
57375
- 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.",
57376
- inputSchema: {
57377
- type: "object",
57378
- properties: {
57379
- ruleId: { type: "string", description: 'e.g. "visual/ai-default-palette".' }
57380
- },
57381
- required: ["ruleId"]
57382
- }
57383
- },
57384
- {
57385
- name: "slop_list_rules",
57386
- description: "List all registered rules with their category, severity, and aiSpecific flag. Optional category filter (visual | logic | wcag | security | perf | typo | layout | component | arch).",
57387
- inputSchema: {
57388
- type: "object",
57389
- properties: {
57390
- category: { type: "string", description: "Optional category filter." }
57391
- }
57392
- }
57393
- },
57567
+ HAND_CURATED_SEED = [
57394
57568
  {
57395
- name: "slop_suggest",
57396
- 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.",
57397
- inputSchema: {
57398
- type: "object",
57399
- properties: {
57400
- maxFiles: {
57401
- type: "number",
57402
- description: "Cap on files scanned to keep the inventory cheap. Defaults to 200."
57403
- }
57404
- }
57405
- }
57406
- },
57407
- {
57408
- name: "slop_suggest_with_structure",
57409
- 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.",
57410
- inputSchema: {
57411
- type: "object",
57412
- properties: {
57413
- maxFiles: {
57414
- type: "number",
57415
- description: "Cap on files scanned for the slow-path fallback. Defaults to 200."
57416
- }
57417
- }
57418
- }
57419
- },
57420
- {
57421
- // v0.39.0: removed 3 deprecated tools (slop_governance,
57422
- // slop_architecture_score, slop_business_logic_score) that
57423
- // were marked for removal in v0.13.0 but never removed.
57424
- // They were strict subsets of slop_suggest; users should
57425
- // call slop_suggest and read repositoryHealth /
57426
- // architectureConsistency / businessLogicCoherence.
57427
- name: "slop_check_constitution",
57428
- 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.",
57429
- inputSchema: {
57430
- type: "object",
57431
- properties: {
57432
- path: { type: "string", description: "Absolute or cwd-relative path to the source file." }
57433
- },
57434
- required: ["path"]
57435
- }
57436
- },
57437
- {
57438
- // v0.10.1: find_similar_function. The GIR (Give-Implementation-
57439
- // Reference) primitive for slop_suggest. Given a function signature
57440
- // (name + hooks + props), find the most similar existing
57441
- // implementations across the codebase. Uses AST fingerprints
57442
- // (sha256 over sorted hooks ∪ props ∪ params) + Jaccard similarity
57443
- // — no LLM, no embeddings, deterministic. Foundation for StackPick.
57444
- name: "slop_find_similar",
57445
- 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].",
57446
- inputSchema: {
57447
- type: "object",
57448
- properties: {
57449
- name: {
57450
- type: "string",
57451
- description: "Function/component name to match. Omit to match by hooks+props only."
57452
- },
57453
- hooks: {
57454
- type: "array",
57455
- items: { type: "string" },
57456
- description: 'React hooks used by the target signature, e.g. ["useState", "useEffect"].'
57457
- },
57458
- props: {
57459
- type: "array",
57460
- items: { type: "string" },
57461
- description: 'Component props for the target signature, e.g. ["variant", "size", "children"].'
57462
- },
57463
- limit: {
57464
- type: "number",
57465
- description: "Top-k results to return. Default 10. Capped at 50."
57466
- }
57467
- }
57569
+ id: "composite/ai-fingerprint-cluster",
57570
+ ruleIds: ["ai/compression-profile", "ai/comment-ratio", "ai/segment-surprisal-cv"],
57571
+ minMatch: 2,
57572
+ severity: "medium",
57573
+ defaultOff: true,
57574
+ 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.",
57575
+ calibration: {
57576
+ recall: 0,
57577
+ FP: 0,
57578
+ precision: 0,
57579
+ F1: 0,
57580
+ nFiles: 0
57581
+ },
57582
+ provenance: {
57583
+ seed: "hand-curated-by-brief",
57584
+ discoveredAt: "2026-07-05T00:00:00.000Z",
57585
+ nFiles: 0,
57586
+ members: 3,
57587
+ npmi: 0,
57588
+ fisherP: 1
57468
57589
  }
57469
57590
  }
57470
57591
  ];
@@ -57477,7 +57598,7 @@ init_config2();
57477
57598
  init_dist2();
57478
57599
 
57479
57600
  // src/cli/program.ts
57480
- import { resolve as resolve41 } from "path";
57601
+ import { resolve as resolve42 } from "path";
57481
57602
  import { Command } from "commander";
57482
57603
 
57483
57604
  // src/cli/options.ts
@@ -57525,13 +57646,13 @@ init_discover();
57525
57646
  init_git();
57526
57647
  init_cache();
57527
57648
  init_logger();
57528
- import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync9, rmSync as rmSync2 } from "fs";
57529
- import { join as join21, dirname as dirname14 } from "path";
57649
+ import { writeFileSync as writeFileSync12, mkdirSync as mkdirSync9, rmSync as rmSync2 } from "fs";
57650
+ import { join as join23, dirname as dirname14 } from "path";
57530
57651
  import { createInterface } from "readline";
57531
57652
 
57532
57653
  // src/rules/registry-loader.ts
57533
- import { existsSync as existsSync20, mkdirSync as mkdirSync8, readFileSync as readFileSync24, writeFileSync as writeFileSync10 } from "fs";
57534
- import { dirname as dirname13, join as join20 } from "path";
57654
+ import { existsSync as existsSync21, mkdirSync as mkdirSync8, readFileSync as readFileSync25, writeFileSync as writeFileSync11 } from "fs";
57655
+ import { dirname as dirname13, join as join21 } from "path";
57535
57656
 
57536
57657
  // src/data/shadcn-registry.json
57537
57658
  var shadcn_registry_default = {
@@ -57665,11 +57786,11 @@ var shadcn_registry_default = {
57665
57786
  var REGISTRY_URL = "https://ui.shadcn.com/registry.json";
57666
57787
  var BUNDLED_REGISTRY_VERSION = shadcn_registry_default.version;
57667
57788
  function cachePath2(cwd) {
57668
- return join20(cwd, ".slopbrick", "cache", "registry-snapshot.json");
57789
+ return join21(cwd, ".slopbrick", "cache", "registry-snapshot.json");
57669
57790
  }
57670
57791
  function ensureCacheDir(cwd) {
57671
57792
  const dir = dirname13(cachePath2(cwd));
57672
- if (!existsSync20(dir)) {
57793
+ if (!existsSync21(dir)) {
57673
57794
  mkdirSync8(dir, { recursive: true });
57674
57795
  }
57675
57796
  }
@@ -57682,9 +57803,9 @@ function isValidSnapshot(value) {
57682
57803
  }
57683
57804
  function isRegistryFresh(cwd) {
57684
57805
  const cached = cachePath2(cwd);
57685
- if (!existsSync20(cached)) return false;
57806
+ if (!existsSync21(cached)) return false;
57686
57807
  try {
57687
- const parsed = JSON.parse(readFileSync24(cached, "utf8"));
57808
+ const parsed = JSON.parse(readFileSync25(cached, "utf8"));
57688
57809
  if (!isValidSnapshot(parsed)) return false;
57689
57810
  return parsed.version === BUNDLED_REGISTRY_VERSION;
57690
57811
  } catch {
@@ -57695,7 +57816,7 @@ async function refreshRegistrySnapshot(cwd, _url = REGISTRY_URL, _timeoutMs = 5e
57695
57816
  try {
57696
57817
  const cacheFile = cachePath2(cwd);
57697
57818
  ensureCacheDir(cwd);
57698
- writeFileSync10(cacheFile, JSON.stringify(shadcn_registry_default, null, 2), "utf-8");
57819
+ writeFileSync11(cacheFile, JSON.stringify(shadcn_registry_default, null, 2), "utf-8");
57699
57820
  } catch {
57700
57821
  }
57701
57822
  return {
@@ -57707,7 +57828,7 @@ async function refreshRegistrySnapshot(cwd, _url = REGISTRY_URL, _timeoutMs = 5e
57707
57828
  }
57708
57829
  function copyBundledSnapshotToCache(cwd) {
57709
57830
  ensureCacheDir(cwd);
57710
- writeFileSync10(cachePath2(cwd), JSON.stringify(shadcn_registry_default, null, 2));
57831
+ writeFileSync11(cachePath2(cwd), JSON.stringify(shadcn_registry_default, null, 2));
57711
57832
  }
57712
57833
 
57713
57834
  // src/cli/init.ts
@@ -57736,17 +57857,17 @@ var UI_LIBRARY_OPTIONS = ["shadcn/ui", "mui", "chakra", "radix", "tamagui", "nat
57736
57857
  var STRICTNESS_OPTIONS = ["strict", "balanced", "permissive"];
57737
57858
  var STRUCTURE_OPTIONS = ["feature-based", "layer-based", "flat", "monorepo", "other"];
57738
57859
  function promptText(rl, question, detected) {
57739
- return new Promise((resolve42) => {
57860
+ return new Promise((resolve43) => {
57740
57861
  const lines = [
57741
57862
  `? ${question} (detected: ${detected || "none"}) \u2014 npm package name, or Enter to skip:`
57742
57863
  ];
57743
57864
  rl.question(lines.join("\n") + "\n", (answer) => {
57744
57865
  const trimmed = answer.trim();
57745
57866
  if (trimmed === "") {
57746
- resolve42(void 0);
57867
+ resolve43(void 0);
57747
57868
  return;
57748
57869
  }
57749
- resolve42(trimmed);
57870
+ resolve43(trimmed);
57750
57871
  });
57751
57872
  });
57752
57873
  }
@@ -57754,7 +57875,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57754
57875
  const defaultIndex = options.indexOf(defaultValue);
57755
57876
  const safeDefaultIndex = defaultIndex >= 0 ? defaultIndex : 0;
57756
57877
  const safeDefaultValue = options[safeDefaultIndex];
57757
- return new Promise((resolve42) => {
57878
+ return new Promise((resolve43) => {
57758
57879
  function ask() {
57759
57880
  const lines = [
57760
57881
  `? ${question} (detected: ${safeDefaultValue}):`,
@@ -57764,7 +57885,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57764
57885
  rl.question(lines.join("\n") + "\n", (answer) => {
57765
57886
  const trimmed = answer.trim();
57766
57887
  if (trimmed === "") {
57767
- resolve42(safeDefaultValue);
57888
+ resolve43(safeDefaultValue);
57768
57889
  return;
57769
57890
  }
57770
57891
  const num = parseInt(trimmed, 10);
@@ -57773,7 +57894,7 @@ function promptSingleSelect(rl, question, options, defaultValue) {
57773
57894
  ask();
57774
57895
  return;
57775
57896
  }
57776
- resolve42(options[num - 1]);
57897
+ resolve43(options[num - 1]);
57777
57898
  });
57778
57899
  }
57779
57900
  ask();
@@ -57783,7 +57904,7 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57783
57904
  const defaultIndices = defaultValue.map((v) => options.indexOf(v)).filter((i) => i >= 0);
57784
57905
  const defaultDisplay = defaultValue.length > 0 ? defaultValue.join(", ") : "none";
57785
57906
  const defaultNumbers = defaultIndices.length > 0 ? defaultIndices.map((i) => i + 2).join(",") : "1";
57786
- return new Promise((resolve42) => {
57907
+ return new Promise((resolve43) => {
57787
57908
  function ask() {
57788
57909
  const lines = [
57789
57910
  `? ${question} (detected: ${defaultDisplay}):`,
@@ -57794,7 +57915,7 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57794
57915
  rl.question(lines.join("\n") + "\n", (answer) => {
57795
57916
  const trimmed = answer.trim();
57796
57917
  if (trimmed === "") {
57797
- resolve42(defaultValue.length > 0 ? defaultValue : []);
57918
+ resolve43(defaultValue.length > 0 ? defaultValue : []);
57798
57919
  return;
57799
57920
  }
57800
57921
  const numbers = trimmed.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n));
@@ -57804,11 +57925,11 @@ function promptMultiSelect(rl, question, options, defaultValue) {
57804
57925
  return;
57805
57926
  }
57806
57927
  if (numbers.includes(1)) {
57807
- resolve42([]);
57928
+ resolve43([]);
57808
57929
  return;
57809
57930
  }
57810
57931
  const selected = [...new Set(numbers.map((n) => options[n - 2]).filter((v) => v !== void 0))];
57811
- resolve42(selected);
57932
+ resolve43(selected);
57812
57933
  });
57813
57934
  }
57814
57935
  ask();
@@ -57896,9 +58017,9 @@ async function runDoctor(cwd) {
57896
58017
  }
57897
58018
  try {
57898
58019
  const { parseFile: tryParse } = await Promise.resolve().then(() => (init_dist2(), dist_exports2));
57899
- const testFile = join21(cwd, ".slopbrick", ".doctor-test.ts");
58020
+ const testFile = join23(cwd, ".slopbrick", ".doctor-test.ts");
57900
58021
  mkdirSync9(dirname14(testFile), { recursive: true });
57901
- writeFileSync11(testFile, "export const x = 1;\n");
58022
+ writeFileSync12(testFile, "export const x = 1;\n");
57902
58023
  await tryParse(testFile);
57903
58024
  rmSync2(testFile, { force: true });
57904
58025
  ok("Parser is working.");
@@ -58032,6 +58153,470 @@ function registerSuggest(program) {
58032
58153
  });
58033
58154
  }
58034
58155
 
58156
+ // src/cli/commands/composite.ts
58157
+ init_logger();
58158
+ init_telemetry();
58159
+ import { resolve as resolve15 } from "path";
58160
+
58161
+ // src/engine/cluster.ts
58162
+ import { createHash as createHash12 } from "crypto";
58163
+ var DEFAULT_CLUSTER_PARAMS = {
58164
+ minSupport: 0.05,
58165
+ // 5% of files
58166
+ minNPMI: 0.3,
58167
+ // Cohen's-Landis-and-Koch "fair" agreement
58168
+ fisherAlpha: 0.01,
58169
+ // Bonferroni-style floor for many-pair tests
58170
+ minClusterSize: 2
58171
+ };
58172
+ function adaptiveMinSupport(fileCount) {
58173
+ if (fileCount <= 0) return DEFAULT_CLUSTER_PARAMS.minSupport;
58174
+ return Math.max(DEFAULT_CLUSTER_PARAMS.minSupport, 5 / fileCount);
58175
+ }
58176
+ function computeRuleSupport(fireMatrix) {
58177
+ const support = /* @__PURE__ */ new Map();
58178
+ for (const ruleIds of fireMatrix.values()) {
58179
+ for (const ruleId of ruleIds) {
58180
+ support.set(ruleId, (support.get(ruleId) ?? 0) + 1);
58181
+ }
58182
+ }
58183
+ return support;
58184
+ }
58185
+ function prefilterBySupport(support, totalFiles, minSupport) {
58186
+ const kept = /* @__PURE__ */ new Set();
58187
+ const minCount = Math.max(1, Math.ceil(minSupport * totalFiles));
58188
+ for (const [ruleId, count] of support) {
58189
+ if (count >= minCount) kept.add(ruleId);
58190
+ }
58191
+ return kept;
58192
+ }
58193
+ function buildContingency(rA, rB, totalFiles) {
58194
+ let aAndB = 0;
58195
+ let aOnly = 0;
58196
+ let bOnly = 0;
58197
+ for (const file of rA) {
58198
+ if (rB.has(file)) aAndB++;
58199
+ else aOnly++;
58200
+ }
58201
+ for (const file of rB) {
58202
+ if (!rA.has(file)) bOnly++;
58203
+ }
58204
+ return {
58205
+ aAndB,
58206
+ aOnly,
58207
+ bOnly,
58208
+ neither: Math.max(0, totalFiles - aAndB - aOnly - bOnly)
58209
+ };
58210
+ }
58211
+ function fisherExactTwoSided(c) {
58212
+ const N = c.aAndB + c.aOnly + c.bOnly + c.neither;
58213
+ const K1 = c.aAndB + c.aOnly;
58214
+ const K2 = c.aAndB + c.bOnly;
58215
+ const n = K1;
58216
+ const kObs = c.aAndB;
58217
+ const logPmf = (k) => {
58218
+ if (k < Math.max(0, K1 + K2 - N) || k > Math.min(K1, K2)) return -Infinity;
58219
+ return lnChoose(K2, k) + lnChoose(N - K2, K1 - k) - lnChoose(N, K1);
58220
+ };
58221
+ const pObs = Math.exp(logPmf(kObs));
58222
+ const kMin = Math.max(0, K1 + K2 - N);
58223
+ const kMax = Math.min(K1, K2);
58224
+ let p = 0;
58225
+ for (let k = kMin; k <= kMax; k++) {
58226
+ const pk = Math.exp(logPmf(k));
58227
+ if (pk <= pObs + 1e-12) {
58228
+ p += pk;
58229
+ }
58230
+ }
58231
+ return Math.min(1, p);
58232
+ }
58233
+ function lnChoose(n, k) {
58234
+ if (k < 0 || k > n) return -Infinity;
58235
+ if (k === 0 || k === n) return 0;
58236
+ k = Math.min(k, n - k);
58237
+ let s = 0;
58238
+ for (let i = 1; i <= k; i++) {
58239
+ s += Math.log((n - k + i) / i);
58240
+ }
58241
+ return s;
58242
+ }
58243
+ function npmi(c, total) {
58244
+ if (total === 0 || c.aAndB === 0) return -1;
58245
+ const pXY = c.aAndB / total;
58246
+ const pX = (c.aAndB + c.aOnly) / total;
58247
+ const pY = (c.aAndB + c.bOnly) / total;
58248
+ if (pX === 0 || pY === 0 || pXY === 0) return -1;
58249
+ const pmi = Math.log2(pXY / (pX * pY));
58250
+ const norm = -Math.log2(pXY);
58251
+ return norm === 0 ? 1 : pmi / norm;
58252
+ }
58253
+ function computeEdges(fireMatrix, rules, total, params) {
58254
+ const ruleToFiles = /* @__PURE__ */ new Map();
58255
+ for (const ruleId of rules) ruleToFiles.set(ruleId, /* @__PURE__ */ new Set());
58256
+ for (const [file, fired] of fireMatrix) {
58257
+ for (const ruleId of fired) {
58258
+ if (rules.has(ruleId)) ruleToFiles.get(ruleId).add(file);
58259
+ }
58260
+ }
58261
+ const ruleList = Array.from(rules).sort();
58262
+ const edges = [];
58263
+ for (let i = 0; i < ruleList.length; i++) {
58264
+ for (let j = i + 1; j < ruleList.length; j++) {
58265
+ const a = ruleList[i];
58266
+ const b = ruleList[j];
58267
+ const rA = ruleToFiles.get(a);
58268
+ const rB = ruleToFiles.get(b);
58269
+ const c = buildContingency(rA, rB, total);
58270
+ const n = npmi(c, total);
58271
+ if (n < params.minNPMI) continue;
58272
+ const p = fisherExactTwoSided(c);
58273
+ if (p > params.fisherAlpha) continue;
58274
+ edges.push({ ruleIds: [a, b], weight: n, fisherP: p });
58275
+ }
58276
+ }
58277
+ return edges;
58278
+ }
58279
+ function singleLinkageCluster(edges, minClusterSize) {
58280
+ const parent = /* @__PURE__ */ new Map();
58281
+ const find = (x) => {
58282
+ if (!parent.has(x)) {
58283
+ parent.set(x, x);
58284
+ return x;
58285
+ }
58286
+ if (parent.get(x) === x) return x;
58287
+ const root = find(parent.get(x));
58288
+ parent.set(x, root);
58289
+ return root;
58290
+ };
58291
+ const union = (a, b) => {
58292
+ const ra = find(a);
58293
+ const rb = find(b);
58294
+ if (ra !== rb) parent.set(ra, rb);
58295
+ };
58296
+ for (const e of edges) {
58297
+ union(e.ruleIds[0], e.ruleIds[1]);
58298
+ }
58299
+ const groups = /* @__PURE__ */ new Map();
58300
+ for (const k of parent.keys()) {
58301
+ const root = find(k);
58302
+ if (!groups.has(root)) groups.set(root, []);
58303
+ groups.get(root).push(k);
58304
+ }
58305
+ const clusters = [];
58306
+ for (const members of groups.values()) {
58307
+ if (members.length < minClusterSize) continue;
58308
+ members.sort();
58309
+ clusters.push(members);
58310
+ }
58311
+ clusters.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
58312
+ return clusters;
58313
+ }
58314
+ function filesForComposite(fireMatrix, members, minMatch) {
58315
+ const fired = /* @__PURE__ */ new Set();
58316
+ for (const [file, ids] of fireMatrix) {
58317
+ let n = 0;
58318
+ for (const m of members) if (ids.has(m)) n++;
58319
+ if (n >= minMatch) fired.add(file);
58320
+ }
58321
+ return fired;
58322
+ }
58323
+ var DEFAULT_CALIBRATION = {
58324
+ // 1.5× is the documented floor.
58325
+ recallFpRatio: 1.5,
58326
+ // 0.05 absolute FP — the same `min` value most rules get via the
58327
+ // v0.18.x calibration contract.
58328
+ minFpFloor: 0.05
58329
+ };
58330
+ function calibrateCluster(cluster2, edges, fireMatrix, positiveFiles, totalFiles, thresholds = DEFAULT_CALIBRATION) {
58331
+ if (cluster2.length < 2) return null;
58332
+ const clusterSet = new Set(cluster2);
58333
+ const clusterEdges = edges.filter(
58334
+ (e) => clusterSet.has(e.ruleIds[0]) && clusterSet.has(e.ruleIds[1])
58335
+ );
58336
+ if (clusterEdges.length === 0) return null;
58337
+ const strongest = clusterEdges.reduce((a, b) => b.weight > a.weight ? b : a);
58338
+ let best = null;
58339
+ for (let k = 1; k <= cluster2.length; k++) {
58340
+ const firedFiles = filesForComposite(fireMatrix, cluster2, k);
58341
+ const nFiles = firedFiles.size;
58342
+ if (nFiles === 0) continue;
58343
+ let recall = 0;
58344
+ let fp = 0;
58345
+ let precision = 0;
58346
+ let canCalibrate = false;
58347
+ if (positiveFiles && positiveFiles.size > 0) {
58348
+ let tp = 0;
58349
+ let fpc = 0;
58350
+ for (const file of firedFiles) {
58351
+ if (positiveFiles.has(file)) tp++;
58352
+ else fpc++;
58353
+ }
58354
+ const totalPos = positiveFiles.size;
58355
+ const totalNeg = Math.max(1, totalFiles - totalPos);
58356
+ recall = totalPos > 0 ? tp / totalPos : 0;
58357
+ fp = fpc / totalNeg;
58358
+ precision = tp + fpc > 0 ? tp / (tp + fpc) : 0;
58359
+ canCalibrate = true;
58360
+ }
58361
+ const F1 = precision + recall > 0 ? 2 * precision * recall / (precision + recall) : 0;
58362
+ if (canCalibrate) {
58363
+ if (recall / Math.max(fp, 1e-9) < thresholds.recallFpRatio) continue;
58364
+ if (fp < thresholds.minFpFloor && recall < 0.5) continue;
58365
+ }
58366
+ if (best === null || F1 > best.F1 + 1e-9 || Math.abs(F1 - best.F1) < 1e-9 && k > best.minMatch) {
58367
+ best = {
58368
+ minMatch: k,
58369
+ npmi: strongest.weight,
58370
+ fisherP: strongest.fisherP,
58371
+ recall,
58372
+ FP: fp,
58373
+ precision,
58374
+ F1,
58375
+ nFiles
58376
+ };
58377
+ }
58378
+ }
58379
+ return best;
58380
+ }
58381
+ function compositeId(members) {
58382
+ const sorted = [...members].sort();
58383
+ return "composite/" + createHash12("sha1").update(sorted.join("\n")).digest("hex").slice(0, 12);
58384
+ }
58385
+ function worstSeverity(members, memberSeverities) {
58386
+ let worst = "low";
58387
+ const order = { low: 0, medium: 1, high: 2 };
58388
+ for (const m of members) {
58389
+ const s = memberSeverities?.get(m);
58390
+ if (s && order[s] > order[worst]) worst = s;
58391
+ }
58392
+ return worst;
58393
+ }
58394
+ function describeComposite(members, minMatch) {
58395
+ const clusterPart = members.length <= 4 ? members.join("+") : `${members.slice(0, 3).join("+")}+${members.length - 3} more`;
58396
+ return `Composite rule: at least ${minMatch} of {${clusterPart}} fire on the same file.`;
58397
+ }
58398
+ function runClusterer(input) {
58399
+ const total = input.fireMatrix.size;
58400
+ const minSupport = input.params?.minSupport ?? adaptiveMinSupport(total);
58401
+ const params = {
58402
+ minSupport,
58403
+ minNPMI: input.params?.minNPMI ?? DEFAULT_CLUSTER_PARAMS.minNPMI,
58404
+ fisherAlpha: input.params?.fisherAlpha ?? DEFAULT_CLUSTER_PARAMS.fisherAlpha,
58405
+ minClusterSize: input.params?.minClusterSize ?? DEFAULT_CLUSTER_PARAMS.minClusterSize
58406
+ };
58407
+ const support = computeRuleSupport(input.fireMatrix);
58408
+ const rulesKept = prefilterBySupport(support, total, params.minSupport);
58409
+ const edges = computeEdges(input.fireMatrix, rulesKept, total, params);
58410
+ const clusters = singleLinkageCluster(edges, params.minClusterSize);
58411
+ const entries = [];
58412
+ const clusterMaxNPMI = /* @__PURE__ */ new Map();
58413
+ for (const cluster2 of clusters) {
58414
+ const cal = calibrateCluster(
58415
+ cluster2,
58416
+ edges,
58417
+ input.fireMatrix,
58418
+ input.positiveFiles,
58419
+ total
58420
+ );
58421
+ if (!cal) continue;
58422
+ const id = compositeId(cluster2);
58423
+ const severity = worstSeverity(cluster2, input.memberSeverities);
58424
+ entries.push({
58425
+ id,
58426
+ ruleIds: [...cluster2].sort(),
58427
+ minMatch: cal.minMatch,
58428
+ severity,
58429
+ defaultOff: true,
58430
+ description: describeComposite(cluster2, cal.minMatch),
58431
+ calibration: {
58432
+ recall: round3(cal.recall),
58433
+ FP: round3(cal.FP),
58434
+ precision: round3(cal.precision),
58435
+ F1: round3(cal.F1),
58436
+ nFiles: cal.nFiles
58437
+ },
58438
+ provenance: {
58439
+ seed: "auto-cluster",
58440
+ discoveredAt: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
58441
+ nFiles: total,
58442
+ members: cluster2.length,
58443
+ npmi: round3(cal.npmi),
58444
+ fisherP: round3(cal.fisherP)
58445
+ }
58446
+ });
58447
+ clusterMaxNPMI.set(id, cal.npmi);
58448
+ }
58449
+ entries.sort((a, b) => b.calibration.F1 - a.calibration.F1 || a.id.localeCompare(b.id));
58450
+ return { entries, supportCounts: support, clusterMaxNPMI, filesScanned: total, params };
58451
+ }
58452
+ function round3(n) {
58453
+ return Math.round(n * 1e3) / 1e3;
58454
+ }
58455
+
58456
+ // src/rules/composite-loader.ts
58457
+ import { existsSync as existsSync23, readFileSync as readFileSync27, mkdirSync as mkdirSync10, writeFileSync as writeFileSync13 } from "fs";
58458
+ import { dirname as dirname15, join as join24 } from "path";
58459
+ var COMPOSITES_FILE = "composites.json";
58460
+ function compositesPath(cwd) {
58461
+ return join24(cwd, COMPOSITES_FILE);
58462
+ }
58463
+ function readComposites(cwd) {
58464
+ const path = compositesPath(cwd);
58465
+ if (!existsSync23(path)) return [];
58466
+ try {
58467
+ const raw = JSON.parse(readFileSync27(path, "utf-8"));
58468
+ if (!Array.isArray(raw)) return [];
58469
+ return raw.filter(isCompositeRuleEntry);
58470
+ } catch {
58471
+ return [];
58472
+ }
58473
+ }
58474
+ function isCompositeRuleEntry(value) {
58475
+ if (!value || typeof value !== "object") return false;
58476
+ const v = value;
58477
+ return typeof v.id === "string" && Array.isArray(v.ruleIds) && typeof v.minMatch === "number";
58478
+ }
58479
+ function writeComposites(cwd, entries) {
58480
+ const path = compositesPath(cwd);
58481
+ mkdirSync10(dirname15(path), { recursive: true });
58482
+ const tmp = `${path}.tmp`;
58483
+ writeFileSync13(tmp, JSON.stringify(entries, null, 2), "utf-8");
58484
+ const fs = __require("fs");
58485
+ fs.renameSync(tmp, path);
58486
+ }
58487
+
58488
+ // src/cli/commands/composite.ts
58489
+ function registerComposite(program) {
58490
+ const cmd = program.command("composite").description(
58491
+ "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."
58492
+ );
58493
+ cmd.command("discover").description(
58494
+ "run the empirical composite clusterer on .slopbrick/flywheel/scans.jsonl; writes composites.json"
58495
+ ).action(async (_cmdOptions, command) => {
58496
+ const options = command.optsWithGlobals();
58497
+ const cwd = resolve15(options.workspace ?? process.cwd());
58498
+ const payloads = readTelemetry(cwd);
58499
+ if (payloads.length === 0) {
58500
+ logger.error(
58501
+ "`composite discover` needs at least 2 scans in .slopbrick/flywheel/scans.jsonl. Run `slopbrick scan` a few times first."
58502
+ );
58503
+ process.exit(1);
58504
+ }
58505
+ const fireMatrix = /* @__PURE__ */ new Map();
58506
+ for (const payload of payloads) {
58507
+ for (const f of payload.files) {
58508
+ const set = fireMatrix.get(f.hash) ?? /* @__PURE__ */ new Set();
58509
+ for (const id of f.ruleIds) set.add(id);
58510
+ if (set.size > 0) fireMatrix.set(f.hash, set);
58511
+ }
58512
+ }
58513
+ const out = runClusterer({
58514
+ fireMatrix,
58515
+ // No labeled positive set; let the clusterer surface for audit.
58516
+ positiveFiles: void 0,
58517
+ now: (/* @__PURE__ */ new Date()).toISOString()
58518
+ });
58519
+ const finalEntries = out.entries.length === 0 ? [...(await Promise.resolve().then(() => (init_composite_seed(), composite_seed_exports))).HAND_CURATED_SEED] : out.entries;
58520
+ writeComposites(cwd, finalEntries);
58521
+ if (out.entries.length === 0) {
58522
+ logger.info("Composite discovery: no clusters passed the empirical gate.");
58523
+ logger.info(
58524
+ " (Try lowering --min-support, or run more scans to densify the ledger.)"
58525
+ );
58526
+ return;
58527
+ }
58528
+ logger.info(
58529
+ `Composite discovery: ${out.entries.length} candidate(s) written to composites.json`
58530
+ );
58531
+ const lines = [];
58532
+ for (const e of out.entries) {
58533
+ lines.push("");
58534
+ lines.push(
58535
+ ` ${e.id} minMatch=${e.minMatch} F1=${e.calibration.F1} (${e.ruleIds.length} members)`
58536
+ );
58537
+ lines.push(` ${describeComposite(e.ruleIds, e.minMatch)}`);
58538
+ lines.push(
58539
+ ` provenance: ${e.provenance.seed} npmi=${e.provenance.npmi} fisherP=${e.provenance.fisherP}`
58540
+ );
58541
+ }
58542
+ logger.info(lines.join("\n"));
58543
+ logger.info(
58544
+ "\nNext step: review each entry. Opt in with `slopbrick composite enable <id>`; opt out by removing the entry."
58545
+ );
58546
+ });
58547
+ cmd.command("enable <id>").description(
58548
+ "opt a specific composite into slopbrick.config.mjs#compositeRules (one composite per call)"
58549
+ ).action(async (id, _cmdOptions, command) => {
58550
+ const options = command.optsWithGlobals();
58551
+ const cwd = resolve15(options.workspace ?? process.cwd());
58552
+ const composites = readComposites(cwd);
58553
+ const target = composites.find((c) => c.id === id);
58554
+ if (!target) {
58555
+ logger.error(
58556
+ `Composite "${id}" not found in composites.json. Run \`slopbrick composite discover\` first to populate the ledger.`
58557
+ );
58558
+ process.exit(1);
58559
+ }
58560
+ const cfgPath = resolve15(cwd, "slopbrick.config.mjs");
58561
+ const fs = __require("fs");
58562
+ if (!fs.existsSync(cfgPath)) {
58563
+ logger.error(`No slopbrick.config.mjs found at ${cfgPath}.`);
58564
+ process.exit(1);
58565
+ }
58566
+ const src = fs.readFileSync(cfgPath, "utf-8");
58567
+ const compositeSnippet = JSON.stringify(
58568
+ {
58569
+ id: target.id,
58570
+ category: "ai",
58571
+ severity: target.severity,
58572
+ aiSpecific: true,
58573
+ description: target.description,
58574
+ defaultOff: true,
58575
+ ruleIds: target.ruleIds,
58576
+ minMatch: target.minMatch,
58577
+ create: "() => ({})",
58578
+ analyze: "() => []"
58579
+ },
58580
+ null,
58581
+ 2
58582
+ );
58583
+ if (src.includes(`id: '${target.id}'`) || src.includes(`id: "${target.id}"`)) {
58584
+ logger.info(`Composite "${id}" is already enabled in slopbrick.config.mjs (idempotent).`);
58585
+ return;
58586
+ }
58587
+ const updated = src.replace(
58588
+ /(export default\s*{[\s\S]*?)(}\s*;?\s*)$/,
58589
+ (_match, head, tail) => `${head} compositeRules: [
58590
+ ${compositeSnippet}
58591
+ ],
58592
+ ${tail}`
58593
+ );
58594
+ fs.writeFileSync(cfgPath, updated, "utf-8");
58595
+ logger.info(`Composite "${id}" enabled.`);
58596
+ logger.info("Next scan will apply it; rerun `slopbrick scan` to see the effect.");
58597
+ });
58598
+ cmd.command("list").description("list composites currently registered (composites.json + config-declared)").action(async (_cmdOptions, command) => {
58599
+ const options = command.optsWithGlobals();
58600
+ const cwd = resolve15(options.workspace ?? process.cwd());
58601
+ const composites = readComposites(cwd);
58602
+ if (composites.length === 0) {
58603
+ logger.info("No composites registered. Run `slopbrick composite discover` first.");
58604
+ return;
58605
+ }
58606
+ const lines = ["Registered composites:"];
58607
+ for (const e of composites) {
58608
+ lines.push(
58609
+ `
58610
+ ${e.id}
58611
+ ${describeComposite(e.ruleIds, e.minMatch)}
58612
+ members=[${e.ruleIds.join(", ")}] minMatch=${e.minMatch}
58613
+ F1=${e.calibration.F1} recall=${e.calibration.recall} FP=${e.calibration.FP}`
58614
+ );
58615
+ }
58616
+ logger.info(lines.join("\n"));
58617
+ });
58618
+ }
58619
+
58035
58620
  // src/cli/explain.ts
58036
58621
  var RULES_BASE_URL2 = "https://github.com/usebrick/platform/blob/main/packages/slopbrick/src/rules";
58037
58622
  function ruleIdToFilename2(ruleId) {
@@ -58081,152 +58666,7 @@ function formatExplain(result) {
58081
58666
  // src/cli/commands/explain.ts
58082
58667
  init_logger();
58083
58668
  init_builtins();
58084
-
58085
- // src/snippet/data.ts
58086
- var CATEGORY_DIRECTIVES = {
58087
- 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.',
58088
- 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.",
58089
- 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.',
58090
- 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".',
58091
- 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.",
58092
- typo: `Never leave TODO / placeholder / "change me" copy in shipped code. Use real i18n strings or the project's content map.`,
58093
- 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].",
58094
- component: "Don't build components > 200 lines. Extract shared subcomponents. Avoid circular prop drilling \u2014 use context.",
58095
- 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.",
58096
- 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'."
58097
- };
58098
- var RULE_HINTS = {
58099
- // v0.38.0 hygiene: 37 v10-DORMANT rule hints were removed. Every
58100
- // remaining key matches a rule in src/rules/builtins.ts. No orphans.
58101
- // (v0.16.0 previously moved 35 out-of-scope orphan hints to
58102
- // docs/research/backlog-rule-hints.md
58103
- // for future implementers to paste back when the corresponding rules ship.)
58104
- "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.",
58105
- "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.",
58106
- "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.",
58107
- "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.",
58108
- "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.",
58109
- "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.",
58110
- "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.',
58111
- "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.",
58112
- "visual/arbitrary-escape": "Never use bracket-notation values like text-[13px] or bg-[#7c3aed]. Use design tokens instead.",
58113
- "visual/spacing-scale-violation": "Use spacing scale tokens (p-2, gap-4, etc.) instead of arbitrary values like p-[13px] or gap-[1.75rem].",
58114
- "visual/radius-scale-violation": "Use radius scale tokens (rounded-md, rounded-lg, etc.) instead of arbitrary values like rounded-[7px].",
58115
- // v0.16.0 — in-scope orphans kept here (corresponding rule ships in v0.16.0).
58116
- "typo/placeholder-text": 'Never leave "TODO", "placeholder", "change me", "your text here" in shipped UI.',
58117
- "logic/key-prop-missing": "Always provide a stable `key` prop when rendering lists.",
58118
- "logic/boundary-violation": "Don't import data-layer / DB code into UI components. Server-side only.",
58119
- "wcag/missing-alt": 'Every <img> needs alt text. Decorative: alt="". Informative: describe the image.',
58120
- "security/localstorage-token": "Never store JWT / access token / refresh token in localStorage or sessionStorage. Issue as httpOnly cookie.",
58121
- "security/eval": "Never use eval() or new Function(). These are RCE vectors if the input is ever attacker-controlled.",
58122
- "security/target-blank-no-noopener": 'Always add rel="noopener" (or rel="noreferrer") to target="_blank" links.',
58123
- "component/giant-component": "Don't build components > 200 lines. Extract shared subcomponents.",
58124
- "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.",
58125
- "component/shadcn-prop-mismatch": "Select shadcn variants via the `variant` prop, not long `className` overrides. See the component registry for available variants.",
58126
- "context/import-path-mismatch": "Use only the canonical import paths declared in brick.config.json (e.g. @/components/ui/, @/lib/).",
58127
- "layout/gap-monopoly": "Mix gap-2 / gap-4 / gap-6 / gap-12 deliberately. Don't repeat the same gap value across the whole project.",
58128
- "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.",
58129
- "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.",
58130
- "layout/spacing-grid": "Use the configured spacing scale (4px or 8px grid). Avoid arbitrary values like p-[13px] that aren't on the scale.",
58131
- "logic/ghost-defensive": "Use optional chaining (?.) or early returns instead of deep && guards. If a defensive chain runs 3+ levels deep, refactor.",
58132
- "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.)",
58133
- "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.)",
58134
- "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.)",
58135
- "logic/math-any-density": "Replace `: any` with proper types. Start with the parameter/return types of the most-used functions.",
58136
- "logic/math-console-log-storm": "Replace debug logs with a proper debugger or logger.debug(). Remove all console.log before shipping.",
58137
- "logic/math-gini-class-usage": "Spread usage across more class tokens instead of repeating the same handful (p-4, p-8, rounded-lg, etc.).",
58138
- "logic/math-variable-name-entropy": "Use domain-specific identifier names (reservations, invoices, customers) instead of generic data/items/value.",
58139
- "logic/optimistic-no-rollback": "In optimistic updates, revert state in the catch block: `setX(prev => prev)`. Never leave stale UI on error.",
58140
- "logic/reactive-hook-soup": "Coordinate state via a single derived value (useMemo) or a state machine. Avoid chained useEffects that sync local state.",
58141
- "logic/zombie-state": "Remove unused useState or wire it into the component. Don't leave declared-but-never-read state bindings.",
58142
- "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.",
58143
- "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.",
58144
- "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.",
58145
- "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.",
58146
- "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.",
58147
- "perf/cls-image": "Add width/height attributes or an aspect-ratio utility to prevent layout shift.",
58148
- "perf/css-bloat": "Extract to a CSS variable (`--surface-card`) or a component prop when a class string repeats 5+ times.",
58149
- "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.',
58150
- "visual/inline-style-dominance": "Replace inline `style={{...}}` with className utilities (e.g. Tailwind `p-4 m-2 gap-3`) or a CSS module class.",
58151
- "visual/math-default-font": "Import a distinctive font (next/font/google, @font-face, or a CSS variable) instead of relying on the framework default.",
58152
- "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.",
58153
- "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.",
58154
- "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.",
58155
- "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%.",
58156
- "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.",
58157
- "wcag/focus-appearance": "Add a focus-visible:ring-* class, or remove outline-none. Keyboard users need a visible focus indicator.",
58158
- "wcag/focus-obscured": "Ensure focused elements are not hidden behind fixed or sticky wrappers.",
58159
- "test/weak-assertion": "Assert on a specific value or shape: `expect(x).toEqual(expectedValue)`. Avoid `.toBeDefined()` / `.toBeTruthy()` placeholders and tautological `expect(x).toBe(x)`.",
58160
- "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.",
58161
- "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`).",
58162
- "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.",
58163
- "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.",
58164
- // v0.13.0 — AI-specific rules (peer-reviewed signals).
58165
- "ai/markdown-leakage": "Delete stray `\\`\\`\\`<lang>\\`\\`\\`` markers; they are Markdown fences, not valid syntax in standalone source files (Yotkova et al. SemEval-2026).",
58166
- "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).",
58167
- "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).",
58168
- "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).",
58169
- "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).",
58170
- "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).",
58171
- "ai/renyi-profile": "The token distribution is mass-concentrated on a few high-frequency tokens. Verify authorship if unexpected (R\xE9nyi 1961, Moslonka 2025).",
58172
- "ai/segment-surprisal-cv": "The cross-entropy is suspiciously uniform across the file. Real codebases have varied registers (Binoculars, Hans 2024).",
58173
- "ai/compression-profile": "The file compresses unusually well and lines are highly repetitive \u2014 characteristic of AI-generated boilerplate (Cilibrasi 2005, Mahoney 1999).",
58174
- // v0.14.5b — 6 new AI tendency detection rules (DORMANT in v0.14.5b;
58175
- // reclassified post-v7 calibration in v0.14.5d)
58176
- "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.",
58177
- "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.",
58178
- "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.",
58179
- "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.",
58180
- "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.",
58181
- "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.",
58182
- // v0.17.0 — db/* rules (Postgres static analysis via pgsql-parser)
58183
- "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.',
58184
- // v0.17.0 — docs/* rules (markdown drift detection)
58185
- "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.",
58186
- "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.",
58187
- "docs/broken-link": "Create the file or fix the link target. On a public docs site, broken links erode trust more than stale copy.",
58188
- // v0.18.9 — Rust-specific rules (tree-sitter-backed)
58189
- "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.",
58190
- "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.",
58191
- "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.",
58192
- "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.',
58193
- // v0.19.0 — TypeScript-specific rules
58194
- "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.",
58195
- "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.",
58196
- "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.",
58197
- "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.",
58198
- // v0.19.0 — Go-specific rules
58199
- "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).',
58200
- "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.",
58201
- // v0.19.0 — Duplication detector
58202
- "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.",
58203
- // v0.23.0 — Type-2 near-duplicate detector (MinHash + LSH)
58204
- "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.)",
58205
- // v0.24.0 — Type-3 structural clone detector (two-stage MinHash)
58206
- "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.)",
58207
- // v0.24.0 — Kotlin rules (DORMANT until v9 Kotlin corpus calibration)
58208
- // v0.29.0 — non-AI Kotlin rules (DORMANT until larger pos arm)
58209
- // v0.30.0 — non-AI Java rules (Option C applied to v9 Java corpus, 92k files)
58210
- "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.)",
58211
- "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.)",
58212
- // v0.35.0 — content-based detection (CoCoNUTS-inspired)
58213
- "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.",
58214
- // v0.35.1 — Raidar-inspired content-based detection
58215
- "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)`.',
58216
- // v0.24.0 — Swift rules (DORMANT until v9 Swift corpus calibration)
58217
- "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.)",
58218
- "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.)",
58219
- "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.)",
58220
- "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.)",
58221
- "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.)",
58222
- // v0.24.0 — C++ rules (DORMANT until v9 C++ corpus calibration)
58223
- "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.)",
58224
- "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.)",
58225
- "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.)",
58226
- "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.)"
58227
- };
58228
-
58229
- // src/cli/commands/explain.ts
58669
+ init_data();
58230
58670
  function registerExplain(program) {
58231
58671
  program.command("explain <ruleId>").description("Print rationale, pattern, and remediation for a single rule").action((ruleId) => {
58232
58672
  const result = explainRule(ruleId, builtinRules, RULE_HINTS);
@@ -58236,7 +58676,7 @@ function registerExplain(program) {
58236
58676
  }
58237
58677
 
58238
58678
  // src/cli/commands/calibration.ts
58239
- import { dirname as dirname15 } from "path";
58679
+ import { dirname as dirname16 } from "path";
58240
58680
  import { fileURLToPath as fileURLToPath2 } from "url";
58241
58681
 
58242
58682
  // src/rules/signal-strength.json
@@ -61829,7 +62269,7 @@ var signal_strength_default2 = {
61829
62269
  };
61830
62270
 
61831
62271
  // src/cli/commands/calibration.ts
61832
- var __dirname = dirname15(fileURLToPath2(import.meta.url));
62272
+ var __dirname = dirname16(fileURLToPath2(import.meta.url));
61833
62273
  function loadData() {
61834
62274
  return signal_strength_default2;
61835
62275
  }
@@ -61952,17 +62392,17 @@ function registerCalibration(program) {
61952
62392
  // src/cli/commands/install.ts
61953
62393
  init_logger();
61954
62394
  init_git();
61955
- import { resolve as resolve15 } from "path";
62395
+ import { resolve as resolve16 } from "path";
61956
62396
 
61957
62397
  // src/cli/installer.ts
61958
62398
  import {
61959
62399
  chmodSync,
61960
- existsSync as existsSync22,
61961
- mkdirSync as mkdirSync10,
61962
- readFileSync as readFileSync26,
61963
- writeFileSync as writeFileSync12
62400
+ existsSync as existsSync24,
62401
+ mkdirSync as mkdirSync11,
62402
+ readFileSync as readFileSync28,
62403
+ writeFileSync as writeFileSync14
61964
62404
  } from "fs";
61965
- import { dirname as dirname16, join as join24 } from "path";
62405
+ import { dirname as dirname17, join as join26 } from "path";
61966
62406
  var BEGIN_SENTINEL = "# slopbrick-hook-begin";
61967
62407
  var END_SENTINEL = "# slopbrick-hook-end";
61968
62408
  var SENTINEL_BLOCK = `${BEGIN_SENTINEL}
@@ -61970,14 +62410,14 @@ npx slopbrick --staged
61970
62410
  ${END_SENTINEL}
61971
62411
  `;
61972
62412
  function hookPath(gitRoot) {
61973
- const huskyDir = join24(gitRoot, ".husky");
61974
- if (existsSync22(huskyDir)) {
61975
- return join24(huskyDir, "pre-commit");
62413
+ const huskyDir = join26(gitRoot, ".husky");
62414
+ if (existsSync24(huskyDir)) {
62415
+ return join26(huskyDir, "pre-commit");
61976
62416
  }
61977
- return join24(gitRoot, ".git", "hooks", "pre-commit");
62417
+ return join26(gitRoot, ".git", "hooks", "pre-commit");
61978
62418
  }
61979
62419
  function readHookContent(path) {
61980
- return readFileSync26(path, "utf8");
62420
+ return readFileSync28(path, "utf8");
61981
62421
  }
61982
62422
  function sentinelsPresent(content) {
61983
62423
  const lines = content.split(/\r?\n/);
@@ -61999,7 +62439,7 @@ function replaceSentinelBlock(content) {
61999
62439
  }
62000
62440
  function installHook(gitRoot) {
62001
62441
  const path = hookPath(gitRoot);
62002
- if (existsSync22(path)) {
62442
+ if (existsSync24(path)) {
62003
62443
  const content = readHookContent(path);
62004
62444
  const { begin, end } = sentinelsPresent(content);
62005
62445
  if (begin && end) {
@@ -62011,7 +62451,7 @@ function installHook(gitRoot) {
62011
62451
  exitCode: 0
62012
62452
  };
62013
62453
  }
62014
- writeFileSync12(path, replaced.endsWith("\n") ? replaced : `${replaced}
62454
+ writeFileSync14(path, replaced.endsWith("\n") ? replaced : `${replaced}
62015
62455
  `);
62016
62456
  chmodSync(path, 493);
62017
62457
  return {
@@ -62031,7 +62471,7 @@ function installHook(gitRoot) {
62031
62471
  }
62032
62472
  const normalized = content.length > 0 && !content.endsWith("\n") ? `${content}
62033
62473
  ` : content;
62034
- writeFileSync12(path, `${normalized}${SENTINEL_BLOCK}`);
62474
+ writeFileSync14(path, `${normalized}${SENTINEL_BLOCK}`);
62035
62475
  chmodSync(path, 493);
62036
62476
  return {
62037
62477
  ok: true,
@@ -62039,8 +62479,8 @@ function installHook(gitRoot) {
62039
62479
  exitCode: 0
62040
62480
  };
62041
62481
  }
62042
- mkdirSync10(dirname16(path), { recursive: true });
62043
- writeFileSync12(path, SENTINEL_BLOCK, { mode: 493 });
62482
+ mkdirSync11(dirname17(path), { recursive: true });
62483
+ writeFileSync14(path, SENTINEL_BLOCK, { mode: 493 });
62044
62484
  chmodSync(path, 493);
62045
62485
  return {
62046
62486
  ok: true,
@@ -62050,7 +62490,7 @@ function installHook(gitRoot) {
62050
62490
  }
62051
62491
  function uninstallHook(gitRoot) {
62052
62492
  const path = hookPath(gitRoot);
62053
- if (!existsSync22(path)) {
62493
+ if (!existsSync24(path)) {
62054
62494
  return {
62055
62495
  ok: true,
62056
62496
  message: "Hook not installed",
@@ -62086,7 +62526,7 @@ function uninstallHook(gitRoot) {
62086
62526
  }
62087
62527
  const remaining = [...lines.slice(0, start), ...lines.slice(end + 1)];
62088
62528
  const result = remaining.join("\n");
62089
- writeFileSync12(path, result.endsWith("\n") ? result : `${result}
62529
+ writeFileSync14(path, result.endsWith("\n") ? result : `${result}
62090
62530
  `);
62091
62531
  return {
62092
62532
  ok: true,
@@ -62099,7 +62539,7 @@ function uninstallHook(gitRoot) {
62099
62539
  function registerInstall(program) {
62100
62540
  program.command("install").description("install the git pre-commit hook").action(async (_cmdOptions, command) => {
62101
62541
  const options = command.optsWithGlobals();
62102
- const cwd = resolve15(options.workspace ?? process.cwd());
62542
+ const cwd = resolve16(options.workspace ?? process.cwd());
62103
62543
  const root = getGitRoot(cwd);
62104
62544
  if (!root) {
62105
62545
  logger.error("Not a Git repository. Run `git init` first, or remove --staged from your command.");
@@ -62116,11 +62556,11 @@ function registerInstall(program) {
62116
62556
  // src/cli/commands/uninstall.ts
62117
62557
  init_logger();
62118
62558
  init_git();
62119
- import { resolve as resolve16 } from "path";
62559
+ import { resolve as resolve17 } from "path";
62120
62560
  function registerUninstall(program) {
62121
62561
  program.command("uninstall").description("uninstall the git pre-commit hook").action(async (_cmdOptions, command) => {
62122
62562
  const options = command.optsWithGlobals();
62123
- const cwd = resolve16(options.workspace ?? process.cwd());
62563
+ const cwd = resolve17(options.workspace ?? process.cwd());
62124
62564
  const root = getGitRoot(cwd);
62125
62565
  if (!root) {
62126
62566
  logger.error("Not a Git repository. Run `git init` first, or remove --staged from your command.");
@@ -62141,7 +62581,374 @@ init_logger();
62141
62581
  init_builtins();
62142
62582
  init_config2();
62143
62583
  init_header();
62144
- init_tools();
62584
+
62585
+ // src/mcp/tools.ts
62586
+ init_worker();
62587
+ init_patterns();
62588
+ init_architecture_score();
62589
+ init_business_logic();
62590
+ init_structure_md();
62591
+ import { readFileSync as readFileSync29 } from "fs";
62592
+ import { resolve as resolve18 } from "path";
62593
+ var TOOL_DEFINITIONS = [
62594
+ {
62595
+ name: "slop_scan_file",
62596
+ 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.",
62597
+ inputSchema: {
62598
+ type: "object",
62599
+ properties: {
62600
+ path: { type: "string", description: "Absolute or cwd-relative path to the source file." },
62601
+ framework: {
62602
+ type: "string",
62603
+ enum: ["react", "vue", "svelte", "astro", "html"],
62604
+ description: "Framework multiplier to apply. Defaults to the configured framework."
62605
+ }
62606
+ },
62607
+ required: ["path"]
62608
+ }
62609
+ },
62610
+ {
62611
+ name: "slop_explain_rule",
62612
+ 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.",
62613
+ inputSchema: {
62614
+ type: "object",
62615
+ properties: {
62616
+ ruleId: { type: "string", description: 'e.g. "visual/ai-default-palette".' }
62617
+ },
62618
+ required: ["ruleId"]
62619
+ }
62620
+ },
62621
+ {
62622
+ name: "slop_list_rules",
62623
+ description: "List all registered rules with their category, severity, and aiSpecific flag. Optional category filter (visual | logic | wcag | security | perf | typo | layout | component | arch).",
62624
+ inputSchema: {
62625
+ type: "object",
62626
+ properties: {
62627
+ category: { type: "string", description: "Optional category filter." }
62628
+ }
62629
+ }
62630
+ },
62631
+ {
62632
+ name: "slop_suggest",
62633
+ 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.",
62634
+ inputSchema: {
62635
+ type: "object",
62636
+ properties: {
62637
+ maxFiles: {
62638
+ type: "number",
62639
+ description: "Cap on files scanned to keep the inventory cheap. Defaults to 200."
62640
+ }
62641
+ }
62642
+ }
62643
+ },
62644
+ {
62645
+ name: "slop_suggest_with_structure",
62646
+ 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.",
62647
+ inputSchema: {
62648
+ type: "object",
62649
+ properties: {
62650
+ maxFiles: {
62651
+ type: "number",
62652
+ description: "Cap on files scanned for the slow-path fallback. Defaults to 200."
62653
+ }
62654
+ }
62655
+ }
62656
+ },
62657
+ {
62658
+ // v0.39.0: removed 3 deprecated tools (slop_governance,
62659
+ // slop_architecture_score, slop_business_logic_score) that
62660
+ // were marked for removal in v0.13.0 but never removed.
62661
+ // They were strict subsets of slop_suggest; users should
62662
+ // call slop_suggest and read repositoryHealth /
62663
+ // architectureConsistency / businessLogicCoherence.
62664
+ name: "slop_check_constitution",
62665
+ 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.",
62666
+ inputSchema: {
62667
+ type: "object",
62668
+ properties: {
62669
+ path: { type: "string", description: "Absolute or cwd-relative path to the source file." }
62670
+ },
62671
+ required: ["path"]
62672
+ }
62673
+ },
62674
+ {
62675
+ // v0.10.1: find_similar_function. The GIR (Give-Implementation-
62676
+ // Reference) primitive for slop_suggest. Given a function signature
62677
+ // (name + hooks + props), find the most similar existing
62678
+ // implementations across the codebase. Uses AST fingerprints
62679
+ // (sha256 over sorted hooks ∪ props ∪ params) + Jaccard similarity
62680
+ // — no LLM, no embeddings, deterministic. Foundation for StackPick.
62681
+ name: "slop_find_similar",
62682
+ 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].",
62683
+ inputSchema: {
62684
+ type: "object",
62685
+ properties: {
62686
+ name: {
62687
+ type: "string",
62688
+ description: "Function/component name to match. Omit to match by hooks+props only."
62689
+ },
62690
+ hooks: {
62691
+ type: "array",
62692
+ items: { type: "string" },
62693
+ description: 'React hooks used by the target signature, e.g. ["useState", "useEffect"].'
62694
+ },
62695
+ props: {
62696
+ type: "array",
62697
+ items: { type: "string" },
62698
+ description: 'Component props for the target signature, e.g. ["variant", "size", "children"].'
62699
+ },
62700
+ limit: {
62701
+ type: "number",
62702
+ description: "Top-k results to return. Default 10. Capped at 50."
62703
+ }
62704
+ }
62705
+ }
62706
+ }
62707
+ ];
62708
+ function toolError(message) {
62709
+ return {
62710
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
62711
+ isError: true
62712
+ };
62713
+ }
62714
+ async function runScanFile(args, ctx) {
62715
+ const path = args.path;
62716
+ if (!path) return toolError("Missing required argument: path");
62717
+ const result = await scanFile(path, ctx.config);
62718
+ const simplified = {
62719
+ filePath: result.filePath,
62720
+ componentCount: result.componentCount,
62721
+ parseError: result.parseError,
62722
+ issues: result.issues.map((i) => ({
62723
+ ruleId: i.ruleId,
62724
+ category: i.category,
62725
+ severity: i.severity,
62726
+ line: i.line,
62727
+ column: i.column,
62728
+ message: i.message,
62729
+ advice: i.advice
62730
+ }))
62731
+ };
62732
+ return {
62733
+ content: [{ type: "text", text: JSON.stringify(simplified, null, 2) }]
62734
+ };
62735
+ }
62736
+ function explainRule2(args, ctx) {
62737
+ const ruleId = args.ruleId;
62738
+ if (!ruleId) return toolError("Missing required argument: ruleId");
62739
+ const rule = ctx.rules.find((r) => r.id === ruleId);
62740
+ if (!rule) return toolError("Unknown rule: " + ruleId);
62741
+ const explanation = {
62742
+ ruleId: rule.id,
62743
+ category: rule.category,
62744
+ severity: rule.severity,
62745
+ aiSpecific: rule.aiSpecific,
62746
+ 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 + ".",
62747
+ whereToLook: "src/rules/" + rule.category + "/" + rule.id.replace(/^[^/]+\//, "") + ".ts"
62748
+ };
62749
+ return {
62750
+ content: [{ type: "text", text: JSON.stringify(explanation, null, 2) }]
62751
+ };
62752
+ }
62753
+ function listRules(args, ctx) {
62754
+ const category = args.category;
62755
+ const filtered = category ? ctx.rules.filter((r) => r.category === category) : ctx.rules;
62756
+ const rules = filtered.map((r) => ({
62757
+ id: r.id,
62758
+ category: r.category,
62759
+ severity: r.severity,
62760
+ aiSpecific: r.aiSpecific
62761
+ }));
62762
+ return {
62763
+ content: [
62764
+ {
62765
+ type: "text",
62766
+ text: JSON.stringify({ count: rules.length, rules }, null, 2)
62767
+ }
62768
+ ]
62769
+ };
62770
+ }
62771
+ 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.";
62772
+ async function runSuggest(args, ctx, options = {}) {
62773
+ const { includeStructure = false } = options;
62774
+ if (includeStructure) {
62775
+ const cached = await readStructureMarkdown(ctx.cwd);
62776
+ if (cached !== null) {
62777
+ return {
62778
+ content: [{ type: "text", text: cached }]
62779
+ };
62780
+ }
62781
+ }
62782
+ const maxFilesRaw = args.maxFiles;
62783
+ const maxFiles = typeof maxFilesRaw === "number" && Number.isFinite(maxFilesRaw) && maxFilesRaw > 0 ? Math.min(2e3, Math.floor(maxFilesRaw)) : 200;
62784
+ try {
62785
+ const inventory = await buildPatternInventory(ctx.cwd, ctx.config, maxFiles);
62786
+ const doNotCreate = [
62787
+ ...ctx.config.constitution?.forbidden ?? []
62788
+ ];
62789
+ const declared = /* @__PURE__ */ new Set();
62790
+ for (const list of [
62791
+ ctx.config.constitution?.stateManagement ?? [],
62792
+ ctx.config.constitution?.dataFetching ?? [],
62793
+ ctx.config.constitution?.uiLibrary ?? [],
62794
+ ctx.config.constitution?.forms ?? [],
62795
+ ctx.config.constitution?.styling ?? [],
62796
+ ctx.config.constitution?.routing ?? []
62797
+ ]) {
62798
+ for (const lib of list) declared.add(lib);
62799
+ }
62800
+ const doNotCreateCapped = doNotCreate.slice(0, 10);
62801
+ const payload = {
62802
+ 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.",
62803
+ doNotCreate: doNotCreateCapped,
62804
+ declaredStack: Array.from(declared),
62805
+ existingPatterns: inventory
62806
+ };
62807
+ if (includeStructure) {
62808
+ payload.structureHint = STRUCTURE_NOT_FOUND_HINT;
62809
+ }
62810
+ try {
62811
+ const { loadHealth: loadHealth2 } = await Promise.resolve().then(() => (init_dist(), dist_exports));
62812
+ const health = loadHealth2(ctx.cwd);
62813
+ if (health?.compositeScore) {
62814
+ payload.compositeScore = health.compositeScore;
62815
+ }
62816
+ } catch {
62817
+ }
62818
+ return {
62819
+ content: [
62820
+ {
62821
+ type: "text",
62822
+ text: JSON.stringify(payload, null, 2)
62823
+ }
62824
+ ]
62825
+ };
62826
+ } catch (err) {
62827
+ return toolError(err instanceof Error ? err.message : String(err));
62828
+ }
62829
+ }
62830
+ function runCheckConstitution(args, ctx) {
62831
+ const path = args.path;
62832
+ if (!path) return toolError("Missing required argument: path");
62833
+ const absPath = resolve18(ctx.cwd, path);
62834
+ let source;
62835
+ try {
62836
+ source = readFileSync29(absPath, "utf-8");
62837
+ } catch (err) {
62838
+ return toolError(
62839
+ `Cannot read file ${absPath}: ${err instanceof Error ? err.message : String(err)}`
62840
+ );
62841
+ }
62842
+ const result = checkFileConstitution(source, ctx.config.constitution);
62843
+ return {
62844
+ content: [
62845
+ {
62846
+ type: "text",
62847
+ text: JSON.stringify(
62848
+ {
62849
+ file: absPath,
62850
+ importCount: result.imports.length,
62851
+ violationCount: result.violations.length,
62852
+ imports: result.imports,
62853
+ violations: result.violations,
62854
+ // Field name kept stable for backward compatibility with
62855
+ // older consumers; the value reflects whether the merged
62856
+ // `config.constitution` was declared, detected, or absent.
62857
+ conventionSource: ctx.config.constitution ? "declared-or-detected" : "none"
62858
+ },
62859
+ null,
62860
+ 2
62861
+ )
62862
+ }
62863
+ ]
62864
+ };
62865
+ }
62866
+ async function runFindSimilar(args, ctx) {
62867
+ const { findSimilarFunctions: findSimilarFunctions2 } = await Promise.resolve().then(() => (init_dist2(), dist_exports2));
62868
+ const hooks = Array.isArray(args.hooks) ? args.hooks : [];
62869
+ const props = Array.isArray(args.props) ? args.props : [];
62870
+ const limitRaw = args.limit;
62871
+ const limit = typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 10;
62872
+ try {
62873
+ const matches = await findSimilarFunctions2(
62874
+ {
62875
+ name: typeof args.name === "string" ? args.name : void 0,
62876
+ hooks,
62877
+ props,
62878
+ limit,
62879
+ workspaceDir: ctx.cwd
62880
+ },
62881
+ { cwd: ctx.cwd }
62882
+ );
62883
+ return {
62884
+ content: [
62885
+ {
62886
+ type: "text",
62887
+ text: JSON.stringify(
62888
+ {
62889
+ 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.",
62890
+ count: matches.length,
62891
+ matches: matches.map((m) => ({
62892
+ name: m.signature.name,
62893
+ file: m.signature.fileRel,
62894
+ line: m.signature.line,
62895
+ similarity: Number(m.similarity.toFixed(3)),
62896
+ fingerprint: m.fingerprint,
62897
+ hooks: m.signature.hooks,
62898
+ props: m.signature.props,
62899
+ params: m.signature.params
62900
+ }))
62901
+ },
62902
+ null,
62903
+ 2
62904
+ )
62905
+ }
62906
+ ]
62907
+ };
62908
+ } catch (err) {
62909
+ return toolError(err instanceof Error ? err.message : String(err));
62910
+ }
62911
+ }
62912
+ async function handleToolCall(toolName, args, ctx) {
62913
+ const deprecation = getDeprecation(toolName);
62914
+ const deprecationNotice = deprecation ? {
62915
+ tool: toolName,
62916
+ replacedBy: deprecation.replacedBy,
62917
+ removedIn: deprecation.removedIn ?? "next major",
62918
+ reason: deprecation.reason
62919
+ } : void 0;
62920
+ switch (toolName) {
62921
+ case "slop_scan_file":
62922
+ return runScanFile(args, ctx);
62923
+ case "slop_explain_rule":
62924
+ return explainRule2(args, ctx);
62925
+ case "slop_list_rules":
62926
+ return listRules(args, ctx);
62927
+ case "slop_suggest":
62928
+ return runSuggest(args, ctx);
62929
+ case "slop_suggest_with_structure":
62930
+ return runSuggest(args, ctx, { includeStructure: true });
62931
+ // v0.39.0: removed 3 deprecated tools (slop_governance,
62932
+ // slop_architecture_score, slop_business_logic_score) that
62933
+ // were marked for removal in v0.13.0 but never removed.
62934
+ // Their runner functions (runGovernance, runArchitectureScore,
62935
+ // runBusinessLogicScore) are kept in the file for now
62936
+ // (marked @deprecated) to keep the diff small; they can be
62937
+ // deleted in a follow-up. New clients will never see these
62938
+ // tools listed in the MCP tools/list response.
62939
+ case "slop_check_constitution":
62940
+ return runCheckConstitution(args, ctx);
62941
+ case "slop_find_similar":
62942
+ return runFindSimilar(args, ctx);
62943
+ default:
62944
+ return toolError("Unknown tool: " + toolName);
62945
+ }
62946
+ }
62947
+ function getDeprecation(toolName) {
62948
+ return TOOL_DEFINITIONS.find((t) => t.name === toolName)?.deprecated;
62949
+ }
62950
+
62951
+ // src/mcp/server.ts
62145
62952
  var SERVER_INFO = {
62146
62953
  name: "slopbrick",
62147
62954
  // v0.39.0: use the VERSION constant (same source as the CLI)
@@ -62209,7 +63016,7 @@ function errorResponse(id, code, message, data2) {
62209
63016
  return { jsonrpc: "2.0", id, error: { code, message, ...data2 !== void 0 ? { data: data2 } : {} } };
62210
63017
  }
62211
63018
  async function runMcpServer(input, output, cwd) {
62212
- return new Promise((resolve42) => {
63019
+ return new Promise((resolve43) => {
62213
63020
  let buffer = "";
62214
63021
  input.setEncoding("utf-8");
62215
63022
  input.on("data", (chunk) => {
@@ -62247,8 +63054,8 @@ async function runMcpServer(input, output, cwd) {
62247
63054
  nlIdx = buffer.indexOf("\n");
62248
63055
  }
62249
63056
  });
62250
- input.on("end", () => resolve42());
62251
- input.on("close", () => resolve42());
63057
+ input.on("end", () => resolve43());
63058
+ input.on("close", () => resolve43());
62252
63059
  });
62253
63060
  }
62254
63061
 
@@ -62272,7 +63079,7 @@ function registerDoctor(program) {
62272
63079
 
62273
63080
  // src/cli/commands/watch.ts
62274
63081
  init_watch();
62275
- import { resolve as resolve18 } from "path";
63082
+ import { resolve as resolve19 } from "path";
62276
63083
  function registerWatch(program, scanAction) {
62277
63084
  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) => {
62278
63085
  const rawGlobals = command.optsWithGlobals();
@@ -62280,7 +63087,7 @@ function registerWatch(program, scanAction) {
62280
63087
  ...rawGlobals,
62281
63088
  noIncrease: rawGlobals.increase === false
62282
63089
  };
62283
- const cwd = resolve18(options.workspace ?? process.cwd());
63090
+ const cwd = resolve19(options.workspace ?? process.cwd());
62284
63091
  await scanAction([], options, command);
62285
63092
  await watchProject(options, cwd, []);
62286
63093
  });
@@ -62288,11 +63095,11 @@ function registerWatch(program, scanAction) {
62288
63095
 
62289
63096
  // src/cli/commands/lock.ts
62290
63097
  init_logger();
62291
- import { resolve as resolve19 } from "path";
63098
+ import { resolve as resolve20 } from "path";
62292
63099
  function registerLock(program) {
62293
63100
  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(
62294
63101
  (cmdOptions) => {
62295
- const cwd = resolve19(cmdOptions.workspace ?? process.cwd());
63102
+ const cwd = resolve20(cmdOptions.workspace ?? process.cwd());
62296
63103
  if (cmdOptions.uninstall) {
62297
63104
  const result2 = uninstallHook(cwd);
62298
63105
  logger.info(result2.message);
@@ -62315,7 +63122,7 @@ function registerLock(program) {
62315
63122
  // src/cli/commands/ci.ts
62316
63123
  init_logger();
62317
63124
  init_dist();
62318
- import { resolve as resolve20 } from "path";
63125
+ import { resolve as resolve21 } from "path";
62319
63126
  function registerCi(program, scanAction) {
62320
63127
  program.command("ci").description("CI gate: run a scan and exit 1 on constitution violations, threshold breach, or new issues since the last run. Use this in GitHub Actions / GitLab CI.").option("--max-slop <n>", "exit 1 if slopIndex exceeds this number", parseCount).option("--max-new-issues <n>", "exit 1 if new issues (vs .slop-audit-cache.json) exceed this number", parseCount).option("--strict-constitution", "exit 1 on any constitution violation").option("--format <pretty|json>", "output format", "json").action(
62321
63128
  async (cmdOptions, command) => {
@@ -62328,7 +63135,7 @@ function registerCi(program, scanAction) {
62328
63135
  // scan only changed files
62329
63136
  format: cmdOptions.format ?? "json"
62330
63137
  };
62331
- const cwd = resolve20(options.workspace ?? process.cwd());
63138
+ const cwd = resolve21(options.workspace ?? process.cwd());
62332
63139
  await scanAction([], options, command);
62333
63140
  const health = loadHealth(cwd);
62334
63141
  if (!health) {
@@ -62357,11 +63164,11 @@ function registerCi(program, scanAction) {
62357
63164
 
62358
63165
  // src/cli/commands/memory.ts
62359
63166
  init_logger();
62360
- import { resolve as resolve21 } from "path";
63167
+ import { resolve as resolve22 } from "path";
62361
63168
  function registerMemory(program) {
62362
63169
  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(
62363
63170
  async (cmdOptions) => {
62364
- const cwd = resolve21(cmdOptions.workspace ?? process.cwd());
63171
+ const cwd = resolve22(cmdOptions.workspace ?? process.cwd());
62365
63172
  const { renderStructureMarkdown: renderStructureMarkdown2, readStructureMarkdown: readStructureMarkdown2, writeStructureMarkdown: writeStructureMarkdown2 } = await Promise.resolve().then(() => (init_structure_md(), structure_md_exports));
62366
63173
  const { loadInventory: loadInventory2, loadConstitution: loadConstitution2, inventoryPath: invPath, constitutionPath: conPath } = await Promise.resolve().then(() => (init_dist(), dist_exports));
62367
63174
  if (cmdOptions.regenerate) {
@@ -62392,48 +63199,48 @@ function registerMemory(program) {
62392
63199
 
62393
63200
  // src/cli/commands/migrate.ts
62394
63201
  init_logger();
62395
- import { resolve as resolve22 } from "path";
63202
+ import { resolve as resolve23 } from "path";
62396
63203
 
62397
63204
  // src/cli/migrate.ts
62398
63205
  init_logger();
62399
- import { existsSync as existsSync23, readFileSync as readFileSync28, renameSync as renameSync4, writeFileSync as writeFileSync13 } from "fs";
62400
- import { join as join25 } from "path";
63206
+ import { existsSync as existsSync25, readFileSync as readFileSync30, renameSync as renameSync4, writeFileSync as writeFileSync15 } from "fs";
63207
+ import { join as join27 } from "path";
62401
63208
  function planMigration(workspaceDir) {
62402
63209
  const moves = [];
62403
63210
  const rewrites = [];
62404
63211
  const gitignoreEdits = [];
62405
- const oldDir = join25(workspaceDir, ".slop-audit");
62406
- const newDir = join25(workspaceDir, ".slopbrick");
62407
- if (existsSync23(oldDir)) {
63212
+ const oldDir = join27(workspaceDir, ".slop-audit");
63213
+ const newDir = join27(workspaceDir, ".slopbrick");
63214
+ if (existsSync25(oldDir)) {
62408
63215
  moves.push({ from: oldDir, to: newDir, kind: "dir" });
62409
63216
  rewrites.push({
62410
- path: join25(newDir, "inventory.json"),
63217
+ path: join27(newDir, "inventory.json"),
62411
63218
  field: "version",
62412
63219
  from: '"1"',
62413
63220
  to: '"2"'
62414
63221
  });
62415
63222
  rewrites.push({
62416
- path: join25(newDir, "constitution.json"),
63223
+ path: join27(newDir, "constitution.json"),
62417
63224
  field: "version",
62418
63225
  from: '"1"',
62419
63226
  to: '"2"'
62420
63227
  });
62421
63228
  }
62422
- const oldCache = join25(workspaceDir, ".slop-audit-cache.json");
62423
- const newCache = join25(workspaceDir, ".slopbrick-cache.json");
62424
- if (existsSync23(oldCache)) {
63229
+ const oldCache = join27(workspaceDir, ".slop-audit-cache.json");
63230
+ const newCache = join27(workspaceDir, ".slopbrick-cache.json");
63231
+ if (existsSync25(oldCache)) {
62425
63232
  moves.push({ from: oldCache, to: newCache, kind: "file" });
62426
63233
  }
62427
63234
  for (const ext of ["mjs", "cjs", "js"]) {
62428
- const oldCfg = join25(workspaceDir, `slop-audit.config.${ext}`);
62429
- const newCfg = join25(workspaceDir, `slopbrick.config.${ext}`);
62430
- if (existsSync23(oldCfg)) {
63235
+ const oldCfg = join27(workspaceDir, `slop-audit.config.${ext}`);
63236
+ const newCfg = join27(workspaceDir, `slopbrick.config.${ext}`);
63237
+ if (existsSync25(oldCfg)) {
62431
63238
  moves.push({ from: oldCfg, to: newCfg, kind: "config" });
62432
63239
  }
62433
63240
  }
62434
- const gi = join25(workspaceDir, ".gitignore");
62435
- if (existsSync23(gi)) {
62436
- const content = readFileSync28(gi, "utf-8");
63241
+ const gi = join27(workspaceDir, ".gitignore");
63242
+ if (existsSync25(gi)) {
63243
+ const content = readFileSync30(gi, "utf-8");
62437
63244
  if (content.includes(".slop-audit/")) {
62438
63245
  gitignoreEdits.push({
62439
63246
  path: gi,
@@ -62452,7 +63259,7 @@ function planMigration(workspaceDir) {
62452
63259
  return { moves, rewrites, gitignoreEdits };
62453
63260
  }
62454
63261
  function isAlreadyMigrated(workspaceDir) {
62455
- return existsSync23(join25(workspaceDir, ".slopbrick")) && !existsSync23(join25(workspaceDir, ".slop-audit"));
63262
+ return existsSync25(join27(workspaceDir, ".slopbrick")) && !existsSync25(join27(workspaceDir, ".slop-audit"));
62456
63263
  }
62457
63264
  function applyMigration(plan, options = {}) {
62458
63265
  if (options.dryRun) return;
@@ -62460,20 +63267,20 @@ function applyMigration(plan, options = {}) {
62460
63267
  renameSync4(m.from, m.to);
62461
63268
  }
62462
63269
  for (const r of plan.rewrites) {
62463
- if (!existsSync23(r.path)) continue;
62464
- const content = readFileSync28(r.path, "utf-8");
63270
+ if (!existsSync25(r.path)) continue;
63271
+ const content = readFileSync30(r.path, "utf-8");
62465
63272
  const next = content.replace(`"version": ${r.from}`, `"version": ${r.to}`);
62466
- writeFileSync13(r.path, next);
63273
+ writeFileSync15(r.path, next);
62467
63274
  }
62468
63275
  for (const g of plan.gitignoreEdits) {
62469
- const content = readFileSync28(g.path, "utf-8");
63276
+ const content = readFileSync30(g.path, "utf-8");
62470
63277
  const next = content.replaceAll(g.from, g.to);
62471
- writeFileSync13(g.path, next);
63278
+ writeFileSync15(g.path, next);
62472
63279
  }
62473
63280
  }
62474
63281
  function runMigrate(options) {
62475
63282
  const { workspace, dryRun = false, force = false } = options;
62476
- if (!existsSync23(workspace)) {
63283
+ if (!existsSync25(workspace)) {
62477
63284
  return {
62478
63285
  ok: false,
62479
63286
  alreadyMigrated: false,
@@ -62483,9 +63290,9 @@ function runMigrate(options) {
62483
63290
  };
62484
63291
  }
62485
63292
  const alreadyMigrated = isAlreadyMigrated(workspace);
62486
- const newDir = join25(workspace, ".slopbrick");
62487
- const oldDir = join25(workspace, ".slop-audit");
62488
- if (existsSync23(newDir) && existsSync23(oldDir) && !force) {
63293
+ const newDir = join27(workspace, ".slopbrick");
63294
+ const oldDir = join27(workspace, ".slop-audit");
63295
+ if (existsSync25(newDir) && existsSync25(oldDir) && !force) {
62489
63296
  return {
62490
63297
  ok: false,
62491
63298
  alreadyMigrated: false,
@@ -62561,7 +63368,7 @@ function registerMigrate(program) {
62561
63368
  (cmdOptions, command) => {
62562
63369
  const globals = command.optsWithGlobals();
62563
63370
  const format = (cmdOptions.format ?? globals.format) === "json" ? "json" : "pretty";
62564
- const cwd = resolve22(cmdOptions.workspace ?? process.cwd());
63371
+ const cwd = resolve23(cmdOptions.workspace ?? process.cwd());
62565
63372
  const result = runMigrate({
62566
63373
  workspace: cwd,
62567
63374
  dryRun: cmdOptions.dryRun,
@@ -62667,12 +63474,12 @@ function registerRules(program) {
62667
63474
  // src/cli/commands/validate-config.ts
62668
63475
  init_logger();
62669
63476
  init_validation();
62670
- import { existsSync as existsSync24 } from "fs";
62671
- import { extname as extname8, resolve as resolve23 } from "path";
63477
+ import { existsSync as existsSync26 } from "fs";
63478
+ import { extname as extname8, resolve as resolve24 } from "path";
62672
63479
  function registerValidateConfig(program) {
62673
63480
  program.command("validate-config [path]").description("Statically validate a slopbrick.config.mjs without scanning").action(async (configPath) => {
62674
- const path = configPath ? resolve23(configPath) : resolve23(process.cwd(), "slopbrick.config.mjs");
62675
- if (!existsSync24(path)) {
63481
+ const path = configPath ? resolve24(configPath) : resolve24(process.cwd(), "slopbrick.config.mjs");
63482
+ if (!existsSync26(path)) {
62676
63483
  logger.error(`Error: config file not found: ${path}`);
62677
63484
  process.exit(2);
62678
63485
  }
@@ -62898,8 +63705,8 @@ ${formatMarkdown(result.report)}`);
62898
63705
 
62899
63706
  // src/cli/commands/calibrate.ts
62900
63707
  init_logger();
62901
- import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync16 } from "fs";
62902
- import { dirname as dirname18, resolve as resolve25 } from "path";
63708
+ import { mkdirSync as mkdirSync14, writeFileSync as writeFileSync18 } from "fs";
63709
+ import { dirname as dirname19, resolve as resolve26 } from "path";
62903
63710
 
62904
63711
  // src/research/providers/openai.ts
62905
63712
  var OpenAiProvider = class {
@@ -62954,8 +63761,8 @@ function createProvider(config) {
62954
63761
  }
62955
63762
 
62956
63763
  // src/research/generator.ts
62957
- import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync14 } from "fs";
62958
- import { join as join26 } from "path";
63764
+ import { mkdirSync as mkdirSync12, writeFileSync as writeFileSync16 } from "fs";
63765
+ import { join as join28 } from "path";
62959
63766
 
62960
63767
  // src/research/prompts.ts
62961
63768
  var DEFAULT_PROMPT_TEMPLATES = [
@@ -63018,14 +63825,14 @@ async function generateSamples(options) {
63018
63825
  }
63019
63826
  const samples = [];
63020
63827
  const ext = extForFramework(framework);
63021
- const dir = join26(outputDir, framework, componentType);
63022
- mkdirSync11(dir, { recursive: true });
63828
+ const dir = join28(outputDir, framework, componentType);
63829
+ mkdirSync12(dir, { recursive: true });
63023
63830
  for (let i = 1; i <= count; i += 1) {
63024
63831
  const raw = await provider.generateSample(renderPrompt(template), { temperature });
63025
63832
  const code = extractCodeFromMarkdown(raw);
63026
63833
  const fileName = `sample-${i}${ext}`;
63027
- const filePath = join26(dir, fileName);
63028
- writeFileSync14(filePath, code, "utf8");
63834
+ const filePath = join28(dir, fileName);
63835
+ writeFileSync16(filePath, code, "utf8");
63029
63836
  const sample = {
63030
63837
  filePath,
63031
63838
  framework,
@@ -63036,8 +63843,8 @@ async function generateSamples(options) {
63036
63843
  };
63037
63844
  samples.push(sample);
63038
63845
  }
63039
- const metadataPath = join26(dir, "metadata.json");
63040
- writeFileSync14(metadataPath, JSON.stringify(samples, null, 2), "utf8");
63846
+ const metadataPath = join28(dir, "metadata.json");
63847
+ writeFileSync16(metadataPath, JSON.stringify(samples, null, 2), "utf8");
63041
63848
  return samples;
63042
63849
  }
63043
63850
 
@@ -63243,46 +64050,46 @@ function slugify(value) {
63243
64050
 
63244
64051
  // src/research/calibrator.ts
63245
64052
  import { execFileSync as execFileSync2 } from "child_process";
63246
- import { existsSync as existsSync25, readFileSync as readFileSync29, writeFileSync as writeFileSync15, mkdirSync as mkdirSync12 } from "fs";
63247
- import { join as join28, resolve as resolve24 } from "path";
64053
+ import { existsSync as existsSync27, readFileSync as readFileSync31, writeFileSync as writeFileSync17, mkdirSync as mkdirSync13 } from "fs";
64054
+ import { join as join30, resolve as resolve25 } from "path";
63248
64055
 
63249
64056
  // src/corpus-paths.ts
63250
- import { join as join27 } from "path";
64057
+ import { join as join29 } from "path";
63251
64058
  var CORPUS_ROOT = process.env["SLOPBRICK_CORPUS_DIR"] ?? "/Users/cheng/corpus-expansion";
63252
- var POSITIVE_DIR = join27(CORPUS_ROOT, "positive");
63253
- var NEGATIVE_DIR = join27(CORPUS_ROOT, "negative");
63254
- var FILELISTS_DIR = join27(CORPUS_ROOT, "filelists");
64059
+ var POSITIVE_DIR = join29(CORPUS_ROOT, "positive");
64060
+ var NEGATIVE_DIR = join29(CORPUS_ROOT, "negative");
64061
+ var FILELISTS_DIR = join29(CORPUS_ROOT, "filelists");
63255
64062
 
63256
64063
  // src/research/calibrator.ts
63257
64064
  var DEFAULT_POSITIVE = POSITIVE_DIR;
63258
64065
  var DEFAULT_NEGATIVE = NEGATIVE_DIR;
63259
64066
  function buildFileList(dir, extensions) {
63260
- const tmpList = join28("/tmp", `cal-build-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
64067
+ const tmpList = join30("/tmp", `cal-build-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
63261
64068
  const expr = extensions.map((e) => `-name '*.${e}'`).join(" -o ");
63262
64069
  execFileSync2("bash", ["-c", `find ${dir} -maxdepth 8 -type f \\( ${expr} \\) -print0 | xargs -0 realpath > ${tmpList}`]);
63263
- const out = readFileSync29(tmpList, "utf8");
64070
+ const out = readFileSync31(tmpList, "utf8");
63264
64071
  execFileSync2("rm", ["-f", tmpList]);
63265
64072
  return out.trim().split("\n").filter(Boolean);
63266
64073
  }
63267
64074
  function runScan2(fileListPath) {
63268
- const files = readFileSync29(fileListPath, "utf8").trim().split("\n").filter(Boolean);
64075
+ const files = readFileSync31(fileListPath, "utf8").trim().split("\n").filter(Boolean);
63269
64076
  const CHUNK = 600;
63270
64077
  const ruleFires = /* @__PURE__ */ new Map();
63271
64078
  const uniqueFilesPerRule = /* @__PURE__ */ new Map();
63272
64079
  let fileCount = 0;
63273
- const tmpOut = join28("/tmp", `calibrate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
64080
+ const tmpOut = join30("/tmp", `calibrate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
63274
64081
  for (let i = 0; i < files.length; i += CHUNK) {
63275
64082
  const chunk = files.slice(i, i + CHUNK);
63276
64083
  try {
63277
64084
  execFileSync2(
63278
64085
  "node",
63279
- [join28(process.cwd(), "bin", "slopbrick.js"), "scan", ...chunk, "--json", tmpOut, "--no-telemetry", "--quiet"],
64086
+ [join30(process.cwd(), "bin", "slopbrick.js"), "scan", ...chunk, "--json", tmpOut, "--no-telemetry", "--quiet"],
63280
64087
  { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
63281
64088
  );
63282
64089
  } catch {
63283
64090
  }
63284
- if (!existsSync25(tmpOut)) continue;
63285
- const report = JSON.parse(readFileSync29(tmpOut, "utf8"));
64091
+ if (!existsSync27(tmpOut)) continue;
64092
+ const report = JSON.parse(readFileSync31(tmpOut, "utf8"));
63286
64093
  fileCount += report.fileCount;
63287
64094
  for (const issue of report.issues) {
63288
64095
  ruleFires.set(issue.ruleId, (ruleFires.get(issue.ruleId) ?? 0) + 1);
@@ -63310,16 +64117,16 @@ function classify(posFiles, negFiles) {
63310
64117
  async function calibrate(cwd, options = {}) {
63311
64118
  const positiveDir = options.positiveDir ?? DEFAULT_POSITIVE;
63312
64119
  const negativeDir = options.negativeDir ?? DEFAULT_NEGATIVE;
63313
- if (!existsSync25(positiveDir)) throw new Error(`Positive corpus not found: ${positiveDir}`);
63314
- if (!existsSync25(negativeDir)) throw new Error(`Negative corpus not found: ${negativeDir}`);
64120
+ if (!existsSync27(positiveDir)) throw new Error(`Positive corpus not found: ${positiveDir}`);
64121
+ if (!existsSync27(negativeDir)) throw new Error(`Negative corpus not found: ${negativeDir}`);
63315
64122
  const positiveFiles = buildFileList(positiveDir, ["tsx", "ts", "jsx", "js"]);
63316
64123
  const negativeFiles = buildFileList(negativeDir, ["tsx", "ts"]);
63317
64124
  const posSample = options.positiveLimit ? positiveFiles.slice(0, options.positiveLimit) : positiveFiles;
63318
64125
  const negSample = options.negativeLimit ? negativeFiles.slice(0, options.negativeLimit) : negativeFiles;
63319
- const posListPath = join28("/tmp", `cal-pos-${Date.now()}.txt`);
63320
- const negListPath = join28("/tmp", `cal-neg-${Date.now()}.txt`);
63321
- writeFileSync15(posListPath, posSample.join("\n"));
63322
- writeFileSync15(negListPath, negSample.join("\n"));
64126
+ const posListPath = join30("/tmp", `cal-pos-${Date.now()}.txt`);
64127
+ const negListPath = join30("/tmp", `cal-neg-${Date.now()}.txt`);
64128
+ writeFileSync17(posListPath, posSample.join("\n"));
64129
+ writeFileSync17(negListPath, negSample.join("\n"));
63323
64130
  const builtins = await Promise.resolve().then(() => (init_builtins(), builtins_exports));
63324
64131
  const builtinRules2 = builtins.builtinRules ?? [];
63325
64132
  const metaById = /* @__PURE__ */ new Map();
@@ -63433,9 +64240,9 @@ function registerCalibrate(program) {
63433
64240
  positiveLimit: cmdOptions.positiveLimit,
63434
64241
  negativeLimit: cmdOptions.negativeLimit
63435
64242
  });
63436
- const outputPath = cmdOptions.output ? resolve25(cwd, cmdOptions.output) : resolve25(cwd, "corpus", "calibration-empirical.md");
63437
- mkdirSync13(dirname18(outputPath), { recursive: true });
63438
- writeFileSync16(outputPath, reportToMarkdown(report), "utf8");
64243
+ const outputPath = cmdOptions.output ? resolve26(cwd, cmdOptions.output) : resolve26(cwd, "corpus", "calibration-empirical.md");
64244
+ mkdirSync14(dirname19(outputPath), { recursive: true });
64245
+ writeFileSync18(outputPath, reportToMarkdown(report), "utf8");
63439
64246
  logger.info(
63440
64247
  "Calibrated " + report.rules.length + " rules across " + report.positiveFileCount + " positive + " + report.negativeFileCount + " negative files."
63441
64248
  );
@@ -63574,12 +64381,13 @@ function registerTrend(program) {
63574
64381
  // src/cli/commands/drift.ts
63575
64382
  init_logger();
63576
64383
  init_scan2();
63577
- import { resolve as resolve26 } from "path";
64384
+ import { resolve as resolve27 } from "path";
63578
64385
 
63579
64386
  // src/cli/drift.ts
63580
64387
  init_discover();
63581
64388
  init_patterns();
63582
- import { readFileSync as readFileSync30 } from "fs";
64389
+ init_telemetry();
64390
+ import { readFileSync as readFileSync32 } from "fs";
63583
64391
  import { basename as basename4, relative as relative16 } from "path";
63584
64392
  async function runDrift(cwd, config, options = {}) {
63585
64393
  const maxFiles = options.maxFiles ?? 1e3;
@@ -63591,7 +64399,7 @@ async function runDrift(cwd, config, options = {}) {
63591
64399
  for (const absPath of limited) {
63592
64400
  let source;
63593
64401
  try {
63594
- source = readFileSync30(absPath, "utf-8");
64402
+ source = readFileSync32(absPath, "utf-8");
63595
64403
  } catch {
63596
64404
  continue;
63597
64405
  }
@@ -63688,26 +64496,217 @@ function formatDrift(result, opts = {}) {
63688
64496
  function driftExitCode(result) {
63689
64497
  return result.totalViolations > 0 ? 1 : 0;
63690
64498
  }
64499
+ async function runDriftOverTime(cwd, config, options) {
64500
+ const payloads = readTelemetry(cwd);
64501
+ const sortedAsc = [...payloads].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
64502
+ const withInventory = sortedAsc.filter((p) => p.inventory !== void 0);
64503
+ const baselineSource = options.since === "baseline" ? "baseline" : "since";
64504
+ let baseline;
64505
+ if (baselineSource === "baseline") {
64506
+ baseline = withInventory[0];
64507
+ } else {
64508
+ baseline = withInventory.find((p) => p.timestamp >= options.since);
64509
+ }
64510
+ const current = withInventory.at(-1);
64511
+ if (!baseline || !current) {
64512
+ return {
64513
+ scannedFiles: 0,
64514
+ filesWithViolations: 0,
64515
+ totalViolations: 0,
64516
+ byCategory: {},
64517
+ byFile: [],
64518
+ conventionSource: deriveSource(config.constitution),
64519
+ constitution: config.constitution,
64520
+ introduced: [],
64521
+ removed: [],
64522
+ introducedUndeclared: [],
64523
+ snapshotsConsidered: withInventory.length,
64524
+ driftScore: 0,
64525
+ baselineAt: baseline?.timestamp ?? "",
64526
+ currentAt: current?.timestamp ?? "",
64527
+ baselineSource
64528
+ };
64529
+ }
64530
+ const baselineNames = flattenPatternNames(baseline.inventory.patternNames);
64531
+ const currentNames = flattenPatternNames(current.inventory.patternNames);
64532
+ const baselineSet = new Set(baselineNames.map((p) => `${p.category}\0${p.name}`));
64533
+ const currentSet = new Set(currentNames.map((p) => `${p.category}\0${p.name}`));
64534
+ const introduced = [];
64535
+ const removed = [];
64536
+ for (const p of currentNames) {
64537
+ const key = `${p.category}\0${p.name}`;
64538
+ if (!baselineSet.has(key)) introduced.push(p);
64539
+ }
64540
+ for (const p of baselineNames) {
64541
+ const key = `${p.category}\0${p.name}`;
64542
+ if (!currentSet.has(key)) removed.push(p);
64543
+ }
64544
+ introduced.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
64545
+ removed.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
64546
+ const declared = collectDeclaredNames(config.constitution);
64547
+ const introducedUndeclared = introduced.filter((p) => !declared.has(p.name.toLowerCase()));
64548
+ const baselineTotal = Math.max(baselineSet.size, 1);
64549
+ const driftScore = Math.min(
64550
+ 100,
64551
+ Math.round((introduced.length + removed.length) / baselineTotal * 100)
64552
+ );
64553
+ return {
64554
+ scannedFiles: current.inventory.scannedFiles,
64555
+ filesWithViolations: 0,
64556
+ totalViolations: 0,
64557
+ byCategory: {},
64558
+ byFile: [],
64559
+ conventionSource: deriveSource(config.constitution),
64560
+ constitution: config.constitution,
64561
+ introduced,
64562
+ removed,
64563
+ introducedUndeclared,
64564
+ snapshotsConsidered: withInventory.length,
64565
+ driftScore,
64566
+ baselineAt: baseline.timestamp,
64567
+ currentAt: current.timestamp,
64568
+ baselineSource
64569
+ };
64570
+ }
64571
+ function flattenPatternNames(patternNames) {
64572
+ const out = [];
64573
+ for (const [category, names] of Object.entries(patternNames)) {
64574
+ for (const name of names) {
64575
+ out.push({ category, name });
64576
+ }
64577
+ }
64578
+ out.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
64579
+ return out;
64580
+ }
64581
+ function collectDeclaredNames(constitution) {
64582
+ const out = /* @__PURE__ */ new Set();
64583
+ if (!constitution) return out;
64584
+ const fields = ["stateManagement", "dataFetching", "uiLibrary", "forms", "styling", "routing"];
64585
+ for (const f of fields) {
64586
+ for (const v of constitution[f] ?? []) {
64587
+ out.add(v.toLowerCase());
64588
+ }
64589
+ }
64590
+ for (const list of Object.values(constitution.custom ?? {})) {
64591
+ for (const v of list) {
64592
+ out.add(v.toLowerCase());
64593
+ }
64594
+ }
64595
+ for (const v of constitution.forbidden ?? []) {
64596
+ out.add(v.toLowerCase());
64597
+ }
64598
+ return out;
64599
+ }
64600
+ function formatDriftOverTime(result) {
64601
+ const lines = [];
64602
+ lines.push("Temporal drift report");
64603
+ lines.push("");
64604
+ if (result.snapshotsConsidered === 0 || !result.baselineAt || !result.currentAt) {
64605
+ lines.push(" No historical telemetry found at .slopbrick/flywheel/scans.jsonl.");
64606
+ lines.push(" Run a few scans with `slopbrick scan` first; the temporal drift");
64607
+ lines.push(" detector needs \u2265 2 scan payloads to compute a baseline window.");
64608
+ return lines.join("\n");
64609
+ }
64610
+ const sinceLabel = result.baselineSource === "baseline" ? `baseline (oldest scan)` : `since ${result.baselineAt}`;
64611
+ lines.push(` Window: ${sinceLabel} \u2192 ${result.currentAt}`);
64612
+ lines.push(` Snapshots considered: ${result.snapshotsConsidered}`);
64613
+ lines.push(` Drift score: ${result.driftScore} / 100 (informational)`);
64614
+ lines.push("");
64615
+ lines.push(` Patterns introduced: ${result.introduced.length}`);
64616
+ for (const p of result.introduced) {
64617
+ lines.push(` + ${p.category}/${p.name}`);
64618
+ }
64619
+ if (result.introducedUndeclared.length > 0) {
64620
+ lines.push("");
64621
+ lines.push(
64622
+ ` Patterns introduced but not in declared constitution: ${result.introducedUndeclared.length}`
64623
+ );
64624
+ for (const p of result.introducedUndeclared) {
64625
+ lines.push(` ! ${p.category}/${p.name}`);
64626
+ }
64627
+ }
64628
+ lines.push("");
64629
+ lines.push(` Patterns removed: ${result.removed.length}`);
64630
+ for (const p of result.removed) {
64631
+ lines.push(` - ${p.category}/${p.name}`);
64632
+ }
64633
+ lines.push("");
64634
+ if (result.introducedUndeclared.length > 0) {
64635
+ lines.push(
64636
+ " Tip: add undeclared patterns to slopbrick.config.mjs#constitution to"
64637
+ );
64638
+ lines.push(" promote them into the declared set on the next scan.");
64639
+ } else if (result.introduced.length === 0 && result.removed.length === 0) {
64640
+ lines.push(" \u2713 No pattern churn since the baseline window.");
64641
+ }
64642
+ return lines.join("\n");
64643
+ }
64644
+ function driftOverTimeExitCode(result) {
64645
+ return result.introducedUndeclared.length > 0 ? 1 : 0;
64646
+ }
64647
+
64648
+ // src/cli/commands/drift.ts
64649
+ init_load();
64650
+
64651
+ // src/cli/commands/_shared.ts
64652
+ init_logger();
64653
+ import { CommanderError } from "commander";
64654
+ function setExitOverride(program) {
64655
+ program.exitOverride();
64656
+ }
64657
+ async function dispatch(program, runFn) {
64658
+ try {
64659
+ await runFn();
64660
+ } catch (err) {
64661
+ if (err instanceof CommanderError) {
64662
+ if (err.code !== "commander.helpDisplayed" && err.code !== "commander.help") {
64663
+ logger.error(err.message);
64664
+ }
64665
+ process.exit(err.exitCode);
64666
+ return;
64667
+ }
64668
+ throw err;
64669
+ }
64670
+ }
64671
+ function withExitCode(result, compute, message) {
64672
+ const code = compute(result);
64673
+ if (code === 0) return;
64674
+ throw new CommanderError(code, "slopbrick.exit", message);
64675
+ }
63691
64676
 
63692
64677
  // src/cli/commands/drift.ts
63693
64678
  function registerDrift(program) {
63694
64679
  program.command("drift").description(
63695
64680
  "detect imports that violate declared constitution (state, data-fetching, UI, forms, styling, routing) or import forbidden packages"
63696
- ).option("--format <pretty|json>", "output format", "pretty").option("--max-files <n>", "cap on files scanned", parseCount, 1e3).action(
64681
+ ).option("--format <pretty|json>", "output format", "pretty").option("--max-files <n>", "cap on files scanned", parseCount, 1e3).option(
64682
+ "--temporal-since <date>",
64683
+ `temporal drift since the given date (ISO-8601, or the literal string \`baseline\` to use the oldest scan in scans.jsonl)`
64684
+ ).action(
63697
64685
  async (cmdOptions, command) => {
63698
- try {
63699
- const options = command.optsWithGlobals();
63700
- const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
63701
- const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
63702
- const cwd = resolve26(options.workspace ?? process.cwd());
63703
- const { config } = await runScan({ ...options, workspace: cwd });
63704
- const result = await runDrift(cwd, config, { maxFiles: cmdOptions.maxFiles });
63705
- logger.info(formatDrift(result, { json: format === "json" }));
63706
- process.exit(driftExitCode(result));
63707
- } catch (err) {
63708
- logger.error(err instanceof Error ? err.message : String(err));
63709
- process.exit(2);
64686
+ const options = command.optsWithGlobals();
64687
+ const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64688
+ const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64689
+ const cwd = resolve27(options.workspace ?? process.cwd());
64690
+ if (cmdOptions.temporalSince !== void 0) {
64691
+ const temporalSince = cmdOptions.temporalSince.trim();
64692
+ if (temporalSince.length === 0) {
64693
+ throw new Error("--temporal-since expects a non-empty ISO date or `baseline`");
64694
+ }
64695
+ const sinceArg = temporalSince === "baseline" ? "baseline" : temporalSince;
64696
+ const config2 = await loadConfig(cwd);
64697
+ const result2 = await runDriftOverTime(cwd, config2, { since: sinceArg });
64698
+ logger.info(formatDriftOverTime(result2));
64699
+ withExitCode(
64700
+ result2,
64701
+ driftOverTimeExitCode,
64702
+ `drift --temporal-since: ${result2.introducedUndeclared.length} undeclared patterns introduced`
64703
+ );
64704
+ return;
63710
64705
  }
64706
+ const { config } = await runScan({ ...options, workspace: cwd });
64707
+ const result = await runDrift(cwd, config, { maxFiles: cmdOptions.maxFiles });
64708
+ logger.info(formatDrift(result, { json: format === "json" }));
64709
+ withExitCode(result, driftExitCode, `drift: ${result.totalViolations} violations`);
63711
64710
  }
63712
64711
  );
63713
64712
  }
@@ -63715,7 +64714,7 @@ function registerDrift(program) {
63715
64714
  // src/cli/commands/pr.ts
63716
64715
  init_logger();
63717
64716
  init_scan2();
63718
- import { resolve as resolve28 } from "path";
64717
+ import { resolve as resolve29 } from "path";
63719
64718
 
63720
64719
  // src/cli/pr.ts
63721
64720
  init_git();
@@ -63723,8 +64722,8 @@ init_worker();
63723
64722
  init_metrics();
63724
64723
  init_patterns();
63725
64724
  init_discover();
63726
- import { readFileSync as readFileSync31 } from "fs";
63727
- import { extname as extname9, relative as relative17, resolve as resolve27 } from "path";
64725
+ import { readFileSync as readFileSync33 } from "fs";
64726
+ import { extname as extname9, relative as relative17, resolve as resolve28 } from "path";
63728
64727
  import { execFile as execFileCb } from "child_process";
63729
64728
  import { promisify as promisify2 } from "util";
63730
64729
  import { minimatch as minimatch3 } from "minimatch";
@@ -63765,7 +64764,7 @@ async function discoverPrFiles(cwd, config, base, head, maxFiles) {
63765
64764
  if (gitFiles.length === 0) return [];
63766
64765
  const sourceFiles = [];
63767
64766
  for (const relOrAbs of gitFiles) {
63768
- const abs = resolve27(cwd, relOrAbs);
64767
+ const abs = resolve28(cwd, relOrAbs);
63769
64768
  const ext = extname9(abs).toLowerCase();
63770
64769
  if (!PR_EXTENSIONS.has(ext)) continue;
63771
64770
  const rel = relative17(cwd, abs).split("\\").join("/");
@@ -63796,7 +64795,7 @@ async function runPrScan(cwd, config, options = {}) {
63796
64795
  for (const absPath of candidates) {
63797
64796
  let source;
63798
64797
  try {
63799
- source = readFileSync31(absPath, "utf-8");
64798
+ source = readFileSync33(absPath, "utf-8");
63800
64799
  } catch {
63801
64800
  continue;
63802
64801
  }
@@ -63971,20 +64970,135 @@ function formatPrMarkdown(result) {
63971
64970
  function prExitCode(result) {
63972
64971
  return result.totalScore > result.threshold ? 1 : 0;
63973
64972
  }
64973
+ function parseUnifiedDiffPaths(diff) {
64974
+ const paths = [];
64975
+ const seen = /* @__PURE__ */ new Set();
64976
+ for (const line of diff.split(/\r?\n/)) {
64977
+ const m = /^\+\+\+\s+(?:b\/)?(\S.*)$/.exec(line);
64978
+ if (!m) continue;
64979
+ const path = m[1];
64980
+ if (path === "/dev/null" || path === "dev/null") continue;
64981
+ if (seen.has(path)) continue;
64982
+ seen.add(path);
64983
+ paths.push(path);
64984
+ }
64985
+ return paths;
64986
+ }
64987
+ async function runPrScanFromDiffFile(cwd, config, diffPath, options = {}) {
64988
+ const threshold = options.threshold ?? config.prScoreThreshold ?? 20;
64989
+ const maxFiles = options.maxFiles ?? 500;
64990
+ const { existsSync: existsSync32, readFileSync: readFileSync42 } = await import("fs");
64991
+ const { isAbsolute: isAbsolute4, resolve: resolvePath } = await import("path");
64992
+ if (!existsSync32(diffPath)) {
64993
+ throw new Error(`Diff file not found: ${diffPath}`);
64994
+ }
64995
+ const diffAbs = isAbsolute4(diffPath) ? diffPath : resolvePath(cwd, diffPath);
64996
+ const diff = readFileSync42(diffAbs, "utf-8");
64997
+ const changedPaths = parseUnifiedDiffPaths(diff);
64998
+ if (changedPaths.length === 0) {
64999
+ return emptyPrResult(threshold);
65000
+ }
65001
+ return scoreChangedPaths(cwd, config, changedPaths, maxFiles, threshold);
65002
+ }
65003
+ async function scoreChangedPaths(cwd, config, paths, maxFiles, threshold) {
65004
+ const { existsSync: existsSync32, statSync: statSync8 } = await import("fs");
65005
+ const { resolve: resolvePath } = await import("path");
65006
+ const { readFile: readFile4 } = await import("fs/promises");
65007
+ const { scanFile: scanFile2 } = await Promise.resolve().then(() => (init_worker(), worker_exports));
65008
+ const limited = paths.slice(0, maxFiles);
65009
+ const files = [];
65010
+ let totalScore = 0;
65011
+ const byCategory = {};
65012
+ const bySeverity = { low: 0, medium: 0, high: 0 };
65013
+ let constitutionViolations = 0;
65014
+ for (const rel of limited) {
65015
+ const abs = resolvePath(cwd, rel);
65016
+ if (!existsSync32(abs) || !statSync8(abs).isFile()) continue;
65017
+ let source;
65018
+ try {
65019
+ source = await readFile4(abs, "utf-8");
65020
+ } catch {
65021
+ continue;
65022
+ }
65023
+ let result;
65024
+ try {
65025
+ result = await scanFile2(abs, config, void 0, cwd);
65026
+ } catch {
65027
+ continue;
65028
+ }
65029
+ if (result.parseError) continue;
65030
+ const issues = result.issues;
65031
+ let slopPoints = 0;
65032
+ const issueEntries = [];
65033
+ for (const issue of issues) {
65034
+ slopPoints += SEVERITY_WEIGHTS[issue.severity] ?? 0;
65035
+ byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1;
65036
+ bySeverity[issue.severity] = (bySeverity[issue.severity] ?? 0) + 1;
65037
+ issueEntries.push({
65038
+ ruleId: issue.ruleId,
65039
+ severity: issue.severity,
65040
+ line: issue.line,
65041
+ category: issue.category,
65042
+ message: issue.message
65043
+ });
65044
+ }
65045
+ totalScore += slopPoints;
65046
+ files.push({
65047
+ file: abs,
65048
+ relPath: rel,
65049
+ score: slopPoints,
65050
+ issueCount: issueEntries.length,
65051
+ constitutionViolationCount: 0,
65052
+ slopPoints,
65053
+ issues: issueEntries,
65054
+ constitutionViolations: []
65055
+ });
65056
+ }
65057
+ return {
65058
+ base: "diff",
65059
+ head: "diff",
65060
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65061
+ passed: totalScore <= threshold,
65062
+ threshold,
65063
+ totalScore,
65064
+ filesChanged: files.length,
65065
+ byCategory,
65066
+ bySeverity,
65067
+ files
65068
+ };
65069
+ }
65070
+ function emptyPrResult(threshold) {
65071
+ return {
65072
+ base: "diff",
65073
+ head: "diff",
65074
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65075
+ passed: true,
65076
+ threshold,
65077
+ totalScore: 0,
65078
+ filesChanged: 0,
65079
+ byCategory: {},
65080
+ bySeverity: { low: 0, medium: 0, high: 0 },
65081
+ files: []
65082
+ };
65083
+ }
63974
65084
 
63975
65085
  // src/cli/commands/pr.ts
63976
65086
  function registerPr(program) {
63977
65087
  program.command("pr").description(
63978
65088
  "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)."
63979
- ).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(
65089
+ ).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(
63980
65090
  async (cmdOptions, command) => {
63981
65091
  try {
63982
65092
  const options = command.optsWithGlobals();
63983
- const cwd = resolve28(options.workspace ?? process.cwd());
65093
+ const cwd = resolve29(options.workspace ?? process.cwd());
63984
65094
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
63985
65095
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
63986
65096
  const { config } = await runScan({ ...options, workspace: cwd });
63987
- const result = await runPrScan(cwd, config, {
65097
+ const result = cmdOptions.diff ? await runPrScanFromDiffFile(cwd, config, cmdOptions.diff, {
65098
+ format,
65099
+ threshold: cmdOptions.threshold,
65100
+ maxFiles: cmdOptions.maxFiles
65101
+ }) : await runPrScan(cwd, config, {
63988
65102
  base: cmdOptions.base,
63989
65103
  head: cmdOptions.head,
63990
65104
  format,
@@ -64005,7 +65119,7 @@ function registerPr(program) {
64005
65119
  init_logger();
64006
65120
  init_scan2();
64007
65121
  init_ai_security_risk();
64008
- import { resolve as resolve29 } from "path";
65122
+ import { resolve as resolve30 } from "path";
64009
65123
  function registerSecurity(program) {
64010
65124
  program.command("security").description(
64011
65125
  "AI Security Risk \u2014 categorical severity for security findings disproportionately introduced by AI-generated code"
@@ -64015,7 +65129,7 @@ function registerSecurity(program) {
64015
65129
  const options = command.optsWithGlobals();
64016
65130
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64017
65131
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64018
- const cwd = resolve29(options.workspace ?? process.cwd());
65132
+ const cwd = resolve30(options.workspace ?? process.cwd());
64019
65133
  const { report } = await runScan({ ...options, workspace: cwd });
64020
65134
  const securityIssues = report.issues.filter((i) => i.category === "security");
64021
65135
  const { risk, findings } = computeAiSecurityRisk(securityIssues);
@@ -64063,13 +65177,13 @@ function registerSecurity(program) {
64063
65177
  // src/cli/commands/test.ts
64064
65178
  init_logger();
64065
65179
  init_scan2();
64066
- import { resolve as resolve31 } from "path";
65180
+ import { resolve as resolve32 } from "path";
64067
65181
 
64068
65182
  // src/cli/test.ts
64069
65183
  init_scan2();
64070
65184
  init_test_quality();
64071
65185
  init_logger();
64072
- import { resolve as resolve30 } from "path";
65186
+ import { resolve as resolve31 } from "path";
64073
65187
  var TEST_INCLUDE_GLOBS = [
64074
65188
  "**/*.test.{ts,tsx,js,jsx}",
64075
65189
  "**/*.spec.{ts,tsx,js,jsx}",
@@ -64137,7 +65251,7 @@ function formatTestReport(result, opts = {}) {
64137
65251
  const sortedFiles = [...byFile.keys()].sort();
64138
65252
  for (const file of sortedFiles) {
64139
65253
  const issues = byFile.get(file) ?? [];
64140
- const rel = file.startsWith(resolve30(process.cwd())) ? file : file;
65254
+ const rel = file.startsWith(resolve31(process.cwd())) ? file : file;
64141
65255
  lines.push("");
64142
65256
  lines.push(` ${rel}`);
64143
65257
  for (const issue of issues.slice(0, 20)) {
@@ -64166,7 +65280,7 @@ function registerTest(program) {
64166
65280
  const options = command.optsWithGlobals();
64167
65281
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64168
65282
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64169
- const cwd = resolve31(options.workspace ?? process.cwd());
65283
+ const cwd = resolve32(options.workspace ?? process.cwd());
64170
65284
  const { config } = await runScan({ ...options, workspace: cwd });
64171
65285
  const { result } = await runTestScan(cwd, config, { strict: options.strict });
64172
65286
  logger.info(formatTestReport(result, { json: format === "json" }));
@@ -64183,7 +65297,7 @@ function registerTest(program) {
64183
65297
  init_logger();
64184
65298
  init_scan2();
64185
65299
  init_architecture_score();
64186
- import { resolve as resolve32 } from "path";
65300
+ import { resolve as resolve33 } from "path";
64187
65301
  function registerArchitecture(program) {
64188
65302
  program.command("architecture").description(
64189
65303
  "compute the Architecture Consistency Score (0-100) \u2014 one number for cross-file pattern duplication"
@@ -64193,7 +65307,7 @@ function registerArchitecture(program) {
64193
65307
  const options = command.optsWithGlobals();
64194
65308
  const rawFormat = options.format ?? cmdOptions.format ?? "pretty";
64195
65309
  const format = rawFormat === "json" || rawFormat === "pretty" ? rawFormat : "pretty";
64196
- const cwd = resolve32(options.workspace ?? process.cwd());
65310
+ const cwd = resolve33(options.workspace ?? process.cwd());
64197
65311
  const { config } = await runScan({ ...options, workspace: cwd });
64198
65312
  const score = await buildArchitectureScore(cwd, config, cmdOptions.maxFiles);
64199
65313
  const out = format === "json" ? JSON.stringify(score, null, 2) : formatArchitectureScore(score);
@@ -64210,12 +65324,12 @@ function registerArchitecture(program) {
64210
65324
  // src/cli/commands/business-logic.ts
64211
65325
  init_logger();
64212
65326
  init_scan2();
64213
- import { resolve as resolve33 } from "path";
65327
+ import { resolve as resolve34 } from "path";
64214
65328
 
64215
65329
  // src/cli/business-logic.ts
64216
65330
  init_discover();
64217
65331
  init_business_logic();
64218
- import { readFileSync as readFileSync32 } from "fs";
65332
+ import { readFileSync as readFileSync34 } from "fs";
64219
65333
  import { relative as relative18 } from "path";
64220
65334
  async function runBusinessLogicScan(cwd, config, options = {}) {
64221
65335
  const maxFiles = options.maxFiles ?? 500;
@@ -64226,7 +65340,7 @@ async function runBusinessLogicScan(cwd, config, options = {}) {
64226
65340
  for (const absPath of limited) {
64227
65341
  let source;
64228
65342
  try {
64229
- source = readFileSync32(absPath, "utf-8");
65343
+ source = readFileSync34(absPath, "utf-8");
64230
65344
  } catch {
64231
65345
  continue;
64232
65346
  }
@@ -64338,7 +65452,7 @@ function registerBusinessLogic(program) {
64338
65452
  const options = command.optsWithGlobals();
64339
65453
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64340
65454
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64341
- const cwd = resolve33(options.workspace ?? process.cwd());
65455
+ const cwd = resolve34(options.workspace ?? process.cwd());
64342
65456
  const { config } = await runScan({ ...options, workspace: cwd });
64343
65457
  const result = await runBusinessLogicScan(cwd, config, {
64344
65458
  maxFiles: cmdOptions.maxFiles
@@ -64356,7 +65470,7 @@ function registerBusinessLogic(program) {
64356
65470
  // src/cli/commands/maintenance-cost.ts
64357
65471
  init_logger();
64358
65472
  init_scan2();
64359
- import { resolve as resolve34 } from "path";
65473
+ import { resolve as resolve35 } from "path";
64360
65474
 
64361
65475
  // src/cli/maintenance-cost.ts
64362
65476
  init_scan2();
@@ -64480,7 +65594,7 @@ function registerMaintenanceCost(program) {
64480
65594
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64481
65595
  const format = rawFormat === "json" || rawFormat === "text" ? rawFormat : "text";
64482
65596
  const strict = options.strict ?? cmdOptions.strict ?? false;
64483
- const cwd = resolve34(options.workspace ?? process.cwd());
65597
+ const cwd = resolve35(options.workspace ?? process.cwd());
64484
65598
  const { config } = await runScan({ ...options, workspace: cwd });
64485
65599
  const result = await runMaintenanceCostScan(cwd, config, {
64486
65600
  maxFiles: cmdOptions.maxFiles,
@@ -64499,7 +65613,7 @@ function registerMaintenanceCost(program) {
64499
65613
  // src/cli/commands/docs.ts
64500
65614
  init_logger();
64501
65615
  init_scan2();
64502
- import { resolve as resolve35 } from "path";
65616
+ import { resolve as resolve36 } from "path";
64503
65617
 
64504
65618
  // src/cli/docs.ts
64505
65619
  init_scan2();
@@ -64624,7 +65738,7 @@ function registerDocs(program) {
64624
65738
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64625
65739
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64626
65740
  const strict = options.strict ?? cmdOptions.strict ?? false;
64627
- const cwd = resolve35(options.workspace ?? process.cwd());
65741
+ const cwd = resolve36(options.workspace ?? process.cwd());
64628
65742
  const { config } = await runScan({ ...options, workspace: cwd });
64629
65743
  const result = await runDocsScan(cwd, config, {
64630
65744
  maxDocFiles: cmdOptions.maxFiles,
@@ -64648,7 +65762,7 @@ function registerDocs(program) {
64648
65762
  // src/cli/commands/db.ts
64649
65763
  init_logger();
64650
65764
  init_scan2();
64651
- import { resolve as resolve36 } from "path";
65765
+ import { resolve as resolve37 } from "path";
64652
65766
 
64653
65767
  // src/cli/db.ts
64654
65768
  init_scan2();
@@ -64767,7 +65881,7 @@ function registerDb(program) {
64767
65881
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
64768
65882
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
64769
65883
  const strict = options.strict ?? cmdOptions.strict ?? false;
64770
- const cwd = resolve36(options.workspace ?? process.cwd());
65884
+ const cwd = resolve37(options.workspace ?? process.cwd());
64771
65885
  const { config } = await runScan({ ...options, workspace: cwd });
64772
65886
  const result = await runDbScan(cwd, config, {
64773
65887
  maxFiles: cmdOptions.maxFiles,
@@ -64791,12 +65905,12 @@ function registerDb(program) {
64791
65905
  // src/cli/commands/patterns.ts
64792
65906
  init_logger();
64793
65907
  init_scan2();
64794
- import { resolve as resolve37 } from "path";
65908
+ import { resolve as resolve38 } from "path";
64795
65909
 
64796
65910
  // src/engine/patterns.ts
64797
65911
  init_patterns();
64798
65912
  init_discover();
64799
- import { readFileSync as readFileSync33 } from "fs";
65913
+ import { readFileSync as readFileSync35 } from "fs";
64800
65914
  import { basename as basename5, relative as relative19 } from "path";
64801
65915
  var PATTERN_CATEGORIES = [
64802
65916
  "modal",
@@ -64899,7 +66013,7 @@ function detectFormsFromFiles(files) {
64899
66013
  for (const f of files) {
64900
66014
  let source;
64901
66015
  try {
64902
- source = readFileSync33(f, "utf-8");
66016
+ source = readFileSync35(f, "utf-8");
64903
66017
  } catch {
64904
66018
  continue;
64905
66019
  }
@@ -65120,7 +66234,7 @@ function registerPatterns(program) {
65120
66234
  const options = command.optsWithGlobals();
65121
66235
  const rawFormat = options.format ?? cmdOptions.format ?? "text";
65122
66236
  const format = rawFormat === "json" || rawFormat === "markdown" || rawFormat === "text" ? rawFormat : "text";
65123
- const cwd = resolve37(options.workspace ?? process.cwd());
66237
+ const cwd = resolve38(options.workspace ?? process.cwd());
65124
66238
  const { config } = await runScan({ ...options, workspace: cwd });
65125
66239
  const result = await runPatternsScan(cwd, config, {
65126
66240
  maxFiles: cmdOptions.maxFiles,
@@ -65139,8 +66253,8 @@ function registerPatterns(program) {
65139
66253
  // src/cli/commands/research.ts
65140
66254
  init_logger();
65141
66255
  init_config2();
65142
- import { existsSync as existsSync26, mkdirSync as mkdirSync14, readFileSync as readFileSync34, writeFileSync as writeFileSync17 } from "fs";
65143
- import { dirname as dirname19, resolve as resolve38 } from "path";
66256
+ import { existsSync as existsSync28, mkdirSync as mkdirSync15, readFileSync as readFileSync36, writeFileSync as writeFileSync19 } from "fs";
66257
+ import { dirname as dirname20, resolve as resolve39 } from "path";
65144
66258
  function registerResearch(program) {
65145
66259
  const research = program.command("research").description("research commands for the AI UI learning loop");
65146
66260
  research.command("generate").description("generate synthetic UI samples").requiredOption("--count <n>", "number of samples", parseCount).requiredOption("--framework <name>", "target framework").requiredOption("--component-type <type>", "component type").requiredOption("--provider <name>", "AI provider (openai)").option("--api-key <key>", "API key for provider").option("--model <name>", "model name").option("--temperature <n>", "sampling temperature", parseFloat, 0.7).option("--output-dir <path>", "output directory", ".slopbrick/corpus/generated").action(async (cmdOptions) => {
@@ -65155,24 +66269,24 @@ function registerResearch(program) {
65155
66269
  framework: cmdOptions.framework,
65156
66270
  componentType: cmdOptions.componentType,
65157
66271
  provider,
65158
- outputDir: resolve38(cmdOptions.outputDir),
66272
+ outputDir: resolve39(cmdOptions.outputDir),
65159
66273
  temperature: cmdOptions.temperature
65160
66274
  });
65161
66275
  logger.info(`Generated ${samples.length} samples in ${cmdOptions.outputDir}`);
65162
66276
  });
65163
66277
  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) => {
65164
66278
  try {
65165
- const metadataPath = resolve38(cmdOptions.inputDir, "metadata.json");
65166
- if (!existsSync26(metadataPath)) {
66279
+ const metadataPath = resolve39(cmdOptions.inputDir, "metadata.json");
66280
+ if (!existsSync28(metadataPath)) {
65167
66281
  logger.error(`No metadata.json found in ${cmdOptions.inputDir}`);
65168
66282
  process.exit(2);
65169
66283
  }
65170
- const samples = JSON.parse(readFileSync34(metadataPath, "utf8"));
66284
+ const samples = JSON.parse(readFileSync36(metadataPath, "utf8"));
65171
66285
  const config = cmdOptions.config ? await loadConfig(cmdOptions.config) : { ...DEFAULT_CONFIG, framework: cmdOptions.framework };
65172
66286
  const analysis = await analyzeSamples(samples, config);
65173
- const outputPath = resolve38(cmdOptions.output);
65174
- mkdirSync14(dirname19(outputPath), { recursive: true });
65175
- writeFileSync17(outputPath, JSON.stringify(analysis, null, 2), "utf8");
66287
+ const outputPath = resolve39(cmdOptions.output);
66288
+ mkdirSync15(dirname20(outputPath), { recursive: true });
66289
+ writeFileSync19(outputPath, JSON.stringify(analysis, null, 2), "utf8");
65176
66290
  logger.info(`Analyzed ${analysis.summary.total} samples; coverage: ${analysis.summary.coverage}%`);
65177
66291
  logger.info(`Wrote analysis to ${outputPath}`);
65178
66292
  } catch (error) {
@@ -65182,12 +66296,12 @@ function registerResearch(program) {
65182
66296
  });
65183
66297
  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) => {
65184
66298
  try {
65185
- const metadataPath = resolve38(cmdOptions.inputDir, "metadata.json");
65186
- if (!existsSync26(metadataPath)) {
66299
+ const metadataPath = resolve39(cmdOptions.inputDir, "metadata.json");
66300
+ if (!existsSync28(metadataPath)) {
65187
66301
  logger.error(`No metadata.json found in ${cmdOptions.inputDir}`);
65188
66302
  process.exit(2);
65189
66303
  }
65190
- const samples = JSON.parse(readFileSync34(metadataPath, "utf8"));
66304
+ const samples = JSON.parse(readFileSync36(metadataPath, "utf8"));
65191
66305
  const config = cmdOptions.config ? await loadConfig(cmdOptions.config) : { ...DEFAULT_CONFIG, framework: cmdOptions.framework };
65192
66306
  const analysis = await analyzeSamples(samples, config);
65193
66307
  const extraction = extractAndCluster(analysis.samples, {
@@ -65197,8 +66311,8 @@ function registerResearch(program) {
65197
66311
  const candidates = clustersToCandidates(extraction.clusters, {
65198
66312
  minFrequency: cmdOptions.minFrequency
65199
66313
  });
65200
- const outputPath = resolve38(cmdOptions.output);
65201
- mkdirSync14(dirname19(outputPath), { recursive: true });
66314
+ const outputPath = resolve39(cmdOptions.output);
66315
+ mkdirSync15(dirname20(outputPath), { recursive: true });
65202
66316
  const payload = {
65203
66317
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
65204
66318
  sampleCount: analysis.summary.total,
@@ -65206,7 +66320,7 @@ function registerResearch(program) {
65206
66320
  fingerprintCount: extraction.total,
65207
66321
  candidates
65208
66322
  };
65209
- writeFileSync17(outputPath, JSON.stringify(payload, null, 2), "utf8");
66323
+ writeFileSync19(outputPath, JSON.stringify(payload, null, 2), "utf8");
65210
66324
  logger.info(`Extracted ${extraction.total} fingerprints across ${analysis.summary.total} samples`);
65211
66325
  logger.info(`Wrote ${candidates.length} candidate rule(s) to ${outputPath}`);
65212
66326
  } catch (error) {
@@ -65219,8 +66333,8 @@ function registerResearch(program) {
65219
66333
  // src/cli/commands/init.ts
65220
66334
  init_logger();
65221
66335
  init_builtins();
65222
- import { existsSync as existsSync27, mkdirSync as mkdirSync15, readFileSync as readFileSync35, writeFileSync as writeFileSync18 } from "fs";
65223
- import { dirname as dirname20, join as join30, resolve as resolve39 } from "path";
66336
+ import { existsSync as existsSync29, mkdirSync as mkdirSync16, readFileSync as readFileSync37, writeFileSync as writeFileSync20 } from "fs";
66337
+ import { dirname as dirname21, join as join32, resolve as resolve40 } from "path";
65224
66338
  init_scan2();
65225
66339
  init_config2();
65226
66340
  init_threshold();
@@ -65228,145 +66342,9 @@ init_git();
65228
66342
  init_cache();
65229
66343
 
65230
66344
  // src/snippet/targets.ts
65231
- import { join as join29 } from "path";
65232
-
65233
- // src/snippet/render.ts
65234
- function aiSpecificRules(rules) {
65235
- return rules.filter((r) => r.aiSpecific).sort((a, b) => a.id.localeCompare(b.id));
65236
- }
65237
- function categorizeRules(rules) {
65238
- const byCategory = /* @__PURE__ */ new Map();
65239
- for (const r of rules) {
65240
- const list = byCategory.get(r.category) ?? [];
65241
- list.push(r);
65242
- byCategory.set(r.category, list);
65243
- }
65244
- for (const list of byCategory.values()) {
65245
- list.sort((a, b) => a.id.localeCompare(b.id));
65246
- }
65247
- return byCategory;
65248
- }
65249
- function baseContent(rules, toolLabel, intro) {
65250
- const lines = [];
65251
- lines.push("# " + toolLabel);
65252
- lines.push("");
65253
- lines.push(intro);
65254
- lines.push("");
65255
- const ai = aiSpecificRules(rules);
65256
- const byCategory = categorizeRules(ai);
65257
- lines.push("## Category-level directives");
65258
- lines.push("");
65259
- for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
65260
- lines.push("- **" + category + "**: " + directive);
65261
- }
65262
- lines.push("");
65263
- lines.push("## Per-rule directives");
65264
- lines.push("");
65265
- for (const [category, list] of byCategory) {
65266
- lines.push("### " + category);
65267
- lines.push("");
65268
- for (const rule of list) {
65269
- const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
65270
- lines.push("- `" + rule.id + "` (" + rule.severity + "): " + hint);
65271
- }
65272
- lines.push("");
65273
- }
65274
- return lines.join("\n");
65275
- }
65276
-
65277
- // src/snippet/generators.ts
65278
- function generateCursorSnippet(rules) {
65279
- const lines = [];
65280
- lines.push("# slopbrick cursor rules");
65281
- lines.push("");
65282
- lines.push("Generated by `slopbrick init --cursor`. These rules describe patterns that slopbrick");
65283
- lines.push("flags as AI-generated frontend slop. Avoid them when writing code in this project.");
65284
- lines.push("");
65285
- lines.push("## Category-level directives");
65286
- lines.push("");
65287
- for (const [category, directive] of Object.entries(CATEGORY_DIRECTIVES)) {
65288
- lines.push("- **" + category + "**: " + directive);
65289
- }
65290
- lines.push("");
65291
- lines.push("## Per-rule directives");
65292
- lines.push("");
65293
- const ai = aiSpecificRules(rules);
65294
- const byCategory = categorizeRules(ai);
65295
- for (const [category, list] of byCategory) {
65296
- lines.push("### " + category);
65297
- lines.push("");
65298
- for (const rule of list) {
65299
- const hint = RULE_HINTS[rule.id] ?? "Avoid patterns that fire rule `" + rule.id + "`.";
65300
- lines.push("- **`" + rule.id + "`** (" + rule.severity + "): " + hint);
65301
- }
65302
- lines.push("");
65303
- }
65304
- lines.push("## Verification");
65305
- lines.push("");
65306
- lines.push("After writing code, run `slopbrick scan` and check for issues in any of the categories above.");
65307
- lines.push("Use `slopbrick fix` for auto-fixable rules. Use `slopbrick mcp` from MCP-aware clients to query rules and scan files programmatically.");
65308
- lines.push("");
65309
- return lines.join("\n");
65310
- }
65311
- function generateAgentsMdSnippet(rules) {
65312
- return baseContent(
65313
- rules,
65314
- "AGENTS.md",
65315
- "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."
65316
- ) + "## How to verify\n\nAfter editing code, run `slopbrick scan`. Any issue with a category in this file is something to fix.\n";
65317
- }
65318
- function generateClaudeMdSnippet(rules) {
65319
- return baseContent(
65320
- rules,
65321
- "CLAUDE.md",
65322
- "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."
65323
- ) + "## 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";
65324
- }
65325
- function generateAiderSnippet(rules) {
65326
- return baseContent(
65327
- rules,
65328
- "CONVENTIONS.md",
65329
- "Generated by `slopbrick init --aider`. Aider reads CONVENTIONS.md (not AGENTS.md) for project conventions."
65330
- );
65331
- }
65332
- function generateWindsurfSnippet(rules) {
65333
- return baseContent(
65334
- rules,
65335
- ".windsurfrules",
65336
- "Generated by `slopbrick init --windsurf`. Windsurf Cascade reads .windsurfrules at project root."
65337
- );
65338
- }
65339
- function generateClineRules(rules) {
65340
- return baseContent(
65341
- rules,
65342
- "AGENTS.md",
65343
- "Generated by `slopbrick init --cline`. Cline reads .clinerules/AGENTS.md from the project root."
65344
- );
65345
- }
65346
- function generateCursorrulesLegacySnippet(rules) {
65347
- return baseContent(
65348
- rules,
65349
- ".cursorrules",
65350
- "Generated by `slopbrick init --cursorrules`. This is the legacy Cursor rule format. Prefer `.cursor/rules/slopbrick.mdc` (use --cursor) for new projects."
65351
- );
65352
- }
65353
- function generateGeminiSnippet(rules) {
65354
- return baseContent(
65355
- rules,
65356
- "GEMINI.md",
65357
- "Generated by `slopbrick init --gemini`. Gemini CLI reads .gemini/GEMINI.md from project root."
65358
- );
65359
- }
65360
- function generateCopilotSnippet(rules) {
65361
- return baseContent(
65362
- rules,
65363
- "copilot-instructions.md",
65364
- "Generated by `slopbrick init --copilot`. GitHub Copilot reads .github/copilot-instructions.md from project root."
65365
- );
65366
- }
65367
-
65368
- // src/snippet/targets.ts
65369
- var SNIPPET_TARGETS = [
66345
+ init_generators();
66346
+ import { join as join31 } from "path";
66347
+ var SNIPPET_TARGETS2 = [
65370
66348
  {
65371
66349
  flag: "cursor",
65372
66350
  cliName: "--cursor",
@@ -65459,7 +66437,7 @@ var SNIPPET_TARGETS = [
65459
66437
  }
65460
66438
  ];
65461
66439
  function resolveTargetPath(target) {
65462
- return target.isFolder ? join29(target.path, target.filename) : target.path;
66440
+ return target.isFolder ? join31(target.path, target.filename) : target.path;
65463
66441
  }
65464
66442
  function renderMatrix() {
65465
66443
  const lines = [];
@@ -65467,7 +66445,7 @@ function renderMatrix() {
65467
66445
  lines.push("");
65468
66446
  lines.push("| Flag | File | Agent |");
65469
66447
  lines.push("|------|------|-------|");
65470
- for (const t of SNIPPET_TARGETS) {
66448
+ for (const t of SNIPPET_TARGETS2) {
65471
66449
  lines.push("| `" + t.cliName + "` | `" + resolveTargetPath(t) + "` | " + t.agent + " |");
65472
66450
  }
65473
66451
  return lines.join("\n");
@@ -65481,13 +66459,13 @@ function registerInit(program) {
65481
66459
  logger.info(renderMatrix());
65482
66460
  process.exit(0);
65483
66461
  }
65484
- const cwd = resolve39(options.workspace ?? process.cwd());
65485
- const configPath = join30(cwd, "slopbrick.config.mjs");
66462
+ const cwd = resolve40(options.workspace ?? process.cwd());
66463
+ const configPath = join32(cwd, "slopbrick.config.mjs");
65486
66464
  const detected = detectStack(cwd);
65487
66465
  const fallbackConfig = { ...DEFAULT_CONFIG, ...detected };
65488
66466
  const proposed = serializeConfig(fallbackConfig);
65489
- if (existsSync27(configPath) && !cmdOptions.yes) {
65490
- const current = readFileSync35(configPath, "utf8");
66467
+ if (existsSync29(configPath) && !cmdOptions.yes) {
66468
+ const current = readFileSync37(configPath, "utf8");
65491
66469
  logger.error(`A config file already exists at ${configPath}.`);
65492
66470
  logger.error("To overwrite it with defaults, run `slopbrick init --yes`.");
65493
66471
  logger.error("");
@@ -65508,7 +66486,7 @@ function registerInit(program) {
65508
66486
  config = buildInitConfig(detected, answers);
65509
66487
  usedWizard = true;
65510
66488
  }
65511
- writeFileSync18(configPath, serializeConfig(config));
66489
+ writeFileSync20(configPath, serializeConfig(config));
65512
66490
  appendGitignore(cwd);
65513
66491
  const refresh = await refreshRegistrySnapshot(cwd);
65514
66492
  if (!refresh.ok) {
@@ -65532,27 +66510,27 @@ function registerInit(program) {
65532
66510
  }
65533
66511
  logger.info(refresh.message);
65534
66512
  }
65535
- const targetsToWrite = SNIPPET_TARGETS.filter((t) => {
66513
+ const targetsToWrite = SNIPPET_TARGETS2.filter((t) => {
65536
66514
  if (cmdOptions.all) return true;
65537
66515
  const opts = cmdOptions;
65538
66516
  return Boolean(opts[t.flag]);
65539
66517
  });
65540
66518
  for (const target of targetsToWrite) {
65541
- const snippetPath = join30(cwd, resolveTargetPath(target));
65542
- mkdirSync15(dirname20(snippetPath), { recursive: true });
66519
+ const snippetPath = join32(cwd, resolveTargetPath(target));
66520
+ mkdirSync16(dirname21(snippetPath), { recursive: true });
65543
66521
  const generated = target.generator(builtinRules);
65544
- if (!target.isFolder && existsSync27(snippetPath)) {
65545
- const existing = readFileSync35(snippetPath, "utf8");
66522
+ if (!target.isFolder && existsSync29(snippetPath)) {
66523
+ const existing = readFileSync37(snippetPath, "utf8");
65546
66524
  if (existing.includes("<!-- slopbrick:begin -->")) {
65547
66525
  const updated = existing.replace(
65548
66526
  /<!-- slopbrick:begin -->[\s\S]*?<!-- slopbrick:end -->/,
65549
66527
  "<!-- slopbrick:begin -->\n" + generated + "<!-- slopbrick:end -->"
65550
66528
  );
65551
- writeFileSync18(snippetPath, updated, "utf8");
66529
+ writeFileSync20(snippetPath, updated, "utf8");
65552
66530
  if (!options.quiet) logger.info(`Updated ${snippetPath}`);
65553
66531
  continue;
65554
66532
  }
65555
- writeFileSync18(
66533
+ writeFileSync20(
65556
66534
  snippetPath,
65557
66535
  existing + (existing.endsWith("\n") ? "\n" : "\n\n") + generated,
65558
66536
  "utf8"
@@ -65560,7 +66538,7 @@ function registerInit(program) {
65560
66538
  if (!options.quiet) logger.info(`Wrote ${snippetPath}`);
65561
66539
  continue;
65562
66540
  }
65563
- writeFileSync18(snippetPath, generated, "utf8");
66541
+ writeFileSync20(snippetPath, generated, "utf8");
65564
66542
  if (!options.quiet) logger.info(`Wrote ${snippetPath}`);
65565
66543
  }
65566
66544
  if (options.baseline) {
@@ -65580,12 +66558,12 @@ function registerInit(program) {
65580
66558
  // src/cli/commands/flywheel.ts
65581
66559
  init_logger();
65582
66560
  init_telemetry();
65583
- import { mkdirSync as mkdirSync16, writeFileSync as writeFileSync19 } from "fs";
65584
- import { dirname as dirname21, resolve as resolve40 } from "path";
66561
+ import { mkdirSync as mkdirSync17, writeFileSync as writeFileSync21 } from "fs";
66562
+ import { dirname as dirname22, resolve as resolve41 } from "path";
65585
66563
 
65586
66564
  // src/report/flywheel.ts
65587
- import { existsSync as existsSync28, readFileSync as readFileSync36 } from "fs";
65588
- import { join as join31 } from "path";
66565
+ import { existsSync as existsSync30, readFileSync as readFileSync38 } from "fs";
66566
+ import { join as join33 } from "path";
65589
66567
  function average(values) {
65590
66568
  if (values.length === 0) return 0;
65591
66569
  return values.reduce((a, b) => a + b, 0) / values.length;
@@ -65675,7 +66653,7 @@ function formatFlywheel(summary, options = {}) {
65675
66653
  function registerFlywheel(program) {
65676
66654
  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) => {
65677
66655
  const options = command.optsWithGlobals();
65678
- const cwd = resolve40(options.workspace ?? process.cwd());
66656
+ const cwd = resolve41(options.workspace ?? process.cwd());
65679
66657
  const payloads = readTelemetry(cwd);
65680
66658
  if (payloads.length === 0) {
65681
66659
  logger.info("No flywheel telemetry found. Run a scan first.");
@@ -65683,9 +66661,9 @@ function registerFlywheel(program) {
65683
66661
  }
65684
66662
  const summary = summarizeTelemetry(payloads);
65685
66663
  if (cmdOptions.export) {
65686
- const exportPath = resolve40(cmdOptions.export);
65687
- mkdirSync16(dirname21(exportPath), { recursive: true });
65688
- writeFileSync19(exportPath, JSON.stringify(summary, null, 2), "utf-8");
66664
+ const exportPath = resolve41(cmdOptions.export);
66665
+ mkdirSync17(dirname22(exportPath), { recursive: true });
66666
+ writeFileSync21(exportPath, JSON.stringify(summary, null, 2), "utf-8");
65689
66667
  logger.info(`Wrote flywheel summary to ${exportPath}`);
65690
66668
  process.exit(0);
65691
66669
  }
@@ -65712,8 +66690,8 @@ init_dist2();
65712
66690
  init_memory_io();
65713
66691
 
65714
66692
  // src/fix/focus-ring.ts
65715
- import { existsSync as existsSync29 } from "fs";
65716
- import { readFileSync as readFileSync37, writeFileSync as writeFileSync20 } from "fs";
66693
+ import { existsSync as existsSync31 } from "fs";
66694
+ import { readFileSync as readFileSync39, writeFileSync as writeFileSync22 } from "fs";
65717
66695
  var ANCHOR_START = "/* @slopbrick:v1.0.0:fix:focus-ring */";
65718
66696
  var CSS_BLOCK = `/* @slopbrick:v1.0.0:fix:focus-ring */
65719
66697
  :focus-visible {
@@ -65722,15 +66700,15 @@ var CSS_BLOCK = `/* @slopbrick:v1.0.0:fix:focus-ring */
65722
66700
  }
65723
66701
  /* @slopbrick:v1.0.0:fix:focus-ring-end */`;
65724
66702
  function applyFocusRingFix(targetFile) {
65725
- if (!existsSync29(targetFile)) {
66703
+ if (!existsSync31(targetFile)) {
65726
66704
  return { applied: false, reason: "missing-global-css-target" };
65727
66705
  }
65728
- const content = readFileSync37(targetFile, "utf-8");
66706
+ const content = readFileSync39(targetFile, "utf-8");
65729
66707
  if (content.includes(ANCHOR_START)) {
65730
66708
  return { applied: false, reason: "already-present" };
65731
66709
  }
65732
66710
  const separator = content.endsWith("\n") ? "" : "\n";
65733
- writeFileSync20(targetFile, `${content}${separator}${CSS_BLOCK}
66711
+ writeFileSync22(targetFile, `${content}${separator}${CSS_BLOCK}
65734
66712
  `);
65735
66713
  return { applied: true };
65736
66714
  }
@@ -65739,21 +66717,21 @@ function applyFocusRingFix(targetFile) {
65739
66717
  init_layout_token();
65740
66718
 
65741
66719
  // src/fix/use-client.ts
65742
- import { readFileSync as readFileSync38, writeFileSync as writeFileSync21 } from "fs";
66720
+ import { readFileSync as readFileSync40, writeFileSync as writeFileSync23 } from "fs";
65743
66721
  function applyUseClientFix(filePath) {
65744
- const content = readFileSync38(filePath, "utf-8");
66722
+ const content = readFileSync40(filePath, "utf-8");
65745
66723
  const trimmed = content.trimStart();
65746
66724
  if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) {
65747
66725
  return { applied: false, reason: "already-present" };
65748
66726
  }
65749
- writeFileSync21(filePath, `"use client";
66727
+ writeFileSync23(filePath, `"use client";
65750
66728
 
65751
66729
  ${content}`);
65752
66730
  return { applied: true };
65753
66731
  }
65754
66732
 
65755
66733
  // src/fix/visual-codemod.ts
65756
- import { readFileSync as readFileSync39, writeFileSync as writeFileSync22 } from "fs";
66734
+ import { readFileSync as readFileSync41, writeFileSync as writeFileSync24 } from "fs";
65757
66735
 
65758
66736
  // src/fix/visual-codemods/tailwind.ts
65759
66737
  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;
@@ -65962,7 +66940,7 @@ var ALL_CODEMODS = [
65962
66940
  { name: "aria-attr-typo", fn: applyAriaAttrTypoCodemod }
65963
66941
  ];
65964
66942
  function applyVisualCodemods(filePath) {
65965
- const original = readFileSync39(filePath, "utf-8");
66943
+ const original = readFileSync41(filePath, "utf-8");
65966
66944
  let content = original;
65967
66945
  const reasons = [];
65968
66946
  const seen = /* @__PURE__ */ new Set();
@@ -65978,7 +66956,7 @@ function applyVisualCodemods(filePath) {
65978
66956
  }
65979
66957
  }
65980
66958
  if (content !== original) {
65981
- writeFileSync22(filePath, content);
66959
+ writeFileSync24(filePath, content);
65982
66960
  }
65983
66961
  return {
65984
66962
  filePath,
@@ -66260,12 +67238,12 @@ import * as http from "http";
66260
67238
  import * as https from "https";
66261
67239
  import { URL as URL2 } from "url";
66262
67240
  var sendBeacon = (url, payload, timeoutMs) => {
66263
- return new Promise((resolve42) => {
67241
+ return new Promise((resolve43) => {
66264
67242
  let parsed;
66265
67243
  try {
66266
67244
  parsed = new URL2(url);
66267
67245
  } catch {
66268
- resolve42();
67246
+ resolve43();
66269
67247
  return;
66270
67248
  }
66271
67249
  const isHttps = parsed.protocol === "https:";
@@ -66286,7 +67264,7 @@ var sendBeacon = (url, payload, timeoutMs) => {
66286
67264
  const settle = () => {
66287
67265
  if (settled) return;
66288
67266
  settled = true;
66289
- resolve42();
67267
+ resolve43();
66290
67268
  };
66291
67269
  let req;
66292
67270
  try {
@@ -66366,14 +67344,16 @@ process.on("uncaughtException", (err) => {
66366
67344
  });
66367
67345
  async function runCli({ start }) {
66368
67346
  try {
66369
- const program = new Command().name("slopbrick").description("Repository Coherence Scanner \u2014 surface AI-induced pattern drift, secret leaks, and design-token violations").version(VERSION).option("--framework <name>", "framework multiplier to apply").option("--include <glob>", "include pattern (repeatable)", collectGlob, []).option("--exclude <glob>", "exclude pattern (repeatable)", collectGlob, []).option("--ai-only", "only report AI-specific issues").option("--human-only", "only report human-facing issues").option("--ignore-wcag22", "ignore WCAG 2.2 related issues").option("--format <pretty|json|sarif|html>", "output format", "pretty").option("--threads <n>", "number of worker threads", parseThreads).option("--since <ref>", "only scan files changed since git ref").option("--diff <ref>", "alias for --since <ref>; also adds PR Slop Score to the report").option("--workspace <path>", "workspace/project path", process.cwd()).option("--tighten", "tighten baseline allowances").option("--fix", "apply auto-fixes").option("--dry-run", "with --fix: print what would change without writing").option("--show-fixes-diff", "print unified diff of proposed auto-fixes").option("--doctor", "run diagnostics").option("--watch", "watch files and re-run").option("--suggest", "print remediation advice").option("--why-failing", "print the top 5 rules dragging the score down").option("--brief", "terse output (verdict + headline + threshold + delta only)").option("--heatmap", "print migration ROI heatmap").option("--quiet", "suppress non-error output").option("--verbose", "enable debug logging (file paths, timings, rule-fire counts)").option("--strict", "exit 2 if any high-severity issue remains").option("--no-increase", "exit 2 if slop index increased since last run").option("--baseline", "save a baseline after this scan").option("--trend [n]", "print a sparkline of the last n runs", parseTrend).option("--json [path]", "write JSON report to path or stdout").option("--html [path]", "write HTML report to path or stdout").option("--staged", "scan only changed files (staged and unstaged)").option("--changed", "scan working-tree changes (staged + unstaged + untracked)").option("--incremental", "skip unchanged files using the persisted hash cache").option("--cache-path <path>", "path to the incremental-scan cache (default: .slopbrick-cache.json)").option("--tokens <path>", "merge tokens.json layout values into the arbitrary-value allowlist").option("--cache", "cache parsed AST results locally").option("--no-color", "suppress ANSI color codes in output").option("--security-only", "run only the security/* rules").option("--full", "show the complete report (all issues, all categories)").option("--report-usage", "opt in to a one-shot usage ping to SLOPBRICK_TELEMETRY_ENDPOINT (no PII)");
67347
+ const program = new Command().name("slopbrick").description("Repository Coherence Scanner \u2014 surface AI-induced pattern drift, secret leaks, and design-token violations").version(VERSION).option("--framework <name>", "framework multiplier to apply").option("--include <glob>", "include pattern (repeatable)", collectGlob, []).option("--exclude <glob>", "exclude pattern (repeatable)", collectGlob, []).option("--ai-only", "only report AI-specific issues").option("--human-only", "only report human-facing issues").option("--ignore-wcag22", "ignore WCAG 2.2 related issues").option("--format <pretty|json|sarif|html>", "output format", "pretty").option("--threads <n>", "number of worker threads", parseThreads).option("--since <ref>", "only scan files changed since git ref").option("--diff <ref>", "alias for --since <ref>; also adds PR Slop Score to the report").option("--workspace <path>", "workspace/project path", process.cwd()).option("--tighten", "tighten baseline allowances").option("--fix", "apply auto-fixes").option("--dry-run", "with --fix: print what would change without writing").option("--show-fixes-diff", "print unified diff of proposed auto-fixes").option("--doctor", "run diagnostics").option("--watch", "watch files and re-run").option("--suggest", "print remediation advice").option("--why-failing", "print the top 5 rules dragging the score down").option("--brief", "terse output (verdict + headline + threshold + delta only)").option("--heatmap", "print migration ROI heatmap").option("--quiet", "suppress non-error output").option("--verbose", "enable debug logging (file paths, timings, rule-fire counts)").option("--strict", "exit 2 if any high-severity issue remains").option("--no-increase", "exit 2 if slop index increased since last run").option("--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)");
66370
67348
  program.helpInformation = () => formatGroupedHelp(program);
67349
+ setExitOverride(program);
66371
67350
  registerInit(program);
66372
67351
  registerInstall(program);
66373
67352
  registerUninstall(program);
66374
67353
  registerBadge(program);
66375
67354
  registerSuggest(program);
66376
67355
  registerExplain(program);
67356
+ registerComposite(program);
66377
67357
  registerCalibration(program);
66378
67358
  registerFlywheel(program);
66379
67359
  const scanAction = async (paths, _options, command) => {
@@ -66392,7 +67372,7 @@ async function runCli({ start }) {
66392
67372
  logger.error("--heatmap and --suggest can't be used together. Pick one: a heatmap of severity, or text advice.");
66393
67373
  process.exit(2);
66394
67374
  }
66395
- const cwd = resolve41(options.workspace ?? process.cwd());
67375
+ const cwd = resolve42(options.workspace ?? process.cwd());
66396
67376
  if (options.trend !== void 0) {
66397
67377
  const runs = await readRuns(cwd, fsMemoryIO);
66398
67378
  if (runs.length === 0) {
@@ -66440,7 +67420,7 @@ async function runCli({ start }) {
66440
67420
  });
66441
67421
  }
66442
67422
  if (options.baseline) {
66443
- const cwd2 = resolve41(options.workspace ?? process.cwd());
67423
+ const cwd2 = resolve42(options.workspace ?? process.cwd());
66444
67424
  const configHash = hashConfig(config);
66445
67425
  const gitHead = await getGitHead(cwd2) ?? "unknown";
66446
67426
  const cache = buildBaselineCache(report, configHash, gitHead, cwd2);
@@ -66552,7 +67532,9 @@ async function runCli({ start }) {
66552
67532
  program.outputHelp();
66553
67533
  process.exit(0);
66554
67534
  }
66555
- await program.parseAsync(process.argv);
67535
+ await dispatch(program, async () => {
67536
+ await program.parseAsync(process.argv);
67537
+ });
66556
67538
  } catch (err) {
66557
67539
  if (err instanceof ConfigValidationError) {
66558
67540
  logger.error(err.message);