@davesheffer/hunch 1.45.2 → 1.46.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 +139 -32
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- 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/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/extractors/git.js +32 -31
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- 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.js +47 -8
- 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 +8 -5
- 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.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -19,6 +19,7 @@ import { join, isAbsolute, dirname, basename, relative, resolve } from "node:pat
|
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { hooksDir, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
21
21
|
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
22
|
+
import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
|
|
22
23
|
import { HUNCH_NPX_PACKAGE_SPEC, HUNCH_PACKAGE_NAME } from "../core/version.js";
|
|
23
24
|
import { eolLengthAt, normalizeEol } from "../core/eol.js";
|
|
24
25
|
const MARK = "# >>> hunch post-commit >>>";
|
|
@@ -617,6 +618,11 @@ function sharedInvocationFor(root, mark, invocation, own) {
|
|
|
617
618
|
* or the block would sit after an exec/exit, nothing is written and the result
|
|
618
619
|
* carries the snippet to add by hand (issue #311). */
|
|
619
620
|
function installManagedBlock(root, hookName, mark, end, build, invocation) {
|
|
621
|
+
// A linked worktree whose branch predates the opt-out marker shares the main
|
|
622
|
+
// checkout's hooks dir: never re-hook a repository `hunch uninstall` opted out (#488).
|
|
623
|
+
const main = mainWorktreeRoot(root);
|
|
624
|
+
if (isOptedOut(main))
|
|
625
|
+
return { path: resolve(root, hooksDir(root), hookName), action: "opted-out", reason: `${join(main, OPT_OUT_FILE)} opts this repository out` };
|
|
620
626
|
const t = resolveHookTarget(root, hookName, mark);
|
|
621
627
|
if (t.manager !== "none") {
|
|
622
628
|
const info = blockInfo(t, mark, root);
|
|
@@ -828,8 +834,8 @@ function repairProvenanceMergeBlock(invocation) {
|
|
|
828
834
|
* (the file itself is new) outranks "appended"/"updated" (an existing file
|
|
829
835
|
* changed), which outrank "unchanged". Non-writing results are handled before
|
|
830
836
|
* ranking (they must never be masked by a sibling's success). */
|
|
831
|
-
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0 };
|
|
832
|
-
const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
|
|
837
|
+
const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0, "opted-out": 0 };
|
|
838
|
+
const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable" && h.action !== "opted-out";
|
|
833
839
|
/** Install a post-merge hook carrying TWO independently-managed blocks:
|
|
834
840
|
* re-sync the committed grounding docs when a merge brought memory in behind
|
|
835
841
|
* them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
|
|
@@ -850,6 +856,8 @@ export function installPostMergeHook(root, invocation) {
|
|
|
850
856
|
// passed invocation unchanged — see selfBuildInvocation.
|
|
851
857
|
const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, (inv, ctx) => groundingMergeBlock(ctx.portable ? inv : selfBuildInvocation(root, inv)), invocation);
|
|
852
858
|
const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock, invocation);
|
|
859
|
+
if (grounding.action === "opted-out")
|
|
860
|
+
return grounding;
|
|
853
861
|
if (!writes(grounding) && !writes(repair)) {
|
|
854
862
|
return { ...grounding, snippet: `${grounding.snippet}\n${stripComment(repair.snippet ?? "", grounding.manager)}` };
|
|
855
863
|
}
|
|
@@ -1025,6 +1033,8 @@ export function formatHookInstall(root, label, h, detail = "") {
|
|
|
1025
1033
|
const how = h.keptFlags?.length ? KEPT_HOW : "";
|
|
1026
1034
|
return [` ✓ ${label} kept — the shared hook keeps running ${h.sharedInvocation}${flags} (not re-pointed at this worktree)${how}`];
|
|
1027
1035
|
}
|
|
1036
|
+
if (h.action === "opted-out")
|
|
1037
|
+
return [` - ${label} skipped — ${h.reason}`];
|
|
1028
1038
|
if (writes(h)) {
|
|
1029
1039
|
// A union write (issue #316). Once an option was KEPT, `detail` — which
|
|
1030
1040
|
// describes only what this run asked for — is no longer the truth about the
|
|
@@ -14,6 +14,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
14
14
|
import { writeFileAtomic } from "../core/io.js";
|
|
15
15
|
import { assertSafeTopLevelConfigFile } from "./gitignore.js";
|
|
16
16
|
import { GROUNDING_DOC_PATHS } from "./providers.js";
|
|
17
|
+
import { mergeDriverShell } from "../cli/invocation.js";
|
|
17
18
|
// Route the .hunch JSON records through the structured driver — but NOT the
|
|
18
19
|
// manifest (an id-less `{schema_version}` object the driver can't merge by id; a
|
|
19
20
|
// normal text merge with conflict markers is the right behavior for it).
|
|
@@ -58,9 +59,10 @@ export function installMergeDriver(root, invShell) {
|
|
|
58
59
|
}
|
|
59
60
|
// 2. Local git config — the driver definitions are per-clone (they reference
|
|
60
61
|
// this machine's node + cli path), so they are NOT committed; teammates
|
|
61
|
-
// re-run init.
|
|
62
|
-
const
|
|
63
|
-
const
|
|
62
|
+
// re-run init. Dev mode swaps `npx tsx` for Hunch's own loader (#492).
|
|
63
|
+
const shell = mergeDriverShell(invShell);
|
|
64
|
+
const driver = `${shell} merge-driver "%O" "%A" "%B" "%P"`;
|
|
65
|
+
const groundingDriver = `${shell} merge-driver-grounding "%O" "%A" "%B" "%P"`;
|
|
64
66
|
const env = targetRepositoryEnv();
|
|
65
67
|
try {
|
|
66
68
|
execFileSync("git", ["config", "merge.hunch.name", "hunch structured JSON merge"], { cwd: root, env });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-out marker `hunch uninstall` leaves (#449), apart from uninstall.ts so the
|
|
3
|
+
* hook installers can honor it without an import cycle (uninstall.ts imports hooks.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { lstatSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
/** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
|
|
8
|
+
export const OPT_OUT_FILE = ".hunch-removed";
|
|
9
|
+
export function isOptedOut(root) {
|
|
10
|
+
try {
|
|
11
|
+
return lstatSync(join(root, OPT_OUT_FILE)).isFile();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=optout.js.map
|
|
@@ -73,8 +73,12 @@ export declare function writeCodexHooks(root: string, inv: Invocation): string;
|
|
|
73
73
|
export declare function writeVscodeHooks(root: string, inv: Invocation): string;
|
|
74
74
|
/** Windsurf's documented workspace hooks. It only supports deterministic
|
|
75
75
|
* pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
|
|
76
|
-
* reserves the hook for strict edit protection and pipeline observation.
|
|
77
|
-
|
|
76
|
+
* reserves the hook for strict edit protection and pipeline observation.
|
|
77
|
+
* Devin Desktop (formerly Windsurf) reads .devin/hooks.json and ignores the
|
|
78
|
+
* legacy .windsurf/hooks.json whenever the former defines any hook, so Hunch
|
|
79
|
+
* merges into both (docs.devin.ai/desktop/cascade/hooks, #494). Both files are
|
|
80
|
+
* parsed before either is written: an unparseable one refuses the whole write. */
|
|
81
|
+
export declare function writeWindsurfHooks(root: string, inv: Invocation): string[];
|
|
78
82
|
/** Antigravity keeps hook groups at the top level (not under `hooks`). Hunch
|
|
79
83
|
* owns only the `hunch` group and replaces its own old entries idempotently. */
|
|
80
84
|
export declare function writeAntigravityHooks(root: string, inv: Invocation): string;
|
|
@@ -419,9 +419,26 @@ function publishedPinEvents(file) {
|
|
|
419
419
|
}
|
|
420
420
|
/** Windsurf's documented workspace hooks. It only supports deterministic
|
|
421
421
|
* pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
|
|
422
|
-
* reserves the hook for strict edit protection and pipeline observation.
|
|
422
|
+
* reserves the hook for strict edit protection and pipeline observation.
|
|
423
|
+
* Devin Desktop (formerly Windsurf) reads .devin/hooks.json and ignores the
|
|
424
|
+
* legacy .windsurf/hooks.json whenever the former defines any hook, so Hunch
|
|
425
|
+
* merges into both (docs.devin.ai/desktop/cascade/hooks, #494). Both files are
|
|
426
|
+
* parsed before either is written: an unparseable one refuses the whole write. */
|
|
423
427
|
export function writeWindsurfHooks(root, inv) {
|
|
424
|
-
const
|
|
428
|
+
const files = [join(root, ".windsurf", "hooks.json"), join(root, ".devin", "hooks.json")];
|
|
429
|
+
// Same shape checks writeHookConfig applies at write time, so neither file is
|
|
430
|
+
// written when the other would refuse.
|
|
431
|
+
for (const file of files) {
|
|
432
|
+
const hooks = objectField(readJsonObj(file), "hooks", file);
|
|
433
|
+
for (const event of WINDSURF_EVENTS) {
|
|
434
|
+
if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
|
|
435
|
+
throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return files.map((file) => writeWindsurfHookFile(file, inv));
|
|
440
|
+
}
|
|
441
|
+
function writeWindsurfHookFile(file, inv) {
|
|
425
442
|
const published = hookCommand(publishedMcpInvocation(), "windsurf");
|
|
426
443
|
let command = hookCommand(inv, "windsurf");
|
|
427
444
|
// A source-checkout run (tsx / local dist) must never replace a tracked
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "node:fs";
|
|
9
9
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { boundedSpawnSync } from "../core/boundedSpawn.js";
|
|
11
12
|
import { writeFileAtomic } from "../core/io.js";
|
|
12
13
|
import { machineFile } from "../core/machine.js";
|
|
13
14
|
import { hunchTreeAttributesAreSafe, safeOverlayGitTreeListing, safeOverlayTree } from "../core/overlaySafety.js";
|
|
@@ -654,13 +655,14 @@ export function cloneValidatedTeamOverlay(sharedRepo, sharedRepoCwd, destination
|
|
|
654
655
|
}
|
|
655
656
|
const cloneEnv = boundedTeamGitEnv();
|
|
656
657
|
stage = "clone";
|
|
657
|
-
|
|
658
|
+
// Bounded so a timeout kills git's whole tree, not just the root (#498).
|
|
659
|
+
const cloned = boundedSpawnSync("git", [
|
|
658
660
|
"-c", "protocol.ext.allow=never",
|
|
659
661
|
"-c", `core.hooksPath=${emptyHooks}`,
|
|
660
662
|
"clone", "--no-checkout", `--template=${emptyTemplate}`,
|
|
661
663
|
"--", repo, stagedDest,
|
|
662
664
|
], {
|
|
663
|
-
|
|
665
|
+
stdout: "ignore",
|
|
664
666
|
env: cloneEnv,
|
|
665
667
|
timeout: timeoutMs,
|
|
666
668
|
});
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
export declare function isOptedOut(root: string): boolean;
|
|
1
|
+
import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
|
|
2
|
+
export { isOptedOut, OPT_OUT_FILE };
|
|
4
3
|
/** Whether `start`, or the repository containing it, opted out: walks up through
|
|
5
4
|
* `start`'s ancestors (which need not exist yet), stopping after the first one holding
|
|
6
5
|
* `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
|
|
@@ -8,7 +7,14 @@ export declare function isOptedOut(root: string): boolean;
|
|
|
8
7
|
* store is deliberately not a boundary: a partition store nested in an opted-out
|
|
9
8
|
* repository stays gated (fail closed). */
|
|
10
9
|
export declare function optedOutAt(start: string): boolean;
|
|
11
|
-
|
|
10
|
+
/** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
|
|
11
|
+
* `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
|
|
12
|
+
* an ancestor's marker gates nothing. */
|
|
13
|
+
export declare function optOutMarkerAt(start: string, opts?: {
|
|
14
|
+
withinRepo?: boolean;
|
|
15
|
+
}): string | null;
|
|
16
|
+
/** The refusal, naming the marker of the repository actually checked (#488). */
|
|
17
|
+
export declare function optOutMessage(marker?: string): string;
|
|
12
18
|
export interface UninstallStep {
|
|
13
19
|
/** Repo-relative (POSIX) where possible, else absolute. */
|
|
14
20
|
path: string;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `.hunch/` holds the engineering memory, so it stays unless `--purge`.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync,
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
17
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
18
18
|
import { parse as parseToml } from "smol-toml";
|
|
@@ -26,16 +26,8 @@ import { gitRun, isInside, planHookRemoval, worktreeTops } from "./hooks.js";
|
|
|
26
26
|
import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
|
|
27
27
|
import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
|
|
28
28
|
import { SLASH_COMMAND_FILES } from "./scaffold.js";
|
|
29
|
-
|
|
30
|
-
export
|
|
31
|
-
export function isOptedOut(root) {
|
|
32
|
-
try {
|
|
33
|
-
return lstatSync(join(root, OPT_OUT_FILE)).isFile();
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
29
|
+
import { isOptedOut, OPT_OUT_FILE } from "./optout.js";
|
|
30
|
+
export { isOptedOut, OPT_OUT_FILE };
|
|
39
31
|
/** Whether `start`, or the repository containing it, opted out: walks up through
|
|
40
32
|
* `start`'s ancestors (which need not exist yet), stopping after the first one holding
|
|
41
33
|
* `.git` or at the filesystem root, so a path inside an opted-out repository (`serve
|
|
@@ -43,19 +35,30 @@ export function isOptedOut(root) {
|
|
|
43
35
|
* store is deliberately not a boundary: a partition store nested in an opted-out
|
|
44
36
|
* repository stays gated (fail closed). */
|
|
45
37
|
export function optedOutAt(start) {
|
|
38
|
+
return optOutMarkerAt(start) !== null;
|
|
39
|
+
}
|
|
40
|
+
/** The opt-out marker that gates `start` (walked as in `optedOutAt`), or null. With
|
|
41
|
+
* `withinRepo`, a marker counts only when the walk reaches a repository: outside one,
|
|
42
|
+
* an ancestor's marker gates nothing. */
|
|
43
|
+
export function optOutMarkerAt(start, opts = {}) {
|
|
44
|
+
let found = null;
|
|
46
45
|
for (let dir = resolve(start);;) {
|
|
47
|
-
if (isOptedOut(dir))
|
|
48
|
-
|
|
46
|
+
if (!found && isOptedOut(dir)) {
|
|
47
|
+
found = join(dir, OPT_OUT_FILE);
|
|
48
|
+
if (!opts.withinRepo)
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
49
51
|
if (existsSync(join(dir, ".git")))
|
|
50
|
-
return
|
|
52
|
+
return found;
|
|
51
53
|
const parent = dirname(dir);
|
|
52
54
|
if (parent === dir)
|
|
53
|
-
return
|
|
55
|
+
return opts.withinRepo ? null : found;
|
|
54
56
|
dir = parent;
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
/** The refusal, naming the marker of the repository actually checked (#488). */
|
|
60
|
+
export function optOutMessage(marker = OPT_OUT_FILE) {
|
|
61
|
+
return `Hunch was removed from this repository on purpose (${marker}), so it will not set itself up here again. To reinstall, delete ${marker} and run \`hunch init\`.`;
|
|
59
62
|
}
|
|
60
63
|
function markerText() {
|
|
61
64
|
return [
|
|
@@ -310,6 +313,7 @@ export function planUninstall(root, opts = {}) {
|
|
|
310
313
|
p.hookConfig(".codex/hooks.json", providerEntry);
|
|
311
314
|
p.hookConfig(".github/hooks/hunch.json", providerEntry);
|
|
312
315
|
p.hookConfig(".windsurf/hooks.json", providerEntry);
|
|
316
|
+
p.hookConfig(".devin/hooks.json", providerEntry);
|
|
313
317
|
p.json(".agents/hooks.json", "removed the hunch hook group", (json) => {
|
|
314
318
|
if (!("hunch" in json))
|
|
315
319
|
return false;
|
|
@@ -459,7 +463,7 @@ export function planUninstall(root, opts = {}) {
|
|
|
459
463
|
}
|
|
460
464
|
/** Directories Hunch's writers create; removed only when left empty. Deepest first. */
|
|
461
465
|
const PRUNE_DIRS = [
|
|
462
|
-
".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf",
|
|
466
|
+
".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf", ".devin",
|
|
463
467
|
".vscode", ".github/hooks", ".github/workflows", ".github", ".codex", ".agents",
|
|
464
468
|
];
|
|
465
469
|
export function applyUninstall(plan) {
|
package/dist/mcp/server.js
CHANGED
|
@@ -69,7 +69,7 @@ import { nowData, wikiStatus, publicHome, readWikiManifestAt } from "../wiki/wik
|
|
|
69
69
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
70
70
|
import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
|
|
71
71
|
import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
|
|
72
|
-
import { formatWhyRecord, more, WHY_CAP } from "../core/whyRecord.js"; // WHY_CAP: per record-type cap in hunch_why
|
|
72
|
+
import { formatWhyRecord, more, WHY_CAP, WHY_RECORD_ID } from "../core/whyRecord.js"; // WHY_CAP: per record-type cap in hunch_why
|
|
73
73
|
import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
|
|
74
74
|
import { readActivePendingRepairs, withheldRewrites } from "../core/repairqueue.js";
|
|
75
75
|
import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
|
|
@@ -1253,6 +1253,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1253
1253
|
}
|
|
1254
1254
|
return null;
|
|
1255
1255
|
};
|
|
1256
|
+
// Exact-id record reads (findings, corrections, imported ADRs, why-by-id): the
|
|
1257
|
+
// same confirmation, gated to shared mode. Null means "re-read; absence is
|
|
1258
|
+
// confirmed or the store is not shared", else the abstention to return instead.
|
|
1259
|
+
const confirmSharedRecord = (subject) => store.mode === "shared" && store.privateDir ? confirmSharedMiss(`${subject} is absent from shared team memory`, subject) : null;
|
|
1256
1260
|
// Policy reads: confirm a local policy (or its referenced proof) miss against
|
|
1257
1261
|
// fresh shared memory before the service reports "not found". Public-only reads
|
|
1258
1262
|
// and non-shared stores never consult the team remote.
|
|
@@ -1508,16 +1512,23 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1508
1512
|
if (as_of && !asOf)
|
|
1509
1513
|
return invalid(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
1510
1514
|
let w = store.why(target, { asOf });
|
|
1515
|
+
// An id-shaped target is an id miss when the store reported one (generated
|
|
1516
|
+
// shape) or, for a hand-written id, when nothing else matched it either.
|
|
1517
|
+
const idMiss = (r) => !r.byId?.record && (r.byId !== undefined || (WHY_RECORD_ID.test(target)
|
|
1518
|
+
&& !r.decisions.length && !r.constraints.length && !r.bugs.length && !r.components.length && !r.symbols.length));
|
|
1511
1519
|
// "No record <id>" is a team-absence claim in shared mode: confirm the local
|
|
1512
1520
|
// miss against a fresh pull first, like hunch_current_decision. A record held
|
|
1513
1521
|
// locally but outside its as_of window is not a team-absence claim: no pull.
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
const abstention =
|
|
1522
|
+
const heldNow = () => !!(asOf && store.why(target).byId?.record);
|
|
1523
|
+
if (idMiss(w) && !heldNow()) {
|
|
1524
|
+
const abstention = confirmSharedRecord(`record ${w.byId?.id ?? target}`);
|
|
1517
1525
|
if (abstention)
|
|
1518
1526
|
return err(abstention);
|
|
1519
1527
|
w = store.why(target, { asOf });
|
|
1520
1528
|
}
|
|
1529
|
+
// A hand-written id held now but not in force at as_of: say so, like a generated id.
|
|
1530
|
+
if (asOf && !w.byId && idMiss(w) && heldNow())
|
|
1531
|
+
w = { ...w, byId: { id: target, record: null, asOf } };
|
|
1521
1532
|
if (w.byId?.record)
|
|
1522
1533
|
return ok(formatWhyRecord(w.byId).join("\n"));
|
|
1523
1534
|
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
@@ -2066,7 +2077,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2066
2077
|
},
|
|
2067
2078
|
}, async ({ decision_id, expected_source_hash, expected_review_hash, disposition, reviewed_by }) => {
|
|
2068
2079
|
try {
|
|
2069
|
-
const
|
|
2080
|
+
const find = () => store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
|
|
2081
|
+
let decision = find();
|
|
2082
|
+
if (!decision) {
|
|
2083
|
+
const abstention = confirmSharedRecord(`imported ADR ${decision_id}`);
|
|
2084
|
+
if (abstention)
|
|
2085
|
+
return err(abstention);
|
|
2086
|
+
decision = find();
|
|
2087
|
+
}
|
|
2070
2088
|
if (!decision)
|
|
2071
2089
|
return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
|
|
2072
2090
|
// Approval grants human authority, so the agent's report that the human approved
|
|
@@ -2681,7 +2699,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2681
2699
|
});
|
|
2682
2700
|
server.registerTool("nuryel_read", {
|
|
2683
2701
|
title: "nuryel.state/1 read — the system-of-record answer for a subject",
|
|
2684
|
-
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains.",
|
|
2702
|
+
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains. facets filters state_of_record and conventions only; the delivery envelope is ranked independently and keeps the constraints in force.",
|
|
2685
2703
|
inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
|
|
2686
2704
|
outputSchema: ReadResponseSchema.shape,
|
|
2687
2705
|
}, async (input) => {
|
|
@@ -2745,7 +2763,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2745
2763
|
});
|
|
2746
2764
|
server.registerTool("nuryel_write", {
|
|
2747
2765
|
title: "nuryel.state/1 write — provenance + idempotency in, durability out",
|
|
2748
|
-
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers.",
|
|
2766
|
+
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version. With capability nuryel.field-provenance/1, a derived record may carry field_provenance: [{selector:{kind:json_pointer,path:/field} or {kind:text,start:0,end:10}, value_hash:stateHash(selected scalar or text), dependency_hashes:[stateHash(existing dependency)]}]. Text offsets count Unicode code points, end exclusive. Citations are writer-supplied traceability, not verified support; negotiate support in every shared reader before writing them. With nuryel.record-visibility/1, records may carry visibility:{owner:principal-id,readers:[ids],writers:[ids]}; the owner is implicit in both lists, and writers must be readers. Owner-only audience changes require expected_version, including supersession. Restricted records require a dedicated partition; local stdio principal assertions assume trusted callers. expected_version is compare-and-swap against the incumbent, or with supersedes against the predecessor (its record hash or ledger seq); for a record not on file pass null, or 0 to assert the id was never written — any other value is refused as a conflict.",
|
|
2749
2767
|
inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
2750
2768
|
outputSchema: WriteResultSchema.shape,
|
|
2751
2769
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
@@ -2850,7 +2868,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2850
2868
|
const concerns = (f) => [...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)";
|
|
2851
2869
|
const links = (f) => [f.violates_constraint ? `violates ${f.violates_constraint}` : "", f.method ? `re-verify via ${f.method}` : "", f.resolved_commit ? `fixed in ${f.resolved_commit.slice(0, 9)}` : ""].filter(Boolean).join("; ");
|
|
2852
2870
|
if (id) {
|
|
2853
|
-
const
|
|
2871
|
+
const find = () => store.recs("findings").find((r) => r.id === id);
|
|
2872
|
+
let f = find();
|
|
2873
|
+
if (!f) {
|
|
2874
|
+
const abstention = confirmSharedRecord(`finding ${id}`);
|
|
2875
|
+
if (abstention)
|
|
2876
|
+
return err(abstention);
|
|
2877
|
+
f = find();
|
|
2878
|
+
}
|
|
2854
2879
|
if (!f)
|
|
2855
2880
|
return ok(`No finding "${id}".`);
|
|
2856
2881
|
return ok(`[${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})\n${f.observation}\nconcerns: ${concerns(f)}${links(f) ? `\n${links(f)}` : ""}`);
|
|
@@ -2883,6 +2908,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2883
2908
|
try {
|
|
2884
2909
|
if (public_only && private_only)
|
|
2885
2910
|
return invalid("Choose only one of public_only or private_only.");
|
|
2911
|
+
// A correction missing from every home this call may read is confirmed against
|
|
2912
|
+
// fresh shared memory before the upgrade reports it "not found". Public-only
|
|
2913
|
+
// reads never consult the team remote.
|
|
2914
|
+
const heldCorrection = () => !!(store.getPrivateRec("constraints", constraint_id)
|
|
2915
|
+
|| (!private_only && store.json.get("constraints", constraint_id)));
|
|
2916
|
+
if (!public_only && !heldCorrection()) {
|
|
2917
|
+
const abstention = confirmSharedRecord(`correction ${constraint_id}`);
|
|
2918
|
+
if (abstention)
|
|
2919
|
+
return err(abstention);
|
|
2920
|
+
}
|
|
2886
2921
|
// Resolve the correction's exact home before any writes. Overlay-first is
|
|
2887
2922
|
// the same selection contract as ConstitutionService.upgradeCorrection;
|
|
2888
2923
|
// deriving this later from a policy id is unsafe when legacy public and
|
|
@@ -3479,6 +3514,10 @@ export async function startServer(cwd = process.cwd(), options = {}) {
|
|
|
3479
3514
|
wireClientRoots(control, fallback);
|
|
3480
3515
|
const transport = new StdioServerTransport();
|
|
3481
3516
|
await control.server.connect(transport);
|
|
3517
|
+
// Exit through process.exit on a termination signal (same conventional codes as
|
|
3518
|
+
// a signal death) so NODE_V8_COVERAGE is flushed for `hunch verify --refresh-map`.
|
|
3519
|
+
process.once("SIGTERM", () => process.exit(143));
|
|
3520
|
+
process.once("SIGINT", () => process.exit(130));
|
|
3482
3521
|
console.error(control.pinned
|
|
3483
3522
|
? `[hunch-mcp] serving Hunch over stdio (pinned root ${control.getRoot()}; client roots ignored)`
|
|
3484
3523
|
: `[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
|
package/dist/serve/app.js
CHANGED
|
@@ -35,6 +35,8 @@ import { operatorHtml, operatorCss, operatorJs } from "./operator.js";
|
|
|
35
35
|
import { MCP_PATH, handleMcpRequest } from "./mcpHttp.js";
|
|
36
36
|
export const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
37
37
|
const POST_ROUTES = new Map([["/nuryel/v1/read", "read"], ["/nuryel/v1/write", "write"], ["/nuryel/v1/capture", "capture"], ["/nuryel/v1/capture-batch", "capture-batch"], ["/nuryel/v1/subscribe", "subscribe"], ["/nuryel/v1/records", "records"]]);
|
|
38
|
+
/** GET-only routes: any other method is 405 with `Allow: GET`, not a POST-table 404. */
|
|
39
|
+
const GET_ROUTES = new Set(["/nuryel/v1/health", "/nuryel/v1/capabilities"]);
|
|
38
40
|
export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
|
|
39
41
|
export class HttpProblem extends Error {
|
|
40
42
|
status;
|
|
@@ -272,6 +274,10 @@ export function createServeApp(config, opts = {}) {
|
|
|
272
274
|
return send(res, 200, { ...liveness, partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
|
|
273
275
|
}
|
|
274
276
|
const principal = await authenticate(req, res, url, activeConfig);
|
|
277
|
+
if (GET_ROUTES.has(url.pathname) && req.method !== "GET") {
|
|
278
|
+
res.setHeader("allow", "GET");
|
|
279
|
+
throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
|
|
280
|
+
}
|
|
275
281
|
if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
|
|
276
282
|
const scope = parseScopeParam(url.searchParams.get("scope"));
|
|
277
283
|
const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
|
|
@@ -286,8 +292,10 @@ export function createServeApp(config, opts = {}) {
|
|
|
286
292
|
const message = await readBody(req);
|
|
287
293
|
return await handleMcpRequest(req, res, message, (route, body) => dispatch(route, principal, body, activeConfig), (error) => problemBody(problemOf(error)), version);
|
|
288
294
|
}
|
|
289
|
-
if (req.method !== "POST")
|
|
295
|
+
if (req.method !== "POST") {
|
|
296
|
+
res.setHeader("allow", "POST");
|
|
290
297
|
throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
|
|
298
|
+
}
|
|
291
299
|
const route = POST_ROUTES.get(url.pathname);
|
|
292
300
|
if (!route)
|
|
293
301
|
throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
|
package/dist/serve/config.js
CHANGED
|
@@ -114,6 +114,11 @@ export function initServeConfig(opts) {
|
|
|
114
114
|
if (!publicOrigin)
|
|
115
115
|
throw new Error("key-bound principals require an HTTPS public origin");
|
|
116
116
|
}
|
|
117
|
+
// Refuse before writing anything: a grant must name a partition this config will serve.
|
|
118
|
+
const served = new Set([...(existing?.partitions ?? []).map((p) => scopePath(p.scope)), scopePath(opts.scope)]);
|
|
119
|
+
for (const g of opts.principal?.grants ?? [])
|
|
120
|
+
if (!served.has(scopePath(g)))
|
|
121
|
+
throw new Error(`cannot grant ${scopePath(g)}: this server does not serve it`);
|
|
117
122
|
const root = resolve(opts.root);
|
|
118
123
|
const hunchDir = resolve(root, ".hunch");
|
|
119
124
|
mkdirSync(hunchDir, { recursive: true });
|
|
@@ -10,10 +10,12 @@ export declare const ACQUIRE_TIMEOUT_MS = 10000;
|
|
|
10
10
|
* claimed section is a few syscalls, so anything this old means its claimer
|
|
11
11
|
* crashed inside it. */
|
|
12
12
|
export declare const CLAIM_STALE_MS = 10000;
|
|
13
|
+
/** `nonce` is optional on READ only: this lock always writes one, but a legacy or
|
|
14
|
+
* foreign writer's record may lack it (issue #491). */
|
|
13
15
|
export interface LockOwner {
|
|
14
16
|
pid: number;
|
|
15
17
|
host: string;
|
|
16
|
-
nonce
|
|
18
|
+
nonce?: string;
|
|
17
19
|
at: string;
|
|
18
20
|
start?: string;
|
|
19
21
|
}
|
package/dist/serve/writelock.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* equality and never by clock arithmetic (see core/procstart.ts). Where no token can
|
|
14
14
|
* compare them (macOS, Windows, legacy records) a stale-looking lock whose live pid
|
|
15
15
|
* belongs to a process CREATED after the lock's recorded `at` is taken over too: that
|
|
16
|
-
* process cannot be the one that took it (issue #287).
|
|
16
|
+
* process cannot be the one that took it (issue #287). That creation-time check
|
|
17
|
+
* runs on macOS and Windows only; on linux `processStartedAt` returns null, so a
|
|
18
|
+
* tokenless live pid there is never taken over. Removing a stale
|
|
17
19
|
* lock is itself serialized behind an exclusive `.reclaim` claim directory, because
|
|
18
20
|
* judging and removing are two steps and two contenders can otherwise both "win"
|
|
19
21
|
* (see `takeOver`).
|
|
@@ -56,12 +58,19 @@ export function writeLockPath(hunchDir) {
|
|
|
56
58
|
function readOwner(path) {
|
|
57
59
|
try {
|
|
58
60
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
59
|
-
|
|
61
|
+
// A missing nonce is not unreadable metadata: the pid still names a
|
|
62
|
+
// same-host owner, so it gets the live-pid judgement `.rmw-lock` gives it,
|
|
63
|
+
// never the age fallback that would steal a live writer's lock (#491).
|
|
64
|
+
// The pid check mirrors `.rmw-lock`'s: `kill(0|-n, 0)` signals a process
|
|
65
|
+
// group and always "succeeds" (a live wedge forever), and a fractional or
|
|
66
|
+
// huge pid throws (stolen even when fresh) — so such a record is unreadable
|
|
67
|
+
// and age decides.
|
|
68
|
+
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid < 1)
|
|
60
69
|
return undefined;
|
|
61
70
|
return {
|
|
62
71
|
pid: parsed.pid,
|
|
63
72
|
host: typeof parsed.host === "string" ? parsed.host : "",
|
|
64
|
-
nonce: parsed.nonce,
|
|
73
|
+
...(typeof parsed.nonce === "string" ? { nonce: parsed.nonce } : {}),
|
|
65
74
|
at: typeof parsed.at === "string" ? parsed.at : "",
|
|
66
75
|
...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
|
|
67
76
|
};
|
|
@@ -268,6 +277,11 @@ export async function withWriteLock(hunchDir, fn, opts = {}) {
|
|
|
268
277
|
// from, the file now protects another writer. The nonce is dropped in a
|
|
269
278
|
// `finally` of its own, so a throw from the conditional remove can never
|
|
270
279
|
// leave us vouching for a lock we no longer hold.
|
|
280
|
+
//
|
|
281
|
+
// DIFFERS from `.rmw-lock` on release: a readable record WITHOUT a nonce is
|
|
282
|
+
// left in place here (it is not ours — a legacy or foreign writer replaced
|
|
283
|
+
// our file), where `.rmw-lock` removes it. Only an unreadable record is
|
|
284
|
+
// removed unconditionally.
|
|
271
285
|
try {
|
|
272
286
|
const current = readOwner(path);
|
|
273
287
|
if (!current || current.nonce === nonce)
|
|
@@ -403,7 +403,8 @@ export declare class HunchStore {
|
|
|
403
403
|
private whyRecord;
|
|
404
404
|
/** Transitive blast radius: every symbol/component that (in)directly depends on
|
|
405
405
|
* `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
|
|
406
|
-
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains
|
|
406
|
+
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains,
|
|
407
|
+
* plus `references` — the only edge YAML/Helm/Kustomize emit (issue #486). */
|
|
407
408
|
getDependents(id: string, maxDepth?: number): Array<{
|
|
408
409
|
id: string;
|
|
409
410
|
depth: number;
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -1350,8 +1350,10 @@ export class HunchStore {
|
|
|
1350
1350
|
const lookup = WHY_RECORD_ID.test(target) ? this.whyRecord(target, opts.canRead, opts.asOf) : undefined;
|
|
1351
1351
|
if (lookup?.record)
|
|
1352
1352
|
return { target, decisions: [], bugs: [], constraints: [], symbols: [], components: [], byId: lookup };
|
|
1353
|
-
// A miss is reported
|
|
1354
|
-
|
|
1353
|
+
// A miss is reported for a generated-shape id, or for any id on file that was not
|
|
1354
|
+
// in force at `asOf` (issue #493); anything else resolves as before.
|
|
1355
|
+
const byId = lookup && (WHY_GENERATED_ID.test(target) || (opts.asOf && this.whyRecord(target, opts.canRead).record))
|
|
1356
|
+
? lookup : undefined;
|
|
1355
1357
|
const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
|
|
1356
1358
|
const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
|
|
1357
1359
|
const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
|
|
@@ -1446,7 +1448,8 @@ export class HunchStore {
|
|
|
1446
1448
|
}
|
|
1447
1449
|
/** Transitive blast radius: every symbol/component that (in)directly depends on
|
|
1448
1450
|
* `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
|
|
1449
|
-
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains
|
|
1451
|
+
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains,
|
|
1452
|
+
* plus `references` — the only edge YAML/Helm/Kustomize emit (issue #486). */
|
|
1450
1453
|
getDependents(id, maxDepth = 6) {
|
|
1451
1454
|
return this.db.prepare(
|
|
1452
1455
|
/* sql */ `
|
|
@@ -1455,7 +1458,7 @@ export class HunchStore {
|
|
|
1455
1458
|
UNION
|
|
1456
1459
|
SELECT e."from", up.depth + 1
|
|
1457
1460
|
FROM edges e JOIN up ON e."to" = up.node
|
|
1458
|
-
WHERE e.type IN ('calls','depends_on','imports','contains','implements') AND up.depth < ?
|
|
1461
|
+
WHERE e.type IN ('calls','depends_on','imports','contains','implements','references') AND up.depth < ?
|
|
1459
1462
|
)
|
|
1460
1463
|
SELECT up.node AS id, MIN(up.depth) AS depth,
|
|
1461
1464
|
COALESCE(s.name || ' @ ' || s.file, c.name, up.node) AS via
|
|
@@ -1497,7 +1500,7 @@ export class HunchStore {
|
|
|
1497
1500
|
UNION
|
|
1498
1501
|
SELECT e."from", up.depth + 1
|
|
1499
1502
|
FROM edges e JOIN up ON e."to" = up.node
|
|
1500
|
-
WHERE e.type IN ('calls','depends_on','imports','contains','implements') AND up.depth < ?
|
|
1503
|
+
WHERE e.type IN ('calls','depends_on','imports','contains','implements','references') AND up.depth < ?
|
|
1501
1504
|
)
|
|
1502
1505
|
SELECT s.file AS file, s.name AS via, MIN(up.depth) AS depth
|
|
1503
1506
|
FROM up JOIN symbols s ON s.id = up.node
|
|
@@ -126,12 +126,22 @@ export declare class JsonStore {
|
|
|
126
126
|
* overlay. Returns the number of files removed. Invalidates the memoized load. */
|
|
127
127
|
dropAll(kind: EntityKind): number;
|
|
128
128
|
/** Persist a schema migration: rewrite every LOADABLE record in its current shape.
|
|
129
|
-
* A record that still fails validation after migration
|
|
130
|
-
*
|
|
131
|
-
*
|
|
129
|
+
* A record that still fails validation after migration (or whose migration
|
|
130
|
+
* throws) is kept in its ORIGINAL shape (never deleted, never half-migrated) and
|
|
131
|
+
* counted as `skipped`, so migration can't lose data. The caller stamps the
|
|
132
|
+
* manifest at the returned `version`: SCHEMA_VERSION only when nothing was
|
|
133
|
+
* skipped, else the FROM version, so a manual fix plus a rerun re-applies the
|
|
134
|
+
* (idempotent) migrations to the skipped records instead of skipping them.
|
|
135
|
+
* A bad record of a kind NO pending migration touches can't be fixed by a
|
|
136
|
+
* rerun, so it is counted as `invalid` instead and doesn't hold the version. */
|
|
132
137
|
persistMigration(): {
|
|
133
138
|
migrated: number;
|
|
134
139
|
skipped: number;
|
|
140
|
+
invalid: number;
|
|
141
|
+
version: number;
|
|
135
142
|
};
|
|
143
|
+
/** Migrate + validate one raw record for persistMigration; a throwing migration
|
|
144
|
+
* step counts as a failure, so the record stays in its original shape. */
|
|
145
|
+
private tryMigrate;
|
|
136
146
|
}
|
|
137
147
|
export {};
|