@davesheffer/hunch 1.6.0 → 1.7.1
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 +220 -0
- package/bench/constitution-exp03-v1.json +70 -0
- package/dist/cli/index.js +1278 -44
- package/dist/constitution/adapters.js +487 -0
- package/dist/constitution/behaviorAttestationBinding.js +17 -0
- package/dist/constitution/behaviorEvaluator.js +220 -0
- package/dist/constitution/behaviorProof.js +205 -0
- package/dist/constitution/behaviorWorkspace.js +124 -0
- package/dist/constitution/bootstrap.js +133 -0
- package/dist/constitution/canonical.js +51 -0
- package/dist/constitution/card.js +133 -0
- package/dist/constitution/compiler.js +176 -0
- package/dist/constitution/composition.js +101 -0
- package/dist/constitution/corpus.js +58 -0
- package/dist/constitution/delta.js +154 -0
- package/dist/constitution/disposition.js +141 -0
- package/dist/constitution/evaluator.js +435 -0
- package/dist/constitution/experiment.js +948 -0
- package/dist/constitution/experimentRunner.js +344 -0
- package/dist/constitution/g2.js +291 -0
- package/dist/constitution/g2BehaviorAttestation.js +209 -0
- package/dist/constitution/g2BehaviorCandidates.js +703 -0
- package/dist/constitution/g2BehaviorDependencies.js +379 -0
- package/dist/constitution/g2BehaviorMaterialization.js +171 -0
- package/dist/constitution/g2BehaviorPolicyMaterializer.js +241 -0
- package/dist/constitution/g2CandidateAttestation.js +179 -0
- package/dist/constitution/g2Candidates.js +195 -0
- package/dist/constitution/g2Drills.js +122 -0
- package/dist/constitution/g3.js +511 -0
- package/dist/constitution/g3Conformance.js +115 -0
- package/dist/constitution/lifecycle.js +189 -0
- package/dist/constitution/mutation.js +262 -0
- package/dist/constitution/nodeTestEvidence.js +47 -0
- package/dist/constitution/plan.js +172 -0
- package/dist/constitution/policyRuntime.js +8 -0
- package/dist/constitution/proof.js +166 -0
- package/dist/constitution/replay.js +361 -0
- package/dist/constitution/replayCache.js +89 -0
- package/dist/constitution/replayWorker.js +34 -0
- package/dist/constitution/repository.js +533 -0
- package/dist/constitution/schema.js +545 -0
- package/dist/constitution/scorecard.js +106 -0
- package/dist/constitution/service.js +1149 -0
- package/dist/constitution/shadow.js +235 -0
- package/dist/constitution/sourceMutation.js +316 -0
- package/dist/constitution/structural.js +601 -0
- package/dist/core/autoreview.js +27 -3
- package/dist/core/dupdetect.js +10 -3
- package/dist/core/events.js +61 -0
- package/dist/core/externalImports.js +24 -0
- package/dist/core/hookpolicy.js +3 -0
- package/dist/core/relativeImports.js +33 -0
- package/dist/core/stats.js +115 -0
- package/dist/extractors/git.js +81 -0
- package/dist/extractors/indexer.js +39 -38
- package/dist/extractors/nativeTreeSitter.js +108 -0
- package/dist/extractors/parse.js +5 -15
- package/dist/integrations/claudemd.js +8 -1
- package/dist/integrations/gitignore.js +8 -0
- package/dist/integrations/providers.js +32 -10
- package/dist/integrations/sync.js +16 -1
- package/dist/mcp/server.js +284 -0
- package/dist/synthesis/provider.js +145 -37
- package/dist/synthesis/synthesize.js +4 -4
- package/package.json +5 -1
package/dist/core/dupdetect.js
CHANGED
|
@@ -68,6 +68,15 @@ export function commitCoveredBy(codeFiles, subject, existing, nowMs) {
|
|
|
68
68
|
}
|
|
69
69
|
return best;
|
|
70
70
|
}
|
|
71
|
+
/** Only a finalized, still-live human decision can justify deleting a draft as
|
|
72
|
+
* redundant. Proposed records may resemble one another, but choosing which one
|
|
73
|
+
* survives is review judgment—not deterministic hygiene. */
|
|
74
|
+
export function isAcceptedDuplicateAnchor(d) {
|
|
75
|
+
return d.status === "accepted"
|
|
76
|
+
&& d.provenance.source.includes("human_confirmed")
|
|
77
|
+
&& !d.superseded_by
|
|
78
|
+
&& !d.valid_to;
|
|
79
|
+
}
|
|
71
80
|
/** Is an existing DRAFT a near-duplicate of an accepted record? Review-time
|
|
72
81
|
* flag; threshold callers use 0.35 (batch-reject) — conservative on purpose. */
|
|
73
82
|
export function draftDuplicateOf(draft, existing) {
|
|
@@ -77,9 +86,7 @@ export function draftDuplicateOf(draft, existing) {
|
|
|
77
86
|
for (const d of existing) {
|
|
78
87
|
if (d.id === draft.id)
|
|
79
88
|
continue;
|
|
80
|
-
if (!d
|
|
81
|
-
continue;
|
|
82
|
-
if (d.status === "superseded" || d.status === "rejected" || d.superseded_by || d.valid_to)
|
|
89
|
+
if (!isAcceptedDuplicateAnchor(d))
|
|
83
90
|
continue;
|
|
84
91
|
const termSim = jaccard(draftTerms, dupTerms(`${d.title} ${d.decision}`));
|
|
85
92
|
let fileBoost = 0;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The catch-log: an append-only, git-tracked JSONL trail of enforcement events
|
|
2
|
+
* (`.hunch/events.log`). Every time the deterministic gate actually BLOCKS an
|
|
3
|
+
* edit — a blocking invariant hit, or the Veto Guard reversing a rejected
|
|
4
|
+
* approach — one line is appended here. Without this trail "the graph caught 37
|
|
5
|
+
* violations" is an assertion; with it, it is a measurement (dec_6253f7e6d6).
|
|
6
|
+
*
|
|
7
|
+
* WHY append-only (not temp-file+rename like the JSON index, con_902759b3dc):
|
|
8
|
+
* the atomicity invariant exists so an interrupted write can never TRUNCATE the
|
|
9
|
+
* index. A single-line append can never truncate what is already on disk — it
|
|
10
|
+
* only ever extends it — so it honors that invariant's spirit without the O(n)
|
|
11
|
+
* read-modify-write a rewrite would cost per event. The log is a derived audit
|
|
12
|
+
* trail, never a source of truth: a lost or malformed line loses one catch's
|
|
13
|
+
* provenance, never a decision.
|
|
14
|
+
*
|
|
15
|
+
* HONESTY (dec_6253f7e6d6): a constraint/veto block carries NO subject/object/
|
|
16
|
+
* assert — those are conformance-only predicates checked by a different gate
|
|
17
|
+
* (`hunch conform`), not the edit hook. This schema records only what each gate
|
|
18
|
+
* actually knows; it never fabricates the conformance shape for a plain block. */
|
|
19
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
export function eventsLogPath(paths) {
|
|
22
|
+
return join(paths.hunch, "events.log");
|
|
23
|
+
}
|
|
24
|
+
/** Append one event as a JSONL line. Best-effort and never throws: the primary
|
|
25
|
+
* call site is the edit hook, which MUST NEVER break an agent on failure
|
|
26
|
+
* (con_03a0b94b2e). A dropped catch-log line is an acceptable loss. */
|
|
27
|
+
export function appendEvent(paths, event) {
|
|
28
|
+
try {
|
|
29
|
+
appendFileSync(eventsLogPath(paths), `${JSON.stringify(event)}\n`);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* best effort — a lost audit line must never surface to the agent */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Read + parse the catch-log. Malformed lines are skipped, not fatal (the log is
|
|
36
|
+
* derived; one bad line never poisons the aggregation). Missing log → []. */
|
|
37
|
+
export function readEvents(paths) {
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = readFileSync(eventsLogPath(paths), "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return []; // no catches recorded yet
|
|
44
|
+
}
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const line of raw.split("\n")) {
|
|
47
|
+
const s = line.trim();
|
|
48
|
+
if (!s)
|
|
49
|
+
continue;
|
|
50
|
+
try {
|
|
51
|
+
const e = JSON.parse(s);
|
|
52
|
+
if (e && typeof e.at === "string" && typeof e.kind === "string")
|
|
53
|
+
out.push(e);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* skip a corrupt line */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=events.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { shortHash } from "./ids.js";
|
|
2
|
+
/** Canonical package identity for a static module specifier. Relative, absolute,
|
|
3
|
+
* package-import-map (#), and URL-like specifiers are repository/runtime-local
|
|
4
|
+
* and deliberately outside the external-dependency evaluator. */
|
|
5
|
+
export function externalPackage(specifier) {
|
|
6
|
+
const value = specifier.trim();
|
|
7
|
+
if (!value || value.startsWith(".") || value.startsWith("/") || value.startsWith("#") || /^[a-z]+:\/\//i.test(value))
|
|
8
|
+
return null;
|
|
9
|
+
if (value.startsWith("node:"))
|
|
10
|
+
return /^node:[A-Za-z0-9_./-]+$/.test(value) ? value : null;
|
|
11
|
+
if (value.startsWith("@")) {
|
|
12
|
+
const [scope, name] = value.split("/");
|
|
13
|
+
return scope && name ? `${scope}/${name}` : null;
|
|
14
|
+
}
|
|
15
|
+
return value.split("/")[0] || null;
|
|
16
|
+
}
|
|
17
|
+
/** Virtual graph target for an external package. It is intentionally not a
|
|
18
|
+
* Component record: package facts stay a bounded evaluator layer and do not
|
|
19
|
+
* inflate the human-curated component graph. */
|
|
20
|
+
export function externalImportNodeId(specifier) {
|
|
21
|
+
const dependency = externalPackage(specifier);
|
|
22
|
+
return dependency ? `ext_${shortHash(dependency)}` : null;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=externalImports.js.map
|
package/dist/core/hookpolicy.js
CHANGED
|
@@ -16,6 +16,7 @@ export function blockingInScope(store, file, proposedAddedLines = []) {
|
|
|
16
16
|
continue;
|
|
17
17
|
return {
|
|
18
18
|
reason: `Hunch: editing ${file} would touch a BLOCKING invariant — "${c.statement}" (${c.id}). Do not proceed unless this change is meant to modify that invariant; otherwise preserve it.`,
|
|
19
|
+
event: { kind: "constraint", constraint: c.id },
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
for (const b of store.blastRadiusFiles(file)) {
|
|
@@ -29,6 +30,7 @@ export function blockingInScope(store, file, proposedAddedLines = []) {
|
|
|
29
30
|
continue;
|
|
30
31
|
return {
|
|
31
32
|
reason: `Hunch: ${file} is in the blast radius of a BLOCKING invariant — "${c.statement}" (${c.id}; via ${b.file}, ${b.via} depth ${b.depth}). Verify the invariant still holds before editing.`,
|
|
33
|
+
event: { kind: "constraint", constraint: c.id },
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -53,6 +55,7 @@ export function vetoInScope(store, file, proposedAddedLines) {
|
|
|
53
55
|
return null;
|
|
54
56
|
return {
|
|
55
57
|
reason: `Hunch: editing ${file} would REVERSE decision ${hit.decision} — you rejected "${hit.alternative}" and chose "${hit.chosen}". Do not re-introduce the rejected approach; preserve the chosen design.`,
|
|
58
|
+
event: { kind: "veto", decision: hit.decision },
|
|
56
59
|
};
|
|
57
60
|
}
|
|
58
61
|
//# sourceMappingURL=hookpolicy.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { dirname, join, posix } from "node:path";
|
|
2
|
+
function toPosix(path) {
|
|
3
|
+
return path.split(/[\\/]/).join(posix.sep);
|
|
4
|
+
}
|
|
5
|
+
/** Candidate source files for one static relative JS/TS import, in the same
|
|
6
|
+
* deterministic precedence order used by the indexer. Bare packages, URLs,
|
|
7
|
+
* absolute paths, and import-map aliases are deliberately unsupported. */
|
|
8
|
+
export function relativeImportCandidates(fromFile, specifier) {
|
|
9
|
+
if (!specifier.startsWith(".") || specifier.includes("\0"))
|
|
10
|
+
return [];
|
|
11
|
+
const base = toPosix(join(dirname(fromFile), specifier));
|
|
12
|
+
return [...new Set([
|
|
13
|
+
base.replace(/\.js$/, ".ts"),
|
|
14
|
+
base.replace(/\.js$/, ".tsx"),
|
|
15
|
+
base.replace(/\.jsx$/, ".tsx"),
|
|
16
|
+
base + ".ts",
|
|
17
|
+
base + ".tsx",
|
|
18
|
+
base,
|
|
19
|
+
base + ".js",
|
|
20
|
+
toPosix(join(base, "index.ts")),
|
|
21
|
+
toPosix(join(base, "index.tsx")),
|
|
22
|
+
toPosix(join(base, "index.js")),
|
|
23
|
+
])];
|
|
24
|
+
}
|
|
25
|
+
/** Resolve against an exact file set. The first candidate preserves existing
|
|
26
|
+
* indexer compatibility; callers that need ambiguity metadata can inspect the
|
|
27
|
+
* returned matches instead of guessing a different target. */
|
|
28
|
+
export function resolveRelativeImport(fromFile, specifier, availableFiles) {
|
|
29
|
+
const available = new Set(availableFiles);
|
|
30
|
+
const matches = relativeImportCandidates(fromFile, specifier).filter((candidate) => available.has(candidate));
|
|
31
|
+
return { path: matches[0] ?? null, matches };
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=relativeImports.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** A constraint that actually has teeth: active, blocking, and human-vouched — the
|
|
2
|
+
* same "ARMED" definition `hunch status` uses. Draft/advisory rules don't count as
|
|
3
|
+
* locked stock. */
|
|
4
|
+
function isLocked(c) {
|
|
5
|
+
const src = c.provenance?.source;
|
|
6
|
+
const vouched = !!src && (src.includes("human_confirmed") || src === "derived");
|
|
7
|
+
return c.status === "active" && c.severity === "blocking" && vouched;
|
|
8
|
+
}
|
|
9
|
+
function eventMs(e) {
|
|
10
|
+
const t = Date.parse(e.at);
|
|
11
|
+
return Number.isNaN(t) ? 0 : t;
|
|
12
|
+
}
|
|
13
|
+
/** Tally the four return metrics over a set of events. `bugs_reprevented` is the
|
|
14
|
+
* strict, deterministic claim: a blocked edit whose enforcing decision was caused
|
|
15
|
+
* by a bug that has DEMONSTRABLY regressed (came back once already). No event ever
|
|
16
|
+
* fabricates that shape — it's a real join over the graph. */
|
|
17
|
+
function tallyReturn(events, decById, bugById) {
|
|
18
|
+
let caught = 0, drifts = 0, reprevented = 0, vetoes = 0;
|
|
19
|
+
for (const e of events) {
|
|
20
|
+
if (e.kind === "constraint" || e.kind === "veto" || e.kind === "conformance")
|
|
21
|
+
caught++;
|
|
22
|
+
if (e.kind === "veto")
|
|
23
|
+
vetoes++;
|
|
24
|
+
if (e.kind === "drift")
|
|
25
|
+
drifts++;
|
|
26
|
+
if (e.decision) {
|
|
27
|
+
const d = decById.get(e.decision);
|
|
28
|
+
const bug = d?.caused_by_bug ? bugById.get(d.caused_by_bug) : undefined;
|
|
29
|
+
if (bug && bug.status === "regressed")
|
|
30
|
+
reprevented++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { violations_caught: caught, drifts_flagged: drifts, bugs_reprevented: reprevented, vetoes_fired: vetoes };
|
|
34
|
+
}
|
|
35
|
+
export function computeStats(input) {
|
|
36
|
+
const { decisions, constraints, bugs, componentIds, events } = input;
|
|
37
|
+
const byStatus = (recs, s) => recs.filter((r) => r.status === s).length;
|
|
38
|
+
const activeConstraints = constraints.filter((c) => c.status === "active");
|
|
39
|
+
const locked = activeConstraints.filter(isLocked).length;
|
|
40
|
+
// Coverage: distinct components named by at least one decision, over total. Only
|
|
41
|
+
// component ids that actually EXIST count (a dangling ref isn't coverage).
|
|
42
|
+
const compSet = new Set(componentIds);
|
|
43
|
+
const touched = new Set();
|
|
44
|
+
for (const d of decisions)
|
|
45
|
+
for (const c of d.related_components)
|
|
46
|
+
if (compSet.has(c))
|
|
47
|
+
touched.add(c);
|
|
48
|
+
const componentsTotal = componentIds.length;
|
|
49
|
+
const pct = componentsTotal ? touched.size / componentsTotal : 0;
|
|
50
|
+
const decById = new Map(decisions.map((d) => [d.id, d]));
|
|
51
|
+
const bugById = new Map(bugs.map((b) => [b.id, b]));
|
|
52
|
+
const inWindow = events.filter((e) => eventMs(e) >= input.windowStart);
|
|
53
|
+
const lifetime = tallyReturn(events, decById, bugById);
|
|
54
|
+
const windowReturn = tallyReturn(inWindow, decById, bugById);
|
|
55
|
+
const rulesRecorded = locked;
|
|
56
|
+
const catchesLifetime = lifetime.violations_caught;
|
|
57
|
+
return {
|
|
58
|
+
schema: "hunch.stats/1",
|
|
59
|
+
generated_at: new Date(input.now).toISOString(),
|
|
60
|
+
window: { since: input.windowLabel, from: new Date(input.windowStart).toISOString(), to: new Date(input.now).toISOString() },
|
|
61
|
+
stock: {
|
|
62
|
+
decisions: {
|
|
63
|
+
total: decisions.length,
|
|
64
|
+
accepted: byStatus(decisions, "accepted"),
|
|
65
|
+
superseded: byStatus(decisions, "superseded"),
|
|
66
|
+
proposed: byStatus(decisions, "proposed"),
|
|
67
|
+
rejected: byStatus(decisions, "rejected"),
|
|
68
|
+
},
|
|
69
|
+
invariants: { total: activeConstraints.length, locked, advisory: activeConstraints.length - locked, stale: input.staleConstraints },
|
|
70
|
+
components: componentsTotal,
|
|
71
|
+
bugs: {
|
|
72
|
+
total: bugs.length,
|
|
73
|
+
open: byStatus(bugs, "open"),
|
|
74
|
+
investigating: byStatus(bugs, "investigating"),
|
|
75
|
+
fixed: byStatus(bugs, "fixed"),
|
|
76
|
+
regressed: byStatus(bugs, "regressed"),
|
|
77
|
+
},
|
|
78
|
+
runbooks: input.runbooksCount,
|
|
79
|
+
coverage: { components_with_decision: touched.size, components_total: componentsTotal, pct: Math.round(pct * 100) / 100 },
|
|
80
|
+
},
|
|
81
|
+
return: { window: windowReturn, lifetime },
|
|
82
|
+
compounding: {
|
|
83
|
+
rules_recorded: rulesRecorded,
|
|
84
|
+
catches_lifetime: catchesLifetime,
|
|
85
|
+
payback_ratio: rulesRecorded ? Math.round((catchesLifetime / rulesRecorded) * 10) / 10 : 0,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Human-readable receipt for the terminal (the CLI's default, non-`--json` output).
|
|
90
|
+
* Same three blocks as the JSON: stock → return → compounding. Honest zeros are
|
|
91
|
+
* shown plainly, never hidden — a young graph reads as young, not as broken. */
|
|
92
|
+
export function formatStats(s) {
|
|
93
|
+
const L = [];
|
|
94
|
+
const pct = Math.round(s.stock.coverage.pct * 100);
|
|
95
|
+
L.push(`\n🧠 Hunch — engineering-memory stats\n`);
|
|
96
|
+
L.push(` STOCK (accumulated)`);
|
|
97
|
+
L.push(` decisions: ${s.stock.decisions.total} (${s.stock.decisions.accepted} accepted · ${s.stock.decisions.proposed} proposed · ${s.stock.decisions.superseded} superseded)`);
|
|
98
|
+
L.push(` invariants: ${s.stock.invariants.total} (${s.stock.invariants.locked} locked · ${s.stock.invariants.advisory} advisory${s.stock.invariants.stale ? ` · ${s.stock.invariants.stale} stale` : ""})`);
|
|
99
|
+
L.push(` components: ${s.stock.components} · bugs: ${s.stock.bugs.total} · runbooks: ${s.stock.runbooks}`);
|
|
100
|
+
L.push(` coverage: ${pct}% of components explained by ≥1 decision (${s.stock.coverage.components_with_decision}/${s.stock.coverage.components_total})`);
|
|
101
|
+
L.push(`\n RETURN (what the stock caught)`);
|
|
102
|
+
const r = s.return.lifetime, w = s.return.window;
|
|
103
|
+
L.push(` this ${s.window.since}: ${w.violations_caught} caught · ${w.drifts_flagged} drift · ${w.bugs_reprevented} re-prevented`);
|
|
104
|
+
L.push(` lifetime: ${r.violations_caught} caught · ${r.bugs_reprevented} re-prevented`);
|
|
105
|
+
L.push(`\n COMPOUNDING`);
|
|
106
|
+
if (s.compounding.rules_recorded && s.compounding.catches_lifetime) {
|
|
107
|
+
L.push(` ${s.compounding.rules_recorded} locked rule(s) → ${s.compounding.catches_lifetime} catch(es) · each rule pays back ${s.compounding.payback_ratio}×`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
L.push(` ${s.compounding.rules_recorded} locked rule(s), ${s.compounding.catches_lifetime} catch(es) so far — the catch-log grows as the gate fires (nothing to inflate).`);
|
|
111
|
+
}
|
|
112
|
+
L.push("");
|
|
113
|
+
return L.join("\n");
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=stats.js.map
|
package/dist/extractors/git.js
CHANGED
|
@@ -18,6 +18,17 @@ function gitSafe(args, cwd, maxBuffer) {
|
|
|
18
18
|
return "";
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
function gitRawSafe(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync("git", args, {
|
|
24
|
+
cwd, encoding: "utf8", maxBuffer,
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
21
32
|
export function isGitRepo(cwd) {
|
|
22
33
|
return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
|
|
23
34
|
}
|
|
@@ -82,6 +93,14 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
|
|
|
82
93
|
}
|
|
83
94
|
return null;
|
|
84
95
|
}
|
|
96
|
+
// Grounding docs refreshed by this capture ride the same memory commit, so committed record
|
|
97
|
+
// counts can never go stale (the refresh-counts treadmill: every capture commit bumped the
|
|
98
|
+
// count and re-staled the docs for the next release-gate clean-tree check). Staged AFTER the
|
|
99
|
+
// memory-only backstop on purpose: alsoStage is a code-controlled list of generated grounding
|
|
100
|
+
// docs the caller verified git-clean BEFORE rewriting, so it can neither weaken the
|
|
101
|
+
// bug_overlay_clobber detection above nor sweep user edits.
|
|
102
|
+
for (const file of opts.alsoStage ?? [])
|
|
103
|
+
run(["add", "--", file]);
|
|
85
104
|
// Only sync+push when a memory commit was actually created — never run pull/push against the
|
|
86
105
|
// enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
|
|
87
106
|
// can't be rejected non-fast-forward; the .hunch merge driver resolves same-record conflicts
|
|
@@ -112,6 +131,16 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
|
|
|
112
131
|
catch { /* released best-effort */ }
|
|
113
132
|
}
|
|
114
133
|
}
|
|
134
|
+
/** True when `rel` is tracked with no staged or unstaged changes (untracked counts as
|
|
135
|
+
* dirty, so a doc the user never committed is never swept into a memory commit). */
|
|
136
|
+
export function isGitCleanPath(root, rel) {
|
|
137
|
+
try {
|
|
138
|
+
return execFileSync("git", ["-C", root, "status", "--porcelain", "--", rel], { encoding: "utf8" }).trim() === "";
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
115
144
|
/** Is the staged set a clean, MEMORY-ONLY change — only JSON record adds/updates, nothing else?
|
|
116
145
|
* The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
|
|
117
146
|
* is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
|
|
@@ -274,6 +303,46 @@ export function commitMeta(sha, cwd) {
|
|
|
274
303
|
const [full = "", short = "", subject = "", body = "", author = "", date = ""] = raw.split("\x1f");
|
|
275
304
|
return { sha: full, shortSha: short, subject, body, author, date, files: commitFiles(sha, cwd) };
|
|
276
305
|
}
|
|
306
|
+
/** First-parent and exact blob seams for deterministic before/after analysis.
|
|
307
|
+
* They never check out a ref or mutate the active worktree. */
|
|
308
|
+
export function firstParent(sha, cwd) {
|
|
309
|
+
const row = gitSafe(["rev-list", "--parents", "-n", "1", sha], cwd);
|
|
310
|
+
const parts = row.split(/\s+/).filter(Boolean);
|
|
311
|
+
return parts.length > 1 ? parts[1] : null;
|
|
312
|
+
}
|
|
313
|
+
export function fileAtRef(ref, file, cwd) {
|
|
314
|
+
return gitRawSafe(["show", `${ref}:${file}`], cwd);
|
|
315
|
+
}
|
|
316
|
+
/** Name-status records for one commit, rename-aware and NUL-delimited so paths
|
|
317
|
+
* with whitespace cannot corrupt the parser. */
|
|
318
|
+
export function commitChanges(sha, cwd) {
|
|
319
|
+
const raw = gitRawSafe(["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-M", "-z", sha], cwd);
|
|
320
|
+
if (raw == null)
|
|
321
|
+
return [];
|
|
322
|
+
const fields = raw.split("\0").filter((v) => v !== "");
|
|
323
|
+
const out = [];
|
|
324
|
+
for (let i = 0; i < fields.length;) {
|
|
325
|
+
const code = fields[i++];
|
|
326
|
+
const kind = code[0];
|
|
327
|
+
if (kind === "R" || kind === "C") {
|
|
328
|
+
const before = fields[i++] ?? null;
|
|
329
|
+
const after = fields[i++] ?? null;
|
|
330
|
+
if (before && after)
|
|
331
|
+
out.push({ status: kind === "R" ? "renamed" : "copied", before, after });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const file = fields[i++] ?? null;
|
|
335
|
+
if (!file)
|
|
336
|
+
continue;
|
|
337
|
+
if (kind === "A")
|
|
338
|
+
out.push({ status: "added", before: null, after: file });
|
|
339
|
+
else if (kind === "D")
|
|
340
|
+
out.push({ status: "deleted", before: file, after: null });
|
|
341
|
+
else
|
|
342
|
+
out.push({ status: "modified", before: file, after: file });
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
277
346
|
/** Machine-generated paths that carry no design "why" — lockfiles, build output,
|
|
278
347
|
* vendored deps, snapshots, source maps. Excluded from synthesis diffs via git
|
|
279
348
|
* pathspec BEFORE git assembles/orders the patch: a huge lockfile sorts ahead of
|
|
@@ -320,6 +389,18 @@ export function lastCommitForFile(file, cwd) {
|
|
|
320
389
|
const sha = gitSafe(["log", "-1", "--format=%h", "--", file], cwd);
|
|
321
390
|
return sha ? `commit:${sha}` : "";
|
|
322
391
|
}
|
|
392
|
+
/** Full SHA of the commit that introduced a path. Unlike lastCommitForFile this
|
|
393
|
+
* remains stable when lifecycle/proof updates later touch the same policy file. */
|
|
394
|
+
export function firstCommitForFile(file, cwd) {
|
|
395
|
+
// Deliberately do NOT use --follow: content-similar, immutable-ID JSON policy
|
|
396
|
+
// files can be misclassified as renames of one another, moving valid_from to a
|
|
397
|
+
// different policy's introduction commit.
|
|
398
|
+
const added = gitSafe(["log", "--diff-filter=A", "--format=%H", "--", file], cwd)
|
|
399
|
+
.split("\n").find(Boolean);
|
|
400
|
+
if (added)
|
|
401
|
+
return added;
|
|
402
|
+
return gitSafe(["log", "--reverse", "--format=%H", "--", file], cwd).split("\n").find(Boolean) ?? "";
|
|
403
|
+
}
|
|
323
404
|
/** ISO author-date of the most recent commit touching a file ("" if none). */
|
|
324
405
|
export function lastChangeDate(file, cwd) {
|
|
325
406
|
return gitSafe(["log", "-1", "--format=%aI", "--", file], cwd);
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* then runs HunchStore.reindex() to refresh the SQLite index.
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync, statSync, readdirSync } from "node:fs";
|
|
11
|
-
import { join, relative,
|
|
11
|
+
import { join, relative, posix } from "node:path";
|
|
12
12
|
import { parseSource, attributeCalls } from "./parse.js";
|
|
13
13
|
import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
|
|
14
|
+
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
15
|
+
import { resolveRelativeImport } from "../core/relativeImports.js";
|
|
14
16
|
import { extracted, inferred } from "../core/types.js";
|
|
15
17
|
import { isGitRepo, trackedFiles, fileGitMetrics } from "./git.js";
|
|
16
18
|
const CODE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
@@ -82,6 +84,10 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
82
84
|
perFileImports.push({ file: rel, imports: parsed.imports });
|
|
83
85
|
}
|
|
84
86
|
const byId = new Map(symbols.map((s) => [s.id, s]));
|
|
87
|
+
const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
|
|
88
|
+
file,
|
|
89
|
+
new Set(imports.map((specifier) => resolveImport(file, specifier, fileSymbols)).filter((target) => !!target)),
|
|
90
|
+
]));
|
|
85
91
|
// ---- pass 2: resolve calls -> symbol-level edges -------------------------
|
|
86
92
|
const edges = [];
|
|
87
93
|
const edgeSeen = new Set();
|
|
@@ -100,7 +106,7 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
100
106
|
continue;
|
|
101
107
|
const callerName = byId.get(callerId)?.name ?? "?";
|
|
102
108
|
for (const [calleeName, memberOnly] of callees) {
|
|
103
|
-
const calleeId = resolveName(calleeName, file, nameIndex, byId);
|
|
109
|
+
const calleeId = resolveName(calleeName, file, importedFiles.get(file) ?? new Set(), nameIndex, byId);
|
|
104
110
|
if (!calleeId || calleeId === callerId)
|
|
105
111
|
continue;
|
|
106
112
|
// A member call `x.foo()` only yields an edge when `foo` resolves to a
|
|
@@ -146,17 +152,31 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
146
152
|
continue;
|
|
147
153
|
for (const spec of imports) {
|
|
148
154
|
const target = resolveImport(file, spec, fileSymbols);
|
|
149
|
-
if (
|
|
155
|
+
if (target) {
|
|
156
|
+
const toCmp = fileToComponent.get(target);
|
|
157
|
+
if (!toCmp || toCmp === fromCmp)
|
|
158
|
+
continue;
|
|
159
|
+
addEdge({
|
|
160
|
+
id: edgeId(fromCmp, toCmp, "depends_on"),
|
|
161
|
+
from: fromCmp, to: toCmp, type: "depends_on",
|
|
162
|
+
reason: `${file} imports ${target}`, strength: 0.6,
|
|
163
|
+
provenance: extracted(0.9, [`${file}:imports:${spec}`]),
|
|
164
|
+
});
|
|
150
165
|
continue;
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
}
|
|
167
|
+
const dependency = externalPackage(spec);
|
|
168
|
+
const external = externalImportNodeId(spec);
|
|
169
|
+
const anchors = [...(fileSymbols.get(file) ?? [])].sort();
|
|
170
|
+
if (!dependency || !external || !anchors.length)
|
|
153
171
|
continue;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
for (const anchor of anchors) {
|
|
173
|
+
addEdge({
|
|
174
|
+
id: edgeId(anchor, external, "imports"),
|
|
175
|
+
from: anchor, to: external, type: "imports",
|
|
176
|
+
reason: `${file} imports external package ${dependency}`, strength: 1,
|
|
177
|
+
provenance: extracted(1, [`${file}:imports:${spec}`]),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
160
180
|
}
|
|
161
181
|
}
|
|
162
182
|
// persist
|
|
@@ -215,8 +235,11 @@ function listCodeFiles(root) {
|
|
|
215
235
|
walk(root);
|
|
216
236
|
return out;
|
|
217
237
|
}
|
|
218
|
-
/** Resolve a callee name to a symbol id: prefer same-file,
|
|
219
|
-
|
|
238
|
+
/** Resolve a callee name to a symbol id: prefer same-file, otherwise require a
|
|
239
|
+
* unique symbol in a statically imported local file. A unique repository-wide
|
|
240
|
+
* name is not evidence of a binding: callback parameters and built-ins often
|
|
241
|
+
* share names with unrelated exported symbols. */
|
|
242
|
+
function resolveName(name, file, importedFiles, nameIndex, byId) {
|
|
220
243
|
const candidates = nameIndex.get(name);
|
|
221
244
|
if (!candidates || candidates.length === 0)
|
|
222
245
|
return null;
|
|
@@ -225,34 +248,12 @@ function resolveName(name, file, nameIndex, byId) {
|
|
|
225
248
|
return sameFile[0];
|
|
226
249
|
if (sameFile.length > 1)
|
|
227
250
|
return null; // ambiguous within the file — don't guess
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// ambiguous across files — skip to avoid wrong edges (keeps the graph clean)
|
|
231
|
-
return null;
|
|
251
|
+
const imported = candidates.filter((id) => importedFiles.has(byId.get(id)?.file ?? ""));
|
|
252
|
+
return imported.length === 1 ? imported[0] : null;
|
|
232
253
|
}
|
|
233
254
|
/** Resolve a relative import specifier to a concrete tracked file path. */
|
|
234
255
|
function resolveImport(fromFile, spec, fileSymbols) {
|
|
235
|
-
|
|
236
|
-
return null; // external package
|
|
237
|
-
const base = toPosix(join(dirname(fromFile), spec));
|
|
238
|
-
// Prefer TS source rewrites over the literal `.js` specifier: in a TS repo an
|
|
239
|
-
// import of "./db.js" resolves to db.ts. Only fall back to the literal path.
|
|
240
|
-
const candidates = [
|
|
241
|
-
base.replace(/\.js$/, ".ts"),
|
|
242
|
-
base.replace(/\.js$/, ".tsx"),
|
|
243
|
-
base.replace(/\.jsx$/, ".tsx"),
|
|
244
|
-
base + ".ts",
|
|
245
|
-
base + ".tsx",
|
|
246
|
-
base,
|
|
247
|
-
base + ".js",
|
|
248
|
-
toPosix(join(base, "index.ts")),
|
|
249
|
-
toPosix(join(base, "index.tsx")),
|
|
250
|
-
toPosix(join(base, "index.js")),
|
|
251
|
-
];
|
|
252
|
-
for (const c of candidates)
|
|
253
|
-
if (fileSymbols.has(c))
|
|
254
|
-
return c;
|
|
255
|
-
return null;
|
|
256
|
+
return resolveRelativeImport(fromFile, spec, fileSymbols.keys()).path;
|
|
256
257
|
}
|
|
257
258
|
/** Derive components from the directory layout: the directory immediately under
|
|
258
259
|
* `src/` (or the top-level dir) groups files into a module component. */
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
const runtimeRequire = createRequire(import.meta.url);
|
|
6
|
+
const COPY_PREFIX = "hunch-tree-sitter-";
|
|
7
|
+
const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript"];
|
|
8
|
+
let runtime = null;
|
|
9
|
+
function processIsAlive(pid) {
|
|
10
|
+
if (pid === process.pid)
|
|
11
|
+
return true;
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
return error.code === "EPERM";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function removeStaleCopies() {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(tmpdir(), { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry.isDirectory())
|
|
30
|
+
continue;
|
|
31
|
+
const match = new RegExp(`^${COPY_PREFIX}(\\d+)-`).exec(entry.name);
|
|
32
|
+
if (!match || processIsAlive(Number(match[1])))
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
rmSync(join(tmpdir(), entry.name), { recursive: true, force: true, maxRetries: 2 });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Another process may have won the cleanup race, or Windows may still be
|
|
39
|
+
// releasing a just-exited native module. A later process can retry.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function environmentKey(packageName) {
|
|
44
|
+
return `${packageName.toUpperCase().replaceAll("-", "_")}_PREBUILD`;
|
|
45
|
+
}
|
|
46
|
+
function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
|
|
47
|
+
const packageRoot = dirname(runtimeRequire.resolve(`${packageName}/package.json`));
|
|
48
|
+
const source = nodeGypBuild.path(packageRoot);
|
|
49
|
+
const packageCopy = join(copyRoot, packageName);
|
|
50
|
+
const normalized = source.replaceAll("\\", "/");
|
|
51
|
+
const prebuild = /\/prebuilds\/([^/]+)\/[^/]+$/.exec(normalized);
|
|
52
|
+
const destination = prebuild
|
|
53
|
+
? join(packageCopy, "prebuilds", prebuild[1], basename(source))
|
|
54
|
+
: join(packageCopy, "build", "Release", basename(source));
|
|
55
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
56
|
+
copyFileSync(source, destination);
|
|
57
|
+
return packageCopy;
|
|
58
|
+
}
|
|
59
|
+
/** Load both native tree-sitter addons from process-owned temp copies. Windows
|
|
60
|
+
* keeps loaded `.node` files locked for the process lifetime; redirecting the
|
|
61
|
+
* upstream loaders means npm can replace the installed package during an active
|
|
62
|
+
* MCP session without killing that session or falling back to a stale binary. */
|
|
63
|
+
export function loadNativeTreeSitter() {
|
|
64
|
+
if (runtime)
|
|
65
|
+
return runtime;
|
|
66
|
+
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript)?\.node$/.test(path)
|
|
67
|
+
&& !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
|
|
68
|
+
if (preloaded.length) {
|
|
69
|
+
throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
removeStaleCopies();
|
|
72
|
+
const copyRoot = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
|
|
73
|
+
const nodeGypBuild = runtimeRequire("node-gyp-build");
|
|
74
|
+
const previous = new Map();
|
|
75
|
+
try {
|
|
76
|
+
for (const packageName of NATIVE_PACKAGES) {
|
|
77
|
+
const key = environmentKey(packageName);
|
|
78
|
+
previous.set(key, process.env[key]);
|
|
79
|
+
process.env[key] = copyNativeBinding(packageName, copyRoot, nodeGypBuild);
|
|
80
|
+
}
|
|
81
|
+
const Parser = runtimeRequire("tree-sitter");
|
|
82
|
+
const languages = runtimeRequire("tree-sitter-typescript");
|
|
83
|
+
runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
try {
|
|
87
|
+
rmSync(copyRoot, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
catch { /* best effort */ }
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
for (const [key, value] of previous) {
|
|
94
|
+
if (value === undefined)
|
|
95
|
+
delete process.env[key];
|
|
96
|
+
else
|
|
97
|
+
process.env[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
process.once("exit", () => {
|
|
101
|
+
try {
|
|
102
|
+
rmSync(copyRoot, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
catch { /* next process prunes it */ }
|
|
105
|
+
});
|
|
106
|
+
return runtime;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=nativeTreeSitter.js.map
|