@davesheffer/hunch 1.38.0 → 1.39.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/dist/cli/index.js +255 -9
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskRecord.js +6 -3
- package/dist/core/taskReport.d.ts +25 -2
- package/dist/core/taskReport.js +92 -18
- package/dist/core/taskReportEvidence.d.ts +4 -1
- package/dist/core/taskReportEvidence.js +5 -2
- package/dist/core/taskReportHook.d.ts +16 -3
- package/dist/core/taskReportHook.js +65 -13
- package/dist/core/taskTouched.d.ts +4 -0
- package/dist/core/taskTouched.js +30 -10
- package/dist/core/types.d.ts +66 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +234 -0
- package/dist/core/workspace.js +335 -0
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +18 -0
- package/dist/extractors/workspaces.js +350 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/hooks.d.ts +2 -0
- package/dist/integrations/hooks.js +25 -0
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +73 -0
- package/dist/integrations/workspaceLedger.js +201 -0
- package/dist/mcp/server.js +54 -0
- package/dist/mcp/taskReportTools.d.ts +9 -0
- package/dist/mcp/taskReportTools.js +13 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace ledger — one record per MACHINE per repository describing that machine's
|
|
3
|
+
* git worktrees and local branches, with deterministic merged verdicts
|
|
4
|
+
* (docs/workspace-ledger.md). A LEAF module (zod + ids + provenance only) so types.ts
|
|
5
|
+
* can register the kind without a cycle, like stateRecords.ts.
|
|
6
|
+
*
|
|
7
|
+
* Security posture, in code: the schema is `.strict()` with bounded lengths, every
|
|
8
|
+
* branch name must be a git-valid ref component, every free-text field passes the
|
|
9
|
+
* credential filter, and NOTHING here reads a record back as authority — the
|
|
10
|
+
* aggregation below produces DISPLAY rows and a recommended action; `prune --apply`
|
|
11
|
+
* (Phase 3) re-snapshots live git and never acts on a stored record.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { shortHash } from "./ids.js";
|
|
15
|
+
import { ProvenanceSchema, isCredentialFreeValue } from "./provenance.js";
|
|
16
|
+
export const WORKSPACE_SCHEMA_VERSION = "hunch.workspace/1";
|
|
17
|
+
/** Record bounds. The extractor keeps the most recently committed entries and says so in
|
|
18
|
+
* provenance, so a huge repository degrades to a truncated record, never to a crash. */
|
|
19
|
+
export const MAX_WORKTREES = 512;
|
|
20
|
+
export const MAX_BRANCHES = 4096;
|
|
21
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
22
|
+
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
23
|
+
export const MACHINE_ID = /^mac_[0-9a-f]{32}$/;
|
|
24
|
+
export const MACHINE_LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
25
|
+
/** A branch name git would accept (`git check-ref-format --branch`), fail-closed: no
|
|
26
|
+
* leading `-` (flag smuggling), no control/whitespace characters, no `..`, `@{`,
|
|
27
|
+
* `.lock` suffix, leading/trailing `.` or `/`, and bounded length. Every real branch
|
|
28
|
+
* from `for-each-ref` passes; a crafted record cannot smuggle an argument. */
|
|
29
|
+
export function isSafeBranchName(name) {
|
|
30
|
+
if (!name || name.length > 256)
|
|
31
|
+
return false;
|
|
32
|
+
if (name.startsWith("-") || name.startsWith("/") || name.endsWith("/") || name.startsWith(".") || name.endsWith("."))
|
|
33
|
+
return false;
|
|
34
|
+
if (name.includes("..") || name.includes("@{") || name.includes("//") || name.endsWith(".lock") || name === "@")
|
|
35
|
+
return false;
|
|
36
|
+
if (/[\s~^:?*[\\\x00-\x1f\x7f]/.test(name))
|
|
37
|
+
return false;
|
|
38
|
+
return !name.split("/").some((part) => part.startsWith(".") || part.endsWith(".lock"));
|
|
39
|
+
}
|
|
40
|
+
const BranchName = z.string().refine(isSafeBranchName, { message: "branch name must be a git-valid, flag-free ref" });
|
|
41
|
+
/** `origin/feature` — a remote-tracking short ref: remote name plus a safe branch name. */
|
|
42
|
+
const UpstreamName = z.string().max(320).refine((v) => {
|
|
43
|
+
const slash = v.indexOf("/");
|
|
44
|
+
return slash > 0 && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v.slice(0, slash)) && isSafeBranchName(v.slice(slash + 1));
|
|
45
|
+
}, { message: "upstream must be <remote>/<branch>" });
|
|
46
|
+
const credentialFree = (label, max) => z.string().min(1).max(max).refine(isCredentialFreeValue, { message: `${label} must not carry credential material` });
|
|
47
|
+
export const WorkspaceWorktreeSchema = z.object({
|
|
48
|
+
/** Stable, path-free handle (hash of the path) so branches can point at a worktree in
|
|
49
|
+
* `branches` publish mode, where the path itself is omitted. */
|
|
50
|
+
id: z.string().regex(/^wt_[0-9a-f]{8}$/),
|
|
51
|
+
path: credentialFree("worktree path", 1024).nullable(),
|
|
52
|
+
branch: BranchName.nullable(),
|
|
53
|
+
head: z.string().regex(SHA),
|
|
54
|
+
is_main: z.boolean(),
|
|
55
|
+
/** null = could not be determined (the path no longer exists). */
|
|
56
|
+
dirty: z.boolean().nullable(),
|
|
57
|
+
locked: z.boolean(),
|
|
58
|
+
prunable: z.boolean(),
|
|
59
|
+
last_commit_at: z.string().regex(ISO).nullable(),
|
|
60
|
+
}).strict();
|
|
61
|
+
export const MERGED_STATUSES = ["merged", "unmerged", "unknown"];
|
|
62
|
+
export const MERGED_METHODS = ["ancestry", "squash", "rebase"];
|
|
63
|
+
export const MergedVerdictSchema = z.object({
|
|
64
|
+
status: z.enum(MERGED_STATUSES),
|
|
65
|
+
method: z.enum(MERGED_METHODS).nullable(),
|
|
66
|
+
evidence: z.array(z.string().max(256).refine(isCredentialFreeValue, { message: "verdict evidence must not carry credential material" })).max(8),
|
|
67
|
+
/** The pull request that landed a merged branch, read from the LOCAL merge / squash commit
|
|
68
|
+
* subject ("Merge pull request #N from …", "… (#N)") — never fetched from a forge. */
|
|
69
|
+
pr: z.number().int().min(1).max(100_000_000).optional(),
|
|
70
|
+
}).strict().superRefine((v, ctx) => {
|
|
71
|
+
if ((v.status === "merged") !== (v.method !== null)) {
|
|
72
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "a merged verdict names its method; any other verdict has none" });
|
|
73
|
+
}
|
|
74
|
+
if (v.pr !== undefined && v.status !== "merged") {
|
|
75
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["pr"], message: "only a merged verdict can name a pull request" });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
export const WorkspaceBranchSchema = z.object({
|
|
79
|
+
name: BranchName,
|
|
80
|
+
head: z.string().regex(SHA),
|
|
81
|
+
is_default: z.boolean(),
|
|
82
|
+
upstream: UpstreamName.nullable(),
|
|
83
|
+
upstream_gone: z.boolean(),
|
|
84
|
+
ahead: z.number().int().min(0).max(1_000_000).nullable(),
|
|
85
|
+
behind: z.number().int().min(0).max(1_000_000).nullable(),
|
|
86
|
+
last_commit_at: z.string().regex(ISO).nullable(),
|
|
87
|
+
/** The worktree (by id) checked out on this branch, if any. */
|
|
88
|
+
worktree: z.string().regex(/^wt_[0-9a-f]{8}$/).nullable(),
|
|
89
|
+
merged: MergedVerdictSchema,
|
|
90
|
+
}).strict();
|
|
91
|
+
export const WorkspaceSchema = z.object({
|
|
92
|
+
schema: z.literal(WORKSPACE_SCHEMA_VERSION),
|
|
93
|
+
id: z.string().regex(/^ws_[0-9a-f]{12}$/),
|
|
94
|
+
machine: z.object({
|
|
95
|
+
id: z.string().regex(MACHINE_ID),
|
|
96
|
+
label: z.string().regex(MACHINE_LABEL),
|
|
97
|
+
platform: z.string().regex(/^[a-z0-9]{1,16}$/),
|
|
98
|
+
}).strict(),
|
|
99
|
+
/** The existing privacy-safe repository label (`stableRepositoryName`): a digest of the
|
|
100
|
+
* canonical fetch remote, never a URL or a path. */
|
|
101
|
+
repository: credentialFree("repository label", 256),
|
|
102
|
+
publish: z.enum(["full", "branches"]),
|
|
103
|
+
observed_at: z.string().regex(ISO),
|
|
104
|
+
fetched_at: z.string().regex(ISO).nullable(),
|
|
105
|
+
default_branch: z.object({ name: BranchName, ref: UpstreamName.or(BranchName), head: z.string().regex(SHA) }).strict().nullable(),
|
|
106
|
+
worktrees: z.array(WorkspaceWorktreeSchema).max(MAX_WORKTREES),
|
|
107
|
+
branches: z.array(WorkspaceBranchSchema).max(MAX_BRANCHES),
|
|
108
|
+
provenance: ProvenanceSchema,
|
|
109
|
+
}).strict().superRefine((record, ctx) => {
|
|
110
|
+
if (record.id !== workspaceId(record.machine.id)) {
|
|
111
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "workspace id must derive from the machine id" });
|
|
112
|
+
}
|
|
113
|
+
if (record.publish === "branches" && record.worktrees.some((w) => w.path !== null)) {
|
|
114
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["worktrees"], message: "branches publish mode carries no worktree paths" });
|
|
115
|
+
}
|
|
116
|
+
const worktreeIds = new Set(record.worktrees.map((w) => w.id));
|
|
117
|
+
if (worktreeIds.size !== record.worktrees.length) {
|
|
118
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["worktrees"], message: "worktree ids must be unique" });
|
|
119
|
+
}
|
|
120
|
+
const names = new Set();
|
|
121
|
+
for (const [index, branch] of record.branches.entries()) {
|
|
122
|
+
if (names.has(branch.name))
|
|
123
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["branches", index], message: "branch names must be unique" });
|
|
124
|
+
names.add(branch.name);
|
|
125
|
+
if (branch.worktree !== null && !worktreeIds.has(branch.worktree)) {
|
|
126
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["branches", index, "worktree"], message: "branch points at a worktree this record does not carry" });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (record.provenance.evidence.length > 64 || record.provenance.evidence.some((e) => e.length > 512 || !isCredentialFreeValue(e))) {
|
|
130
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance"], message: "workspace provenance must remain bounded and credential-free" });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
/** One record per machine: the id derives from the machine id, so a re-snapshot
|
|
134
|
+
* UPDATES the machine's record and two machines can never collide on a file. */
|
|
135
|
+
export function workspaceId(machineId) {
|
|
136
|
+
return "ws_" + machineId.replace(/^mac_/, "").slice(0, 12);
|
|
137
|
+
}
|
|
138
|
+
/** Path-free worktree handle. */
|
|
139
|
+
export function worktreeId(path) {
|
|
140
|
+
return "wt_" + shortHash(path, 8);
|
|
141
|
+
}
|
|
142
|
+
/** The same observation, published under `publish`: `branches` drops every worktree path
|
|
143
|
+
* (the default), `full` keeps them. Pure, so a caller that already took a live snapshot
|
|
144
|
+
* (paths included, for its own display) can publish it without re-running git. */
|
|
145
|
+
export function withPublishMode(record, publish) {
|
|
146
|
+
if (record.publish === publish)
|
|
147
|
+
return record;
|
|
148
|
+
return WorkspaceSchema.parse({
|
|
149
|
+
...record,
|
|
150
|
+
publish,
|
|
151
|
+
worktrees: record.worktrees.map((w) => ({ ...w, path: publish === "full" ? w.path : null })),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** True when two snapshots of the same machine describe the same workspace, ignoring the
|
|
155
|
+
* observation stamps — so an idle machine's hook does not commit a new record per
|
|
156
|
+
* checkout. Provenance is constant per build and is compared too. */
|
|
157
|
+
export function sameWorkspaceContent(a, b) {
|
|
158
|
+
const strip = (w) => JSON.stringify({ ...w, observed_at: null, fetched_at: null });
|
|
159
|
+
return strip(a) === strip(b);
|
|
160
|
+
}
|
|
161
|
+
export const DEFAULT_STALE_AFTER_DAYS = 7;
|
|
162
|
+
export function isUnverified(record, opts = {}) {
|
|
163
|
+
const now = (opts.now ?? new Date()).getTime();
|
|
164
|
+
const days = opts.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
|
|
165
|
+
const observed = Date.parse(record.observed_at);
|
|
166
|
+
return !Number.isFinite(observed) || now - observed > days * 86_400_000;
|
|
167
|
+
}
|
|
168
|
+
/** Same machine id → the newest observation wins; a stale duplicate never shadows a fresh one. */
|
|
169
|
+
export function latestPerMachine(records) {
|
|
170
|
+
const byMachine = new Map();
|
|
171
|
+
for (const r of records) {
|
|
172
|
+
const prev = byMachine.get(r.machine.id);
|
|
173
|
+
if (!prev || Date.parse(r.observed_at) > Date.parse(prev.observed_at))
|
|
174
|
+
byMachine.set(r.machine.id, r);
|
|
175
|
+
}
|
|
176
|
+
return [...byMachine.values()].sort((a, b) => a.machine.label.localeCompare(b.machine.label));
|
|
177
|
+
}
|
|
178
|
+
export function worktreeRows(records, opts = {}) {
|
|
179
|
+
const rows = [];
|
|
180
|
+
for (const r of latestPerMachine(records)) {
|
|
181
|
+
const unverified = isUnverified(r, opts);
|
|
182
|
+
for (const w of r.worktrees) {
|
|
183
|
+
rows.push({
|
|
184
|
+
machine: r.machine.label, worktree_id: w.id, path: w.path, branch: w.branch, head: w.head,
|
|
185
|
+
dirty: w.dirty, locked: w.locked, prunable: w.prunable, last_commit_at: w.last_commit_at,
|
|
186
|
+
seen_at: r.observed_at, unverified,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return rows;
|
|
191
|
+
}
|
|
192
|
+
function daysIdle(iso, now) {
|
|
193
|
+
if (!iso)
|
|
194
|
+
return null;
|
|
195
|
+
const t = Date.parse(iso);
|
|
196
|
+
return Number.isFinite(t) ? Math.floor((now.getTime() - t) / 86_400_000) : null;
|
|
197
|
+
}
|
|
198
|
+
/** The recommendation rules from docs/workspace-ledger.md — deterministic text an agent
|
|
199
|
+
* or a human reads; nothing executes it. */
|
|
200
|
+
export function recommendAction(row, opts = {}) {
|
|
201
|
+
const now = opts.now ?? new Date();
|
|
202
|
+
const staleDays = opts.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
|
|
203
|
+
const suffix = row.unverified_on.length ? ` (unverified: ${row.unverified_on.join(", ")})` : "";
|
|
204
|
+
if (row.is_default)
|
|
205
|
+
return "keep: default branch";
|
|
206
|
+
if (row.heads.length > 1)
|
|
207
|
+
return `review: local heads differ across ${row.machines.join(", ")}${suffix}`;
|
|
208
|
+
if (row.merged.status === "merged") {
|
|
209
|
+
if (row.dirty_on.length)
|
|
210
|
+
return `keep: dirty worktree on ${row.dirty_on.join(", ")}${suffix}`;
|
|
211
|
+
const parts = [`delete local on ${row.machines.join(", ")}`];
|
|
212
|
+
if (row.worktree_on.length)
|
|
213
|
+
parts.push(`prune worktree on ${row.worktree_on.join(", ")}`);
|
|
214
|
+
return parts.join("; ") + suffix;
|
|
215
|
+
}
|
|
216
|
+
// Unmerged: a dirty worktree is not a reason to keep (the branch is kept anyway) but
|
|
217
|
+
// it is what the reader needs to know before touching the branch on that machine.
|
|
218
|
+
const dirty = row.dirty_on.length ? `; dirty worktree on ${row.dirty_on.join(", ")}` : "";
|
|
219
|
+
if (row.merged.status === "unknown")
|
|
220
|
+
return `review: merge state unknown${dirty}${suffix}`;
|
|
221
|
+
if (row.upstream === null || row.upstream_gone) {
|
|
222
|
+
const idle = daysIdle(row.last_commit_at, now);
|
|
223
|
+
const why = row.upstream_gone ? "upstream deleted, unmerged work" : "unpushed";
|
|
224
|
+
if (idle !== null && idle > staleDays)
|
|
225
|
+
return `review: ${why}, ${idle}d idle${dirty}${suffix}`;
|
|
226
|
+
return `keep: ${why}${dirty}${suffix}`;
|
|
227
|
+
}
|
|
228
|
+
return `keep${dirty}${suffix}`;
|
|
229
|
+
}
|
|
230
|
+
/** Merge verdicts across machines for one branch at one head: any machine that PROVED a
|
|
231
|
+
* merge (with evidence) wins over machines that could not tell; `unknown` never
|
|
232
|
+
* outranks `unmerged`, so an offline machine cannot mask real unmerged work. */
|
|
233
|
+
function bestVerdict(verdicts) {
|
|
234
|
+
return verdicts.find((v) => v.status === "merged")
|
|
235
|
+
?? verdicts.find((v) => v.status === "unmerged")
|
|
236
|
+
?? verdicts[0]
|
|
237
|
+
?? { status: "unknown", method: null, evidence: [] };
|
|
238
|
+
}
|
|
239
|
+
export function branchRows(records, opts = {}) {
|
|
240
|
+
const latest = latestPerMachine(records);
|
|
241
|
+
const byName = new Map();
|
|
242
|
+
for (const record of latest) {
|
|
243
|
+
const unverified = isUnverified(record, opts);
|
|
244
|
+
for (const branch of record.branches) {
|
|
245
|
+
const list = byName.get(branch.name) ?? [];
|
|
246
|
+
list.push({ record, branch, unverified });
|
|
247
|
+
byName.set(branch.name, list);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const rows = [];
|
|
251
|
+
for (const [name, entries] of [...byName.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
252
|
+
const worktreeOf = (e) => e.branch.worktree ? e.record.worktrees.find((w) => w.id === e.branch.worktree) : undefined;
|
|
253
|
+
const withUpstream = entries.find((e) => e.branch.upstream !== null) ?? entries[0];
|
|
254
|
+
const partial = {
|
|
255
|
+
name,
|
|
256
|
+
machines: entries.map((e) => e.record.machine.label),
|
|
257
|
+
worktree_on: entries.filter((e) => worktreeOf(e)).map((e) => e.record.machine.label),
|
|
258
|
+
dirty_on: entries.filter((e) => worktreeOf(e)?.dirty === true).map((e) => e.record.machine.label),
|
|
259
|
+
heads: [...new Set(entries.map((e) => e.branch.head))],
|
|
260
|
+
is_default: entries.some((e) => e.branch.is_default),
|
|
261
|
+
upstream: withUpstream.branch.upstream,
|
|
262
|
+
upstream_gone: entries.some((e) => e.branch.upstream_gone),
|
|
263
|
+
ahead: withUpstream.branch.ahead,
|
|
264
|
+
behind: withUpstream.branch.behind,
|
|
265
|
+
last_commit_at: entries.map((e) => e.branch.last_commit_at).filter((d) => !!d).sort().at(-1) ?? null,
|
|
266
|
+
merged: bestVerdict(entries.map((e) => e.branch.merged)),
|
|
267
|
+
unverified_on: entries.filter((e) => e.unverified).map((e) => e.record.machine.label),
|
|
268
|
+
};
|
|
269
|
+
rows.push({ ...partial, action: recommendAction(partial, opts) });
|
|
270
|
+
}
|
|
271
|
+
return rows;
|
|
272
|
+
}
|
|
273
|
+
/** "2h ago" / "9d ago" for the SEEN column. */
|
|
274
|
+
export function ago(iso, now = new Date()) {
|
|
275
|
+
const ms = now.getTime() - Date.parse(iso);
|
|
276
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
277
|
+
return "just now";
|
|
278
|
+
const m = Math.floor(ms / 60_000);
|
|
279
|
+
if (m < 60)
|
|
280
|
+
return `${m}m ago`;
|
|
281
|
+
const h = Math.floor(m / 60);
|
|
282
|
+
if (h < 48)
|
|
283
|
+
return `${h}h ago`;
|
|
284
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
285
|
+
}
|
|
286
|
+
function pruneStepsFor(record, opts) {
|
|
287
|
+
const steps = [];
|
|
288
|
+
const skipped = [];
|
|
289
|
+
for (const b of record.branches) {
|
|
290
|
+
const wt = b.worktree ? record.worktrees.find((w) => w.id === b.worktree) : undefined;
|
|
291
|
+
const reason = opts.skip(b, wt);
|
|
292
|
+
if (reason) {
|
|
293
|
+
if (b.merged.status === "merged" && !b.is_default)
|
|
294
|
+
skipped.push({ branch: b.name, reason });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const commands = [];
|
|
298
|
+
if (wt)
|
|
299
|
+
commands.push(`git worktree remove -- ${wt.path ?? "<its worktree>"}`);
|
|
300
|
+
commands.push(`git branch -d -- ${b.name}`);
|
|
301
|
+
steps.push({
|
|
302
|
+
branch: b.name, head: b.head, worktree: wt ? { id: wt.id, path: wt.path } : null, commands,
|
|
303
|
+
why: `${b.merged.method}${b.merged.pr ? ` (PR #${b.merged.pr})` : ""}: ${b.merged.evidence[0] ?? ""}`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return { steps, skipped };
|
|
307
|
+
}
|
|
308
|
+
/** Why a branch must not be pruned, or null when it may. The rules are the documented ones:
|
|
309
|
+
* proven merged, not the default branch, worktree (if any) clean, unlocked and present. */
|
|
310
|
+
export function pruneRefusal(b, wt) {
|
|
311
|
+
if (b.is_default)
|
|
312
|
+
return "default branch";
|
|
313
|
+
if (b.merged.status !== "merged")
|
|
314
|
+
return b.merged.status === "unknown" ? "merge state unknown" : "not merged";
|
|
315
|
+
if (wt?.is_main)
|
|
316
|
+
return "checked out in the main worktree (switch away first)";
|
|
317
|
+
if (wt?.locked)
|
|
318
|
+
return "worktree is locked";
|
|
319
|
+
if (wt?.prunable)
|
|
320
|
+
return "worktree path is missing (git worktree prune first)";
|
|
321
|
+
if (wt && wt.dirty !== false)
|
|
322
|
+
return wt.dirty === null ? "worktree state could not be read" : "worktree has uncommitted or untracked changes";
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
export function planPrune(live, others) {
|
|
326
|
+
const mine = pruneStepsFor(live, { skip: pruneRefusal });
|
|
327
|
+
const plan = { local: mine.steps, others: {}, skipped: mine.skipped };
|
|
328
|
+
for (const record of latestPerMachine(others.filter((r) => r.machine.id !== live.machine.id))) {
|
|
329
|
+
const theirs = pruneStepsFor(record, { skip: pruneRefusal });
|
|
330
|
+
if (theirs.steps.length)
|
|
331
|
+
plan.others[record.machine.label] = theirs.steps;
|
|
332
|
+
}
|
|
333
|
+
return plan;
|
|
334
|
+
}
|
|
335
|
+
//# sourceMappingURL=workspace.js.map
|
|
@@ -1,31 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* slightly-long symbol range) — the same class of accepted limitation
|
|
17
|
-
* `toleratedErrorScopes` documents for the tree-sitter grammars, not a silent gap.
|
|
18
|
-
* The chart-wide `importedFiles` widening this module's output flows through
|
|
19
|
-
* (indexer.ts) also lets a YAML alias resolve to an anchor in a sibling chart
|
|
20
|
-
* file, even though YAML anchors are properly document-scoped; this only fires
|
|
21
|
-
* when the alias has no matching anchor in its own file (i.e. only on input
|
|
22
|
-
* that's already invalid YAML on its own terms), so it's bounded, but it's a
|
|
23
|
-
* real, disclosed side effect of the chart-scoping mechanism, not something to
|
|
24
|
-
* silently rely on.
|
|
25
|
-
*/
|
|
26
|
-
import { type ParsedSymbol, type ParsedCall } from "./parse.js";
|
|
1
|
+
import type { ParsedSymbolKind } from "./languages.js";
|
|
2
|
+
export interface HelmSymbol {
|
|
3
|
+
name: string;
|
|
4
|
+
kind: ParsedSymbolKind;
|
|
5
|
+
startChar: number;
|
|
6
|
+
endChar: number;
|
|
7
|
+
loc: number;
|
|
8
|
+
bodyText: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HelmCall {
|
|
11
|
+
callee: string;
|
|
12
|
+
atChar: number;
|
|
13
|
+
endChar: number;
|
|
14
|
+
member: boolean;
|
|
15
|
+
}
|
|
27
16
|
export interface HelmExtraction {
|
|
28
|
-
symbols:
|
|
29
|
-
calls:
|
|
17
|
+
symbols: HelmSymbol[];
|
|
18
|
+
calls: HelmCall[];
|
|
30
19
|
}
|
|
31
20
|
export declare function extractHelmDirectives(source: string): HelmExtraction;
|
package/dist/extractors/helm.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Known bounded limitation: this is a token scan, not a full parser. A literal
|
|
13
13
|
* `}}` inside a quoted argument, or an include/define-shaped string inside a
|
|
14
|
-
* `{{/* comment *}}`, can misattribute a
|
|
14
|
+
* `{{/* comment *}}`, can misattribute a char range or produce a phantom call.
|
|
15
15
|
* Both are bounded failure modes (a stray reference to a real symbol name, or a
|
|
16
16
|
* slightly-long symbol range) — the same class of accepted limitation
|
|
17
17
|
* `toleratedErrorScopes` documents for the tree-sitter grammars, not a silent gap.
|
|
@@ -39,20 +39,20 @@ export function extractHelmDirectives(source) {
|
|
|
39
39
|
const stack = [];
|
|
40
40
|
for (const m of source.matchAll(ACTION)) {
|
|
41
41
|
const raw = m[1];
|
|
42
|
-
// "{{" is 2 chars; a trim-marker "{{-" is 3 — this is the
|
|
43
|
-
// `raw`'s first character within `source`, needed for call-site
|
|
42
|
+
// "{{" is 2 chars; a trim-marker "{{-" is 3 — this is the char offset of
|
|
43
|
+
// `raw`'s first character within `source`, needed for call-site atChar math.
|
|
44
44
|
const innerStart = m.index + (source[m.index + 2] === "-" ? 3 : 2);
|
|
45
|
-
const
|
|
45
|
+
const endChar = m.index + m[0].length;
|
|
46
46
|
const body = raw.trim();
|
|
47
47
|
const spaceIdx = body.search(/\s/);
|
|
48
48
|
const keyword = spaceIdx === -1 ? body : body.slice(0, spaceIdx);
|
|
49
49
|
if (keyword === "define") {
|
|
50
50
|
const name = NAME_ARG.exec(body.slice(spaceIdx + 1).trim())?.[1];
|
|
51
|
-
stack.push({ name,
|
|
51
|
+
stack.push({ name, startChar: m.index });
|
|
52
52
|
}
|
|
53
53
|
else if (BLOCK_OPEN.has(keyword)) {
|
|
54
54
|
// if/range/with/block: depth marker only, no symbol on its own.
|
|
55
|
-
stack.push({
|
|
55
|
+
stack.push({ startChar: m.index });
|
|
56
56
|
}
|
|
57
57
|
else if (keyword === "end") {
|
|
58
58
|
const open = stack.pop();
|
|
@@ -60,16 +60,16 @@ export function extractHelmDirectives(source) {
|
|
|
60
60
|
symbols.push({
|
|
61
61
|
name: open.name,
|
|
62
62
|
kind: "variable",
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
loc: source.slice(open.
|
|
66
|
-
bodyText: source.slice(open.
|
|
63
|
+
startChar: open.startChar,
|
|
64
|
+
endChar,
|
|
65
|
+
loc: source.slice(open.startChar, endChar).split("\n").length,
|
|
66
|
+
bodyText: source.slice(open.startChar, endChar).slice(0, MAX_BODY_TEXT_CHARS),
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
for (const call of raw.matchAll(CALL_SITE)) {
|
|
71
|
-
const
|
|
72
|
-
calls.push({ callee: call[1],
|
|
71
|
+
const atChar = innerStart + call.index;
|
|
72
|
+
calls.push({ callee: call[1], atChar, endChar: atChar + call[0].length, member: false });
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
return { symbols, calls };
|