@davesheffer/hunch 1.8.2 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- package/tooling/md1-benchmark.mjs +628 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CODE_EXTENSIONS } from "../extractors/languages.js";
|
|
2
|
+
import { replacementFreeCommitFiles, replacementFreeExactCommit, replacementFreeIsAncestorOrSame, } from "./replacementFreeGit.js";
|
|
3
|
+
const touchesGraphOrCheckoutInputs = (files) => files.some((file) => CODE_EXTENSIONS.some((extension) => file.endsWith(extension))
|
|
4
|
+
|| file === ".gitattributes"
|
|
5
|
+
|| file.endsWith("/.gitattributes"));
|
|
6
|
+
/** Canonical committed identity for the deterministic static graph.
|
|
7
|
+
*
|
|
8
|
+
* Public Hunch pumping creates clone-local commits containing `.hunch/` JSON and
|
|
9
|
+
* sometimes refreshed grounding docs. Those commits do not change anything the
|
|
10
|
+
* indexer parses, so binding a shared static receipt to their local SHA makes the
|
|
11
|
+
* same graph produce different artifact ids in otherwise identical clones. Walk
|
|
12
|
+
* first-parent until the newest indexed-code or checkout-attribute change instead.
|
|
13
|
+
* Attributes are a boundary because proof replay validates them before materializing
|
|
14
|
+
* exact source bytes. A merge is always a boundary: its resolution can change the
|
|
15
|
+
* effective tree even when a simple diff listing is incomplete. Reverts touch code
|
|
16
|
+
* and therefore remain distinct. */
|
|
17
|
+
export function canonicalStaticGraphBaseline(root, ref = "HEAD") {
|
|
18
|
+
const repositoryRef = replacementFreeExactCommit(root, ref);
|
|
19
|
+
if (!repositoryRef)
|
|
20
|
+
throw new Error(`static graph baseline needs a resolvable Git commit, got ${ref}`);
|
|
21
|
+
let current = repositoryRef;
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
while (!seen.has(current)) {
|
|
24
|
+
seen.add(current);
|
|
25
|
+
if (replacementFreeExactCommit(root, `${current}^2`))
|
|
26
|
+
return current;
|
|
27
|
+
if (touchesGraphOrCheckoutInputs(replacementFreeCommitFiles(root, current)))
|
|
28
|
+
return current;
|
|
29
|
+
// With no indexed-code commit at all, the repository root is the one
|
|
30
|
+
// cross-clone-resolvable anchor for the empty static graph. Returning the
|
|
31
|
+
// caller's docs/memory-only HEAD would reintroduce clone-local receipt churn.
|
|
32
|
+
const parent = replacementFreeExactCommit(root, `${current}^1`);
|
|
33
|
+
if (!parent)
|
|
34
|
+
return current;
|
|
35
|
+
if (parent === current)
|
|
36
|
+
return repositoryRef;
|
|
37
|
+
current = parent;
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
export function isAncestorOrSame(root, ancestor, descendant) {
|
|
42
|
+
return replacementFreeIsAncestorOrSame(root, ancestor, descendant);
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=staticGraphBaseline.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { builtinModules } from "node:module";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
2
3
|
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
3
4
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
4
5
|
import { shortHash } from "../core/ids.js";
|
|
@@ -9,6 +10,7 @@ import { clampCandidateLimit, durationCutoff } from "./bootstrap.js";
|
|
|
9
10
|
import { compileStructuralPolicy } from "./compiler.js";
|
|
10
11
|
import { extractStructuralDelta } from "./delta.js";
|
|
11
12
|
import { EvidenceEventSchema, PolicySpecSchema, } from "./schema.js";
|
|
13
|
+
const builtinModuleSpecifiers = new Set(builtinModules);
|
|
12
14
|
const exactSelector = (symbol) => ({ selector: `symbol:${symbol.file}:${symbol.name}` });
|
|
13
15
|
function scopeFor(store, file, publicOnly) {
|
|
14
16
|
const components = (publicOnly ? store.json.loadAll("components") : store.recs("components"))
|
|
@@ -20,6 +22,58 @@ function scopeFor(store, file, publicOnly) {
|
|
|
20
22
|
function candidateId(assertion, scope) {
|
|
21
23
|
return `cand_${shortHash(canonicalHash({ assertion, scope }))}`;
|
|
22
24
|
}
|
|
25
|
+
/** Bind one exact file/package boundary to the existing static import graph.
|
|
26
|
+
* The file-level fact is anchored to the first stable symbol because the
|
|
27
|
+
* indexer attaches every external import in a file to every symbol in it. */
|
|
28
|
+
export function inspectExternalImportBoundary(store, file, specifier, opts = {}) {
|
|
29
|
+
const supportedExtensions = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
30
|
+
if (!supportedExtensions.has(extname(file).toLowerCase())) {
|
|
31
|
+
return {
|
|
32
|
+
candidate: null,
|
|
33
|
+
reason: `${file} is outside MD-1a's TypeScript/JavaScript static ESM import-declaration projection`,
|
|
34
|
+
code: "unsupported_file",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const exactSpecifier = specifier.trim();
|
|
38
|
+
const dependency = externalPackage(exactSpecifier);
|
|
39
|
+
const external = externalImportNodeId(exactSpecifier);
|
|
40
|
+
const exactNpmPackage = /^(?:@[A-Za-z0-9][A-Za-z0-9._~-]*\/)?[A-Za-z0-9][A-Za-z0-9._~-]*$/;
|
|
41
|
+
if (!dependency || !external || dependency !== exactSpecifier || builtinModuleSpecifiers.has(exactSpecifier)
|
|
42
|
+
|| !exactNpmPackage.test(exactSpecifier)) {
|
|
43
|
+
return {
|
|
44
|
+
candidate: null,
|
|
45
|
+
reason: `${specifier} is not one exact top-level npm package identity; package subpaths, built-ins, and scheme specifiers are unsupported`,
|
|
46
|
+
code: "unsupported_dependency",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const symbols = (opts.publicOnly ? store.json.loadAll("symbols") : store.recs("symbols"))
|
|
50
|
+
.filter((symbol) => symbol.file === file)
|
|
51
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
52
|
+
if (!symbols.length)
|
|
53
|
+
return { candidate: null, reason: `${file} has no exact current symbol to anchor a file-scoped import policy`, code: "missing_anchor" };
|
|
54
|
+
const edges = opts.publicOnly ? store.json.loadAll("edges") : store.recs("edges");
|
|
55
|
+
const symbolIds = new Set(symbols.map((symbol) => symbol.id));
|
|
56
|
+
if (edges.some((edge) => edge.type === "imports" && symbolIds.has(edge.from) && edge.to === external)) {
|
|
57
|
+
return { candidate: null, reason: `${file} currently imports ${dependency}; fix the baseline before Hunch builds a proof packet`, code: "baseline_violated" };
|
|
58
|
+
}
|
|
59
|
+
const assertion = {
|
|
60
|
+
kind: "not-reaches",
|
|
61
|
+
subject: exactSelector(symbols[0]),
|
|
62
|
+
relation: { edges: ["imports"], transitive: false, max_depth: 1 },
|
|
63
|
+
object: { selector: `external:${dependency}` },
|
|
64
|
+
};
|
|
65
|
+
const scope = scopeFor(store, file, !!opts.publicOnly);
|
|
66
|
+
return {
|
|
67
|
+
candidate: {
|
|
68
|
+
id: candidateId(assertion, scope),
|
|
69
|
+
assertion,
|
|
70
|
+
scope,
|
|
71
|
+
basis: "correction-forbidden-import",
|
|
72
|
+
reason: `human correction forbids direct external package ${dependency} in ${file}`,
|
|
73
|
+
},
|
|
74
|
+
reason: null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
23
77
|
function alternativeFor(candidate) {
|
|
24
78
|
return {
|
|
25
79
|
id: candidate.id,
|
|
@@ -45,16 +99,18 @@ function candidateContext(enumerated, selected, conflicts = [], incumbent = null
|
|
|
45
99
|
counterexamples: [...new Set(counterexamples)].sort(),
|
|
46
100
|
};
|
|
47
101
|
}
|
|
48
|
-
function structuralKey(policy) {
|
|
102
|
+
export function structuralKey(policy) {
|
|
49
103
|
return canonicalHash({ assertion: policy.assertion, scope: policy.scope, data_class: policy.data_class });
|
|
50
104
|
}
|
|
51
105
|
function scopesOverlap(left, right) {
|
|
52
106
|
const repoOverlap = !left.repos.length || !right.repos.length || left.repos.some((repo) => right.repos.includes(repo));
|
|
53
|
-
const pathOverlap = !left.paths.length || !right.paths.length || left.paths.some((
|
|
107
|
+
const pathOverlap = !left.paths.length || !right.paths.length || left.paths.some((leftPath) => right.paths.some((rightPath) => leftPath === rightPath
|
|
108
|
+
|| pathMatchesGlob(leftPath, rightPath)
|
|
109
|
+
|| pathMatchesGlob(rightPath, leftPath)));
|
|
54
110
|
const componentOverlap = !left.components.length || !right.components.length || left.components.some((component) => right.components.includes(component));
|
|
55
111
|
return repoOverlap && pathOverlap && componentOverlap;
|
|
56
112
|
}
|
|
57
|
-
function directConflict(candidate, incumbent) {
|
|
113
|
+
export function directConflict(candidate, incumbent) {
|
|
58
114
|
const left = candidate.assertion;
|
|
59
115
|
const right = incumbent.assertion;
|
|
60
116
|
if (!((left.kind === "reaches" && right.kind === "not-reaches") || (left.kind === "not-reaches" && right.kind === "reaches")))
|
package/dist/core/autoreview.js
CHANGED
|
@@ -48,7 +48,7 @@ export function planAutoReview(drafts, allDecisions, verdicts, cfg = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
return plan;
|
|
50
50
|
}
|
|
51
|
-
/** Total drafts the plan would mutate (accept + both
|
|
51
|
+
/** Total drafts the plan would mutate (accept + both rejection buckets). */
|
|
52
52
|
export function planMutations(plan) {
|
|
53
53
|
return plan.accept.length + plan.rejectDuplicate.length + plan.rejectIrrelevant.length;
|
|
54
54
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Locale-free UTF-16 code-unit ordering for content-addressed artifacts.
|
|
2
|
+
* Never use localeCompare where ordering contributes to a hash or selection. */
|
|
3
|
+
export function compareCodeUnits(left, right) {
|
|
4
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
5
|
+
}
|
|
6
|
+
//# sourceMappingURL=canonicalOrder.js.map
|
package/dist/core/conformance.js
CHANGED
|
@@ -1,56 +1,97 @@
|
|
|
1
|
-
function
|
|
2
|
-
const syms =
|
|
1
|
+
function resolveSymbols(graph, ref) {
|
|
2
|
+
const syms = graph.symbols;
|
|
3
3
|
if (ref.startsWith("sym_"))
|
|
4
|
-
return syms.
|
|
4
|
+
return syms.filter((s) => s.id === ref);
|
|
5
5
|
if (ref.includes(":")) {
|
|
6
|
-
const
|
|
7
|
-
|
|
6
|
+
const split = ref.lastIndexOf(":");
|
|
7
|
+
const file = ref.slice(0, split);
|
|
8
|
+
const name = ref.slice(split + 1);
|
|
9
|
+
return syms.filter((s) => s.name === name && (s.file === file || s.file.endsWith("/" + file)));
|
|
8
10
|
}
|
|
9
|
-
return syms.
|
|
11
|
+
return syms.filter((s) => s.name === ref);
|
|
10
12
|
}
|
|
11
|
-
function reaches(
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
function reaches(graph, id, transitive) {
|
|
14
|
+
const reached = new Set();
|
|
15
|
+
const seen = new Set([id]);
|
|
16
|
+
const queue = [{ id, depth: 0 }];
|
|
17
|
+
const maxDepth = transitive ? 6 : 1;
|
|
18
|
+
const traversable = new Set(["calls", "depends_on", "imports", "contains"]);
|
|
19
|
+
const outgoing = new Map();
|
|
20
|
+
for (const edge of graph.edges) {
|
|
21
|
+
if (!traversable.has(edge.type))
|
|
22
|
+
continue;
|
|
23
|
+
const targets = outgoing.get(edge.from) ?? [];
|
|
24
|
+
targets.push(edge.to);
|
|
25
|
+
outgoing.set(edge.from, targets);
|
|
16
26
|
}
|
|
17
|
-
|
|
27
|
+
while (queue.length) {
|
|
28
|
+
const current = queue.shift();
|
|
29
|
+
if (current.depth >= maxDepth)
|
|
30
|
+
continue;
|
|
31
|
+
for (const next of outgoing.get(current.id) ?? []) {
|
|
32
|
+
reached.add(next);
|
|
33
|
+
if (seen.has(next))
|
|
34
|
+
continue;
|
|
35
|
+
seen.add(next);
|
|
36
|
+
queue.push({ id: next, depth: current.depth + 1 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return reached;
|
|
18
40
|
}
|
|
19
|
-
function evalPredicate(
|
|
41
|
+
function evalPredicate(graph, d, p) {
|
|
20
42
|
const base = { decision: d.id, title: d.title, assert: p.assert, subject: p.subject, object: p.object };
|
|
21
|
-
const
|
|
43
|
+
const subjects = resolveSymbols(graph, p.subject);
|
|
22
44
|
if (p.assert === "exists") {
|
|
23
|
-
return { ...base, satisfied:
|
|
45
|
+
return { ...base, satisfied: subjects.length > 0, detail: subjects.length ? `${p.subject} exists (${subjects.map((subject) => subject.file).join(", ")})` : `${p.subject} no longer exists in the graph` };
|
|
24
46
|
}
|
|
25
|
-
if (!
|
|
47
|
+
if (!subjects.length)
|
|
26
48
|
return { ...base, satisfied: false, detail: `subject "${p.subject}" not found in the graph — intent's subject is gone` };
|
|
27
49
|
const wantReach = p.assert === "calls" || p.assert === "imports";
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
50
|
+
const objects = p.object ? resolveSymbols(graph, p.object) : [];
|
|
51
|
+
if (!objects.length) {
|
|
30
52
|
// a required target gone ⇒ the link can't hold (violated); a forbidden one trivially holds.
|
|
31
53
|
return { ...base, satisfied: !wantReach, detail: `target "${p.object ?? ""}" not found in the graph` };
|
|
32
54
|
}
|
|
33
|
-
|
|
55
|
+
// A required relation cannot guess which same-name symbol carries the intent.
|
|
56
|
+
// Force qualification instead of accidentally proving a different binding.
|
|
57
|
+
if (wantReach && (subjects.length !== 1 || objects.length !== 1)) {
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
satisfied: false,
|
|
61
|
+
detail: `ambiguous required binding (${subjects.length} subject, ${objects.length} target matches) — qualify as file:symbol; intent VIOLATED`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// A forbidden relation is conservative in the other direction: ANY matching
|
|
65
|
+
// subject reaching ANY same-name target is a real counterexample. Looking at
|
|
66
|
+
// only the first target lets a duplicate symbol hide a violation.
|
|
67
|
+
const linked = subjects.some((subject) => {
|
|
68
|
+
const reached = reaches(graph, subject.id, p.transitive);
|
|
69
|
+
return objects.some((object) => reached.has(object.id));
|
|
70
|
+
});
|
|
34
71
|
const satisfied = wantReach ? linked : !linked;
|
|
35
72
|
const via = p.transitive ? " (transitively)" : "";
|
|
36
73
|
const detail = satisfied
|
|
37
74
|
? wantReach
|
|
38
|
-
? `${
|
|
39
|
-
: `${
|
|
75
|
+
? `${p.subject} →${via} ${p.object} ✓`
|
|
76
|
+
: `${p.subject} does not reach ${p.object} ✓`
|
|
40
77
|
: wantReach
|
|
41
|
-
? `${
|
|
42
|
-
: `${
|
|
78
|
+
? `${p.subject} no longer reaches${via} ${p.object} — intent VIOLATED`
|
|
79
|
+
: `${p.subject} now reaches${via} ${p.object} — intent VIOLATED`;
|
|
43
80
|
return { ...base, satisfied, detail };
|
|
44
81
|
}
|
|
45
82
|
/** Check every in-force decision's conformance predicates against the CURRENT graph.
|
|
46
|
-
* `.satisfied === false` means the code drifted from the recorded intent. Deterministic.
|
|
47
|
-
|
|
83
|
+
* `.satisfied === false` means the code drifted from the recorded intent. Deterministic.
|
|
84
|
+
* `publicOnly` selects every input at the JSON read boundary so a private decision,
|
|
85
|
+
* symbol, or edge can never influence (or be rendered into) a public CI receipt. */
|
|
86
|
+
export function checkConformance(store, opts = {}) {
|
|
87
|
+
const load = (kind) => opts.publicOnly ? store.json.loadAll(kind) : store.recs(kind);
|
|
88
|
+
const graph = opts.graph ?? { symbols: load("symbols"), edges: load("edges") };
|
|
48
89
|
const out = [];
|
|
49
|
-
for (const d of
|
|
90
|
+
for (const d of load("decisions")) {
|
|
50
91
|
if (d.status === "superseded" || d.superseded_by)
|
|
51
92
|
continue; // in-force decisions only
|
|
52
93
|
for (const p of d.conformance ?? [])
|
|
53
|
-
out.push(evalPredicate(
|
|
94
|
+
out.push(evalPredicate(graph, d, p));
|
|
54
95
|
}
|
|
55
96
|
return out;
|
|
56
97
|
}
|
package/dist/core/docscan.js
CHANGED
|
@@ -19,6 +19,7 @@ import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
|
19
19
|
import { join, extname } from "node:path";
|
|
20
20
|
import { parseDocAnchors } from "./docanchors.js";
|
|
21
21
|
import { currentForTopic } from "./topics.js";
|
|
22
|
+
import { compareCodeUnits } from "./canonicalOrder.js";
|
|
22
23
|
export const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
|
|
23
24
|
export const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
|
|
24
25
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "vscode-extension", "site"]);
|
|
@@ -111,6 +112,6 @@ export function scanRepoDocs(decisions, root) {
|
|
|
111
112
|
const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? doc.rel;
|
|
112
113
|
out.push({ rel: doc.rel, title, topics, srcRefs, status, issues });
|
|
113
114
|
}
|
|
114
|
-
return out.sort((a, b) => a.rel
|
|
115
|
+
return out.sort((a, b) => compareCodeUnits(a.rel, b.rel));
|
|
115
116
|
}
|
|
116
117
|
//# sourceMappingURL=docscan.js.map
|
package/dist/core/escalations.js
CHANGED
|
@@ -46,6 +46,17 @@ export function policyEscalations(policies) {
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
else if (p.state === "proposed") {
|
|
49
|
+
if (p.activation_gate?.status === "blocked") {
|
|
50
|
+
out.push({
|
|
51
|
+
kind: "policy-proposal",
|
|
52
|
+
topic: p.id,
|
|
53
|
+
decisionIds: [p.id],
|
|
54
|
+
question: `Proposed rule "${clip(p.statement)}" (${p.id}) is ready for review but mechanically blocked from activation — keep it as evidence?`,
|
|
55
|
+
detail: `state proposed · ${p.proof ? `proof ${p.proof}` : "no proof"} · authority none · activation gate ${p.activation_gate.kind}`,
|
|
56
|
+
resolution: `inspect: hunch policy card ${p.id} — activation remains unavailable until the source-currentness gate is implemented and cleared`,
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
49
60
|
out.push({
|
|
50
61
|
kind: "policy-proposal",
|
|
51
62
|
topic: p.id,
|
package/dist/core/io.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/** Durable file writes for the Hunch. */
|
|
2
|
-
import { writeFileSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { linkSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
3
3
|
let counter = 0;
|
|
4
|
+
const renameRetryDelaysMs = [10, 20, 40, 80];
|
|
5
|
+
const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
4
6
|
/**
|
|
5
7
|
* Write `data` to `file` via a temp file + rename, so an interrupted write can't
|
|
6
8
|
* leave the target truncated (the symbols/edges index is the worst to half-write).
|
|
7
9
|
*
|
|
8
10
|
* Windows caveat: renameSync can't REPLACE a file another process holds open (even
|
|
9
11
|
* for read) — it throws EPERM/EBUSY/EACCES, exactly when the MCP server is reading
|
|
10
|
-
* while a CLI writes.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* while a CLI writes. Retry that atomic replacement with bounded backoff. If the
|
|
13
|
+
* contention persists, fail with the old target untouched; never trade availability
|
|
14
|
+
* for a direct write that an interruption could truncate. Failed writes clean up the
|
|
15
|
+
* temporary file.
|
|
13
16
|
*/
|
|
14
17
|
export function writeFileAtomic(file, data) {
|
|
15
18
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
@@ -21,16 +24,48 @@ export function writeFileAtomic(file, data) {
|
|
|
21
24
|
throw e;
|
|
22
25
|
}
|
|
23
26
|
try {
|
|
24
|
-
|
|
27
|
+
renameWithContentionRetry(tmp, file);
|
|
25
28
|
}
|
|
26
29
|
catch (e) {
|
|
27
30
|
safeRm(tmp);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function renameWithContentionRetry(from, to) {
|
|
35
|
+
for (let attempt = 0;; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
renameSync(from, to);
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
|
-
|
|
40
|
+
catch (error) {
|
|
41
|
+
const delayMs = renameRetryDelaysMs[attempt];
|
|
42
|
+
if (delayMs === undefined || !isRenameContention(error))
|
|
43
|
+
throw error;
|
|
44
|
+
Atomics.wait(renameRetryWaiter, 0, 0, delayMs);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function isRenameContention(error) {
|
|
49
|
+
const code = error.code;
|
|
50
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
51
|
+
}
|
|
52
|
+
/** Atomically create a complete file only when no target exists. A same-dir
|
|
53
|
+
* hard link publishes the fully written temp inode with create-if-absent
|
|
54
|
+
* semantics, so concurrent lifecycle writers can never be overwritten. */
|
|
55
|
+
export function writeFileAtomicIfAbsent(file, data) {
|
|
56
|
+
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(tmp, data);
|
|
59
|
+
linkSync(tmp, file);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === "EEXIST")
|
|
64
|
+
return false;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
safeRm(tmp);
|
|
34
69
|
}
|
|
35
70
|
}
|
|
36
71
|
function safeRm(p) {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { ENTITY_KINDS } from "./types.js";
|
|
4
|
+
function pathIsWithin(path, parent) {
|
|
5
|
+
const rel = relative(parent, path);
|
|
6
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
7
|
+
}
|
|
8
|
+
/** Validate the materialized overlay without following links. Git's metadata is
|
|
9
|
+
* deliberately skipped, but its own directory must still be contained under the
|
|
10
|
+
* canonical overlay root. Every remotely controlled entry must be an ordinary
|
|
11
|
+
* file or real directory whose canonical path stays inside that root. */
|
|
12
|
+
export function safeOverlayTree(root) {
|
|
13
|
+
try {
|
|
14
|
+
const lexicalRoot = resolve(root);
|
|
15
|
+
const rootStat = lstatSync(lexicalRoot);
|
|
16
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
|
|
17
|
+
return false;
|
|
18
|
+
const canonicalRoot = realpathSync(lexicalRoot);
|
|
19
|
+
const walk = (dir, topLevel = false) => {
|
|
20
|
+
const dirStat = lstatSync(dir);
|
|
21
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory())
|
|
22
|
+
return false;
|
|
23
|
+
if (!pathIsWithin(realpathSync(dir), canonicalRoot))
|
|
24
|
+
return false;
|
|
25
|
+
for (const name of readdirSync(dir)) {
|
|
26
|
+
const entry = join(dir, name);
|
|
27
|
+
const stat = lstatSync(entry);
|
|
28
|
+
if (stat.isSymbolicLink())
|
|
29
|
+
return false;
|
|
30
|
+
if (!pathIsWithin(realpathSync(entry), canonicalRoot))
|
|
31
|
+
return false;
|
|
32
|
+
if (topLevel && (name === ".gitignore" || name === ".gitattributes")
|
|
33
|
+
&& (!stat.isFile() || stat.nlink !== 1))
|
|
34
|
+
return false;
|
|
35
|
+
if (topLevel && name === ".hunch" && !stat.isDirectory())
|
|
36
|
+
return false;
|
|
37
|
+
if (topLevel && name === ".git") {
|
|
38
|
+
if (!stat.isDirectory())
|
|
39
|
+
return false;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (stat.isDirectory()) {
|
|
43
|
+
if (!walk(entry))
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
else if (!stat.isFile()) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
if (!walk(lexicalRoot, true))
|
|
53
|
+
return false;
|
|
54
|
+
const hunchDir = join(lexicalRoot, ".hunch");
|
|
55
|
+
if (existsSync(hunchDir)) {
|
|
56
|
+
for (const kind of ENTITY_KINDS) {
|
|
57
|
+
const kindDir = join(hunchDir, kind);
|
|
58
|
+
if (existsSync(kindDir) && !lstatSync(kindDir).isDirectory())
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
for (const name of ["manifest.json", "config.json"]) {
|
|
62
|
+
const file = join(hunchDir, name);
|
|
63
|
+
if (existsSync(file) && !lstatSync(file).isFile())
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Validate `git ls-tree -r -t -z <exact-oid>` output before checkout. A Git
|
|
74
|
+
* remote can encode symlinks (120000) and gitlinks (160000); accepting only
|
|
75
|
+
* ordinary blobs and trees makes the fetched object graph safe to materialize.
|
|
76
|
+
* The explicit Hunch topology rules keep canonical record directories and
|
|
77
|
+
* capability/config paths from changing shape on a later pull. */
|
|
78
|
+
export function safeOverlayGitTreeListing(listing) {
|
|
79
|
+
const entries = new Map();
|
|
80
|
+
for (const row of listing.split("\0")) {
|
|
81
|
+
if (!row)
|
|
82
|
+
continue;
|
|
83
|
+
const tab = row.indexOf("\t");
|
|
84
|
+
if (tab <= 0)
|
|
85
|
+
return false;
|
|
86
|
+
const header = row.slice(0, tab).match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]+)$/);
|
|
87
|
+
if (!header)
|
|
88
|
+
return false;
|
|
89
|
+
const path = row.slice(tab + 1);
|
|
90
|
+
const segments = path.split("/");
|
|
91
|
+
if (!path || path.startsWith("/") || path.includes("\\")
|
|
92
|
+
|| segments.some((segment) => !segment || segment === "." || segment === ".."
|
|
93
|
+
|| segment.toLowerCase() === ".git" || segment === ".hunch-commit.lock")) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
// These are clone-local/derived runtime artifacts, never graph source of
|
|
97
|
+
// truth. Accepting a tracked pointer can disclose or redirect a machine's
|
|
98
|
+
// private store; tracked SQLite/temp/cache artifacts poison the clean-tree
|
|
99
|
+
// and additive-publication contracts on every later request.
|
|
100
|
+
if (path === ".hunch/local.json"
|
|
101
|
+
|| path === ".hunch-cache" || path.startsWith(".hunch-cache/")
|
|
102
|
+
|| /^\.hunch\/[^/]+\.sqlite[^/]*$/i.test(path)
|
|
103
|
+
|| (path.startsWith(".hunch/") && segments.slice(1).some((segment) => segment.includes(".tmp")))) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const mode = header[1];
|
|
107
|
+
const type = header[2];
|
|
108
|
+
const ordinaryTree = mode === "040000" && type === "tree";
|
|
109
|
+
const ordinaryBlob = (mode === "100644" || mode === "100755") && type === "blob";
|
|
110
|
+
if (!ordinaryTree && !ordinaryBlob)
|
|
111
|
+
return false;
|
|
112
|
+
if (entries.has(path))
|
|
113
|
+
return false;
|
|
114
|
+
entries.set(path, type);
|
|
115
|
+
}
|
|
116
|
+
const isTree = (path) => !entries.has(path) || entries.get(path) === "tree";
|
|
117
|
+
const isBlob = (path) => !entries.has(path) || entries.get(path) === "blob";
|
|
118
|
+
if (!isTree(".hunch"))
|
|
119
|
+
return false;
|
|
120
|
+
for (const kind of ENTITY_KINDS)
|
|
121
|
+
if (!isTree(`.hunch/${kind}`))
|
|
122
|
+
return false;
|
|
123
|
+
for (const path of [".gitattributes", ".gitignore", ".hunch/manifest.json", ".hunch/config.json"]) {
|
|
124
|
+
if (!isBlob(path))
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/** A dedicated Hunch overlay needs exactly one attribute capability: selecting
|
|
130
|
+
* the locally installed `merge=hunch` JSON merge driver, plus Hunch's exact
|
|
131
|
+
* `.hunch/manifest.json merge=text` override (the manifest has no record id and
|
|
132
|
+
* must use Git's built-in text merge). Reject every other token/pattern pair,
|
|
133
|
+
* including byte-transforming built-ins such as `ident` and
|
|
134
|
+
* `working-tree-encoding`, rather than maintaining a command-key blacklist.
|
|
135
|
+
* Blank lines and comments remain harmless. */
|
|
136
|
+
export function hunchAttributesAreSafe(content) {
|
|
137
|
+
for (const line of content.split(/\r?\n/)) {
|
|
138
|
+
const candidate = line.trimStart();
|
|
139
|
+
if (!candidate || candidate.startsWith("#"))
|
|
140
|
+
continue;
|
|
141
|
+
const fields = candidate.split(/\s+/);
|
|
142
|
+
if (fields.length < 2)
|
|
143
|
+
return false;
|
|
144
|
+
const attributes = fields.slice(1);
|
|
145
|
+
if (attributes.every((attribute) => attribute === "merge=hunch"))
|
|
146
|
+
continue;
|
|
147
|
+
if (fields[0] === ".hunch/manifest.json"
|
|
148
|
+
&& attributes.every((attribute) => attribute === "merge=text"))
|
|
149
|
+
continue;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/** Validate every committed .gitattributes blob in an already-safe ls-tree
|
|
155
|
+
* listing. Blob loading is injected so clone and later-pull seams share one
|
|
156
|
+
* parser without either trusting worktree bytes before materialization. */
|
|
157
|
+
export function hunchTreeAttributesAreSafe(listing, readBlob) {
|
|
158
|
+
if (!safeOverlayGitTreeListing(listing))
|
|
159
|
+
return false;
|
|
160
|
+
for (const row of listing.split("\0")) {
|
|
161
|
+
if (!row)
|
|
162
|
+
continue;
|
|
163
|
+
const tab = row.indexOf("\t");
|
|
164
|
+
if (tab < 1)
|
|
165
|
+
return false;
|
|
166
|
+
const header = row.slice(0, tab).match(/^100(?:644|755) blob ([0-9a-f]{40,64})$/i);
|
|
167
|
+
const path = row.slice(tab + 1);
|
|
168
|
+
if (path !== ".gitattributes" && !path.endsWith("/.gitattributes"))
|
|
169
|
+
continue;
|
|
170
|
+
if (!header)
|
|
171
|
+
return false;
|
|
172
|
+
const content = readBlob(header[1]);
|
|
173
|
+
if (content === null || !hunchAttributesAreSafe(content))
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=overlaySafety.js.map
|
package/dist/core/paths.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
5
|
export const HUNCH_DIR = ".hunch";
|
|
6
6
|
/** Canonicalize a free-form path/target to repo-relative POSIX form. Hunch stores
|
|
@@ -27,7 +27,18 @@ export function hunchPaths(root) {
|
|
|
27
27
|
* PRIVATE overlay store (HUNCH_PRIVATE_DIR), which lives in a separate repo the
|
|
28
28
|
* user controls rather than under the current repo's `.hunch/`. */
|
|
29
29
|
export function hunchPathsForDir(hunchDir) {
|
|
30
|
-
const
|
|
30
|
+
const lexical = resolve(hunchDir);
|
|
31
|
+
// An explicitly configured PRIVATE overlay may intentionally be a
|
|
32
|
+
// final-component symlink to a distinct physical repository. Resolve that
|
|
33
|
+
// user-selected root before handing it to JsonStore; public hunchPaths()
|
|
34
|
+
// deliberately does not do this, so a committed public `.hunch` symlink and
|
|
35
|
+
// every kind/record symlink remain fail-closed.
|
|
36
|
+
let hunch = lexical;
|
|
37
|
+
try {
|
|
38
|
+
if (statSync(lexical).isDirectory())
|
|
39
|
+
hunch = realpathSync(lexical);
|
|
40
|
+
}
|
|
41
|
+
catch { /* missing overlay root is created at the lexical location */ }
|
|
31
42
|
return {
|
|
32
43
|
root: dirname(hunch),
|
|
33
44
|
hunch,
|