@davesheffer/hunch 1.43.0 → 1.45.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 +16 -16
- package/dist/cli/index.js +267 -89
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +11 -0
- 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/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/escalations.d.ts +20 -0
- package/dist/core/escalations.js +47 -0
- package/dist/core/hookText.js +1 -1
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -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 +5 -3
- package/dist/integrations/gitignore.d.ts +23 -0
- package/dist/integrations/gitignore.js +48 -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 +16 -0
- package/dist/integrations/providers.js +35 -6
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +13 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/leanToolList.d.ts +9 -0
- package/dist/mcp/leanToolList.js +89 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +62 -6
- package/dist/serve/mcpHttp.js +2 -0
- 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 -6
- package/server.json +2 -2
- package/tooling/md1-benchmark.mjs +1 -1
- package/tooling/competitive-watch.mjs +0 -142
package/dist/core/eol.js
CHANGED
|
@@ -6,6 +6,12 @@ export function normalizeEol(text) {
|
|
|
6
6
|
export function lineContent(line) {
|
|
7
7
|
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
8
8
|
}
|
|
9
|
+
/** Length of the line break (CRLF, CR, or LF) starting at `at`, or 0. */
|
|
10
|
+
export function eolLengthAt(text, at) {
|
|
11
|
+
if (text[at] === "\r")
|
|
12
|
+
return text[at + 1] === "\n" ? 2 : 1;
|
|
13
|
+
return text[at] === "\n" ? 1 : 0;
|
|
14
|
+
}
|
|
9
15
|
/** Read logical lines while keeping offsets in the original UTF-16 source. */
|
|
10
16
|
export function* sourceLines(text) {
|
|
11
17
|
let start = 0;
|
|
@@ -127,3 +127,23 @@ export interface PolicyLite {
|
|
|
127
127
|
* accept/reject) is a human call. Machine conclusions never appear here as
|
|
128
128
|
* approvals — every entry is a QUESTION with its explicit resolution verb. */
|
|
129
129
|
export declare function policyEscalations(policies: readonly PolicyLite[]): Escalation[];
|
|
130
|
+
/** What one prompt is about, for task-scoped escalation delivery (dec_12ba37db1c). */
|
|
131
|
+
export interface EscalationScope {
|
|
132
|
+
/** the prompt text as the human wrote it. */
|
|
133
|
+
prompt: string;
|
|
134
|
+
/** repo-relative paths the prompt names (taskSelection.promptPaths). */
|
|
135
|
+
paths: readonly string[];
|
|
136
|
+
/** record ids the prompt's task selection put on top, when there is one. */
|
|
137
|
+
selectedIds?: ReadonlySet<string>;
|
|
138
|
+
}
|
|
139
|
+
/** The escalations one prompt's task touches. A prompt hook asks the human only
|
|
140
|
+
* about these; every other entry stays exactly as pending — still listed by
|
|
141
|
+
* `hunch escalations`, hunch_escalations and hunch_now, never dropped and
|
|
142
|
+
* never answered for the human. Deterministic explicit anchors only: a
|
|
143
|
+
* prompt that asks about escalations themselves (path tokens aside), an entry
|
|
144
|
+
* id or topic the prompt names, an id the task selection chose, or a recorded
|
|
145
|
+
* anchor path the prompt names (a directory covers the files under it; a glob
|
|
146
|
+
* covers the paths it matches). `relatedFiles` maps an entry id to its anchor
|
|
147
|
+
* paths: a decision's related_files, a policy's scope paths/globs. An entry
|
|
148
|
+
* with no anchor the prompt names stays CLI/MCP-only. */
|
|
149
|
+
export declare function relevantEscalations(items: readonly Escalation[], scope: EscalationScope, relatedFiles: (id: string) => readonly string[]): Escalation[];
|
package/dist/core/escalations.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { topicCollisions } from "./topics.js";
|
|
2
2
|
import { liveRewrites, deadRewrites, commitRepairReviewHash, firstFor } from "./commitrepair.js";
|
|
3
3
|
import { importedAdrReviewHash, importedAdrSourceHash, pendingImportedAdrReviews } from "./importReview.js";
|
|
4
|
+
import { pathMatchesGlob } from "./glob.js";
|
|
4
5
|
/** Escalations gating surfaces (CI exit codes, "needs your call" counts/lists)
|
|
5
6
|
* should act on: everything except an entry explicitly marked
|
|
6
7
|
* actionable:false (#61). The full list — including non-actionable
|
|
@@ -259,4 +260,50 @@ export function policyEscalations(policies) {
|
|
|
259
260
|
}
|
|
260
261
|
return out;
|
|
261
262
|
}
|
|
263
|
+
const trimSlash = (x) => x.replace(/\/+$/, "");
|
|
264
|
+
const samePathOrNested = (x, y) => {
|
|
265
|
+
const a = trimSlash(x), b = trimSlash(y);
|
|
266
|
+
return !!a && !!b && (a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`));
|
|
267
|
+
};
|
|
268
|
+
/** A recorded related path or scope glob vs a path the prompt names. A plain
|
|
269
|
+
* entry relates both ways by directory nesting; a glob entry matches a path it
|
|
270
|
+
* covers, or a directory that holds its literal prefix (`src/` ~ `src/store/**`). */
|
|
271
|
+
const relatedPathHit = (entry, promptPath) => {
|
|
272
|
+
if (!/[*?]/.test(entry))
|
|
273
|
+
return samePathOrNested(entry, promptPath);
|
|
274
|
+
if (pathMatchesGlob(promptPath, entry))
|
|
275
|
+
return true;
|
|
276
|
+
const literal = trimSlash(entry.split("/").filter((_, i, segs) => !segs.slice(0, i + 1).some((s) => /[*?]/.test(s))).join("/"));
|
|
277
|
+
const dir = trimSlash(promptPath);
|
|
278
|
+
return !!literal && !!dir && (literal === dir || literal.startsWith(`${dir}/`));
|
|
279
|
+
};
|
|
280
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
281
|
+
/** The prompt's prose without path-like tokens (a `/` or a file extension), so a
|
|
282
|
+
* file named `escalations.test.ts` is not a question about escalations. */
|
|
283
|
+
const proseOnly = (prompt) => prompt.split(/\s+/).filter((t) => { const w = t.replace(/[)\],.;:!?'"`]+$/, ""); return !w.includes("/") && !/\.[a-z0-9]{1,8}$/i.test(w); }).join(" ");
|
|
284
|
+
/** The escalations one prompt's task touches. A prompt hook asks the human only
|
|
285
|
+
* about these; every other entry stays exactly as pending — still listed by
|
|
286
|
+
* `hunch escalations`, hunch_escalations and hunch_now, never dropped and
|
|
287
|
+
* never answered for the human. Deterministic explicit anchors only: a
|
|
288
|
+
* prompt that asks about escalations themselves (path tokens aside), an entry
|
|
289
|
+
* id or topic the prompt names, an id the task selection chose, or a recorded
|
|
290
|
+
* anchor path the prompt names (a directory covers the files under it; a glob
|
|
291
|
+
* covers the paths it matches). `relatedFiles` maps an entry id to its anchor
|
|
292
|
+
* paths: a decision's related_files, a policy's scope paths/globs. An entry
|
|
293
|
+
* with no anchor the prompt names stays CLI/MCP-only. */
|
|
294
|
+
export function relevantEscalations(items, scope, relatedFiles) {
|
|
295
|
+
const prompt = scope.prompt ?? "";
|
|
296
|
+
if (/(?:^|[^a-z0-9-])escalations?(?:$|[^a-z0-9])/i.test(proseOnly(prompt)))
|
|
297
|
+
return [...items];
|
|
298
|
+
const lower = prompt.toLowerCase();
|
|
299
|
+
const named = (term) => {
|
|
300
|
+
const t = term.trim().toLowerCase();
|
|
301
|
+
if (t.length < 3)
|
|
302
|
+
return false;
|
|
303
|
+
return new RegExp(`(?:^|[^a-z0-9_-])${escapeRegExp(t)}(?:$|[^a-z0-9_-])`).test(lower);
|
|
304
|
+
};
|
|
305
|
+
return items.filter((e) => named(e.topic)
|
|
306
|
+
|| e.decisionIds.some((id) => named(id) || scope.selectedIds?.has(id))
|
|
307
|
+
|| e.decisionIds.some((id) => relatedFiles(id).some((file) => scope.paths.some((p) => relatedPathHit(file, p)))));
|
|
308
|
+
}
|
|
262
309
|
//# sourceMappingURL=escalations.js.map
|
package/dist/core/hookText.js
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
export const HOOK_REMINDER = "Hunch (engineering memory) is available for this repo. Before editing, call " +
|
|
5
5
|
"hunch_check_constraints(scope) for do-not-break invariants and hunch_why(target) " +
|
|
6
6
|
"for the rationale; use hunch_get_dependents for blast radius and hunch_bug_lineage " +
|
|
7
|
-
"for prior root causes.
|
|
7
|
+
"for prior root causes. Record only what the human decides or corrects.";
|
|
8
8
|
//# sourceMappingURL=hookText.js.map
|
package/dist/core/procstart.d.ts
CHANGED
|
@@ -66,10 +66,14 @@ export declare function ownerInstance(ownerStart: string | undefined, pid: numbe
|
|
|
66
66
|
* held nonce and could age-steal its live lock. */
|
|
67
67
|
export declare const heldLockNonces: Set<string>;
|
|
68
68
|
/** What a contender should do about a SAME-HOST lock owner.
|
|
69
|
-
* - `stale`
|
|
70
|
-
* - `live`
|
|
71
|
-
* - `
|
|
72
|
-
|
|
69
|
+
* - `stale` — provably not the owner's process any more: take it over.
|
|
70
|
+
* - `live` — authoritative, however old the file looks: never steal it.
|
|
71
|
+
* - `unproven` — a live pid whose process instance no token can compare (macOS,
|
|
72
|
+
* Windows, a legacy record). Treated as live; a caller with a
|
|
73
|
+
* better probe may still disprove it (src/serve/writelock.ts
|
|
74
|
+
* asks the OS when that process was created, issue #287).
|
|
75
|
+
* - `age` — nothing is proven; fall back to the caller's own age rule. */
|
|
76
|
+
export type SameHostVerdict = "stale" | "live" | "unproven" | "age";
|
|
73
77
|
/** The parts of a lock record this decision reads. Both lock formats supply it. */
|
|
74
78
|
export interface SameHostOwner {
|
|
75
79
|
pid: number;
|
|
@@ -91,4 +95,11 @@ export interface SameHostEnv {
|
|
|
91
95
|
* process state beyond what `env` hands it.
|
|
92
96
|
*/
|
|
93
97
|
export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEnv): SameHostVerdict;
|
|
98
|
+
/** Wall-clock ms at which the process now holding `pid` was created, from the
|
|
99
|
+
* OS, or null when it cannot be read. SPAWNS (PowerShell on Windows, `ps` on
|
|
100
|
+
* macOS), so it is for rare paths only — a lock that already looks stale —
|
|
101
|
+
* never for every acquisition. Precision is a second on `ps`. Null on every
|
|
102
|
+
* other platform: linux derives `lstart` from boot time, which a forward clock
|
|
103
|
+
* step shifts for live processes too (see the header). */
|
|
104
|
+
export declare function processStartedAt(pid: number): number | null;
|
|
94
105
|
export {};
|
package/dist/core/procstart.js
CHANGED
|
@@ -23,19 +23,23 @@
|
|
|
23
23
|
* puts two writers inside the mutex. Reproduced by simply `utimes`-ing a held
|
|
24
24
|
* lock 30 s into the past. Nothing in this file may read a clock.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
* (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces)
|
|
28
|
-
* macOS and Windows a DIFFERENT live process that recycled the owner's pid
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
26
|
+
* OFF LINUX (issue #287). Only linux exposes a spawn-free, stable process-instance
|
|
27
|
+
* id (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces), so
|
|
28
|
+
* on macOS and Windows a DIFFERENT live process that recycled the owner's pid is
|
|
29
|
+
* "unproven" here, which this table treats as live. The write lock closes that
|
|
30
|
+
* forever-deadlock on its own rare path, once a lock already looks stale: it asks
|
|
31
|
+
* the OS when the process now holding the pid was CREATED (`processStartedAt`, a
|
|
32
|
+
* spawn) and compares that with the `at` the owner recorded when it took the lock.
|
|
33
|
+
* A process created after the lock was taken cannot have taken it. Both instants
|
|
34
|
+
* are the HOST's wall clock, read on this same host, so the filesystem clock never
|
|
35
|
+
* enters; only a backward clock step of more than the slack between the owner's
|
|
36
|
+
* start and its acquisition could fake it. macOS and Windows record that creation
|
|
37
|
+
* instant absolutely; linux does not (`ps lstart` is boot time + uptime, so a
|
|
38
|
+
* forward clock step after boot moves every live process's "creation" forward and
|
|
39
|
+
* would fake a reuse), so on linux the probe reports nothing and a tokenless
|
|
40
|
+
* record keeps main's pid rule — linux writers record a token instead. The earlier objection to such a probe —
|
|
41
|
+
* that it needs either an owner-recorded start time (a spawn on every write) or a
|
|
42
|
+
* start-vs-mtime comparison — missed that `at` is already in every record.
|
|
39
43
|
*
|
|
40
44
|
* UNSUPPORTED TOPOLOGIES. A linux-written token carries its boot id and its pid
|
|
41
45
|
* and time namespaces, so a lock written under another boot or another namespace
|
|
@@ -51,12 +55,14 @@
|
|
|
51
55
|
* while our own token is memoized from before the checkpoint — so a restored
|
|
52
56
|
* holder may judge itself against a token that no longer describes it. Tokenless
|
|
53
57
|
* records (a legacy pre-token hunch, any non-linux writer, an unreadable /proc)
|
|
54
|
-
* get main's unchanged pid rule — a live same-host pid keeps its lock
|
|
55
|
-
*
|
|
58
|
+
* get main's unchanged pid rule — a live same-host pid keeps its lock (unless the
|
|
59
|
+
* write lock's creation-time check above proves the pid reused), a dead one is
|
|
60
|
+
* stealable — with exactly one exception, the own-pid + foreign-nonce row
|
|
56
61
|
* described in `judgeSameHostOwner`, which gets the AGE rule instead of main's
|
|
57
62
|
* "live forever".
|
|
58
63
|
*/
|
|
59
64
|
import { readFileSync, readlinkSync } from "node:fs";
|
|
65
|
+
import { spawnSync } from "node:child_process";
|
|
60
66
|
/** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
|
|
61
67
|
* printed — it is an identity component, never a quantity, so it is never
|
|
62
68
|
* converted to a Number (which would lose precision above 2^53 and invite
|
|
@@ -237,8 +243,40 @@ export function judgeSameHostOwner(owner, env) {
|
|
|
237
243
|
// that a live writer may still hold.
|
|
238
244
|
if (owner.pid === env.selfPid && owner.nonce !== undefined)
|
|
239
245
|
return "age";
|
|
240
|
-
// A
|
|
241
|
-
//
|
|
242
|
-
|
|
246
|
+
// 7. A live pid no token can compare — another process's, or a nonce-less
|
|
247
|
+
// record of our own pid. Never stolen on a guess: "unproven" is "live" to a
|
|
248
|
+
// caller that has nothing better. The write lock asks the OS for that
|
|
249
|
+
// process's creation time, which can prove the pid was reused (issue #287).
|
|
250
|
+
return "unproven";
|
|
251
|
+
}
|
|
252
|
+
/** Wall-clock ms at which the process now holding `pid` was created, from the
|
|
253
|
+
* OS, or null when it cannot be read. SPAWNS (PowerShell on Windows, `ps` on
|
|
254
|
+
* macOS), so it is for rare paths only — a lock that already looks stale —
|
|
255
|
+
* never for every acquisition. Precision is a second on `ps`. Null on every
|
|
256
|
+
* other platform: linux derives `lstart` from boot time, which a forward clock
|
|
257
|
+
* step shifts for live processes too (see the header). */
|
|
258
|
+
export function processStartedAt(pid) {
|
|
259
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
260
|
+
return null;
|
|
261
|
+
try {
|
|
262
|
+
if (process.platform === "win32") {
|
|
263
|
+
// Epoch milliseconds, not a formatted date: a custom format string follows the
|
|
264
|
+
// culture's time separator (fi-FI prints `09.04.19`), which Date.parse rejects.
|
|
265
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `([DateTimeOffset](Get-Process -Id ${pid} -ErrorAction Stop).StartTime).ToUnixTimeMilliseconds()`], { encoding: "utf8", timeout: 3_000, windowsHide: true });
|
|
266
|
+
const ms = r.status === 0 && /^\d+$/.test(r.stdout.trim()) ? Number(r.stdout.trim()) : NaN;
|
|
267
|
+
return Number.isFinite(ms) ? ms : null;
|
|
268
|
+
}
|
|
269
|
+
if (process.platform !== "darwin")
|
|
270
|
+
return null;
|
|
271
|
+
const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 3_000, env: { ...process.env, LC_ALL: "C", LANG: "C" } });
|
|
272
|
+
const m = /^\s*\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(\d{4})\s*$/.exec(r.status === 0 ? r.stdout : "");
|
|
273
|
+
const month = m ? ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].indexOf(m[1]) : -1;
|
|
274
|
+
if (!m || month < 0)
|
|
275
|
+
return null;
|
|
276
|
+
return new Date(Number(m[6]), month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])).getTime(); // ps prints local time
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
243
281
|
}
|
|
244
282
|
//# sourceMappingURL=procstart.js.map
|
|
@@ -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;
|
package/dist/core/taskTouched.js
CHANGED
|
@@ -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`;
|
package/dist/extractors/git.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
374
|
-
*
|
|
375
|
-
|
|
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
|
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);
|