@davesheffer/hunch 1.46.0 → 1.47.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.
Files changed (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
@@ -3,12 +3,11 @@ import { join } from "node:path";
3
3
  import { existsSync, realpathSync, statSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  export const HUNCH_DIR = ".hunch";
6
- /** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
7
- * path with "/" — git emits it on all OSes — so any user- or agent-supplied
8
- * target must be normalized before comparison, otherwise a Windows caller passing
9
- * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
10
- * symbol names too: they contain no backslashes. This does NOT make a path
11
- * repo-relative — an absolute path passes through with its separators flipped,
6
+ /** Convert a user- or agent-supplied path/target to forward-slash form. Hunch
7
+ * stores every path with "/" (git emits it on every OS), so a Windows-style
8
+ * backslash path would otherwise never match the stored form. Harmless on
9
+ * symbol names, which carry no backslashes. It does NOT make a path
10
+ * repo-relative: an absolute path only has its separators flipped and is left
12
11
  * unchanged otherwise; use `repoRelativeTarget` for that.
13
12
  *
14
13
  * Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
@@ -21,47 +20,42 @@ export const HUNCH_DIR = ".hunch";
21
20
  export function toPosixTarget(target) {
22
21
  return target.replace(/\\/g, "/").replace(/^\.\//, "");
23
22
  }
24
- /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
25
- * to Write, or a glob whose concrete segments aren't literal): resolve the longest
26
- * existing ancestor, then re-append the missing tail. Idempotent on an
27
- * already-resolved path. */
23
+ /** Canonical real path of `p`, tolerant of paths that do not exist yet (a file
24
+ * about to be written, a glob with non-literal segments): the longest existing
25
+ * ancestor is resolved through the native realpath and the missing trailing
26
+ * segments are re-appended unchanged. Terminates at the filesystem root,
27
+ * is idempotent on a resolved path and never throws for a missing one. */
28
28
  export function realpathNorm(p) {
29
- try {
30
- return realpathSync.native(p);
31
- }
32
- catch {
33
- const parent = dirname(p);
34
- if (parent === p)
35
- return p; // hit the root; nothing more to resolve
36
- return join(realpathNorm(parent), basename(p));
29
+ const tail = [];
30
+ let cur = p;
31
+ for (;;) {
32
+ try {
33
+ const real = realpathSync.native(cur);
34
+ return tail.length ? join(real, ...tail.reverse()) : real;
35
+ }
36
+ catch {
37
+ const parent = dirname(cur);
38
+ if (parent === cur)
39
+ return p;
40
+ tail.push(basename(cur));
41
+ cur = parent;
42
+ }
37
43
  }
38
44
  }
39
- /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
40
- * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
41
- * repo-relative, so a caller that hands over an absolute edit-payload path
42
- * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
43
- * returned empty for an absolute target that plainly had a matching rule). A
44
- * relative target, a glob, or an absolute path outside `root` passes through
45
- * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
46
- *
47
- * BOTH ends are realpath-normalized before the relative-path computation: on
48
- * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
49
- * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
50
- * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
51
- * caller treats the file as outside the repo, silently dropping all context
52
- * (dec_e0a36efbf5). */
45
+ /** Rewrite an ABSOLUTE `target` to repo-relative forward-slash form when it lies
46
+ * inside `root`. Stored Hunch paths are always repo-relative, so an absolute
47
+ * path lifted from an edit payload otherwise matches nothing (issue #296).
48
+ * Both ends go through `realpathNorm`, so a symlinked root (macOS /var vs
49
+ * /private/var, /tmp, a symlinked home) cancels out (dec_e0a36efbf5). Anything
50
+ * with no safe repo-relative form (the root itself, an escape, a drive-letter
51
+ * remainder) is returned in its normalized form unchanged. */
53
52
  export function repoRelativeTarget(target, root) {
54
- const t = toPosixTarget(target);
55
- const looksAbsolute = isAbsolute(t) || /^[a-zA-Z]:/.test(t);
56
- if (!looksAbsolute)
57
- return t;
58
- // `root` is posix'd too, not just `target`: realpathNorm's fallback walk decomposes
59
- // by "/" (via node:path, which on a POSIX host does NOT treat "\" as a separator),
60
- // so an un-posix'd root and a posix'd target would decompose into differently-shaped
61
- // ancestor chains and never share a common realpath'd prefix.
62
- const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
63
- if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
64
- return t;
53
+ const norm = toPosixTarget(target);
54
+ if (!isAbsolute(norm) && !/^[A-Za-z]:/.test(norm))
55
+ return norm;
56
+ const rel = relative(realpathNorm(toPosixTarget(root)), realpathNorm(norm)).replace(/\\/g, "/");
57
+ if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
58
+ return norm;
65
59
  return rel;
66
60
  }
67
61
  /** True when `target` (already repo-relative POSIX form) names a regular FILE
@@ -12,10 +12,11 @@ export interface DecisionReferenceRepair {
12
12
  relatedFiles: number;
13
13
  evidence: number;
14
14
  }
15
- export declare function replaceExact(values: string[], from: string, to: string): {
15
+ export type ExactReplacement = {
16
16
  values: string[];
17
17
  changed: number;
18
18
  };
19
+ export declare function replaceExact(refs: string[], target: string, replacement: string): ExactReplacement;
19
20
  /**
20
21
  * Return a corrected copy of a decision, or `null` when the source reference is
21
22
  * not present. The decision's semantic content and verification timestamp are
@@ -1,10 +1,10 @@
1
- export function replaceExact(values, from, to) {
1
+ export function replaceExact(refs, target, replacement) {
2
2
  let changed = 0;
3
- const replaced = values.map((value) => {
4
- if (value !== from)
3
+ const replaced = refs.map((value) => {
4
+ if (value !== target)
5
5
  return value;
6
6
  changed++;
7
- return to;
7
+ return replacement;
8
8
  });
9
9
  // A decision may already cite the destination. Keep the reference list a set
10
10
  // after the repair so a correction cannot create duplicate scope/evidence.
@@ -1,40 +1,23 @@
1
- import { type CommitRewrite, type DroppedRewrite } from "./commitrepair.js";
2
- /** Tolerant read: a missing or corrupt queue file is treated as empty — this
3
- * is local scratch state, never a source of truth worth failing loudly over. */
1
+ import type { CommitRewrite, DroppedRewrite } from "./commitrepair.js";
2
+ /** The raw queue. Missing, corrupt or non-array files read as `[]`; never throws. */
4
3
  export declare function readPendingRepairs(root: string): CommitRewrite[];
4
+ /** Atomically replace the queue. Writing `[]` clears it. */
5
5
  export declare function writePendingRepairs(root: string, rewrites: readonly CommitRewrite[]): void;
6
- /** Tolerant read, same discipline as readPendingRepairs. */
6
+ /** The tombstones of rewrites rejected with `--drop`. Same tolerance as the queue. */
7
7
  export declare function readDroppedRepairs(root: string): DroppedRewrite[];
8
+ /** Atomically replace the tombstone file. Writing `[]` clears it. */
8
9
  export declare function writeDroppedRepairs(root: string, dropped: readonly DroppedRewrite[]): void;
9
- /** The queue as every READ-ONLY consumer should see it: raw entries minus
10
- * anything a human already rejected via `--drop`, regardless of how a
11
- * tombstoned entry ended up back in the raw queue file (e.g. the post-merge
12
- * hook's backgrounded detection racing a `--drop`). A read-time filter, not
13
- * a mutation — the raw queue file is untouched.
14
- *
15
- * `repair-provenance`'s own action is the one exception: it needs the RAW
16
- * read (via readPendingRepairs) because it diffs raw-vs-swept to report what
17
- * it cleaned up, and it persists the sweep back to disk. Every other
18
- * reader — `hunch escalations`, SessionStart orientation, the `hunch_now`
19
- * and `hunch_escalations` MCP tools — must call this instead, or a
20
- * tombstoned entry that hasn't yet been swept by a `repair-provenance` run
21
- * re-surfaces as if the human never answered it. */
10
+ /**
11
+ * The raw queue minus anything tombstoned. Read-only: it never changes either
12
+ * file. Every read-only consumer uses this -- `hunch escalations`, SessionStart
13
+ * orientation, and the `hunch_now` and `hunch_escalations` MCP tools. Only
14
+ * `repair-provenance` itself reads the raw queue.
15
+ */
22
16
  export declare function readActivePendingRepairs(root: string): CommitRewrite[];
23
- /** The `queued` entries whose proposed `to` doesn't resolve to a real commit
24
- * in this repository — the same withheldForUnresolvableTo gate
25
- * `repair-provenance --apply` itself runs before writing, surfaced here so
26
- * a READ-ONLY consumer (hunch escalations, hunch_now/hunch_escalations,
27
- * SessionStart orientation) can tell a permanently-stuck entry apart from
28
- * one `--apply` can still resolve, instead of advertising a
29
- * `--apply --only <id>` that's guaranteed to no-op forever. `null` from
30
- * commitsExist (the check itself failed to run) is treated as fail-open —
31
- * same discipline as the apply path — so nothing is misreported as
32
- * withheld just because the check couldn't run.
33
- *
34
- * Returns the OBJECTS, not their ids, and every caller must pass the exact
35
- * same `queued` array into both this function and whatever it hands the
36
- * result to (commitRepairEscalations) — an id-keyed set would collapse a
37
- * corrupted queue's two same-id entries (one resolvable, one not) into one
38
- * unit, exactly the identity confusion withheldForUnresolvableTo's own
39
- * docstring warns about. */
17
+ /**
18
+ * The queued entries whose proposed `to` does not resolve in this repository.
19
+ * Fails open (nothing withheld) when git cannot answer. The set holds the
20
+ * caller's own objects, so callers must pass the same `queued` array here and to
21
+ * whatever consumes the result.
22
+ */
40
23
  export declare function withheldRewrites(root: string, queued: readonly CommitRewrite[]): Set<CommitRewrite>;
@@ -1,131 +1,81 @@
1
1
  /**
2
- * Local-only queue of commit-provenance repairs detected opportunistically
3
- * (typically by the post-merge hook, src/integrations/hooks.ts) but not yet
4
- * applied. `.hunch/pending-commit-repairs.json` is gitignored the same way
5
- * `.hunch/local.json` is — never committed, never pushed. It exists because
6
- * the opportunistic detection window is narrow: `ORIG_HEAD` gets overwritten
7
- * by the next merge, and the matched-away commit can eventually be gc'd —
8
- * queuing the exact {id, from, to} triple here lets a human confirm the match
9
- * later (`hunch repair-provenance --apply`) without losing it.
2
+ * Local-only queue of detected-but-not-yet-applied commit-provenance repairs.
10
3
  *
11
- * `.hunch/dropped-commit-repairs.json` is the sibling tombstone file: a human
12
- * rejecting a queued match via `--drop` records the exact {id, from, to}
13
- * triple here, durably, so detection re-deriving the identical match on a
14
- * later merge doesn't re-queue what was already rejected — a genuinely
15
- * different replacement (`to`) for the same still-orphaned commit is a new
16
- * proposal and still surfaces (see commitrepair.ts's withoutDropped). Same
17
- * local-only, gitignored discipline as the queue.
4
+ * It is filled opportunistically, usually by the post-merge hook, and is
5
+ * gitignored like `.hunch/local.json` -- never committed. It exists because the
6
+ * detection window is narrow: `ORIG_HEAD` gets overwritten and the matched-away
7
+ * commit can be garbage-collected. A human confirms later with
8
+ * `hunch repair-provenance --apply`. A sibling tombstone file records the exact
9
+ * `{id, from, to}` triples rejected with `--drop`, so re-detection does not
10
+ * re-queue them.
18
11
  */
19
12
  import { existsSync, readFileSync } from "node:fs";
20
13
  import { join } from "node:path";
21
14
  import { writeFileAtomic } from "./io.js";
22
15
  import { withoutDropped, withheldForUnresolvableTo } from "./commitrepair.js";
23
16
  import { commitsExist } from "../extractors/git.js";
24
- function queuePath(root) {
25
- return join(root, ".hunch", "pending-commit-repairs.json");
26
- }
27
- function droppedPath(root) {
28
- return join(root, ".hunch", "dropped-commit-repairs.json");
29
- }
30
- function isCommitRewrite(value) {
31
- if (!value || typeof value !== "object")
17
+ const queuePath = (root) => join(root, ".hunch", "pending-commit-repairs.json");
18
+ const droppedPath = (root) => join(root, ".hunch", "dropped-commit-repairs.json");
19
+ const nonEmpty = (v) => typeof v === "string" && v.length > 0;
20
+ /**
21
+ * Shape-only validation: non-empty string id/from/to, and `to !== from` (a no-op
22
+ * is rejected). There is deliberately no hex check -- a bad `to` is caught later
23
+ * by the apply-time existence check, so this loader stays shape-only.
24
+ */
25
+ function isRewrite(v) {
26
+ if (typeof v !== "object" || v === null)
32
27
  return false;
33
- const { id, from, to } = value;
34
- // Non-empty, and to !== from — an empty target is never a useful rewrite,
35
- // and a no-op entry would apply "successfully" while accomplishing nothing.
36
- // Deliberately NOT a hex-shape check: from/to are opaque strings elsewhere
37
- // in this codebase's test fixtures, and repairDecisionCommit's own
38
- // `d.commit === mine.from` guard already makes a garbage `from` inert (it
39
- // can never match a real decision). A garbage `to` is caught elsewhere,
40
- // not here: `hunch repair-provenance --apply` runs commitsExist against
41
- // every `to` it's actually about to act on (the whole queue, or just the
42
- // one entry `--only <id>` targets) before repairDecisionCommit ever sees
43
- // it, and leaves a non-resolving entry queued rather than applying it
44
- // (withheldForUnresolvableTo, src/core/commitrepair.ts) — with the caveat
45
- // that commitsExist itself fails OPEN if the check can't run at all (not a
46
- // git repo, git missing, timeout), same as drift.ts's own use of it. This
47
- // filter stays shape-only on purpose — the existence check needs a
48
- // repository to run against, which this read-only queue loader doesn't
49
- // have.
50
- return typeof id === "string" && !!id
51
- && typeof from === "string" && !!from
52
- && typeof to === "string" && !!to
53
- && to !== from;
28
+ const r = v;
29
+ return nonEmpty(r.id) && nonEmpty(r.from) && nonEmpty(r.to) && r.to !== r.from;
54
30
  }
55
- function isDroppedRewrite(value) {
56
- if (!value || typeof value !== "object")
31
+ function isDropped(v) {
32
+ if (typeof v !== "object" || v === null)
57
33
  return false;
58
- const { id, from, to } = value;
59
- return typeof id === "string" && !!id
60
- && typeof from === "string" && !!from
61
- && typeof to === "string" && !!to;
34
+ const r = v;
35
+ return nonEmpty(r.id) && nonEmpty(r.from) && nonEmpty(r.to);
62
36
  }
63
- /** Tolerant read: a missing or corrupt queue file is treated as empty — this
64
- * is local scratch state, never a source of truth worth failing loudly over. */
65
- export function readPendingRepairs(root) {
66
- const path = queuePath(root);
67
- if (!existsSync(path))
37
+ function readList(file, guard) {
38
+ if (!existsSync(file))
68
39
  return [];
69
40
  try {
70
- const parsed = JSON.parse(readFileSync(path, "utf8"));
71
- return Array.isArray(parsed) ? parsed.filter(isCommitRewrite) : [];
41
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
42
+ return Array.isArray(parsed) ? parsed.filter(guard) : [];
72
43
  }
73
44
  catch {
74
45
  return [];
75
46
  }
76
47
  }
48
+ /** The raw queue. Missing, corrupt or non-array files read as `[]`; never throws. */
49
+ export function readPendingRepairs(root) {
50
+ return readList(queuePath(root), isRewrite);
51
+ }
52
+ /** Atomically replace the queue. Writing `[]` clears it. */
77
53
  export function writePendingRepairs(root, rewrites) {
78
54
  writeFileAtomic(queuePath(root), JSON.stringify(rewrites, null, 2) + "\n");
79
55
  }
80
- /** Tolerant read, same discipline as readPendingRepairs. */
56
+ /** The tombstones of rewrites rejected with `--drop`. Same tolerance as the queue. */
81
57
  export function readDroppedRepairs(root) {
82
- const path = droppedPath(root);
83
- if (!existsSync(path))
84
- return [];
85
- try {
86
- const parsed = JSON.parse(readFileSync(path, "utf8"));
87
- return Array.isArray(parsed) ? parsed.filter(isDroppedRewrite) : [];
88
- }
89
- catch {
90
- return [];
91
- }
58
+ return readList(droppedPath(root), isDropped);
92
59
  }
60
+ /** Atomically replace the tombstone file. Writing `[]` clears it. */
93
61
  export function writeDroppedRepairs(root, dropped) {
94
62
  writeFileAtomic(droppedPath(root), JSON.stringify(dropped, null, 2) + "\n");
95
63
  }
96
- /** The queue as every READ-ONLY consumer should see it: raw entries minus
97
- * anything a human already rejected via `--drop`, regardless of how a
98
- * tombstoned entry ended up back in the raw queue file (e.g. the post-merge
99
- * hook's backgrounded detection racing a `--drop`). A read-time filter, not
100
- * a mutation — the raw queue file is untouched.
101
- *
102
- * `repair-provenance`'s own action is the one exception: it needs the RAW
103
- * read (via readPendingRepairs) because it diffs raw-vs-swept to report what
104
- * it cleaned up, and it persists the sweep back to disk. Every other
105
- * reader — `hunch escalations`, SessionStart orientation, the `hunch_now`
106
- * and `hunch_escalations` MCP tools — must call this instead, or a
107
- * tombstoned entry that hasn't yet been swept by a `repair-provenance` run
108
- * re-surfaces as if the human never answered it. */
64
+ /**
65
+ * The raw queue minus anything tombstoned. Read-only: it never changes either
66
+ * file. Every read-only consumer uses this -- `hunch escalations`, SessionStart
67
+ * orientation, and the `hunch_now` and `hunch_escalations` MCP tools. Only
68
+ * `repair-provenance` itself reads the raw queue.
69
+ */
109
70
  export function readActivePendingRepairs(root) {
110
71
  return withoutDropped(readPendingRepairs(root), readDroppedRepairs(root));
111
72
  }
112
- /** The `queued` entries whose proposed `to` doesn't resolve to a real commit
113
- * in this repository — the same withheldForUnresolvableTo gate
114
- * `repair-provenance --apply` itself runs before writing, surfaced here so
115
- * a READ-ONLY consumer (hunch escalations, hunch_now/hunch_escalations,
116
- * SessionStart orientation) can tell a permanently-stuck entry apart from
117
- * one `--apply` can still resolve, instead of advertising a
118
- * `--apply --only <id>` that's guaranteed to no-op forever. `null` from
119
- * commitsExist (the check itself failed to run) is treated as fail-open —
120
- * same discipline as the apply path — so nothing is misreported as
121
- * withheld just because the check couldn't run.
122
- *
123
- * Returns the OBJECTS, not their ids, and every caller must pass the exact
124
- * same `queued` array into both this function and whatever it hands the
125
- * result to (commitRepairEscalations) — an id-keyed set would collapse a
126
- * corrupted queue's two same-id entries (one resolvable, one not) into one
127
- * unit, exactly the identity confusion withheldForUnresolvableTo's own
128
- * docstring warns about. */
73
+ /**
74
+ * The queued entries whose proposed `to` does not resolve in this repository.
75
+ * Fails open (nothing withheld) when git cannot answer. The set holds the
76
+ * caller's own objects, so callers must pass the same `queued` array here and to
77
+ * whatever consumes the result.
78
+ */
129
79
  export function withheldRewrites(root, queued) {
130
80
  const existing = commitsExist(queued.map((r) => r.to), root);
131
81
  return new Set(withheldForUnresolvableTo(queued, existing).withheld);
@@ -111,12 +111,12 @@ export type Resource = z.infer<typeof ResourceSchema>;
111
111
  export declare const EdgeType: z.ZodEnum<{
112
112
  depends_on: "depends_on";
113
113
  calls: "calls";
114
+ references: "references";
114
115
  imports: "imports";
115
116
  contains: "contains";
116
117
  implements: "implements";
117
118
  supersedes: "supersedes";
118
119
  related_to: "related_to";
119
- references: "references";
120
120
  provides: "provides";
121
121
  belongs_to: "belongs_to";
122
122
  implemented_by: "implemented_by";
@@ -173,12 +173,12 @@ export declare const EdgeSchema: z.ZodObject<{
173
173
  type: z.ZodEnum<{
174
174
  depends_on: "depends_on";
175
175
  calls: "calls";
176
+ references: "references";
176
177
  imports: "imports";
177
178
  contains: "contains";
178
179
  implements: "implements";
179
180
  supersedes: "supersedes";
180
181
  related_to: "related_to";
181
- references: "references";
182
182
  provides: "provides";
183
183
  belongs_to: "belongs_to";
184
184
  implemented_by: "implemented_by";
@@ -783,12 +783,12 @@ export declare const SCHEMAS: {
783
783
  type: z.ZodEnum<{
784
784
  depends_on: "depends_on";
785
785
  calls: "calls";
786
+ references: "references";
786
787
  imports: "imports";
787
788
  contains: "contains";
788
789
  implements: "implements";
789
790
  supersedes: "supersedes";
790
791
  related_to: "related_to";
791
- references: "references";
792
792
  provides: "provides";
793
793
  belongs_to: "belongs_to";
794
794
  implemented_by: "implemented_by";
@@ -147,12 +147,12 @@ export const ResourceSchema = z.object({
147
147
  export const EdgeType = z.enum([
148
148
  "depends_on",
149
149
  "calls",
150
+ "references",
150
151
  "imports",
151
152
  "contains",
152
153
  "implements",
153
154
  "supersedes",
154
155
  "related_to",
155
- "references",
156
156
  "provides",
157
157
  "belongs_to",
158
158
  "implemented_by",
@@ -29,10 +29,10 @@ export interface DiffAnalysis {
29
29
  removedDeps: string[];
30
30
  addedLines: number;
31
31
  removedLines: number;
32
- /** Added line bodies (the "+" content, marker stripped) per file — every file,
33
- * not just substantive ones. The text veto's symbol/pattern tiers match against
34
- * — call sites, not just declarations, which addedSymbols can't see. Keyed by
35
- * the same (new-path) key as perFile. */
32
+ /** Added line bodies (content after the `+`) per file, for EVERY file, not only
33
+ * substantive ones. The text veto's symbol/pattern tiers match call sites, which
34
+ * `addedSymbols` cannot see. Keyed by the same new-side path as the per-file
35
+ * declarations map. */
36
36
  addedLinesByFile: Map<string, string[]>;
37
37
  }
38
38
  /** Undo git's C-style path quoting. Even with core.quotePath=false, git quotes a
@@ -20,25 +20,21 @@ const DECL_PATTERNS = [
20
20
  { kind: "type", re: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*[=<]/ },
21
21
  { kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/ },
22
22
  { kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?function/ },
23
+ // No Python class pattern: the generic class pattern above already matches a
24
+ // Python class header, and the first match wins.
23
25
  { kind: "function", re: /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/ },
24
- // No Python-specific class pattern needed: the generic TS `class` pattern above has no
25
- // trailing-syntax requirement (no `{`/`:`), so it already matches Python's
26
- // `class Foo(Bar):` header too, and — since declOf() returns on the first match —
27
- // always wins for Python class lines before any Python-specific pattern would run.
28
26
  ];
29
27
  import { languageFor, isSubstantive } from "./languages.js";
30
28
  const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
31
29
  const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
32
30
  const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
33
31
  const PHP_REQUIRE_RE = /\b(?:require|require_once|include|include_once)\s*(?:\(\s*)?['"]([^'"]+)['"]/;
34
- // "import os" / "import a.b.c" / "import os as o" / "import os, sys" / trailing "# comment".
35
- // Anchored to the END of the line (optional "as alias", comma-separated modules, comment)
36
- // so it matches a COMPLETE Python import statement only — this deliberately rejects
37
- // TypeScript's `import Foo = Bar.Baz;` (import-equals), which would otherwise falsely
38
- // look like a Python "import Foo" prefix match.
39
- const PY_IMPORT_RE = /^\s*import\s+([A-Za-z_][\w.]*)(?:\s+as\s+\w+)?(?:\s*,\s*[A-Za-z_][\w.]*(?:\s+as\s+\w+)?)*\s*(?:#.*)?$/;
40
- const PY_FROM_IMPORT_RE = /^\s*from\s+([.\w]+)\s+import\s+/; // "from os import path" / "from . import x"
41
- const isCode = (p) => !!p && languageFor(p) !== null;
32
+ // A COMPLETE Python import statement: comma-separated dotted modules, each with an
33
+ // optional alias, an optional trailing comment, and nothing else. The anchoring is
34
+ // what rejects TypeScript's `import Foo = Bar.Baz;`.
35
+ const PY_IMPORT_RE = /^\s*import\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)(?:\s+as\s+\w+)?(?:\s*,\s*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s+as\s+\w+)?)*\s*(?:#.*)?$/;
36
+ const PY_FROM_RE = /^\s*from\s+(\.*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*|\.+)\s+import\b/;
37
+ const isCode = (p) => p.length > 0 && languageFor(p) !== null;
42
38
  function declOf(line) {
43
39
  for (const { kind, re } of DECL_PATTERNS) {
44
40
  const m = re.exec(line);
@@ -52,7 +48,7 @@ function importOf(line) {
52
48
  CONT_IMPORT_RE.exec(line) ??
53
49
  REQUIRE_RE.exec(line) ??
54
50
  PHP_REQUIRE_RE.exec(line) ??
55
- PY_FROM_IMPORT_RE.exec(line) ??
51
+ PY_FROM_RE.exec(line) ??
56
52
  PY_IMPORT_RE.exec(line);
57
53
  return m ? m[1] : null;
58
54
  }
@@ -280,15 +276,15 @@ export function analyzeDiff(diff) {
280
276
  // ---- inside a hunk: content lines ----
281
277
  if (raw.startsWith("+")) {
282
278
  const body = raw.slice(1);
283
- // Raw added lines are captured for EVERY file, before the substantive gate:
279
+ // Raw added lines are captured for every file, before the substantive gate:
284
280
  // content-matched constraints and Veto tripwires are not code-only rules
285
281
  // (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
286
282
  // Dockerfile). Skipping them here left `scopedAdded` empty, which
287
283
  // buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
288
284
  // pre-edit hook denied the edit while `hunch check --strict` passed the
289
- // very commit that landed it. The churn counters below now also include
290
- // prose (isSubstantive, issue #12); symbol/import extraction stays
291
- // code-only (isCode/languageFor) since declarations are a code concept.
285
+ // same diff. Churn counters below now include prose (isSubstantive, issue
286
+ // #12), while symbol/import extraction stays code-only (isCode): a
287
+ // declaration is a code concept.
292
288
  let lines = addedLinesBy.get(curFile);
293
289
  if (!lines) {
294
290
  lines = [];
@@ -301,12 +297,12 @@ export function analyzeDiff(diff) {
301
297
  if (!curAdded && !curDeleted)
302
298
  filesModified.add(curFile);
303
299
  if (isCode(curFile)) {
304
- const d = declOf(body);
305
- if (d)
306
- declsFor(curFile)?.added.set(d.name, d);
307
- const imp = importOf(body);
308
- if (imp && !imp.startsWith("."))
309
- addedImports.add(imp);
300
+ const decl = declOf(body);
301
+ if (decl)
302
+ declsFor(curFile).added.set(decl.name, decl);
303
+ const spec = importOf(body);
304
+ if (spec && !spec.startsWith("."))
305
+ addedImports.add(spec);
310
306
  }
311
307
  }
312
308
  else if (raw.startsWith("-")) {
@@ -317,12 +313,12 @@ export function analyzeDiff(diff) {
317
313
  filesModified.add(curFile);
318
314
  if (isCode(curFile)) {
319
315
  const body = raw.slice(1);
320
- const d = declOf(body);
321
- if (d)
322
- declsFor(curFile)?.removed.set(d.name, d);
323
- const imp = importOf(body);
324
- if (imp && !imp.startsWith("."))
325
- removedImports.add(imp);
316
+ const decl = declOf(body);
317
+ if (decl)
318
+ declsFor(curFile).removed.set(decl.name, decl);
319
+ const spec = importOf(body);
320
+ if (spec && !spec.startsWith("."))
321
+ removedImports.add(spec);
326
322
  }
327
323
  }
328
324
  }
@@ -307,32 +307,17 @@ export interface CommitCandidate {
307
307
  sha: string;
308
308
  files: string[];
309
309
  }
310
- /** Every commit newly reachable in `oldRef..newRef` (capped to the most
311
- * recent `maxCount`), with the files each one changed (rename-aware, via
312
- * commitChanges). Used to find the commit a squash-merge produced from a
313
- * set of now-orphaned source-branch commits. */
310
+ /** Commits in `oldRef..newRef` (most recent `maxCount`, oldest first), each with the
311
+ * non-deleted paths it touched. Squash-merge provenance repair reads this. A git
312
+ * failure yields []. */
314
313
  export declare function mergeRangeChanges(oldRef: string, newRef: string, cwd: string, maxCount?: number): CommitCandidate[];
315
- /** Batched existence check for many commit shas at once — one `git cat-file
316
- * --batch-check` process instead of one `rev-parse --verify` per sha. Used
317
- * by drift.ts's default commitResolvable so hunch drift/heal/doctor spawn a
318
- * single git process regardless of how many decisions carry a commit, not
319
- * one per decision. Returns the subset of `shas` that resolve to a real
320
- * commit object, or `null` if the check itself failed to run (not a git
321
- * repo, git missing, timeout) — distinct from an empty set, which means
322
- * "checked, and none of them resolve." A caller that collapsed those two
323
- * would flag every commit as unresolvable on a transient environment error
324
- * instead of failing open. */
314
+ /** Which of `shas` resolve to a commit, via one `git cat-file --batch-check`.
315
+ * Malformed values are dropped before the stdin lines are built (positional mapping).
316
+ * Returns null when git itself could not run: callers fail open. */
325
317
  export declare function commitsExist(shas: readonly string[], cwd: string): Set<string> | null;
326
318
  export type CommitRepairStatus = "orphaned" | "current" | "unresolvable" | "unknown";
327
- /** Classifies `commit` relative to `ref` for squash-merge repair eligibility.
328
- * "orphaned" (exists in this repo, but is not an ancestor of `ref`) is the
329
- * ONLY status planCommitRepair should ever act on. "unresolvable" (the
330
- * commit doesn't exist here at all) is explicitly out of repair scope —
331
- * drift.ts's commit-unresolvable kind is what flags that case, because
332
- * there's no reliable signal left to match a replacement against. "unknown"
333
- * (the git command itself failed to run — timeout, git missing) must never
334
- * be treated as repair-eligible; collapsing "false" and "error" into one
335
- * boolean is exactly the bug this type exists to prevent. */
319
+ /** Whether `commit` is reachable from `ref`. Never collapses "no" and "error": only
320
+ * "orphaned" is repair-eligible. A missing or non-directory cwd is "unresolvable". */
336
321
  export declare function commitRepairStatus(commit: string, ref: string, cwd: string): CommitRepairStatus;
337
322
  /** commitRepairStatus against several tips: "orphaned" only when NO ref
338
323
  * reaches the commit. A historical `--range old..new` must not make a commit