@davesheffer/hunch 1.39.2 → 1.39.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +97 -50
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/core/correction.js +12 -7
- package/dist/core/docanchors.js +16 -2
- package/dist/core/glob.d.ts +19 -0
- package/dist/core/glob.js +37 -0
- package/dist/core/paths.d.ts +27 -5
- package/dist/core/paths.js +51 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.js +14 -2
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +17 -1
- package/dist/extractors/k8sManifest.js +104 -16
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +38 -7
- package/dist/integrations/hooks.js +250 -24
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +21 -13
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/hunchStore.js +41 -11
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/extractors/git.d.ts
CHANGED
|
@@ -162,6 +162,11 @@ export declare function isLinkedWorktree(cwd: string): boolean;
|
|
|
162
162
|
/** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
|
|
163
163
|
* Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
|
|
164
164
|
export declare function currentBranch(cwd: string): string;
|
|
165
|
+
/** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
|
|
166
|
+
* ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
|
|
167
|
+
* null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
|
|
168
|
+
* git missing), so a caller keeps its pre-existing behavior rather than failing closed. */
|
|
169
|
+
export declare function gitHeadUnsettled(cwd: string): "git-operation-in-progress" | "detached-head" | null;
|
|
165
170
|
/** Files changed in a single commit. `--root` makes the initial commit (which
|
|
166
171
|
* has no parent) report its files as additions instead of returning nothing. */
|
|
167
172
|
export declare function commitFiles(sha: string, cwd: string): string[];
|
package/dist/extractors/git.js
CHANGED
|
@@ -4,7 +4,7 @@ import { execFileSync } from "node:child_process";
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { devNull, tmpdir } from "node:os";
|
|
6
6
|
import { isAbsolute, resolve, join, basename, dirname, relative, sep } from "node:path";
|
|
7
|
-
import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync } from "node:fs";
|
|
7
|
+
import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
10
10
|
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
@@ -1686,14 +1686,25 @@ export function syncExistingHunch(hunchDir, protectedRepoRoot, timeoutMs = CAPTU
|
|
|
1686
1686
|
}
|
|
1687
1687
|
}
|
|
1688
1688
|
const UNKNOWN_LOCK_STALE_MS = 10 * 60_000;
|
|
1689
|
+
// An owner-less lock directory is also a NORMAL transient state: the window
|
|
1690
|
+
// between the two mkdir calls in createOwnedCommitLock, the release window where
|
|
1691
|
+
// recursive cleanup removes owner-<pid> before the outer directory, and a lost
|
|
1692
|
+
// reclaim race. This bounds how long a lock may stay owner-less before it is
|
|
1693
|
+
// treated as stranded. At ENTRY it is compared with the directory's mtime
|
|
1694
|
+
// (creating the directory, or adding/removing a child entry, touches it), so an
|
|
1695
|
+
// old stranded lock no-ops instantly. INSIDE the wait loop it is a local
|
|
1696
|
+
// continuously-owner-less timer instead, so the wait depends on neither
|
|
1697
|
+
// filesystem mtime semantics nor clock agreement between writers.
|
|
1698
|
+
const OWNERLESS_LOCK_FRESH_MS = 2_000;
|
|
1689
1699
|
function createOwnedCommitLock(lock) {
|
|
1690
1700
|
let created = false;
|
|
1691
1701
|
try {
|
|
1692
1702
|
// mkdir is the exclusive atomic operation here. Renaming a staged directory
|
|
1693
1703
|
// is NOT exclusive on POSIX: it may replace an already-existing empty lock
|
|
1694
1704
|
// directory, which would steal a fresh legacy/ownerless lock. There is a
|
|
1695
|
-
// harmless ownerless window between these two mkdir calls
|
|
1696
|
-
//
|
|
1705
|
+
// harmless ownerless window between these two mkdir calls: it reads as held,
|
|
1706
|
+
// and is reclaimed only after the conservative legacy TTL expires. A capture
|
|
1707
|
+
// waits it out (waitForCommitLockHandoff); other callers just report busy.
|
|
1697
1708
|
mkdirSync(lock);
|
|
1698
1709
|
created = true;
|
|
1699
1710
|
// Empty directories are not Git worktree entries, so owner metadata cannot
|
|
@@ -1781,21 +1792,66 @@ function acquireCommitLock(lock) {
|
|
|
1781
1792
|
}
|
|
1782
1793
|
return createOwnedCommitLock(lock) ? { state: "acquired" } : { state: "held-unknown" };
|
|
1783
1794
|
}
|
|
1795
|
+
/** Is an owner-less lock directory recent enough to be a transient window
|
|
1796
|
+
* rather than a stranded one? Only meaningful for the FIRST snapshot, where
|
|
1797
|
+
* there is no local history to measure against. An unreadable directory is not
|
|
1798
|
+
* assumed transient: only ENOENT is, because the owner may have just finished
|
|
1799
|
+
* releasing. `Math.abs` so a clock-skewed far-FUTURE mtime is not read as
|
|
1800
|
+
* recent forever. */
|
|
1801
|
+
function ownerlessLockLooksTransient(lock) {
|
|
1802
|
+
try {
|
|
1803
|
+
return Math.abs(Date.now() - statSync(lock).mtimeMs) <= OWNERLESS_LOCK_FRESH_MS;
|
|
1804
|
+
}
|
|
1805
|
+
catch (error) {
|
|
1806
|
+
return error.code === "ENOENT";
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1784
1809
|
function waitForCommitLockHandoff(lock, first, timeoutMs) {
|
|
1785
|
-
if (first.state
|
|
1810
|
+
if (first.state === "held-live" && first.ownerPid === process.pid)
|
|
1811
|
+
return false;
|
|
1812
|
+
// An owner-less FIRST snapshot is either a transient window (creation,
|
|
1813
|
+
// release, lost reclaim race) or a stranded/legacy lock. Only the former is
|
|
1814
|
+
// worth waiting for: a stranded lock must not block the capture for the whole
|
|
1815
|
+
// handoff. This is the one place the WAIT POLICY consults the directory's
|
|
1816
|
+
// mtime; acquireCommitLock still stats it for the legacy reclaim TTL.
|
|
1817
|
+
if (first.state === "held-unknown" && !ownerlessLockLooksTransient(lock))
|
|
1786
1818
|
return false;
|
|
1787
1819
|
const deadline = Date.now() + timeoutMs;
|
|
1788
1820
|
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
1789
1821
|
let attempt = first;
|
|
1822
|
+
// How long the lock has read as held-unknown CONTINUOUSLY, measured locally:
|
|
1823
|
+
// a sighting of a live owner resets it, because a genuine release window lasts
|
|
1824
|
+
// milliseconds. Reading it from this clock keeps the wait independent of the
|
|
1825
|
+
// filesystem's mtime granularity and of other writers' clocks. Besides a truly
|
|
1826
|
+
// owner-less directory this also covers a dead owner whose reclaim another
|
|
1827
|
+
// process has claimed — equally not a handoff this contender can wait for.
|
|
1828
|
+
let ownerlessSince = first.state === "held-unknown" ? Date.now() : null;
|
|
1829
|
+
// "Held" while the directory is not even there means mkdir keeps failing for a
|
|
1830
|
+
// reason that is not contention — a missing, read-only or full store — and no
|
|
1831
|
+
// amount of waiting will hand anything off. Require a short streak so one
|
|
1832
|
+
// sample taken mid-release is not mistaken for it.
|
|
1833
|
+
let vanishedStreak = 0;
|
|
1790
1834
|
while (Date.now() < deadline) {
|
|
1791
1835
|
if (attempt.state === "acquired")
|
|
1792
1836
|
return true;
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1837
|
+
if (attempt.state === "held-live") {
|
|
1838
|
+
if (attempt.ownerPid === process.pid)
|
|
1839
|
+
return false;
|
|
1840
|
+
ownerlessSince = null;
|
|
1841
|
+
vanishedStreak = 0;
|
|
1842
|
+
}
|
|
1843
|
+
else if (attempt.state === "held-unknown") {
|
|
1844
|
+
ownerlessSince ??= Date.now();
|
|
1845
|
+
if (Date.now() - ownerlessSince > OWNERLESS_LOCK_FRESH_MS)
|
|
1846
|
+
return false;
|
|
1847
|
+
if (!existsSync(lock)) {
|
|
1848
|
+
vanishedStreak += 1;
|
|
1849
|
+
if (vanishedStreak >= 3)
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
else
|
|
1853
|
+
vanishedStreak = 0;
|
|
1854
|
+
}
|
|
1799
1855
|
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
|
|
1800
1856
|
attempt = acquireCommitLock(lock);
|
|
1801
1857
|
}
|
|
@@ -1877,6 +1933,27 @@ export function currentBranch(cwd) {
|
|
|
1877
1933
|
const b = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
1878
1934
|
return b === "HEAD" ? "" : b; // detached HEAD reports "HEAD" — treat as no branch
|
|
1879
1935
|
}
|
|
1936
|
+
/** The state files git writes while it is replaying history. `git rev-parse --git-path`
|
|
1937
|
+
* resolves each against THIS worktree's private git dir (a linked worktree keeps its own
|
|
1938
|
+
* rebase-merge/MERGE_HEAD), so a rebase in one worktree never reads as one in another. */
|
|
1939
|
+
const UNSETTLED_HEAD_PATHS = ["rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG"];
|
|
1940
|
+
/** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
|
|
1941
|
+
* ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
|
|
1942
|
+
* null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
|
|
1943
|
+
* git missing), so a caller keeps its pre-existing behavior rather than failing closed. */
|
|
1944
|
+
export function gitHeadUnsettled(cwd) {
|
|
1945
|
+
// One spawn: `git rev-parse --git-path a --git-path b …` prints one line per path.
|
|
1946
|
+
const out = gitSafe(["rev-parse", ...UNSETTLED_HEAD_PATHS.flatMap((p) => ["--git-path", p])], cwd);
|
|
1947
|
+
if (!out)
|
|
1948
|
+
return null; // git could not answer (not a repo) — fail open
|
|
1949
|
+
const paths = out.split("\n").map((p) => p.trim()).filter(Boolean);
|
|
1950
|
+
if (paths.length !== UNSETTLED_HEAD_PATHS.length)
|
|
1951
|
+
return null;
|
|
1952
|
+
if (paths.some((p) => existsSync(isAbsolute(p) ? p : resolve(cwd, p))))
|
|
1953
|
+
return "git-operation-in-progress";
|
|
1954
|
+
// `git symbolic-ref -q HEAD` exits non-zero exactly when HEAD is detached.
|
|
1955
|
+
return gitSafe(["symbolic-ref", "-q", "HEAD"], cwd) ? null : "detached-head";
|
|
1956
|
+
}
|
|
1880
1957
|
/** Files changed in a single commit. `--root` makes the initial commit (which
|
|
1881
1958
|
* has no parent) report its files as additions instead of returning nothing. */
|
|
1882
1959
|
export function commitFiles(sha, cwd) {
|
|
@@ -51,5 +51,15 @@ export declare function assertCompleteRepoScan(scan: RepoScan): void;
|
|
|
51
51
|
* the durable graph merely by inspecting it. Existing public graph records remain
|
|
52
52
|
* inputs for the same churn/component enrichment semantics as a persisted index. */
|
|
53
53
|
export declare function scanRepo(store: HunchStore, root: string, opts?: ScanRepoOptions): RepoScan;
|
|
54
|
+
/** True for an edge this indexer produced: the scan re-derives exactly these on
|
|
55
|
+
* every pass, so they — and only they — are safe to replace wholesale. */
|
|
56
|
+
export declare function isExtractorEdge(edge: Edge): boolean;
|
|
57
|
+
/** Merge a fresh scan into the stored edge set. A scan can only re-derive what
|
|
58
|
+
* it extracted; `supersedes` edges (written by the store) and human-reviewed
|
|
59
|
+
* Landscape relationships have no other source, so replacing the whole index
|
|
60
|
+
* with the scan deleted them on every `hunch index` (issue #288). Non-extractor
|
|
61
|
+
* edges are carried forward in their stored order and win an id collision:
|
|
62
|
+
* a reviewed or store-written fact outranks a re-derivable one. */
|
|
63
|
+
export declare function mergeScannedEdges(stored: Edge[], scanned: Edge[]): Edge[];
|
|
54
64
|
/** Persist one pure scan into the Git-native source of truth. */
|
|
55
65
|
export declare function indexRepo(store: HunchStore, root: string, opts?: IndexRepoOptions): IndexResult;
|
|
@@ -560,6 +560,22 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
560
560
|
issues,
|
|
561
561
|
};
|
|
562
562
|
}
|
|
563
|
+
/** True for an edge this indexer produced: the scan re-derives exactly these on
|
|
564
|
+
* every pass, so they — and only they — are safe to replace wholesale. */
|
|
565
|
+
export function isExtractorEdge(edge) {
|
|
566
|
+
return edge.schema === "hunch.edge/1" && edge.provenance.source === "extracted";
|
|
567
|
+
}
|
|
568
|
+
/** Merge a fresh scan into the stored edge set. A scan can only re-derive what
|
|
569
|
+
* it extracted; `supersedes` edges (written by the store) and human-reviewed
|
|
570
|
+
* Landscape relationships have no other source, so replacing the whole index
|
|
571
|
+
* with the scan deleted them on every `hunch index` (issue #288). Non-extractor
|
|
572
|
+
* edges are carried forward in their stored order and win an id collision:
|
|
573
|
+
* a reviewed or store-written fact outranks a re-derivable one. */
|
|
574
|
+
export function mergeScannedEdges(stored, scanned) {
|
|
575
|
+
const carried = stored.filter((edge) => !isExtractorEdge(edge));
|
|
576
|
+
const carriedIds = new Set(carried.map((edge) => edge.id));
|
|
577
|
+
return [...carried, ...scanned.filter((edge) => !carriedIds.has(edge.id))];
|
|
578
|
+
}
|
|
563
579
|
/** Persist one pure scan into the Git-native source of truth. */
|
|
564
580
|
export function indexRepo(store, root, opts = {}) {
|
|
565
581
|
if (opts.requireClean) {
|
|
@@ -576,7 +592,7 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
576
592
|
if (opts.requireComplete)
|
|
577
593
|
assertCompleteRepoScan(scan);
|
|
578
594
|
store.json.replaceAll("symbols", scan.symbols);
|
|
579
|
-
store.json.replaceAll("edges", scan.edges);
|
|
595
|
+
store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), scan.edges));
|
|
580
596
|
store.json.replaceAll("components", scan.components);
|
|
581
597
|
return scan.result;
|
|
582
598
|
}
|
|
@@ -71,6 +71,20 @@ const BARE_TEMPLATE_LINE = /^\s*(?:-\s+)?\{\{[\s\S]*$/;
|
|
|
71
71
|
// -- reparenting the item's real children one level up (`containers.name`
|
|
72
72
|
// instead of `containers[0].name`), silently dropping every reference under it.
|
|
73
73
|
const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
|
|
74
|
+
// A list item whose ENTIRE content is a block-scalar header (`- |`, `- |-`,
|
|
75
|
+
// `- >`, `- |2`, `- | # comment`) -- the idiomatic way a chart inlines a shell
|
|
76
|
+
// script into `args:`/`command:`. The item's value is opaque TEXT, not YAML,
|
|
77
|
+
// yet the body lines look exactly like manifest fields (a heredoc'd
|
|
78
|
+
// `secretKeyRef:` block is the canonical case), and this line matches neither
|
|
79
|
+
// KEY_LINE (no colon) nor BARE_LIST_MARKER (something follows the dash), so it
|
|
80
|
+
// fell to the unrecognized-line branch and the script body was then scanned as
|
|
81
|
+
// the surrounding container's children -- inventing a reference to a resource
|
|
82
|
+
// that only ever existed as text. The `key: |` form needs no such handling:
|
|
83
|
+
// its body nests under the key's OWN frame, where no reference field-path can
|
|
84
|
+
// match. The rest is captured (not `[|>]` inline) so the header can be run
|
|
85
|
+
// through stripTrailingComment before BLOCK_SCALAR_HEADER, exactly as a
|
|
86
|
+
// key line's value is.
|
|
87
|
+
const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)\r?$/;
|
|
74
88
|
// A YAML block-scalar header (`|`, `>`, plus an optional chomping indicator
|
|
75
89
|
// `+`/`-` and/or an explicit indent digit, in either order: `|`, `|-`, `>+`,
|
|
76
90
|
// `|2`, `|2-`, `|-2`). When a key's value is JUST this header, the real
|
|
@@ -138,8 +152,21 @@ function scanFieldPaths(text, baseChar) {
|
|
|
138
152
|
// avoid. Silently ignoring what the scanner can't parse is not safe here;
|
|
139
153
|
// "I can't tell" must read as "unresolved," the same as a real template.
|
|
140
154
|
const unresolvedContainers = new Set();
|
|
155
|
+
// The framePath of every container that DIRECTLY holds a key written with no
|
|
156
|
+
// value at all (`tier:`). Deliberately its own set rather than more entries
|
|
157
|
+
// in unresolvedContainers: an ordinary container like `spec:` legitimately
|
|
158
|
+
// holds value-less keys (every nested mapping starts with one), so folding
|
|
159
|
+
// these in would destroy that set's meaning -- "the scanner could not
|
|
160
|
+
// account for this line". Only a map that is supposed to be FLAT
|
|
161
|
+
// (selector/labels) cares, and it checks this set itself.
|
|
162
|
+
const valuelessKeyParents = new Set();
|
|
141
163
|
const stack = [];
|
|
142
164
|
let charOffset = baseChar;
|
|
165
|
+
// Indent of the `-` of a `- |` list item whose block-scalar body is being
|
|
166
|
+
// skipped, or null when no such skip is active. The body ends at the first
|
|
167
|
+
// line indented at or shallower than that dash (YAML's own rule for where a
|
|
168
|
+
// sequence item's content stops).
|
|
169
|
+
let blockScalarSkipIndent = null;
|
|
143
170
|
const popToForListItem = (dashIndent) => {
|
|
144
171
|
while (stack.length) {
|
|
145
172
|
const top = stack[stack.length - 1];
|
|
@@ -205,6 +232,31 @@ function scanFieldPaths(text, baseChar) {
|
|
|
205
232
|
for (const line of text.split("\n")) {
|
|
206
233
|
const lineStartChar = charOffset;
|
|
207
234
|
charOffset += line.length + 1; // +1 for the \n split() consumed
|
|
235
|
+
// Inside a `- |` item's body: these lines are script/config TEXT, so they
|
|
236
|
+
// must never be read as field structure. Runs after the charOffset
|
|
237
|
+
// bookkeeping above so skipped lines still advance the offset exactly --
|
|
238
|
+
// every atChar/endChar after the block scalar depends on it.
|
|
239
|
+
if (blockScalarSkipIndent !== null) {
|
|
240
|
+
// A blank line is part of the block scalar (YAML lets a scalar body
|
|
241
|
+
// contain empty lines at any indent), never its terminator.
|
|
242
|
+
if (line.trim().length === 0)
|
|
243
|
+
continue;
|
|
244
|
+
if (BARE_TEMPLATE_LINE.test(line)) {
|
|
245
|
+
// A `{{ }}` action line's own indentation carries no structural
|
|
246
|
+
// meaning (`{{-` chomps it; the `| indent N` idiom puts the action at
|
|
247
|
+
// column 0 -- see markAllOpenContainersUnresolved), so a column-0
|
|
248
|
+
// action inside a script body must NOT be read as a dedent that ends
|
|
249
|
+
// the skip and re-exposes the remaining body lines as fields. Taint
|
|
250
|
+
// conservatively and stay in the skip: we cannot tell whether the
|
|
251
|
+
// action sits inside the scalar or after it.
|
|
252
|
+
markAllOpenContainersUnresolved();
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const indent = line.length - line.trimStart().length;
|
|
256
|
+
if (indent > blockScalarSkipIndent)
|
|
257
|
+
continue; // still the scalar's body
|
|
258
|
+
blockScalarSkipIndent = null; // dedented out of the item -- real YAML again
|
|
259
|
+
}
|
|
208
260
|
if (BARE_TEMPLATE_LINE.test(line)) {
|
|
209
261
|
markAllOpenContainersUnresolved();
|
|
210
262
|
continue;
|
|
@@ -215,6 +267,18 @@ function scanFieldPaths(text, baseChar) {
|
|
|
215
267
|
enterListItem(dashIndent, dashIndent + 1);
|
|
216
268
|
continue;
|
|
217
269
|
}
|
|
270
|
+
// Checked BEFORE the KEY_LINE match: KEY_LINE can't match this shape
|
|
271
|
+
// anyway (no colon), but without this branch it would land in the
|
|
272
|
+
// unrecognized-line branch below and the body would be scanned as fields.
|
|
273
|
+
const blockScalarItem = BLOCK_SCALAR_LIST_ITEM.exec(line);
|
|
274
|
+
if (blockScalarItem && BLOCK_SCALAR_HEADER.test(stripTrailingComment(blockScalarItem[2]).trim())) {
|
|
275
|
+
const dashIndent = blockScalarItem[1].length;
|
|
276
|
+
// It IS a real sequence item, so enter it exactly as the bare-marker
|
|
277
|
+
// form does -- sibling items after this one must keep their indices.
|
|
278
|
+
enterListItem(dashIndent, dashIndent + 1);
|
|
279
|
+
blockScalarSkipIndent = dashIndent;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
218
282
|
const m = KEY_LINE.exec(line);
|
|
219
283
|
if (!m) {
|
|
220
284
|
// Any other non-blank, non-comment line is a shape this scanner can't
|
|
@@ -268,6 +332,18 @@ function scanFieldPaths(text, baseChar) {
|
|
|
268
332
|
unresolvedContainers.add(parentPath);
|
|
269
333
|
continue;
|
|
270
334
|
}
|
|
335
|
+
// A key written with NO value (`tier:`) creates a frame but never an
|
|
336
|
+
// entry, so it is invisible to extractLiteralLabelMap -- which then
|
|
337
|
+
// returns the map MINUS that key. For a flat string->string map
|
|
338
|
+
// (selector/labels) a value-less direct child is either a null value or a
|
|
339
|
+
// nested container; either way the key is silently absent, and a selector
|
|
340
|
+
// short one key matches strictly MORE workloads than the real one -- the
|
|
341
|
+
// same over-permissive failure as every other unresolved case here.
|
|
342
|
+
// Recorded after the block-scalar branch above, which already taints the
|
|
343
|
+
// parent for its own reason. Note `tier: ""` is NOT value-less: quotes
|
|
344
|
+
// survive stripTrailingComment/trim, so it stays a literal empty string.
|
|
345
|
+
if (value.length === 0)
|
|
346
|
+
valuelessKeyParents.add(parentPath);
|
|
271
347
|
if (value.length > 0) {
|
|
272
348
|
const colonIdx = line.indexOf(":", dashIndent);
|
|
273
349
|
const valueStartInLine = line.indexOf(value, colonIdx);
|
|
@@ -309,7 +385,7 @@ function scanFieldPaths(text, baseChar) {
|
|
|
309
385
|
// line, since that line's own popToForListItem/popOrdinary call pops
|
|
310
386
|
// back to (but never past) this key's frame.
|
|
311
387
|
}
|
|
312
|
-
return { entries, unresolvedContainers };
|
|
388
|
+
return { entries, unresolvedContainers, valuelessKeyParents };
|
|
313
389
|
}
|
|
314
390
|
function findEntry(entries, path) {
|
|
315
391
|
return entries.find((e) => e.path === path);
|
|
@@ -422,9 +498,14 @@ export const LABELS_PATH_BY_KIND = {
|
|
|
422
498
|
* exists, or any direct-child leaf is itself templated -- a partially-literal
|
|
423
499
|
* map is still unusable for subset-match without evaluating the templated
|
|
424
500
|
* half, so the whole map is treated as unresolved. */
|
|
425
|
-
function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
|
|
501
|
+
function extractLiteralLabelMap(prefix, entries, unresolvedContainers, valuelessKeyParents) {
|
|
426
502
|
if (unresolvedContainers.has(prefix))
|
|
427
503
|
return null;
|
|
504
|
+
// A value-less direct child (`tier:`) never produced an entry at all, so the
|
|
505
|
+
// map below would come back without it -- see valuelessKeyParents' comment
|
|
506
|
+
// in scanFieldPaths for why a short map is the dangerous direction.
|
|
507
|
+
if (valuelessKeyParents.has(prefix))
|
|
508
|
+
return null;
|
|
428
509
|
// A real Kubernetes label/selector map is always flat (string -> string) --
|
|
429
510
|
// any entry whose parentPath is a DEEPER descendant of prefix (not prefix
|
|
430
511
|
// itself) means some direct child of prefix was itself a nested container
|
|
@@ -464,7 +545,7 @@ function extractLiteralLabelMap(prefix, entries, unresolvedContainers) {
|
|
|
464
545
|
}
|
|
465
546
|
return pairs.length > 0 ? Object.fromEntries(pairs) : null;
|
|
466
547
|
}
|
|
467
|
-
function buildDocument(text, docStartChar, entries, unresolvedContainers) {
|
|
548
|
+
function buildDocument(text, docStartChar, entries, unresolvedContainers, valuelessKeyParents) {
|
|
468
549
|
const kindEntry = findEntry(entries, "kind");
|
|
469
550
|
const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
|
|
470
551
|
if (!kind || !ALLOWED_KINDS.has(kind))
|
|
@@ -474,20 +555,27 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers) {
|
|
|
474
555
|
? { kind, name: nameEntry.value, startChar: docStartChar, endChar: docStartChar + text.length }
|
|
475
556
|
: null;
|
|
476
557
|
const references = [...extractFieldReferences(kind, entries), ...extractOwnerReferenceCandidates(entries)];
|
|
477
|
-
const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers) : null;
|
|
558
|
+
const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers, valuelessKeyParents) : null;
|
|
478
559
|
const labelsPath = LABELS_PATH_BY_KIND[kind];
|
|
479
|
-
const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers) : null;
|
|
560
|
+
const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers, valuelessKeyParents) : null;
|
|
480
561
|
return { resource, references, selector, labels };
|
|
481
562
|
}
|
|
482
|
-
// Matches a YAML document-start marker (
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
563
|
+
// Matches a YAML document-start marker (`---`) or a document-end marker
|
|
564
|
+
// (`...`). Per the YAML spec `---` FOLLOWED BY WHITESPACE starts a document
|
|
565
|
+
// whatever trails it on that line -- a comment (`--- # second doc`), a tag
|
|
566
|
+
// (`--- !!map`), an anchor (`--- &base`) -- so the trailing text is matched
|
|
567
|
+
// generically rather than as a comment only. Without that, a tagged or
|
|
568
|
+
// anchored separator silently failed to split at all, merging two documents
|
|
569
|
+
// into one: the later document's fields overwrite the earlier one's (object
|
|
570
|
+
// spread order), and the earlier resource's symbol/edges vanish entirely.
|
|
571
|
+
// Whatever trails the marker is consumed WITH the boundary -- for a tag or
|
|
572
|
+
// anchor nothing is lost, and inline content written after the marker
|
|
573
|
+
// (`--- kind: Pod`, legal but vanishingly rare) is dropped, which errs toward
|
|
574
|
+
// fewer edges, this module's standing bias. `----` (four or more dashes) is
|
|
575
|
+
// deliberately NOT a separator -- real YAML doesn't treat it as one either,
|
|
576
|
+
// and it still isn't here: the dashes must be followed by whitespace or end
|
|
577
|
+
// of line.
|
|
578
|
+
const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*\r?$/m;
|
|
491
579
|
export function extractK8sManifest(source) {
|
|
492
580
|
const docs = [];
|
|
493
581
|
const boundaries = [];
|
|
@@ -499,8 +587,8 @@ export function extractK8sManifest(source) {
|
|
|
499
587
|
const start = starts[i];
|
|
500
588
|
const end = ends[i];
|
|
501
589
|
const text = source.slice(start, end);
|
|
502
|
-
const { entries, unresolvedContainers } = scanFieldPaths(text, start);
|
|
503
|
-
docs.push(buildDocument(text, start, entries, unresolvedContainers));
|
|
590
|
+
const { entries, unresolvedContainers, valuelessKeyParents } = scanFieldPaths(text, start);
|
|
591
|
+
docs.push(buildDocument(text, start, entries, unresolvedContainers, valuelessKeyParents));
|
|
504
592
|
}
|
|
505
593
|
return docs;
|
|
506
594
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single rule for "is this hook command Hunch's own?", shared by every hook
|
|
3
|
+
* writer (scaffold.ts for Claude Code's .claude/settings.json, providers.ts for
|
|
4
|
+
* Cursor/Codex/VS Code/Windsurf/Antigravity). One rule, because each writer
|
|
5
|
+
* deletes what it classifies as ours, and a writer that guesses wide deletes the
|
|
6
|
+
* user's hooks (con_8460b6770f).
|
|
7
|
+
*/
|
|
8
|
+
/** Does one command string invoke Hunch's own hook?
|
|
9
|
+
*
|
|
10
|
+
* Anchored to the shapes hookCommand() / resolveInvocation() write — a Hunch
|
|
11
|
+
* launcher (the pinned npm package spec, or a …/dist|src/cli/index.js|ts path for
|
|
12
|
+
* source installs) plus a `hook` tail, tokens quoted or bare. The old unanchored
|
|
13
|
+
* /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
|
|
14
|
+
* `node ./hook/index.js` as ours and silently deleted them, violating the
|
|
15
|
+
* leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
|
|
16
|
+
*
|
|
17
|
+
* `requireProvider` distinguishes the two writers. Provider configs always carry
|
|
18
|
+
* `hook --provider <name>`, so their tail must too; only the LEGACY fully-quoted
|
|
19
|
+
* form (written before the quoting fix, and by hunch versions that predate
|
|
20
|
+
* --provider) may omit it, and its quotes keep it unambiguous. Claude Code's own
|
|
21
|
+
* settings.json never carries --provider, so a bare `hook` tail is all that is
|
|
22
|
+
* left to match on — and a bare tail alone is far too weak, so there the WHOLE
|
|
23
|
+
* command must be one of the two shapes `hunch init` has ever written (see
|
|
24
|
+
* isClaudeCodeHookCommand). */
|
|
25
|
+
export declare function isHunchHookCommand(command: string, requireProvider: boolean): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single rule for "is this hook command Hunch's own?", shared by every hook
|
|
3
|
+
* writer (scaffold.ts for Claude Code's .claude/settings.json, providers.ts for
|
|
4
|
+
* Cursor/Codex/VS Code/Windsurf/Antigravity). One rule, because each writer
|
|
5
|
+
* deletes what it classifies as ours, and a writer that guesses wide deletes the
|
|
6
|
+
* user's hooks (con_8460b6770f).
|
|
7
|
+
*/
|
|
8
|
+
/** Does one command string invoke Hunch's own hook?
|
|
9
|
+
*
|
|
10
|
+
* Anchored to the shapes hookCommand() / resolveInvocation() write — a Hunch
|
|
11
|
+
* launcher (the pinned npm package spec, or a …/dist|src/cli/index.js|ts path for
|
|
12
|
+
* source installs) plus a `hook` tail, tokens quoted or bare. The old unanchored
|
|
13
|
+
* /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
|
|
14
|
+
* `node ./hook/index.js` as ours and silently deleted them, violating the
|
|
15
|
+
* leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
|
|
16
|
+
*
|
|
17
|
+
* `requireProvider` distinguishes the two writers. Provider configs always carry
|
|
18
|
+
* `hook --provider <name>`, so their tail must too; only the LEGACY fully-quoted
|
|
19
|
+
* form (written before the quoting fix, and by hunch versions that predate
|
|
20
|
+
* --provider) may omit it, and its quotes keep it unambiguous. Claude Code's own
|
|
21
|
+
* settings.json never carries --provider, so a bare `hook` tail is all that is
|
|
22
|
+
* left to match on — and a bare tail alone is far too weak, so there the WHOLE
|
|
23
|
+
* command must be one of the two shapes `hunch init` has ever written (see
|
|
24
|
+
* isClaudeCodeHookCommand). */
|
|
25
|
+
export function isHunchHookCommand(command, requireProvider) {
|
|
26
|
+
if (!requireProvider)
|
|
27
|
+
return isClaudeCodeHookCommand(command);
|
|
28
|
+
const published = /@davesheffer\/hunch/.test(command);
|
|
29
|
+
const launcher = published || /(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)(?=["\s]|$)/.test(command);
|
|
30
|
+
const legacyTail = /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
|
|
31
|
+
return launcher && (legacyTail || /\s"?hook"?\s+"?--provider"?\s+"?[a-z]+"?\s*$/.test(command));
|
|
32
|
+
}
|
|
33
|
+
const ABSOLUTE = String.raw `(?:\/|[A-Za-z]:[\\/]|\\\\)`;
|
|
34
|
+
const CLI_ENTRY = String.raw `[\\/](?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)`;
|
|
35
|
+
const SOURCE_HOOK = new RegExp(String.raw `^\s*(?:"?npx(?:\.cmd)?"?\s+"?tsx"?|"[^"]+"|\S+)\s+(?:"${ABSOLUTE}[^"]*${CLI_ENTRY}"|${ABSOLUTE}\S*${CLI_ENTRY})\s+"?hook"?\s*$`);
|
|
36
|
+
/** The bare-`hook` shapes written into .claude/settings.json, matched as a whole
|
|
37
|
+
* command so nothing chained before or after one can be swept out with it:
|
|
38
|
+
*
|
|
39
|
+
* - published: `npx -y --package=[hunch-exact@npm:]@davesheffer/hunch[@v] hunch hook`
|
|
40
|
+
* - source / `npm link` / dev: `<runtime> <ABSOLUTE …/dist|src/cli/index.js|ts> hook`
|
|
41
|
+
*
|
|
42
|
+
* Quoting is NOT the discriminator. shellInvocation() leaves a safe token bare, so
|
|
43
|
+
* since v1.21.1 a POSIX source install writes `/usr/bin/node /abs/dist/cli/index.js
|
|
44
|
+
* hook` with no quotes at all (quotes appear only for a space or a Windows
|
|
45
|
+
* backslash, and on every token before v1.21.1). Requiring them left our own hook
|
|
46
|
+
* in place and appended another on each `hunch init`. What every version shares is
|
|
47
|
+
* an absolute entry path — resolveInvocation() derives it from import.meta.url —
|
|
48
|
+
* and that is what keeps a tool which merely shares the layout
|
|
49
|
+
* (`node tools/lint/dist/cli/index.js hook`) foreign; deleting it was issue #310.
|
|
50
|
+
* A foreign tool invoked by ABSOLUTE path with this exact layout and a lone `hook`
|
|
51
|
+
* argument is still indistinguishable by string alone. */
|
|
52
|
+
function isClaudeCodeHookCommand(command) {
|
|
53
|
+
const published = /^\s*"?npx(?:\.cmd)?"?\s+/i.test(command)
|
|
54
|
+
&& /--package=(?:hunch-exact@npm:)?@davesheffer\/hunch(?:@[^"\s]+)?(?=["\s])/.test(command)
|
|
55
|
+
&& /\s"?hunch"?\s+"?hook"?\s*$/.test(command);
|
|
56
|
+
return published || SOURCE_HOOK.test(command);
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=hookmatch.js.map
|
|
@@ -18,6 +18,9 @@ export interface HookInstall {
|
|
|
18
18
|
reason?: string;
|
|
19
19
|
/** What to add to `path` by hand (non-writing actions only). */
|
|
20
20
|
snippet?: string;
|
|
21
|
+
/** The file already carries a Hunch block, but a dead one: the snippet
|
|
22
|
+
* REPLACES it rather than being added (non-writing actions only). */
|
|
23
|
+
stale?: boolean;
|
|
21
24
|
}
|
|
22
25
|
/** Portable invocation for text that lands in a TRACKED file (a husky script, a
|
|
23
26
|
* committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
|
|
@@ -30,7 +33,23 @@ export declare const PORTABLE_HOOK_INVOCATION: string;
|
|
|
30
33
|
* or a trailing top-level `if … else … fi` whose every branch ends in
|
|
31
34
|
* `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
|
|
32
35
|
export declare function terminalExitLine(content: string): string | null;
|
|
33
|
-
type BlockState = "installed" | "unreachable" | "missing";
|
|
36
|
+
type BlockState = "installed" | "unreachable" | "stale" | "missing";
|
|
37
|
+
/** The result of inspecting the command a managed block actually runs (issue #315). */
|
|
38
|
+
export type HookInvocationHealth = {
|
|
39
|
+
ok: true;
|
|
40
|
+
invocation: string;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
invocation: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
};
|
|
46
|
+
/** Whether the command in front of a managed block's subcommand is a Hunch
|
|
47
|
+
* launcher that could actually run (issue #315): a block whose command was
|
|
48
|
+
* hand-edited into something else, or whose absolute node/CLI path no longer
|
|
49
|
+
* exists after a reinstall, is present but dead. `command` is the text BEFORE
|
|
50
|
+
* the subcommand; env assignments and a leading `(` belong to the block's own
|
|
51
|
+
* shape and are stripped. Never spawns anything, never touches the network. */
|
|
52
|
+
export declare function hookInvocationHealth(command: string, root: string): HookInvocationHealth;
|
|
34
53
|
export declare function installPostCommitHook(root: string, invocation: string, opts?: {
|
|
35
54
|
private?: boolean;
|
|
36
55
|
commit?: boolean;
|
|
@@ -64,8 +83,13 @@ export interface HookReportEntry {
|
|
|
64
83
|
manager: HookManagerKind;
|
|
65
84
|
/** The file where the block lives (or should live). */
|
|
66
85
|
path: string;
|
|
67
|
-
/** Why an `unreachable` block never runs. */
|
|
86
|
+
/** Why an `unreachable` block never runs, or why a `stale` one could not work. */
|
|
68
87
|
reason?: string;
|
|
88
|
+
/** Where the hook points: the distinct invocations of its marks' blocks. */
|
|
89
|
+
invocation?: string;
|
|
90
|
+
/** Hunch's own option flags the hook's blocks carry (`--private`, `--commit`,
|
|
91
|
+
* `--strict`) — what a plain `hunch init` re-run would drop. Omitted when none. */
|
|
92
|
+
flags?: string[];
|
|
69
93
|
}
|
|
70
94
|
export interface HookReport {
|
|
71
95
|
postCommit: HookReportEntry;
|
|
@@ -74,22 +98,29 @@ export interface HookReport {
|
|
|
74
98
|
postCheckout: HookReportEntry;
|
|
75
99
|
}
|
|
76
100
|
/** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
|
|
77
|
-
* `installed` (present where git will run it
|
|
78
|
-
* after an exec/exit or in a
|
|
101
|
+
* `installed` (present where git will run it, running a Hunch launcher that
|
|
102
|
+
* exists), `unreachable` (present, but after an exec/exit or in a
|
|
103
|
+
* manager-owned file git never reaches), `stale` (present where git runs it,
|
|
104
|
+
* but its command is not a working Hunch launcher — issue #315), or
|
|
79
105
|
* `missing`. postMerge requires BOTH halves (grounding-refresh and
|
|
80
106
|
* repair-provenance) — a repo carrying only one is a partial install, same as
|
|
81
107
|
* `installPostMergeHook` self-healing it. Never writes anything. */
|
|
82
108
|
export declare function hookReport(root: string): HookReport;
|
|
83
109
|
/** Boolean view of `hookReport`: a hook counts as installed only when its
|
|
84
|
-
* managed block is present where git will actually run it
|
|
85
|
-
*
|
|
86
|
-
* (issue #
|
|
110
|
+
* managed block is present where git will actually run it AND the command
|
|
111
|
+
* inside it is a Hunch launcher that exists. An unreachable block (issue #311)
|
|
112
|
+
* and a stale one (issue #315) are both NOT installed. */
|
|
87
113
|
export declare function hookStatus(root: string): {
|
|
88
114
|
postCommit: boolean;
|
|
89
115
|
preCommit: boolean;
|
|
90
116
|
postMerge: boolean;
|
|
91
117
|
postCheckout: boolean;
|
|
92
118
|
};
|
|
119
|
+
/** Doctor lines for where each installed/stale hook points (issue #315): hooks
|
|
120
|
+
* sharing one invocation share one line, and a line whose invocation is not the
|
|
121
|
+
* Hunch currently running says so. Informational — a hook may legitimately
|
|
122
|
+
* point at a different install. Pure; the caller adds indentation and dim(). */
|
|
123
|
+
export declare function hookInvocationLines(report: HookReport, running: string): string[];
|
|
93
124
|
/** CLI lines for one install result: the usual ✓ line when the block is in a
|
|
94
125
|
* file git runs, otherwise a warning with the reason and the snippet to add to
|
|
95
126
|
* the manager's own file. */
|