@davesheffer/hunch 1.45.2 → 1.47.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 +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +77 -95
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/extractors/diff.js
CHANGED
|
@@ -20,25 +20,21 @@ const DECL_PATTERNS = [
|
|
|
20
20
|
{ kind: "type", re: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*[=<]/ },
|
|
21
21
|
{ kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/ },
|
|
22
22
|
{ kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?function/ },
|
|
23
|
+
// No Python class pattern: the generic class pattern above already matches a
|
|
24
|
+
// Python class header, and the first match wins.
|
|
23
25
|
{ kind: "function", re: /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/ },
|
|
24
|
-
// No Python-specific class pattern needed: the generic TS `class` pattern above has no
|
|
25
|
-
// trailing-syntax requirement (no `{`/`:`), so it already matches Python's
|
|
26
|
-
// `class Foo(Bar):` header too, and — since declOf() returns on the first match —
|
|
27
|
-
// always wins for Python class lines before any Python-specific pattern would run.
|
|
28
26
|
];
|
|
29
27
|
import { languageFor, isSubstantive } from "./languages.js";
|
|
30
28
|
const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
|
|
31
29
|
const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
|
|
32
30
|
const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
33
31
|
const PHP_REQUIRE_RE = /\b(?:require|require_once|include|include_once)\s*(?:\(\s*)?['"]([^'"]+)['"]/;
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const PY_FROM_IMPORT_RE = /^\s*from\s+([.\w]+)\s+import\s+/; // "from os import path" / "from . import x"
|
|
41
|
-
const isCode = (p) => !!p && languageFor(p) !== null;
|
|
32
|
+
// A COMPLETE Python import statement: comma-separated dotted modules, each with an
|
|
33
|
+
// optional alias, an optional trailing comment, and nothing else. The anchoring is
|
|
34
|
+
// what rejects TypeScript's `import Foo = Bar.Baz;`.
|
|
35
|
+
const PY_IMPORT_RE = /^\s*import\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)(?:\s+as\s+\w+)?(?:\s*,\s*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s+as\s+\w+)?)*\s*(?:#.*)?$/;
|
|
36
|
+
const PY_FROM_RE = /^\s*from\s+(\.*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*|\.+)\s+import\b/;
|
|
37
|
+
const isCode = (p) => p.length > 0 && languageFor(p) !== null;
|
|
42
38
|
function declOf(line) {
|
|
43
39
|
for (const { kind, re } of DECL_PATTERNS) {
|
|
44
40
|
const m = re.exec(line);
|
|
@@ -52,7 +48,7 @@ function importOf(line) {
|
|
|
52
48
|
CONT_IMPORT_RE.exec(line) ??
|
|
53
49
|
REQUIRE_RE.exec(line) ??
|
|
54
50
|
PHP_REQUIRE_RE.exec(line) ??
|
|
55
|
-
|
|
51
|
+
PY_FROM_RE.exec(line) ??
|
|
56
52
|
PY_IMPORT_RE.exec(line);
|
|
57
53
|
return m ? m[1] : null;
|
|
58
54
|
}
|
|
@@ -280,15 +276,15 @@ export function analyzeDiff(diff) {
|
|
|
280
276
|
// ---- inside a hunk: content lines ----
|
|
281
277
|
if (raw.startsWith("+")) {
|
|
282
278
|
const body = raw.slice(1);
|
|
283
|
-
// Raw added lines are captured for
|
|
279
|
+
// Raw added lines are captured for every file, before the substantive gate:
|
|
284
280
|
// content-matched constraints and Veto tripwires are not code-only rules
|
|
285
281
|
// (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
|
|
286
282
|
// Dockerfile). Skipping them here left `scopedAdded` empty, which
|
|
287
283
|
// buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
|
|
288
284
|
// pre-edit hook denied the edit while `hunch check --strict` passed the
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
285
|
+
// same diff. Churn counters below now include prose (isSubstantive, issue
|
|
286
|
+
// #12), while symbol/import extraction stays code-only (isCode): a
|
|
287
|
+
// declaration is a code concept.
|
|
292
288
|
let lines = addedLinesBy.get(curFile);
|
|
293
289
|
if (!lines) {
|
|
294
290
|
lines = [];
|
|
@@ -301,12 +297,12 @@ export function analyzeDiff(diff) {
|
|
|
301
297
|
if (!curAdded && !curDeleted)
|
|
302
298
|
filesModified.add(curFile);
|
|
303
299
|
if (isCode(curFile)) {
|
|
304
|
-
const
|
|
305
|
-
if (
|
|
306
|
-
declsFor(curFile)
|
|
307
|
-
const
|
|
308
|
-
if (
|
|
309
|
-
addedImports.add(
|
|
300
|
+
const decl = declOf(body);
|
|
301
|
+
if (decl)
|
|
302
|
+
declsFor(curFile).added.set(decl.name, decl);
|
|
303
|
+
const spec = importOf(body);
|
|
304
|
+
if (spec && !spec.startsWith("."))
|
|
305
|
+
addedImports.add(spec);
|
|
310
306
|
}
|
|
311
307
|
}
|
|
312
308
|
else if (raw.startsWith("-")) {
|
|
@@ -317,12 +313,12 @@ export function analyzeDiff(diff) {
|
|
|
317
313
|
filesModified.add(curFile);
|
|
318
314
|
if (isCode(curFile)) {
|
|
319
315
|
const body = raw.slice(1);
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
declsFor(curFile)
|
|
323
|
-
const
|
|
324
|
-
if (
|
|
325
|
-
removedImports.add(
|
|
316
|
+
const decl = declOf(body);
|
|
317
|
+
if (decl)
|
|
318
|
+
declsFor(curFile).removed.set(decl.name, decl);
|
|
319
|
+
const spec = importOf(body);
|
|
320
|
+
if (spec && !spec.startsWith("."))
|
|
321
|
+
removedImports.add(spec);
|
|
326
322
|
}
|
|
327
323
|
}
|
|
328
324
|
}
|
package/dist/extractors/git.d.ts
CHANGED
|
@@ -307,32 +307,17 @@ export interface CommitCandidate {
|
|
|
307
307
|
sha: string;
|
|
308
308
|
files: string[];
|
|
309
309
|
}
|
|
310
|
-
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* set of now-orphaned source-branch commits. */
|
|
310
|
+
/** Commits in `oldRef..newRef` (most recent `maxCount`, oldest first), each with the
|
|
311
|
+
* non-deleted paths it touched. Squash-merge provenance repair reads this. A git
|
|
312
|
+
* failure yields []. */
|
|
314
313
|
export declare function mergeRangeChanges(oldRef: string, newRef: string, cwd: string, maxCount?: number): CommitCandidate[];
|
|
315
|
-
/**
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* single git process regardless of how many decisions carry a commit, not
|
|
319
|
-
* one per decision. Returns the subset of `shas` that resolve to a real
|
|
320
|
-
* commit object, or `null` if the check itself failed to run (not a git
|
|
321
|
-
* repo, git missing, timeout) — distinct from an empty set, which means
|
|
322
|
-
* "checked, and none of them resolve." A caller that collapsed those two
|
|
323
|
-
* would flag every commit as unresolvable on a transient environment error
|
|
324
|
-
* instead of failing open. */
|
|
314
|
+
/** Which of `shas` resolve to a commit, via one `git cat-file --batch-check`.
|
|
315
|
+
* Malformed values are dropped before the stdin lines are built (positional mapping).
|
|
316
|
+
* Returns null when git itself could not run: callers fail open. */
|
|
325
317
|
export declare function commitsExist(shas: readonly string[], cwd: string): Set<string> | null;
|
|
326
318
|
export type CommitRepairStatus = "orphaned" | "current" | "unresolvable" | "unknown";
|
|
327
|
-
/**
|
|
328
|
-
* "orphaned"
|
|
329
|
-
* ONLY status planCommitRepair should ever act on. "unresolvable" (the
|
|
330
|
-
* commit doesn't exist here at all) is explicitly out of repair scope —
|
|
331
|
-
* drift.ts's commit-unresolvable kind is what flags that case, because
|
|
332
|
-
* there's no reliable signal left to match a replacement against. "unknown"
|
|
333
|
-
* (the git command itself failed to run — timeout, git missing) must never
|
|
334
|
-
* be treated as repair-eligible; collapsing "false" and "error" into one
|
|
335
|
-
* boolean is exactly the bug this type exists to prevent. */
|
|
319
|
+
/** Whether `commit` is reachable from `ref`. Never collapses "no" and "error": only
|
|
320
|
+
* "orphaned" is repair-eligible. A missing or non-directory cwd is "unresolvable". */
|
|
336
321
|
export declare function commitRepairStatus(commit: string, ref: string, cwd: string): CommitRepairStatus;
|
|
337
322
|
/** commitRepairStatus against several tips: "orphaned" only when NO ref
|
|
338
323
|
* reaches the commit. A historical `--range old..new` must not make a commit
|
package/dist/extractors/git.js
CHANGED
|
@@ -7,6 +7,7 @@ import { isAbsolute, resolve, join, basename, dirname, relative, sep, posix } fr
|
|
|
7
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
|
+
import { boundedExecFileSync } from "../core/boundedSpawn.js";
|
|
10
11
|
import { writeFileAtomic } from "../core/io.js";
|
|
11
12
|
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
12
13
|
import { createRepoFileReader } from "../core/safeRepoFile.js";
|
|
@@ -580,6 +581,8 @@ function observeGit(observer, event) {
|
|
|
580
581
|
catch { /* observation cannot change a Git outcome */ }
|
|
581
582
|
}
|
|
582
583
|
const CAPTURE_REMOTE_TIMEOUT_MS = 15_000;
|
|
584
|
+
/** A user's whole branch can be large, so `hunch push` gets far longer than a memory capture push. */
|
|
585
|
+
const BRANCH_PUSH_TIMEOUT_MS = 300_000;
|
|
583
586
|
export const DEFAULT_READ_REMOTE_TIMEOUT_MS = 5_000;
|
|
584
587
|
// A capture can spend roughly 90s in commit + bounded merge/push/retry seams.
|
|
585
588
|
// A contending writer waits beyond that proven ceiling, then takes the lock and
|
|
@@ -858,9 +861,6 @@ export function headFileContent(root, rel) {
|
|
|
858
861
|
encoding: "utf8",
|
|
859
862
|
env: foreignRepoEnv(process.env),
|
|
860
863
|
maxBuffer: 16 * 1024 * 1024,
|
|
861
|
-
// An untracked/absent-at-HEAD path is an expected, silently-handled case
|
|
862
|
-
// (falls through to the catch below) — don't let git's "fatal: path ...
|
|
863
|
-
// does not exist in 'HEAD'" leak onto the caller's stderr for it.
|
|
864
864
|
stdio: ["ignore", "pipe", "ignore"],
|
|
865
865
|
});
|
|
866
866
|
}
|
|
@@ -937,10 +937,7 @@ function isDerivedStoreArtifact(relativeName) {
|
|
|
937
937
|
// staged alongside the record; treating it as a violation made every served write skip
|
|
938
938
|
// the commit quietly and report durability "local" forever (1.26.0/1.26.1).
|
|
939
939
|
|| relativeName === "write.lock"
|
|
940
|
-
//
|
|
941
|
-
// rejected-match tombstones (repairqueue.ts): clone-local scratch, never a
|
|
942
|
-
// memory record — must never ride a public flush's `git add .` or an
|
|
943
|
-
// overlay's force-add allowlist into shared/pushed memory.
|
|
940
|
+
// Clone-local scratch that must never ride a public flush or an overlay force-add into shared memory.
|
|
944
941
|
|| relativeName === "pending-commit-repairs.json"
|
|
945
942
|
|| relativeName === "dropped-commit-repairs.json";
|
|
946
943
|
}
|
|
@@ -1193,16 +1190,13 @@ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
|
|
|
1193
1190
|
if (!hooksDir)
|
|
1194
1191
|
return null;
|
|
1195
1192
|
try {
|
|
1196
|
-
|
|
1193
|
+
// Network git runs under boundedExecFileSync: execFileSync's timeout kills only
|
|
1194
|
+
// the root, orphaning git-remote-https.exe on Windows (issue #498).
|
|
1195
|
+
const out = boundedExecFileSync("git", [
|
|
1197
1196
|
"-C", hunchDir,
|
|
1198
1197
|
"-c", `core.hooksPath=${hooksDir}`,
|
|
1199
1198
|
"ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", url,
|
|
1200
|
-
], {
|
|
1201
|
-
encoding: "utf8",
|
|
1202
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1203
|
-
env: boundedTeamEnv(env),
|
|
1204
|
-
timeout: timeoutMs,
|
|
1205
|
-
});
|
|
1199
|
+
], { env: boundedTeamEnv(env), timeout: timeoutMs });
|
|
1206
1200
|
if (!contractReady(contract))
|
|
1207
1201
|
return null;
|
|
1208
1202
|
const heads = [];
|
|
@@ -1226,12 +1220,12 @@ function fetchContractRef(hunchDir, contract, env, timeoutMs) {
|
|
|
1226
1220
|
if (!hooksDir)
|
|
1227
1221
|
return "";
|
|
1228
1222
|
try {
|
|
1229
|
-
|
|
1223
|
+
boundedExecFileSync("git", [
|
|
1230
1224
|
"-C", hunchDir,
|
|
1231
1225
|
"-c", `core.hooksPath=${hooksDir}`,
|
|
1232
1226
|
"fetch", "--no-tags", "--no-write-fetch-head", "--upload-pack=git-upload-pack",
|
|
1233
1227
|
contract.fetchUrl, `+${contract.ref}:${TEAM_FETCH_REF}`,
|
|
1234
|
-
], {
|
|
1228
|
+
], { stdout: "ignore", env: boundedTeamEnv(env), timeout: timeoutMs });
|
|
1235
1229
|
if (!contractReady(contract))
|
|
1236
1230
|
return "";
|
|
1237
1231
|
const oid = execFileSync("git", ["-C", hunchDir, "rev-parse", "--verify", TEAM_FETCH_REF], {
|
|
@@ -1391,17 +1385,21 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1391
1385
|
const tryGit = (args, timeout = timeoutMs) => {
|
|
1392
1386
|
const startedAt = Date.now();
|
|
1393
1387
|
lastPullGitError = undefined;
|
|
1388
|
+
const argv = [
|
|
1389
|
+
"-C", hunchDir,
|
|
1390
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
1391
|
+
"-c", `core.attributesFile=${gitNullDevice()}`,
|
|
1392
|
+
"-c", "core.autocrlf=false",
|
|
1393
|
+
"-c", "commit.gpgsign=false",
|
|
1394
|
+
...args,
|
|
1395
|
+
];
|
|
1394
1396
|
try {
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
"
|
|
1399
|
-
|
|
1400
|
-
"
|
|
1401
|
-
...args,
|
|
1402
|
-
], {
|
|
1403
|
-
stdio: ["ignore", "ignore", "pipe"], env, timeout,
|
|
1404
|
-
});
|
|
1397
|
+
// Only the network call pays for the tree-killing supervisor (#498); the
|
|
1398
|
+
// merge of an already-fetched sha and rev-parse/abort stay local.
|
|
1399
|
+
if (args[0] === "fetch")
|
|
1400
|
+
boundedExecFileSync("git", argv, { stdout: "ignore", env, timeout });
|
|
1401
|
+
else
|
|
1402
|
+
execFileSync("git", argv, { stdio: ["ignore", "ignore", "pipe"], env, timeout });
|
|
1405
1403
|
return true;
|
|
1406
1404
|
}
|
|
1407
1405
|
catch (error) {
|
|
@@ -1496,7 +1494,7 @@ function upstreamSha(hunchDir, env) {
|
|
|
1496
1494
|
* and discard oversized/unavailable output without changing its exit result. */
|
|
1497
1495
|
function runObservedPush(args, env, timeoutMs, observe) {
|
|
1498
1496
|
if (!observe) {
|
|
1499
|
-
|
|
1497
|
+
boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
|
|
1500
1498
|
return null;
|
|
1501
1499
|
}
|
|
1502
1500
|
let directory, descriptor;
|
|
@@ -1511,11 +1509,11 @@ function runObservedPush(args, env, timeoutMs, observe) {
|
|
|
1511
1509
|
}
|
|
1512
1510
|
catch { }
|
|
1513
1511
|
}
|
|
1514
|
-
|
|
1512
|
+
boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
|
|
1515
1513
|
return null;
|
|
1516
1514
|
}
|
|
1517
1515
|
try {
|
|
1518
|
-
|
|
1516
|
+
boundedExecFileSync("git", args, { stdout: descriptor, env, timeout: timeoutMs });
|
|
1519
1517
|
try {
|
|
1520
1518
|
const buffer = Buffer.alloc(1_000_001);
|
|
1521
1519
|
const bytes = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
@@ -1563,8 +1561,8 @@ function observePushResult(hunchDir, env, output, observer) {
|
|
|
1563
1561
|
if (!destination || /[\x00-\x1f\x7f]/.test(destination) || Date.now() >= deadline)
|
|
1564
1562
|
continue;
|
|
1565
1563
|
try {
|
|
1566
|
-
const result =
|
|
1567
|
-
env,
|
|
1564
|
+
const result = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", "--exit-code", "--", destination, ref], {
|
|
1565
|
+
env, timeout: Math.max(1, deadline - Date.now()), maxBuffer: 16_384,
|
|
1568
1566
|
}).trim();
|
|
1569
1567
|
const rows = result.split("\n").filter(row => row.split("\t")[1] === ref);
|
|
1570
1568
|
const commitSha = rows.length === 1 ? rows[0].split("\t")[0] : "";
|
|
@@ -1656,12 +1654,7 @@ function establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutM
|
|
|
1656
1654
|
if (!before)
|
|
1657
1655
|
return false;
|
|
1658
1656
|
try {
|
|
1659
|
-
const refs =
|
|
1660
|
-
encoding: "utf8",
|
|
1661
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1662
|
-
env,
|
|
1663
|
-
timeout: timeoutMs,
|
|
1664
|
-
}).trim();
|
|
1657
|
+
const refs = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], { env, timeout: timeoutMs }).trim();
|
|
1665
1658
|
if (refs)
|
|
1666
1659
|
return false;
|
|
1667
1660
|
}
|
|
@@ -2332,7 +2325,9 @@ export function memoryMoveDiff(sha, root) {
|
|
|
2332
2325
|
* false when there is no upstream / offline / not a repo. */
|
|
2333
2326
|
export function pushCurrentBranch(root) {
|
|
2334
2327
|
try {
|
|
2335
|
-
|
|
2328
|
+
// Bounded so an unreachable remote can't hang the caller, and a timeout
|
|
2329
|
+
// kills git's whole tree, not just the root (#498).
|
|
2330
|
+
boundedExecFileSync("git", ["-C", root, "push"], { stdout: "ignore", timeout: BRANCH_PUSH_TIMEOUT_MS });
|
|
2336
2331
|
return true;
|
|
2337
2332
|
}
|
|
2338
2333
|
catch {
|
|
@@ -2652,83 +2647,70 @@ export function commitChanges(sha, cwd) {
|
|
|
2652
2647
|
}
|
|
2653
2648
|
return out;
|
|
2654
2649
|
}
|
|
2655
|
-
/** Default cap on
|
|
2656
|
-
*
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
const shas =
|
|
2650
|
+
/** Default cap on commits the range walk inspects: insurance against an unbounded
|
|
2651
|
+
* background hook run when a pull brings in hundreds of commits. */
|
|
2652
|
+
const DEFAULT_RANGE_CAP = 500;
|
|
2653
|
+
/** Commits in `oldRef..newRef` (most recent `maxCount`, oldest first), each with the
|
|
2654
|
+
* non-deleted paths it touched. Squash-merge provenance repair reads this. A git
|
|
2655
|
+
* failure yields []. */
|
|
2656
|
+
export function mergeRangeChanges(oldRef, newRef, cwd, maxCount = DEFAULT_RANGE_CAP) {
|
|
2657
|
+
const raw = gitSafe(["rev-list", "--abbrev-commit", `--max-count=${maxCount}`, `${oldRef}..${newRef}`], cwd);
|
|
2658
|
+
if (!raw)
|
|
2659
|
+
return [];
|
|
2660
|
+
const shas = raw.split("\n").map((s) => s.trim()).filter(Boolean).reverse();
|
|
2666
2661
|
return shas.map((sha) => ({
|
|
2667
2662
|
sha,
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2663
|
+
files: commitChanges(sha, cwd)
|
|
2664
|
+
.filter((c) => c.status !== "deleted")
|
|
2665
|
+
.map((c) => c.after ?? c.before)
|
|
2666
|
+
.filter((f) => !!f),
|
|
2671
2667
|
}));
|
|
2672
2668
|
}
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
*
|
|
2676
|
-
*
|
|
2677
|
-
* one per decision. Returns the subset of `shas` that resolve to a real
|
|
2678
|
-
* commit object, or `null` if the check itself failed to run (not a git
|
|
2679
|
-
* repo, git missing, timeout) — distinct from an empty set, which means
|
|
2680
|
-
* "checked, and none of them resolve." A caller that collapsed those two
|
|
2681
|
-
* would flag every commit as unresolvable on a transient environment error
|
|
2682
|
-
* instead of failing open. */
|
|
2669
|
+
const HEX_COMMIT_RE = /^[0-9a-f]{7,64}$/i;
|
|
2670
|
+
/** Which of `shas` resolve to a commit, via one `git cat-file --batch-check`.
|
|
2671
|
+
* Malformed values are dropped before the stdin lines are built (positional mapping).
|
|
2672
|
+
* Returns null when git itself could not run: callers fail open. */
|
|
2683
2673
|
export function commitsExist(shas, cwd) {
|
|
2684
|
-
const resolvable = new Set();
|
|
2685
2674
|
if (!shas.length)
|
|
2686
|
-
return
|
|
2687
|
-
|
|
2688
|
-
// embedding its own newline would count as more input lines than entries
|
|
2689
|
-
// in `shas`, shifting every later sha's output line and misclassifying it.
|
|
2690
|
-
const valid = shas.filter((sha) => /^[0-9a-f]{7,64}$/i.test(sha));
|
|
2675
|
+
return new Set();
|
|
2676
|
+
const valid = shas.filter((s) => HEX_COMMIT_RE.test(s));
|
|
2691
2677
|
if (!valid.length)
|
|
2692
|
-
return
|
|
2693
|
-
let
|
|
2678
|
+
return new Set();
|
|
2679
|
+
let out;
|
|
2694
2680
|
try {
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
2699
|
-
timeout: 10_000,
|
|
2681
|
+
out = execFileSync("git", ["-C", cwd, "cat-file", "--batch-check"], {
|
|
2682
|
+
input: valid.map((s) => `${s}^{commit}`).join("\n") + "\n",
|
|
2683
|
+
encoding: "utf8", timeout: 10_000, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024,
|
|
2700
2684
|
});
|
|
2701
2685
|
}
|
|
2702
2686
|
catch {
|
|
2703
2687
|
return null;
|
|
2704
2688
|
}
|
|
2705
|
-
const lines =
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
*
|
|
2715
|
-
* commit doesn't exist here at all) is explicitly out of repair scope —
|
|
2716
|
-
* drift.ts's commit-unresolvable kind is what flags that case, because
|
|
2717
|
-
* there's no reliable signal left to match a replacement against. "unknown"
|
|
2718
|
-
* (the git command itself failed to run — timeout, git missing) must never
|
|
2719
|
-
* be treated as repair-eligible; collapsing "false" and "error" into one
|
|
2720
|
-
* boolean is exactly the bug this type exists to prevent. */
|
|
2689
|
+
const lines = out.split("\n");
|
|
2690
|
+
const found = new Set();
|
|
2691
|
+
valid.forEach((sha, i) => {
|
|
2692
|
+
if (lines[i] !== undefined && !/\bmissing$/.test(lines[i].trim()))
|
|
2693
|
+
found.add(sha);
|
|
2694
|
+
});
|
|
2695
|
+
return found;
|
|
2696
|
+
}
|
|
2697
|
+
/** Whether `commit` is reachable from `ref`. Never collapses "no" and "error": only
|
|
2698
|
+
* "orphaned" is repair-eligible. A missing or non-directory cwd is "unresolvable". */
|
|
2721
2699
|
export function commitRepairStatus(commit, ref, cwd) {
|
|
2722
|
-
if (
|
|
2700
|
+
if (!HEX_COMMIT_RE.test(commit))
|
|
2723
2701
|
return "unresolvable";
|
|
2702
|
+
const opts = { cwd, stdio: "ignore", timeout: 5_000 };
|
|
2724
2703
|
try {
|
|
2725
|
-
execFileSync("git", ["-
|
|
2704
|
+
execFileSync("git", ["cat-file", "-e", `${commit}^{commit}`], opts);
|
|
2726
2705
|
}
|
|
2727
2706
|
catch (e) {
|
|
2728
|
-
|
|
2707
|
+
const err = e;
|
|
2708
|
+
if (typeof err.status === "number" || err.code === "ENOENT" || err.code === "ENOTDIR")
|
|
2709
|
+
return "unresolvable";
|
|
2710
|
+
return "unknown";
|
|
2729
2711
|
}
|
|
2730
2712
|
try {
|
|
2731
|
-
execFileSync("git", ["
|
|
2713
|
+
execFileSync("git", ["merge-base", "--is-ancestor", commit, ref], opts);
|
|
2732
2714
|
return "current";
|
|
2733
2715
|
}
|
|
2734
2716
|
catch (e) {
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import type { ParsedSymbolKind } from "./languages.js";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic token scan over Helm chart template text. Reports `define`
|
|
4
|
+
* blocks as symbols and `include` / `template` references as calls. This is
|
|
5
|
+
* not a Go-template parser: comments and quoting are deliberately ignored.
|
|
6
|
+
* All offsets are JS string (UTF-16) indices into the source.
|
|
7
|
+
*/
|
|
2
8
|
export interface HelmSymbol {
|
|
3
9
|
name: string;
|
|
4
10
|
kind: ParsedSymbolKind;
|
|
11
|
+
/** Index of the first `{` of the opening define action (inclusive). */
|
|
5
12
|
startChar: number;
|
|
13
|
+
/** Index just past the `}}` of the matching end action (exclusive). */
|
|
6
14
|
endChar: number;
|
|
7
15
|
loc: number;
|
|
8
16
|
bodyText: string;
|
|
9
17
|
}
|
|
10
18
|
export interface HelmCall {
|
|
11
19
|
callee: string;
|
|
20
|
+
/** Index of the first letter of the `include` / `template` keyword. */
|
|
12
21
|
atChar: number;
|
|
22
|
+
/** Index just past the closing quote of the name (exclusive). */
|
|
13
23
|
endChar: number;
|
|
14
24
|
member: boolean;
|
|
15
25
|
}
|
package/dist/extractors/helm.js
CHANGED
|
@@ -1,76 +1,60 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic text scan for Helm's `define`/`include`/`template` Go-template
|
|
3
|
-
* actions — NOT a Go-template parser, and NOT a tree-sitter query. tree-sitter-yaml
|
|
4
|
-
* has no notion of `{{ }}` content: parsing `{{ include "x" . }}` inside a real
|
|
5
|
-
* YAML mapping value produces only stray `{` flow-mapping-open tokens, with the
|
|
6
|
-
* enclosed text lost to ERROR recovery (verified directly against this repo's
|
|
7
|
-
* tree-sitter-yaml bundle). There is nothing for a tree-sitter query to capture,
|
|
8
|
-
* so this runs as a sidecar text scan over the raw source — invoked only for
|
|
9
|
-
* files under a Helm chart (indexer.ts's chart-root detection), never for
|
|
10
|
-
* arbitrary YAML.
|
|
11
|
-
*
|
|
12
|
-
* Known bounded limitation: this is a token scan, not a full parser. A literal
|
|
13
|
-
* `}}` inside a quoted argument, or an include/define-shaped string inside a
|
|
14
|
-
* `{{/* comment *}}`, can misattribute a char range or produce a phantom call.
|
|
15
|
-
* Both are bounded failure modes (a stray reference to a real symbol name, or a
|
|
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
1
|
import { MAX_BODY_TEXT_CHARS } from "./parse.js";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const ACTION = /\{\{-?([\s\S]*?)-?\}\}/g;
|
|
30
|
-
const BLOCK_OPEN = new Set(["if", "range", "with", "define", "block"]);
|
|
31
|
-
const NAME_ARG = /^"([^"]*)"/;
|
|
32
|
-
// Matched against an action's RAW inner text (not just a leading keyword) so
|
|
33
|
-
// `{{ $labels := include "x" . }}` and `{{ if include "x" . }}` are caught,
|
|
34
|
-
// not only the standalone `{{ include "x" . }}` form.
|
|
35
|
-
const CALL_SITE = /\b(?:include|template)\s+"([^"]+)"/g;
|
|
2
|
+
const OPENERS = new Set(["if", "range", "with", "block"]);
|
|
3
|
+
const CALL_RE = /(?<![A-Za-z0-9_])(?:include|template)\s+"([^"]+)"/g;
|
|
36
4
|
export function extractHelmDirectives(source) {
|
|
37
5
|
const symbols = [];
|
|
38
6
|
const calls = [];
|
|
39
7
|
const stack = [];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
8
|
+
let pos = 0;
|
|
9
|
+
while (pos < source.length) {
|
|
10
|
+
const open = source.indexOf("{{", pos);
|
|
11
|
+
if (open === -1)
|
|
12
|
+
break;
|
|
13
|
+
const close = source.indexOf("}}", open + 2);
|
|
14
|
+
if (close === -1)
|
|
15
|
+
break;
|
|
16
|
+
const innerStart = source[open + 2] === "-" ? open + 3 : open + 2;
|
|
17
|
+
const innerEnd = close - 1 >= innerStart && source[close - 1] === "-" ? close - 1 : close;
|
|
18
|
+
const inner = innerEnd > innerStart ? source.slice(innerStart, innerEnd) : "";
|
|
19
|
+
const actionEnd = close + 2;
|
|
20
|
+
pos = actionEnd;
|
|
21
|
+
// Calls: any action, any keyword.
|
|
22
|
+
CALL_RE.lastIndex = 0;
|
|
23
|
+
for (let m = CALL_RE.exec(inner); m; m = CALL_RE.exec(inner)) {
|
|
24
|
+
const atChar = innerStart + m.index;
|
|
25
|
+
calls.push({ callee: m[1], atChar, endChar: atChar + m[0].length, member: false });
|
|
26
|
+
}
|
|
27
|
+
const trimmed = inner.trim();
|
|
28
|
+
const ws = trimmed.search(/\s/);
|
|
29
|
+
const keyword = ws === -1 ? trimmed : trimmed.slice(0, ws);
|
|
49
30
|
if (keyword === "define") {
|
|
50
|
-
const
|
|
51
|
-
|
|
31
|
+
const rest = trimmed.slice(keyword.length).trimStart();
|
|
32
|
+
let name = null;
|
|
33
|
+
if (rest.startsWith('"')) {
|
|
34
|
+
const q = rest.indexOf('"', 1);
|
|
35
|
+
const candidate = q === -1 ? "" : rest.slice(1, q);
|
|
36
|
+
if (candidate.length > 0)
|
|
37
|
+
name = candidate;
|
|
38
|
+
}
|
|
39
|
+
stack.push({ start: open, name });
|
|
52
40
|
}
|
|
53
|
-
else if (
|
|
54
|
-
|
|
55
|
-
stack.push({ startChar: m.index });
|
|
41
|
+
else if (OPENERS.has(keyword)) {
|
|
42
|
+
stack.push({ start: open, name: null });
|
|
56
43
|
}
|
|
57
44
|
else if (keyword === "end") {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
45
|
+
const frame = stack.pop();
|
|
46
|
+
if (frame && frame.name !== null) {
|
|
47
|
+
const span = source.slice(frame.start, actionEnd);
|
|
60
48
|
symbols.push({
|
|
61
|
-
name:
|
|
49
|
+
name: frame.name,
|
|
62
50
|
kind: "variable",
|
|
63
|
-
startChar:
|
|
64
|
-
endChar,
|
|
65
|
-
loc:
|
|
66
|
-
bodyText:
|
|
51
|
+
startChar: frame.start,
|
|
52
|
+
endChar: actionEnd,
|
|
53
|
+
loc: span.split("\n").length,
|
|
54
|
+
bodyText: span.slice(0, MAX_BODY_TEXT_CHARS),
|
|
67
55
|
});
|
|
68
56
|
}
|
|
69
57
|
}
|
|
70
|
-
for (const call of raw.matchAll(CALL_SITE)) {
|
|
71
|
-
const atChar = innerStart + call.index;
|
|
72
|
-
calls.push({ callee: call[1], atChar, endChar: atChar + call[0].length, member: false });
|
|
73
|
-
}
|
|
74
58
|
}
|
|
75
59
|
return { symbols, calls };
|
|
76
60
|
}
|
|
@@ -68,3 +68,9 @@ export declare function isExtractorEdge(edge: Edge): boolean;
|
|
|
68
68
|
export declare function mergeScannedEdges(stored: Edge[], scanned: Edge[]): Edge[];
|
|
69
69
|
/** Persist one pure scan into the Git-native source of truth. */
|
|
70
70
|
export declare function indexRepo(store: HunchStore, root: string, opts?: IndexRepoOptions): IndexResult;
|
|
71
|
+
/** Build a lookup from a tracked file to the root directory of its nearest Helm
|
|
72
|
+
* chart ("" = repo root), or null. A directory is a chart root when
|
|
73
|
+
* `<dir>/Chart.yaml` is tracked; nested subcharts resolve to their OWN root. This
|
|
74
|
+
* is a conservative approximation of Helm's release-global template namespace
|
|
75
|
+
* (issue #42): a parent including a subchart define is missed, never wrongly linked. */
|
|
76
|
+
export declare function nearestChartRoot(tracked: readonly string[]): (file: string) => string | null;
|