@davesheffer/hunch 1.42.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.
- package/README.md +14 -0
- package/dist/cli/index.js +206 -37
- package/dist/core/changeProof.js +2 -1
- package/dist/core/commitrepair.d.ts +9 -3
- package/dist/core/commitrepair.js +23 -6
- package/dist/core/correction.d.ts +1 -1
- package/dist/core/correction.js +4 -3
- package/dist/core/delivery.d.ts +12 -0
- package/dist/core/delivery.js +4 -4
- package/dist/core/docanchors.d.ts +11 -2
- package/dist/core/docanchors.js +232 -65
- package/dist/core/drift.d.ts +1 -1
- package/dist/core/drift.js +10 -3
- package/dist/core/eol.d.ts +2 -0
- package/dist/core/eol.js +6 -0
- package/dist/core/hookcache.d.ts +22 -0
- package/dist/core/hookcache.js +53 -2
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/publication.js +17 -3
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -0
- package/dist/core/taskReportHook.js +1 -1
- package/dist/core/taskSelection.d.ts +67 -0
- package/dist/core/taskSelection.js +196 -0
- package/dist/core/taskTouched.d.ts +2 -0
- package/dist/core/taskTouched.js +1 -1
- package/dist/extractors/git.d.ts +36 -6
- package/dist/extractors/git.js +86 -9
- package/dist/extractors/indexer.js +53 -14
- package/dist/extractors/k8sManifest.d.ts +31 -1
- package/dist/extractors/k8sManifest.js +133 -8
- package/dist/integrations/claudemd.d.ts +2 -0
- package/dist/integrations/claudemd.js +4 -2
- package/dist/integrations/gitignore.d.ts +7 -0
- package/dist/integrations/gitignore.js +18 -0
- package/dist/integrations/hooks.d.ts +15 -0
- package/dist/integrations/hooks.js +49 -1
- package/dist/integrations/mergeDriver.d.ts +2 -0
- package/dist/integrations/mergeDriver.js +2 -2
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +6 -5
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +14 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +58 -4
- package/dist/mcp/taskReportTools.js +1 -1
- package/dist/serve/writelock.d.ts +7 -0
- package/dist/serve/writelock.js +30 -3
- package/dist/store/changeLedger.d.ts +5 -2
- package/dist/store/changeLedger.js +88 -37
- package/dist/store/hunchStore.js +4 -1
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2718
|
-
|
|
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
|
-
/**
|
|
2845
|
-
*
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
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
|
-
|
|
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 =
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
const
|
|
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
|
|
@@ -144,6 +144,21 @@ export declare function hookStatus(root: string): {
|
|
|
144
144
|
postMerge: boolean;
|
|
145
145
|
postCheckout: boolean;
|
|
146
146
|
};
|
|
147
|
+
/** One git hook file `hunch uninstall` would change. `next === null` deletes the
|
|
148
|
+
* file (only a shebang was left); a `reason` means nothing is edited and the
|
|
149
|
+
* user removes the block by hand. */
|
|
150
|
+
export interface HookRemoval {
|
|
151
|
+
path: string;
|
|
152
|
+
hookName: string;
|
|
153
|
+
next?: string | null;
|
|
154
|
+
reason?: string;
|
|
155
|
+
}
|
|
156
|
+
/** Plan the removal of every Hunch-managed block from the hooks git runs (#449).
|
|
157
|
+
* Read-only. Only a hook file Hunch itself writes (manager "none", or an
|
|
158
|
+
* exec/exit file whose block it appended) is edited, and only a block with both
|
|
159
|
+
* markers; a hook manager's file, a tracked hooks dir, or an unclosed block is
|
|
160
|
+
* reported instead — the same lines Hunch refuses to write on install. */
|
|
161
|
+
export declare function planHookRemoval(root: string): HookRemoval[];
|
|
147
162
|
/** Doctor lines for where each installed/stale hook points (issue #315): hooks
|
|
148
163
|
* sharing one invocation share one line, and a line whose invocation is not the
|
|
149
164
|
* Hunch currently running says so. Informational — a hook may legitimately
|
|
@@ -20,7 +20,7 @@ import { homedir } from "node:os";
|
|
|
20
20
|
import { hooksDir, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
21
21
|
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
22
22
|
import { HUNCH_NPX_PACKAGE_SPEC, HUNCH_PACKAGE_NAME } from "../core/version.js";
|
|
23
|
-
import { normalizeEol } from "../core/eol.js";
|
|
23
|
+
import { eolLengthAt, normalizeEol } from "../core/eol.js";
|
|
24
24
|
const MARK = "# >>> hunch post-commit >>>";
|
|
25
25
|
const ENDMARK = "# <<< hunch post-commit <<<";
|
|
26
26
|
const GIT_CONTEXT = { checkoutType: "$3", portable: false };
|
|
@@ -942,6 +942,54 @@ export function hookStatus(root) {
|
|
|
942
942
|
postCheckout: r.postCheckout.state === "installed",
|
|
943
943
|
};
|
|
944
944
|
}
|
|
945
|
+
/** Plan the removal of every Hunch-managed block from the hooks git runs (#449).
|
|
946
|
+
* Read-only. Only a hook file Hunch itself writes (manager "none", or an
|
|
947
|
+
* exec/exit file whose block it appended) is edited, and only a block with both
|
|
948
|
+
* markers; a hook manager's file, a tracked hooks dir, or an unclosed block is
|
|
949
|
+
* reported instead — the same lines Hunch refuses to write on install. */
|
|
950
|
+
export function planHookRemoval(root) {
|
|
951
|
+
const ends = {
|
|
952
|
+
[MARK]: ENDMARK, [PRE_MARK]: PRE_END, [GROUNDING_MERGE_MARK]: GROUNDING_MERGE_END,
|
|
953
|
+
[REPAIR_MERGE_MARK]: REPAIR_MERGE_END, [CHECKOUT_MARK]: CHECKOUT_END,
|
|
954
|
+
};
|
|
955
|
+
const byHook = new Map();
|
|
956
|
+
for (const [hookName, mark] of managedBlocks())
|
|
957
|
+
byHook.set(hookName, [...(byHook.get(hookName) ?? []), mark]);
|
|
958
|
+
const out = [];
|
|
959
|
+
for (const [hookName, marks] of byHook) {
|
|
960
|
+
const t = resolveHookTarget(root, hookName, marks[0]);
|
|
961
|
+
if (t.manager !== "none" && t.manager !== "exec-exit") {
|
|
962
|
+
const owner = readText(t.ownerFile);
|
|
963
|
+
if (owner !== null && marks.some((m) => owner.includes(m))) {
|
|
964
|
+
out.push({ path: t.ownerFile, hookName, reason: `${t.manager} owns this hook; remove the Hunch block from it by hand` });
|
|
965
|
+
}
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const cur = readText(t.hookPath);
|
|
969
|
+
if (cur === null || !marks.some((m) => cur.includes(m)))
|
|
970
|
+
continue;
|
|
971
|
+
let next = cur;
|
|
972
|
+
let damaged = false;
|
|
973
|
+
for (const mark of marks) {
|
|
974
|
+
if (!next.includes(mark))
|
|
975
|
+
continue;
|
|
976
|
+
const m = new RegExp(`${escapeRe(mark)}[\\s\\S]*?${escapeRe(ends[mark])}`).exec(next);
|
|
977
|
+
if (!m) {
|
|
978
|
+
damaged = true;
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
const end = m.index + m[0].length;
|
|
982
|
+
next = next.slice(0, m.index) + next.slice(end + eolLengthAt(next, end));
|
|
983
|
+
}
|
|
984
|
+
if (damaged) {
|
|
985
|
+
out.push({ path: t.hookPath, hookName, reason: "a Hunch block here has no closing marker; remove it by hand" });
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
const body = normalizeEol(next).split("\n").filter((l, i) => !(i === 0 && l.startsWith("#!")));
|
|
989
|
+
out.push({ path: t.hookPath, hookName, next: body.every((l) => l.trim() === "") ? null : next });
|
|
990
|
+
}
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
945
993
|
/** Doctor lines for where each installed/stale hook points (issue #315): hooks
|
|
946
994
|
* sharing one invocation share one line, and a line whose invocation is not the
|
|
947
995
|
* Hunch currently running says so. Informational — a hook may legitimately
|
|
@@ -20,12 +20,12 @@ import { GROUNDING_DOC_PATHS } from "./providers.js";
|
|
|
20
20
|
//
|
|
21
21
|
// The five generated grounding docs get the OTHER driver — narrower in scope
|
|
22
22
|
// (it only ever touches a hard conflict confined to the counts sentence).
|
|
23
|
-
const ATTR_LINES = [
|
|
23
|
+
export const ATTR_LINES = [
|
|
24
24
|
".hunch/**/*.json merge=hunch",
|
|
25
25
|
".hunch/manifest.json merge=text",
|
|
26
26
|
...GROUNDING_DOC_PATHS.map((f) => `${f} merge=hunch-grounding`),
|
|
27
27
|
];
|
|
28
|
-
function targetRepositoryEnv() {
|
|
28
|
+
export function targetRepositoryEnv() {
|
|
29
29
|
const env = { ...process.env };
|
|
30
30
|
for (const key of [
|
|
31
31
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { HunchStore } from "../store/hunchStore.js";
|
|
2
2
|
import type { Invocation } from "./scaffold.js";
|
|
3
|
+
/** Read a JSON/JSONC object. Returns {} only for an ABSENT or empty file. A
|
|
4
|
+
* non-empty file we cannot parse THROWS — overwriting it would silently wipe the
|
|
5
|
+
* user's other MCP servers. */
|
|
6
|
+
export declare function readJsonObj(file: string): Record<string, unknown>;
|
|
7
|
+
export declare function isHunchProviderHook(entry: unknown): boolean;
|
|
3
8
|
/** Cursor: .cursor/mcp.json — same `mcpServers` shape as Claude Desktop/Code. */
|
|
4
9
|
export declare function writeCursorMcp(root: string, inv: Invocation): string;
|
|
5
10
|
/** VS Code (Copilot agent mode): .vscode/mcp.json — root key is `servers`, and
|
|
@@ -20,6 +25,8 @@ export declare function writeAntigravityMcp(inv: Invocation, home?: string): str
|
|
|
20
25
|
* committed with the repository, so every clone gets the same private/local
|
|
21
26
|
* Hunch server without touching a user's home directory. */
|
|
22
27
|
export declare function writeAntigravityWorkspaceMcp(root: string, inv: Invocation): string;
|
|
28
|
+
export declare const TOML_START = "# >>> hunch mcp (managed) >>>";
|
|
29
|
+
export declare const TOML_END = "# <<< hunch mcp <<<";
|
|
23
30
|
/** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
|
|
24
31
|
* a marker-delimited block; any other TOML the user has is preserved. Paths use
|
|
25
32
|
* TOML single-quote LITERAL strings so Windows backslashes need no escaping. */
|