@davesheffer/hunch 1.43.0 → 1.44.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 (47) hide show
  1. package/README.md +10 -0
  2. package/dist/cli/index.js +114 -19
  3. package/dist/core/changeProof.js +2 -1
  4. package/dist/core/commitrepair.d.ts +9 -3
  5. package/dist/core/commitrepair.js +23 -6
  6. package/dist/core/correction.d.ts +1 -1
  7. package/dist/core/correction.js +4 -3
  8. package/dist/core/docanchors.d.ts +11 -2
  9. package/dist/core/docanchors.js +232 -65
  10. package/dist/core/drift.d.ts +1 -1
  11. package/dist/core/drift.js +10 -3
  12. package/dist/core/eol.d.ts +2 -0
  13. package/dist/core/eol.js +6 -0
  14. package/dist/core/procstart.d.ts +15 -4
  15. package/dist/core/procstart.js +56 -18
  16. package/dist/core/runbookDraft.d.ts +7 -0
  17. package/dist/core/runbookDraft.js +43 -0
  18. package/dist/core/taskTouched.d.ts +2 -0
  19. package/dist/core/taskTouched.js +1 -1
  20. package/dist/extractors/git.d.ts +36 -6
  21. package/dist/extractors/git.js +86 -9
  22. package/dist/extractors/indexer.js +53 -14
  23. package/dist/extractors/k8sManifest.d.ts +31 -1
  24. package/dist/extractors/k8sManifest.js +133 -8
  25. package/dist/integrations/claudemd.d.ts +2 -0
  26. package/dist/integrations/claudemd.js +4 -2
  27. package/dist/integrations/gitignore.d.ts +7 -0
  28. package/dist/integrations/gitignore.js +18 -0
  29. package/dist/integrations/hooks.d.ts +15 -0
  30. package/dist/integrations/hooks.js +49 -1
  31. package/dist/integrations/mergeDriver.d.ts +2 -0
  32. package/dist/integrations/mergeDriver.js +2 -2
  33. package/dist/integrations/providers.d.ts +7 -0
  34. package/dist/integrations/providers.js +4 -4
  35. package/dist/integrations/scaffold.d.ts +5 -0
  36. package/dist/integrations/scaffold.js +12 -7
  37. package/dist/integrations/uninstall.d.ts +40 -0
  38. package/dist/integrations/uninstall.js +435 -0
  39. package/dist/mcp/server.d.ts +2 -1
  40. package/dist/mcp/server.js +58 -4
  41. package/dist/serve/writelock.d.ts +7 -0
  42. package/dist/serve/writelock.js +30 -3
  43. package/dist/store/changeLedger.d.ts +5 -2
  44. package/dist/store/changeLedger.js +88 -37
  45. package/dist/store/hunchStore.js +4 -1
  46. package/package.json +3 -3
  47. package/server.json +2 -2
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The deterministic runbook draft (`hunch runbook <range> --task`): the commits and files
3
+ * of a range that describe the work, without Hunch's own bookkeeping (issue #425). With
4
+ * auto-commit on, a range interleaves capture commits (`hunch: capture …`) whose files are
5
+ * the store (.hunch/**) and the grounding docs flushCapture regenerates beside it.
6
+ *
7
+ * Same notion of "Hunch-owned" as sync (src/synthesis/synthesize.ts) and the task report
8
+ * (src/core/taskTouched.ts): the store is never the work; a grounding doc is Hunch's when
9
+ * it changed alongside the store, and the work's when a human edited it on its own.
10
+ */
11
+ import { HUNCH_COMMIT_SUBJECT, HUNCH_MANAGED_FILES } from "./taskTouched.js";
12
+ /** Grounding docs that are wholly generated (no user text): never the work. */
13
+ const FULLY_MANAGED = new Set([".cursor/rules/hunch.mdc", ".windsurf/rules/hunch.md"]);
14
+ const STORE_SEGMENTS = new Set([".hunch", ".hunch-cache"]);
15
+ const norm = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
16
+ /** The store lives at the repository root; a user folder named .hunch further down is theirs. */
17
+ const isStore = (p) => STORE_SEGMENTS.has(norm(p).split("/")[0]);
18
+ export function runbookDraft(commits, rangeFiles) {
19
+ const steps = [];
20
+ const workDocs = new Set(); // grounding docs a non-capture commit edited on its own
21
+ for (const c of commits) {
22
+ if (HUNCH_COMMIT_SUBJECT.test(c.subject))
23
+ continue;
24
+ const files = c.files.map(norm);
25
+ const touchesStore = files.some(isStore);
26
+ const work = files.filter((f) => !isStore(f) && !FULLY_MANAGED.has(f) && !(touchesStore && HUNCH_MANAGED_FILES.has(f)));
27
+ if (files.length && !work.length)
28
+ continue; // only Hunch's own paths
29
+ if (c.subject.trim())
30
+ steps.push(c.subject); // an empty-message commit is no step
31
+ for (const f of work)
32
+ if (HUNCH_MANAGED_FILES.has(f))
33
+ workDocs.add(f);
34
+ }
35
+ const files = rangeFiles.filter((raw) => {
36
+ const f = norm(raw);
37
+ if (isStore(f) || FULLY_MANAGED.has(f))
38
+ return false;
39
+ return !HUNCH_MANAGED_FILES.has(f) || workDocs.has(f);
40
+ });
41
+ return { steps, files };
42
+ }
43
+ //# sourceMappingURL=runbookDraft.js.map
@@ -1,6 +1,8 @@
1
1
  /** Files Hunch regenerates on every capture (src/integrations/providers.ts,
2
2
  * claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
3
3
  export declare const HUNCH_MANAGED_FILES: ReadonlySet<string>;
4
+ /** Commit subjects Hunch writes itself. */
5
+ export declare const HUNCH_COMMIT_SUBJECT: RegExp;
4
6
  export declare function gitTouchedFiles(root: string, startedAt: string, finishedAt: string | null, options?: {
5
7
  limit?: number;
6
8
  timeoutMs?: number;
@@ -27,7 +27,7 @@ const EXCLUDED_SEGMENTS = new Set([".hunch", ".hunch-cache", ".git"]);
27
27
  * claudemd.ts): a fresh mtime on them is the capture, not the task's work. */
28
28
  export const HUNCH_MANAGED_FILES = new Set(["CLAUDE.md", "AGENTS.md", ".cursor/rules/hunch.mdc", ".github/copilot-instructions.md", ".windsurf/rules/hunch.md"]);
29
29
  /** Commit subjects Hunch writes itself. */
30
- const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
30
+ export const HUNCH_COMMIT_SUBJECT = /^hunch:/i;
31
31
  function gitDate(ms) {
32
32
  // Second resolution, a format every git accepts.
33
33
  return `${new Date(ms).toISOString().slice(0, 19).replace("T", " ")} +0000`;
@@ -125,7 +125,11 @@ export declare function headFileContent(root: string, rel: string): string | nul
125
125
  * machines' memory. Never throws; leaves a clean tree. Serialized with the commit lock so it
126
126
  * can't race a concurrent flush. Returns true only when the checked-out memory HEAD moved,
127
127
  * allowing callers to avoid rebuilding the derived SQLite index after a no-op pull. */
128
- export type HunchPullStatus = "updated" | "current" | "busy" | "failed" | "unconfigured";
128
+ export type HunchPullStatus = "updated" | "current" | "busy" | "failed" | "unconfigured" | "empty";
129
+ /** A strict guard may pass only on a pull that proved this clone's memory matches the
130
+ * advertised remote: it advanced, was already current, or both are provably empty (a
131
+ * brand-new shared memory repo before its first capture, #427). */
132
+ export declare function teamPullIsFresh(status: HunchPullStatus): boolean;
129
133
  export interface PullHunchOptions {
130
134
  timeoutMs?: number;
131
135
  env?: NodeJS.ProcessEnv;
@@ -298,6 +302,17 @@ export type CommitRepairStatus = "orphaned" | "current" | "unresolvable" | "unkn
298
302
  * be treated as repair-eligible; collapsing "false" and "error" into one
299
303
  * boolean is exactly the bug this type exists to prevent. */
300
304
  export declare function commitRepairStatus(commit: string, ref: string, cwd: string): CommitRepairStatus;
305
+ /** commitRepairStatus against several tips: "orphaned" only when NO ref
306
+ * reaches the commit. A historical `--range old..new` must not make a commit
307
+ * that HEAD still reaches look orphaned just because it is newer than `new`
308
+ * (#450). The first non-orphaned answer wins, so "unknown" is never upgraded. */
309
+ export declare function commitRepairStatusAgainst(commit: string, refs: readonly string[], cwd: string): CommitRepairStatus;
310
+ /** `git patch-id --stable` of each commit, keyed by the sha exactly as given
311
+ * (full or abbreviated). Same diff flags as the workspace ledger's squash
312
+ * check. A merge or empty commit, an unknown sha, or any git failure leaves
313
+ * no entry: a missing patch-id is never a match. One `git log` + one
314
+ * `git patch-id` process for all of them. */
315
+ export declare function commitPatchIds(shas: readonly string[], cwd: string): Map<string, string>;
301
316
  /** Byte budget for a diff embedded in a SYNTHESIS prompt. Gates never use it: a
302
317
  * constraint check over a prefix of a change would read every file past the
303
318
  * cutoff as "no added lines" and pass it. */
@@ -328,8 +343,19 @@ export declare function lastCommitForFile(file: string, cwd: string): string;
328
343
  /** Full SHA of the commit that introduced a path. Unlike lastCommitForFile this
329
344
  * remains stable when lifecycle/proof updates later touch the same policy file. */
330
345
  export declare function firstCommitForFile(file: string, cwd: string): string;
331
- /** ISO author-date of the most recent commit touching a file ("" if none). */
332
- export declare function lastChangeDate(file: string, cwd: string): string;
346
+ /** ISO author-date of the most recent commit touching a file ("" if none), as of `rev`
347
+ * (default HEAD). */
348
+ export declare function lastChangeDate(file: string, cwd: string, rev?: string): string;
349
+ /** The revision a checked change starts from, so freshness is judged WITHOUT the change
350
+ * itself: a committed change under review must not make its own guarding record stale
351
+ * (#426). `--base` diffs are 3-dot, so the merge-base; `--commit` is its parent;
352
+ * staged/working changes are not committed yet, so HEAD (undefined). `--commit` judges
353
+ * one commit in isolation, so an earlier commit of the same branch can still age the
354
+ * record; `--base` (the CI guard) judges the whole branch and is the authoritative gate. */
355
+ export declare function checkFreshnessRevision(source: {
356
+ base?: string;
357
+ commit?: string;
358
+ }, cwd: string): string | undefined;
333
359
  /**
334
360
  * Newest author date for every changed path selected by a bounded set of repository-relative
335
361
  * Hunch scopes. One Git history walk replaces the per-file process loop used by `staleness()`.
@@ -370,9 +396,13 @@ export declare function revExists(ref: string, cwd: string): boolean;
370
396
  /** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
371
397
  * i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
372
398
  export declare function rangeFiles(base: string, cwd: string, head?: string): string[];
373
- /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
374
- * oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
375
- export declare function rangeSubjects(base: string, cwd: string, head?: string, max?: number): string[];
399
+ /** Commits on `head` since `base` (2-dot: commits added by the task), oldest-first, each
400
+ * with its subject and the files it changed, for distilling a runbook's ordered steps
401
+ * (roadmap #5); `runbookDraft` uses the files to tell Hunch's own captures from the work. */
402
+ export declare function rangeCommits(base: string, cwd: string, head?: string, max?: number): {
403
+ subject: string;
404
+ files: string[];
405
+ }[];
376
406
  /** Complete gating diff of a PR vs `base` (3-dot: the CI Constraint Guard's surface). */
377
407
  export declare function rangeGateDiff(base: string, cwd: string, head?: string): GateDiff;
378
408
  /** The PR's unified diff vs `base` (3-dot), noise-excluded and capped at the
@@ -1607,6 +1607,12 @@ function pushWithOneRemoteAdvanceRetry(hunchDir, env, protectedRepoRoot, timeout
1607
1607
  return false;
1608
1608
  return tryPush(hunchDir, env, timeoutMs, contract, observe);
1609
1609
  }
1610
+ /** A strict guard may pass only on a pull that proved this clone's memory matches the
1611
+ * advertised remote: it advanced, was already current, or both are provably empty (a
1612
+ * brand-new shared memory repo before its first capture, #427). */
1613
+ export function teamPullIsFresh(status) {
1614
+ return status === "updated" || status === "current" || status === "empty";
1615
+ }
1610
1616
  export function pullHunchStatus(hunchDir, opts = {}) {
1611
1617
  const env = foreignRepoEnv({
1612
1618
  ...process.env,
@@ -1622,7 +1628,13 @@ export function pullHunchStatus(hunchDir, opts = {}) {
1622
1628
  try {
1623
1629
  const merged = mergeRemote(hunchDir, env, opts.timeoutMs ?? READ_REMOTE_TIMEOUT_MS, opts.remote, opts.allowUnrelatedHistories ?? false);
1624
1630
  if (merged === "failed" || merged === "unconfigured" || !contractReady(opts.remote)) {
1625
- return merged === "unconfigured" ? merged : "failed";
1631
+ if (merged !== "unconfigured" || !contractReady(opts.remote))
1632
+ return "failed";
1633
+ // The advertised remote has no branch yet AND this clone never committed memory:
1634
+ // both sides are empty, so there is nothing to be stale against (#427). A clone
1635
+ // WITH memory against an empty remote stays "unconfigured" (unpublished, or the
1636
+ // remote lost its history) so strict guards keep failing closed on it.
1637
+ return opts.remote && !before && !headShaWithEnv(hunchDir, env) ? "empty" : merged;
1626
1638
  }
1627
1639
  return headShaWithEnv(hunchDir, env) !== before ? "updated" : "current";
1628
1640
  }
@@ -2612,6 +2624,51 @@ export function commitRepairStatus(commit, ref, cwd) {
2612
2624
  return e.status === 1 ? "orphaned" : "unknown";
2613
2625
  }
2614
2626
  }
2627
+ /** commitRepairStatus against several tips: "orphaned" only when NO ref
2628
+ * reaches the commit. A historical `--range old..new` must not make a commit
2629
+ * that HEAD still reaches look orphaned just because it is newer than `new`
2630
+ * (#450). The first non-orphaned answer wins, so "unknown" is never upgraded. */
2631
+ export function commitRepairStatusAgainst(commit, refs, cwd) {
2632
+ for (const ref of new Set(refs)) {
2633
+ const status = commitRepairStatus(commit, ref, cwd);
2634
+ if (status !== "orphaned")
2635
+ return status;
2636
+ }
2637
+ return "orphaned";
2638
+ }
2639
+ /** `git patch-id --stable` of each commit, keyed by the sha exactly as given
2640
+ * (full or abbreviated). Same diff flags as the workspace ledger's squash
2641
+ * check. A merge or empty commit, an unknown sha, or any git failure leaves
2642
+ * no entry: a missing patch-id is never a match. One `git log` + one
2643
+ * `git patch-id` process for all of them. */
2644
+ export function commitPatchIds(shas, cwd) {
2645
+ const out = new Map();
2646
+ // Only shas that resolve: one unknown sha (a withheld queue entry) would make `git log`
2647
+ // fail outright and drop every patch-id. Fed on stdin, so no argv length limit applies.
2648
+ const exists = commitsExist([...new Set(shas)], cwd);
2649
+ const valid = exists ? [...exists] : [];
2650
+ if (!valid.length)
2651
+ return out;
2652
+ let ids;
2653
+ try {
2654
+ const log = execFileSync("git", ["-C", cwd, "log", "--stdin", "--no-walk=unsorted", "--format=%H", "-p", "--no-merges", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv", "--"], { input: valid.join("\n") + "\n", maxBuffer: 256 * 1024 * 1024, timeout: 30_000, stdio: ["pipe", "pipe", "ignore"] });
2655
+ if (!log.byteLength)
2656
+ return out;
2657
+ ids = execFileSync("git", ["-C", cwd, "patch-id", "--stable"], { input: log, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 30_000, stdio: ["pipe", "pipe", "ignore"] });
2658
+ }
2659
+ catch {
2660
+ return out;
2661
+ }
2662
+ for (const line of ids.split("\n")) {
2663
+ const [patchId = "", commit = ""] = line.trim().split(/\s+/);
2664
+ if (!/^[0-9a-f]{40,64}$/.test(patchId))
2665
+ continue;
2666
+ for (const sha of valid)
2667
+ if (commit.startsWith(sha.toLowerCase()))
2668
+ out.set(sha, patchId);
2669
+ }
2670
+ return out;
2671
+ }
2615
2672
  /** Machine-generated paths that carry no design "why" — lockfiles, build output,
2616
2673
  * vendored deps, snapshots, source maps. Excluded from synthesis diffs via git
2617
2674
  * pathspec BEFORE git assembles/orders the patch: a huge lockfile sorts ahead of
@@ -2713,9 +2770,23 @@ export function firstCommitForFile(file, cwd) {
2713
2770
  return added;
2714
2771
  return gitSafe(["log", "--reverse", "--format=%H", "--", file], cwd).split("\n").find(Boolean) ?? "";
2715
2772
  }
2716
- /** ISO author-date of the most recent commit touching a file ("" if none). */
2717
- export function lastChangeDate(file, cwd) {
2718
- return gitSafe(["log", "-1", "--format=%aI", "--", file], cwd);
2773
+ /** ISO author-date of the most recent commit touching a file ("" if none), as of `rev`
2774
+ * (default HEAD). */
2775
+ export function lastChangeDate(file, cwd, rev) {
2776
+ return gitSafe(["log", "-1", "--format=%aI", ...(rev ? [rev] : []), "--", file], cwd);
2777
+ }
2778
+ /** The revision a checked change starts from, so freshness is judged WITHOUT the change
2779
+ * itself: a committed change under review must not make its own guarding record stale
2780
+ * (#426). `--base` diffs are 3-dot, so the merge-base; `--commit` is its parent;
2781
+ * staged/working changes are not committed yet, so HEAD (undefined). `--commit` judges
2782
+ * one commit in isolation, so an earlier commit of the same branch can still age the
2783
+ * record; `--base` (the CI guard) judges the whole branch and is the authoritative gate. */
2784
+ export function checkFreshnessRevision(source, cwd) {
2785
+ if (source.commit)
2786
+ return `${source.commit}^`;
2787
+ if (source.base)
2788
+ return gitSafe(["merge-base", source.base, "HEAD"], cwd) || source.base;
2789
+ return undefined;
2719
2790
  }
2720
2791
  /**
2721
2792
  * Newest author date for every changed path selected by a bounded set of repository-relative
@@ -2841,11 +2912,17 @@ export function revExists(ref, cwd) {
2841
2912
  export function rangeFiles(base, cwd, head = "HEAD") {
2842
2913
  return nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR", `${base}...${head}`], cwd));
2843
2914
  }
2844
- /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
2845
- * oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
2846
- export function rangeSubjects(base, cwd, head = "HEAD", max = 50) {
2847
- const out = gitSafe(["log", "--reverse", `-n${max}`, "--format=%s", `${base}..${head}`], cwd);
2848
- return out ? out.split("\n").filter(Boolean) : [];
2915
+ /** Commits on `head` since `base` (2-dot: commits added by the task), oldest-first, each
2916
+ * with its subject and the files it changed, for distilling a runbook's ordered steps
2917
+ * (roadmap #5); `runbookDraft` uses the files to tell Hunch's own captures from the work. */
2918
+ export function rangeCommits(base, cwd, head = "HEAD", max = 50) {
2919
+ const out = gitSafe(["-c", "core.quotePath=false", "log", "--reverse", `-n${max}`, "--format=%x1e%s", "--name-only", `${base}..${head}`], cwd);
2920
+ if (!out)
2921
+ return [];
2922
+ return out.split("\x1e").filter(Boolean).map((block) => {
2923
+ const [subject = "", ...files] = block.split("\n");
2924
+ return { subject: subject.trim(), files: files.map((f) => f.trim()).filter(Boolean) };
2925
+ });
2849
2926
  }
2850
2927
  /** Complete gating diff of a PR vs `base` (3-dot: the CI Constraint Guard's surface). */
2851
2928
  export function rangeGateDiff(base, cwd, head = "HEAD") {
@@ -13,7 +13,7 @@ import { dirname, join, posix } from "node:path";
13
13
  import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
14
14
  import { isParserLoadError } from "./nativeTreeSitter.js";
15
15
  import { extractHelmDirectives } from "./helm.js";
16
- import { extractK8sManifest, namespacesCompatible } from "./k8sManifest.js";
16
+ import { extractK8sManifest, extractKustomization, isKustomizationFile, kustomizeNamespaces, namespacesCompatible } from "./k8sManifest.js";
17
17
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
18
18
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
19
19
  import { resolveRelativeImport } from "../core/relativeImports.js";
@@ -143,9 +143,25 @@ export function scanRepo(store, root, opts = {}) {
143
143
  }
144
144
  const gitMeta = useGit ? fileGitMetrics(root, rels, opts.churn === false ? 0 : 90) : null;
145
145
  let skipped = 0;
146
+ // Kustomize namespace transformers must be known before any manifest is
147
+ // extracted, so kustomization files are read up front (their reads are
148
+ // reused by the main loop below, not repeated).
149
+ const preRead = new Map();
150
+ const kustomizations = new Map();
146
151
  for (const file of files) {
147
- const rel = file.path;
152
+ if (!isKustomizationFile(file.path))
153
+ continue;
148
154
  const read = file.read();
155
+ preRead.set(file.path, read);
156
+ if (!read.absent && read.source !== null) {
157
+ const dir = posix.dirname(file.path);
158
+ kustomizations.set(dir === "." ? "" : dir, extractKustomization(read.source));
159
+ }
160
+ }
161
+ const kustomizeNamespaceFor = kustomizeNamespaces(kustomizations, new Set(rels));
162
+ for (const file of files) {
163
+ const rel = file.path;
164
+ const read = preRead.get(rel) ?? file.read();
149
165
  if (read.absent) {
150
166
  sourceFingerprint.push({ path: rel, mode: read.mode, content: "absent" });
151
167
  continue;
@@ -227,20 +243,41 @@ export function scanRepo(store, root, opts = {}) {
227
243
  // otherwise be indistinguishable by offset alone, and the wrong (Helm)
228
244
  // symbol id could get recorded instead of the K8s one.
229
245
  const k8sSymbolObjects = new Set();
246
+ // Identity name a K8s symbol's id is derived from when its kind/name is
247
+ // NOT unique in this file (issue #382). Absent for the common unique case,
248
+ // so that id is exactly what it always was; a same-kind/name pair in
249
+ // different namespaces gets kind/name@namespace for EVERY member, never a
250
+ // document-order ordinal, so reordering documents can't rename them. The
251
+ // file path is already in every symbol id, so the same resource repeated
252
+ // across overlay dirs is distinct by path without any extra disambiguator.
253
+ const k8sIdNames = new Map();
230
254
  if (languageFor(rel)?.id === "yaml") {
231
- k8sDocs = extractK8sManifest(src);
232
- const k8sSymbols = k8sDocs
233
- .filter((d) => d.resource !== null)
255
+ k8sDocs = kustomizeNamespaceFor.has(rel)
256
+ ? extractK8sManifest(src, { namespace: kustomizeNamespaceFor.get(rel) })
257
+ : extractK8sManifest(src);
258
+ const resourceDocs = k8sDocs
259
+ .filter((d) => d.resource !== null);
260
+ const nameCounts = new Map();
261
+ for (const d of resourceDocs) {
262
+ const name = `${d.resource.kind}/${displayNameText(d.resource.name)}`;
263
+ nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
264
+ }
265
+ const k8sSymbols = resourceDocs
234
266
  // k8sManifest.ts's own offsets are named *Char; mapped here into the
235
267
  // shared startByte/endByte fields, same as the Helm merge above.
236
- .map((d) => ({
237
- name: `${d.resource.kind}/${displayNameText(d.resource.name)}`,
238
- kind: "variable",
239
- startByte: d.resource.startChar,
240
- endByte: d.resource.endChar,
241
- loc: src.slice(d.resource.startChar, d.resource.endChar).split("\n").length,
242
- bodyText: src.slice(d.resource.startChar, d.resource.endChar).slice(0, MAX_BODY_TEXT_CHARS),
243
- }));
268
+ .map((d) => {
269
+ const sym = {
270
+ name: `${d.resource.kind}/${displayNameText(d.resource.name)}`,
271
+ kind: "variable",
272
+ startByte: d.resource.startChar,
273
+ endByte: d.resource.endChar,
274
+ loc: src.slice(d.resource.startChar, d.resource.endChar).split("\n").length,
275
+ bodyText: src.slice(d.resource.startChar, d.resource.endChar).slice(0, MAX_BODY_TEXT_CHARS),
276
+ };
277
+ if ((nameCounts.get(sym.name) ?? 0) > 1)
278
+ k8sIdNames.set(sym, `${sym.name}@${d.resource.namespace ?? ""}`);
279
+ return sym;
280
+ });
244
281
  for (const s of k8sSymbols)
245
282
  k8sSymbolObjects.add(s);
246
283
  parsed.symbols = [...parsed.symbols, ...k8sSymbols].sort((a, b) => a.startByte - b.startByte);
@@ -253,10 +290,12 @@ export function scanRepo(store, root, opts = {}) {
253
290
  const idCounts = new Map(); // disambiguate same (file,name,kind)
254
291
  const k8sSymbolIdByStartByte = new Map();
255
292
  for (const [index, ps] of parsed.symbols.entries()) {
256
- const base = symbolId(rel, ps.name, ps.kind);
293
+ const base = symbolId(rel, k8sIdNames.get(ps) ?? ps.name, ps.kind);
257
294
  const n = idCounts.get(base) ?? 0;
258
295
  idCounts.set(base, n + 1);
259
296
  // parse() returns symbols sorted by start byte, so the ordinal is stable
297
+ // (for K8s resources it is only reached by two documents with the SAME
298
+ // kind, namespace and name in one file -- indistinguishable by identity)
260
299
  const id = n === 0 ? base : `${base}_${n}`;
261
300
  idsInFile.push(id);
262
301
  symbolIndexId.set(index, id);
@@ -68,5 +68,35 @@ export declare function namespacesCompatible(a: string | null, b: string | null)
68
68
  * paths below aren't hand-duplicated per kind. */
69
69
  export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;
70
70
  export declare const LABELS_PATH_BY_KIND: Record<string, string>;
71
- export declare function extractK8sManifest(source: string): K8sManifestDocument[];
71
+ /** `namespace` is a Kustomize transformer namespace for this file (from
72
+ * kustomizeNamespaces): undefined = no transformer, null = UNKNOWN (two
73
+ * kustomizations render this file into different namespaces). */
74
+ export declare function extractK8sManifest(source: string, opts?: {
75
+ namespace?: string | null;
76
+ }): K8sManifestDocument[];
77
+ export interface KustomizationInfo {
78
+ /** Top-level `namespace:` when it is a valid literal namespace, else null. */
79
+ namespace: string | null;
80
+ /** `resources:` entries exactly as written (paths relative to the kustomization's directory). */
81
+ resources: string[];
82
+ }
83
+ export declare function isKustomizationFile(path: string): boolean;
84
+ /** Reads only the two top-level fields the namespace transformer needs:
85
+ * `namespace:` and the `resources:` list (block `- item` lines or a one-line
86
+ * flow `[a, b]`). One linear pass; everything else in the file is ignored. */
87
+ export declare function extractKustomization(source: string): KustomizationInfo;
88
+ /** The Kustomize namespace each manifest file is rendered into, keyed by repo
89
+ * path. `kustomizations` is keyed by the kustomization's DIRECTORY ("" for
90
+ * the repo root); `files` is the indexed file set.
91
+ *
92
+ * A `resources:` entry naming a file applies the namespace to that file; an
93
+ * entry naming a directory that holds its own kustomization is a base, and
94
+ * the referring (outer) kustomization's namespace wins over the base's, as
95
+ * Kustomize applies the overlay transformer to the base's output. Only
96
+ * kustomizations no other kustomization references are rendered as roots.
97
+ * A file rendered into two different namespaces (one base shared by dev and
98
+ * prod overlays, the common layout) maps to null = UNKNOWN; a file rendered
99
+ * only without any transformer is absent. Not modelled: remote resources,
100
+ * `components:`, the deprecated `bases:` field, and globs. */
101
+ export declare function kustomizeNamespaces(kustomizations: ReadonlyMap<string, KustomizationInfo>, files: ReadonlySet<string>): Map<string, string | null>;
72
102
  export {};
@@ -1,4 +1,5 @@
1
- import { sourceLines } from "../core/eol.js";
1
+ import { posix } from "node:path";
2
+ import { eolLengthAt, sourceLines } from "../core/eol.js";
2
3
  // ReplicaSet is included alongside the pod-spec-embedding/networking kinds
3
4
  // specifically because it's the dominant real-world bearer of
4
5
  // ownerReferences (Deployment -> ReplicaSet -> Pod is the common chain) --
@@ -606,7 +607,16 @@ function extractLiteralLabelMap(prefix, entries, unresolvedContainers, valueless
606
607
  }
607
608
  return pairs.length > 0 ? Object.fromEntries(pairs) : null;
608
609
  }
609
- function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents) {
610
+ /** Cluster-scoped kinds Kustomize's namespace transformer leaves alone. None
611
+ * of them is in ALLOWED_KINDS today, so this is a guard for the override in
612
+ * buildDocument rather than a live branch: an allowlist widening must not
613
+ * start stamping a namespace onto a resource that cannot have one. */
614
+ const CLUSTER_SCOPED_KINDS = new Set([
615
+ "Namespace", "Node", "PersistentVolume", "StorageClass", "CustomResourceDefinition",
616
+ "ClusterRole", "ClusterRoleBinding", "PriorityClass", "IngressClass", "RuntimeClass",
617
+ "MutatingWebhookConfiguration", "ValidatingWebhookConfiguration", "APIService",
618
+ ]);
619
+ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents, namespaceOverride) {
610
620
  const kindEntry = findEntry(entries, "kind");
611
621
  const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
612
622
  if (!kind || !ALLOWED_KINDS.has(kind))
@@ -615,8 +625,13 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuel
615
625
  // scanner's quote-stripping, comment-stripping and CRLF tolerance for free.
616
626
  // Read OUTSIDE the `resource` branch below: a document with no
617
627
  // metadata.name still emits references, and those references carry this
618
- // document's namespace.
619
- const namespace = namespaceAt(entries, "metadata.namespace");
628
+ // document's namespace. A Kustomize namespace transformer (see
629
+ // kustomizeNamespaces) REPLACES metadata.namespace on every namespaced
630
+ // resource, explicit or not, so it wins here; an explicit backendRefs
631
+ // namespace sibling is not rewritten and keeps its own.
632
+ const namespace = namespaceOverride !== undefined && !CLUSTER_SCOPED_KINDS.has(kind)
633
+ ? namespaceOverride
634
+ : namespaceAt(entries, "metadata.namespace");
620
635
  const nameEntry = findEntry(entries, "metadata.name");
621
636
  const resource = nameEntry
622
637
  ? { kind, name: nameEntry.value, namespace, startChar: docStartChar, endChar: docStartChar + text.length }
@@ -643,12 +658,18 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuel
643
658
  // and it still isn't here: the dashes must be followed by whitespace or end
644
659
  // of line.
645
660
  const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*$/;
646
- export function extractK8sManifest(source) {
661
+ /** `namespace` is a Kustomize transformer namespace for this file (from
662
+ * kustomizeNamespaces): undefined = no transformer, null = UNKNOWN (two
663
+ * kustomizations render this file into different namespaces). */
664
+ export function extractK8sManifest(source, opts = {}) {
647
665
  const docs = [];
648
666
  const boundaries = [];
649
667
  for (const line of sourceLines(source)) {
650
- if (DOC_SEPARATOR.test(line.content))
651
- boundaries.push(line.start, line.end);
668
+ if (!DOC_SEPARATOR.test(line.content))
669
+ continue;
670
+ // The next document starts AFTER the separator's line terminator, so the
671
+ // same document text has the same span wherever it sits (#451).
672
+ boundaries.push(line.start, line.end + eolLengthAt(source, line.end));
652
673
  }
653
674
  const starts = [0, ...boundaries.filter((_, i) => i % 2 === 1)];
654
675
  const ends = [...boundaries.filter((_, i) => i % 2 === 0), source.length];
@@ -657,8 +678,112 @@ export function extractK8sManifest(source) {
657
678
  const end = ends[i];
658
679
  const text = source.slice(start, end);
659
680
  const { entries, unresolvedContainers, valuelessKeyParents } = scanFieldPaths(text, start);
660
- docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents));
681
+ docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents, opts.namespace));
661
682
  }
662
683
  return docs;
663
684
  }
685
+ const KUSTOMIZATION_FILE = /(?:^|\/)(?:kustomization\.ya?ml|Kustomization)$/;
686
+ export function isKustomizationFile(path) {
687
+ return KUSTOMIZATION_FILE.test(path);
688
+ }
689
+ /** Reads only the two top-level fields the namespace transformer needs:
690
+ * `namespace:` and the `resources:` list (block `- item` lines or a one-line
691
+ * flow `[a, b]`). One linear pass; everything else in the file is ignored. */
692
+ export function extractKustomization(source) {
693
+ let namespace = null;
694
+ const resources = [];
695
+ let inResources = false;
696
+ for (const { content } of sourceLines(source)) {
697
+ const line = stripTrailingComment(content).trimEnd();
698
+ if (line.trim() === "")
699
+ continue;
700
+ const item = /^\s*-\s+(.+)$/.exec(line);
701
+ if (inResources && item) {
702
+ resources.push(stripQuotes(item[1].trim()));
703
+ continue;
704
+ }
705
+ if (/^\s/.test(line))
706
+ continue; // nested content of some other top-level key
707
+ inResources = false;
708
+ const top = /^([A-Za-z]+):[ \t]*(.*)$/.exec(line);
709
+ if (!top)
710
+ continue;
711
+ const value = top[2].trim();
712
+ if (top[1] === "namespace") {
713
+ const ns = stripQuotes(value);
714
+ namespace = DNS_LABEL.test(ns) && ns !== "null" ? ns : null;
715
+ }
716
+ else if (top[1] === "resources") {
717
+ if (value === "")
718
+ inResources = true;
719
+ else if (value.startsWith("[") && value.endsWith("]")) {
720
+ for (const part of value.slice(1, -1).split(","))
721
+ if (part.trim())
722
+ resources.push(stripQuotes(part.trim()));
723
+ }
724
+ }
725
+ }
726
+ return { namespace, resources };
727
+ }
728
+ /** The Kustomize namespace each manifest file is rendered into, keyed by repo
729
+ * path. `kustomizations` is keyed by the kustomization's DIRECTORY ("" for
730
+ * the repo root); `files` is the indexed file set.
731
+ *
732
+ * A `resources:` entry naming a file applies the namespace to that file; an
733
+ * entry naming a directory that holds its own kustomization is a base, and
734
+ * the referring (outer) kustomization's namespace wins over the base's, as
735
+ * Kustomize applies the overlay transformer to the base's output. Only
736
+ * kustomizations no other kustomization references are rendered as roots.
737
+ * A file rendered into two different namespaces (one base shared by dev and
738
+ * prod overlays, the common layout) maps to null = UNKNOWN; a file rendered
739
+ * only without any transformer is absent. Not modelled: remote resources,
740
+ * `components:`, the deprecated `bases:` field, and globs. */
741
+ export function kustomizeNamespaces(kustomizations, files) {
742
+ const KEEP = "\0keep";
743
+ const childFiles = new Map();
744
+ const childDirs = new Map();
745
+ const referenced = new Set();
746
+ for (const [dir, k] of kustomizations) {
747
+ for (const entry of k.resources) {
748
+ let p = posix.normalize(posix.join(dir, entry)).replace(/\/+$/, "");
749
+ if (p === ".")
750
+ p = "";
751
+ if (p === ".." || p.startsWith("../"))
752
+ continue;
753
+ if (files.has(p))
754
+ (childFiles.get(dir) ?? childFiles.set(dir, []).get(dir)).push(p);
755
+ else if (p !== dir && kustomizations.has(p)) {
756
+ (childDirs.get(dir) ?? childDirs.set(dir, []).get(dir)).push(p);
757
+ referenced.add(p);
758
+ }
759
+ }
760
+ }
761
+ const seen = new Map();
762
+ const visited = new Set();
763
+ const render = (dir, outer) => {
764
+ const ns = outer ?? kustomizations.get(dir).namespace;
765
+ const key = `${dir}\0${ns ?? KEEP}`;
766
+ if (visited.has(key))
767
+ return; // memoised per (dir, namespace); also breaks cycles
768
+ visited.add(key);
769
+ for (const f of childFiles.get(dir) ?? [])
770
+ (seen.get(f) ?? seen.set(f, new Set()).get(f)).add(ns ?? KEEP);
771
+ for (const child of childDirs.get(dir) ?? [])
772
+ render(child, ns);
773
+ };
774
+ for (const dir of [...kustomizations.keys()].sort())
775
+ if (!referenced.has(dir))
776
+ render(dir, null);
777
+ const out = new Map();
778
+ for (const [file, set] of seen) {
779
+ if (set.size > 1)
780
+ out.set(file, null);
781
+ else {
782
+ const only = [...set][0];
783
+ if (only !== KEEP)
784
+ out.set(file, only);
785
+ }
786
+ }
787
+ return out;
788
+ }
664
789
  //# sourceMappingURL=k8sManifest.js.map
@@ -1,5 +1,7 @@
1
1
  import type { HunchStore } from "../store/hunchStore.js";
2
2
  import { groundingTemplate } from "../core/groundingLag.js";
3
+ export declare const HUNCH_START = "<!-- HUNCH:START \u2014 auto-generated, do not edit by hand -->";
4
+ export declare const HUNCH_END = "<!-- HUNCH:END -->";
3
5
  /** Version of the block's PROSE. Bump it whenever renderHunchSection's wording
4
6
  * changes. A block without the stamp is template 1. */
5
7
  export declare const GROUNDING_TEMPLATE = 2;
@@ -9,8 +9,10 @@ import { basename, join, dirname } from "node:path";
9
9
  import { wikiSummary } from "../wiki/wiki.js";
10
10
  import { PolicyRepository } from "../constitution/repository.js";
11
11
  import { renderCountsMatch, parseGroundingCounts, groundingTemplate } from "../core/groundingLag.js";
12
- const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
13
- const END = "<!-- HUNCH:END -->";
12
+ export const HUNCH_START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
13
+ export const HUNCH_END = "<!-- HUNCH:END -->";
14
+ const START = HUNCH_START;
15
+ const END = HUNCH_END;
14
16
  /** Version of the block's PROSE. Bump it whenever renderHunchSection's wording
15
17
  * changes. A block without the stamp is template 1. */
16
18
  export const GROUNDING_TEMPLATE = 2;
@@ -39,3 +39,10 @@ export interface ManagedGitignoreUpgrade {
39
39
  export declare function upgradeManagedGitignore(root: string): ManagedGitignoreUpgrade;
40
40
  /** Output lines describing what an upgrade changed; empty when nothing did. */
41
41
  export declare function describeGitignoreUpgrade(upgrade: ManagedGitignoreUpgrade): string[];
42
+ /** `.gitignore` text with both managed Hunch blocks removed (markers inclusive),
43
+ * every other byte kept, for `hunch uninstall --purge` (#449). `damaged` when a
44
+ * marker line has no matching end line: the block is never guessed at. */
45
+ export declare function stripManagedGitignore(text: string): {
46
+ next: string;
47
+ damaged: boolean;
48
+ };
@@ -231,4 +231,22 @@ export function describeGitignoreUpgrade(upgrade) {
231
231
  }
232
232
  return out;
233
233
  }
234
+ /** `.gitignore` text with both managed Hunch blocks removed (markers inclusive),
235
+ * every other byte kept, for `hunch uninstall --purge` (#449). `damaged` when a
236
+ * marker line has no matching end line: the block is never guessed at. */
237
+ export function stripManagedGitignore(text) {
238
+ let lines = text.split("\n");
239
+ let damaged = false;
240
+ for (const [mark, end] of [[MARK, END], [MEM_MARK, MEM_END]]) {
241
+ if (!lines.some((l) => cleanLine(l) === mark))
242
+ continue;
243
+ const span = findBlock(lines, mark, end);
244
+ if (!span) {
245
+ damaged = true;
246
+ continue;
247
+ }
248
+ lines = [...lines.slice(0, span.start), ...lines.slice(span.end + 1)];
249
+ }
250
+ return { next: lines.join("\n"), damaged };
251
+ }
234
252
  //# sourceMappingURL=gitignore.js.map