@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.
Files changed (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. 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 { basename } from "node:path";
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((path) => right.paths.includes(path));
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")))
@@ -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 delete buckets). */
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
@@ -1,56 +1,97 @@
1
- function resolveSymbol(store, ref) {
2
- const syms = store.recs("symbols");
1
+ function resolveSymbols(graph, ref) {
2
+ const syms = graph.symbols;
3
3
  if (ref.startsWith("sym_"))
4
- return syms.find((s) => s.id === ref) ?? null;
4
+ return syms.filter((s) => s.id === ref);
5
5
  if (ref.includes(":")) {
6
- const [f, n] = ref.split(":");
7
- return syms.find((s) => s.name === n && (s.file === f || s.file.endsWith("/" + (f ?? "")))) ?? null;
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.find((s) => s.name === ref) ?? null;
11
+ return syms.filter((s) => s.name === ref);
10
12
  }
11
- function reaches(store, id, transitive) {
12
- const set = new Set();
13
- for (const d of store.getDependencies(id, transitive ? 6 : 1)) {
14
- if (transitive || d.depth === 1)
15
- set.add(d.id);
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
- return set;
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(store, d, p) {
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 subj = resolveSymbol(store, p.subject);
43
+ const subjects = resolveSymbols(graph, p.subject);
22
44
  if (p.assert === "exists") {
23
- return { ...base, satisfied: !!subj, detail: subj ? `${p.subject} exists (${subj.file})` : `${p.subject} no longer exists in the graph` };
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 (!subj)
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 obj = p.object ? resolveSymbol(store, p.object) : null;
29
- if (!obj) {
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
- const linked = reaches(store, subj.id, p.transitive).has(obj.id);
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
- ? `${subj.name} →${via} ${obj.name} ✓`
39
- : `${subj.name} does not reach ${obj.name} ✓`
75
+ ? `${p.subject} →${via} ${p.object} ✓`
76
+ : `${p.subject} does not reach ${p.object} ✓`
40
77
  : wantReach
41
- ? `${subj.name} no longer reaches${via} ${obj.name} — intent VIOLATED`
42
- : `${subj.name} now reaches${via} ${obj.name} — intent VIOLATED`;
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
- export function checkConformance(store) {
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 store.recs("decisions")) {
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(store, d, p));
94
+ out.push(evalPredicate(graph, d, p));
54
95
  }
55
96
  return out;
56
97
  }
@@ -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.localeCompare(b.rel));
115
+ return out.sort((a, b) => compareCodeUnits(a.rel, b.rel));
115
116
  }
116
117
  //# sourceMappingURL=docscan.js.map
@@ -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. Atomicity is crash-safety insurance, not worth FAILING a write
11
- * the old in-place writeFileSync would have completed so we fall back to a direct
12
- * write there. The temp file is always cleaned up; a failed write never leaks it.
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
- renameSync(tmp, file);
27
+ renameWithContentionRetry(tmp, file);
25
28
  }
26
29
  catch (e) {
27
30
  safeRm(tmp);
28
- const code = e.code;
29
- if (code === "EPERM" || code === "EBUSY" || code === "EACCES") {
30
- writeFileSync(file, data); // non-atomic fallback (matches pre-hardening behavior)
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
- throw e;
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
@@ -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 hunch = resolve(hunchDir);
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,