@davesheffer/hunch 0.15.4 → 0.16.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/cli/index.js CHANGED
@@ -557,7 +557,7 @@ program
557
557
  if (sources.length > 1)
558
558
  return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
559
559
  const markdown = opts.format === "markdown";
560
- const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
560
+ const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
561
561
  const { store, root } = storeFor();
562
562
  // Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
563
563
  // branch) — otherwise the diff is empty and the guard passes vacuously.
@@ -633,7 +633,7 @@ const vetoCmd = program
633
633
  }
634
634
  // Render ONLY the veto class — zero the other sections so the shared renderer
635
635
  // shows just the rejected-alternative reversals (the rest is `hunch check`).
636
- const vetoOnly = { ...full, direct: [], near: [], regressions: [], strictBlockers: 0, regBlocking: 0 };
636
+ const vetoOnly = { ...full, direct: [], near: [], regressions: [], redundant: [], strictBlockers: 0, regBlocking: 0 };
637
637
  console.log(markdown ? renderMarkdown(vetoOnly) : renderText(vetoOnly));
638
638
  if (reportFailsStrict(vetoOnly))
639
639
  process.exitCode = 1;
@@ -4,7 +4,7 @@
4
4
  * then renders it as text (terminal, unchanged) or markdown (a PR comment posted
5
5
  * by the GitHub Action). The exit-code decision lives with the caller. */
6
6
  export function reportIsClean(r) {
7
- return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0 && r.vetoes.length === 0;
7
+ return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0 && r.vetoes.length === 0 && r.redundant.length === 0;
8
8
  }
9
9
  /** True when --strict should FAIL the commit/PR. */
10
10
  export function reportFailsStrict(r) {
@@ -76,6 +76,12 @@ export function renderText(r) {
76
76
  out.push(` ${v.blocking ? "⛔" : "⚠"} ${v.decision} rejected this approach${v.blocking ? " (human-confirmed)" : " (advisory)"}\n you rejected: ${clip(v.alternative)}\n you chose: ${clip(v.chosen)}\n evidence: ${v.evidence.slice(0, 4).join(", ")}`);
77
77
  }
78
78
  }
79
+ if (r.redundant.length) {
80
+ out.push(`${r.direct.length || r.near.length || r.regressions.length || r.vetoes.length ? "\n" : ""}Possibly re-implements ${r.redundant.length} symbol(s) that already exist (advisory — review, never blocks):\n`);
81
+ for (const x of r.redundant) {
82
+ out.push(` ⟲ adds ${x.kind} \`${x.name}\` — already defined in ${x.existingFile}`);
83
+ }
84
+ }
79
85
  if (reportFailsStrict(r)) {
80
86
  const reasons = [
81
87
  r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
@@ -142,6 +148,13 @@ export function renderMarkdown(r) {
142
148
  }
143
149
  out.push("");
144
150
  }
151
+ if (r.redundant.length) {
152
+ out.push(`### ⟲ Possibly re-implements existing code (advisory)`);
153
+ for (const x of r.redundant) {
154
+ out.push(`- \`${x.name}\` (${x.kind}) — already defined in \`${x.existingFile}\``);
155
+ }
156
+ out.push("");
157
+ }
145
158
  out.push("---");
146
159
  if (reportFailsStrict(r)) {
147
160
  const reasons = [
@@ -443,6 +443,10 @@ export class HunchStore {
443
443
  const staleIds = new Set(staleRecords.filter((s) => s.kind === "constraint").map((s) => s.id));
444
444
  const staleDecisionIds = new Set(staleRecords.filter((s) => s.kind === "decision").map((s) => s.id));
445
445
  const vetoes = this.vetoHits(an, files, staleDecisionIds);
446
+ const redundant = this.redundantSymbols(an.addedSymbols, files, {
447
+ movedFrom: [...an.filesRenamed.map((r) => r.from), ...an.filesDeleted],
448
+ removedNames: new Set(an.removedSymbols.map((s) => s.name)),
449
+ });
446
450
  const directReport = [...direct.values()].map(({ c, files: fs }) => {
447
451
  const stale = staleIds.has(c.id);
448
452
  const strictBlocks = isStrictBlocker(c, stale);
@@ -460,11 +464,64 @@ export class HunchStore {
460
464
  near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
461
465
  regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
462
466
  vetoes: vetoes.map((v) => ({ decision: v.decision, title: v.title, alternative: v.alternative, chosen: v.chosen, tier: v.tier, evidence: v.evidence, blocking: v.blocks })),
467
+ redundant,
463
468
  strictBlockers: directReport.filter((d) => d.strictBlocks).length,
464
469
  regBlocking: regHits.filter((h) => h.blocking).length,
465
470
  vetoBlocking: vetoes.filter((v) => v.blocks).length,
466
471
  };
467
472
  }
473
+ /** Sprawl/"this already exists" guard (ADVISORY, never blocks). A symbol the diff
474
+ * ADDS whose name already exists in the indexed graph in a file NOT touched by the
475
+ * diff is a likely re-implementation the agent's local context window couldn't see.
476
+ * Read-only. Heuristic, so noise is controlled: top-level function/class/const only,
477
+ * name length ≥ 4, a stopword list of generic names, deduped, and capped. */
478
+ redundantSymbols(added, files, opts = {}) {
479
+ if (!added.length)
480
+ return [];
481
+ const KINDS = new Set(["function", "class", "const"]);
482
+ const STOP = new Set([
483
+ "index", "handler", "handlers", "default", "main", "run", "start", "stop", "setup", "init",
484
+ "constructor", "render", "create", "update", "build", "parse", "load", "save", "next", "data",
485
+ "value", "item", "items", "props", "state", "config", "options", "result", "route", "routes",
486
+ "app", "server", "client", "test", "tests", "mock", "stub", "helper", "helpers", "util", "utils",
487
+ "types", "schema", "constants", "common", "shared", "base", "model", "models", "view", "store",
488
+ ]);
489
+ // Match only against top-level VALUE declarations already in the graph. A method
490
+ // (`obj.close()`) or the file node itself sharing a name is not a re-implementation.
491
+ // ("variable" is kept for forward-compat; the current indexer emits arrow-fn consts
492
+ // as "function", so const re-implementations are still matched.)
493
+ const EXISTING_KINDS = new Set(["function", "class", "variable"]);
494
+ // A symbol carried into a moved/deleted file is being relocated, not duplicated. The
495
+ // changed-file list uses --diff-filter=ACMR, so a sub-threshold move (Add new + Delete
496
+ // old) drops the old path — add the move-from / deleted paths back so their lingering
497
+ // graph entries are not mistaken for a separate "existing" implementation.
498
+ const changed = new Set(files);
499
+ for (const p of opts.movedFrom ?? [])
500
+ changed.add(p);
501
+ const removedNames = opts.removedNames ?? new Set();
502
+ // Only compare within the diff's own top-level root(s). A name that also exists in a
503
+ // test fixture or a separate sub-project (test/, vscode-extension/, site/) is not
504
+ // sprawl in the source under change — different roots, different ownership.
505
+ const roots = new Set(files.map((f) => f.split("/")[0]));
506
+ const symbols = this.json.loadAll("symbols");
507
+ const out = [];
508
+ const seen = new Set();
509
+ for (const sc of added) {
510
+ const name = sc.name;
511
+ if (seen.has(name) || name.length < 4 || !KINDS.has(sc.kind) || STOP.has(name.toLowerCase()))
512
+ continue;
513
+ if (removedNames.has(name))
514
+ continue; // the same name was removed in this diff → moved, not duplicated
515
+ const hit = symbols.find((s) => s.name === name && !changed.has(s.file) && EXISTING_KINDS.has(s.kind) && roots.has(s.file.split("/")[0]));
516
+ if (hit) {
517
+ seen.add(name);
518
+ out.push({ name, kind: sc.kind, existingFile: hit.file });
519
+ if (out.length >= 10)
520
+ break;
521
+ }
522
+ }
523
+ return out;
524
+ }
468
525
  /** Time-travel: the decision history for a target — every decision touching it,
469
526
  * newest-first, with its valid-time window and supersession links. Answers
470
527
  * "what did we believe, and when/why did it change?" (hunch_timeline). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",