@davesheffer/hunch 1.7.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.
Files changed (63) hide show
  1. package/README.md +214 -0
  2. package/bench/constitution-exp03-v1.json +70 -0
  3. package/dist/cli/index.js +1203 -24
  4. package/dist/constitution/adapters.js +487 -0
  5. package/dist/constitution/behaviorAttestationBinding.js +17 -0
  6. package/dist/constitution/behaviorEvaluator.js +220 -0
  7. package/dist/constitution/behaviorProof.js +205 -0
  8. package/dist/constitution/behaviorWorkspace.js +124 -0
  9. package/dist/constitution/bootstrap.js +133 -0
  10. package/dist/constitution/canonical.js +51 -0
  11. package/dist/constitution/card.js +133 -0
  12. package/dist/constitution/compiler.js +176 -0
  13. package/dist/constitution/composition.js +101 -0
  14. package/dist/constitution/corpus.js +58 -0
  15. package/dist/constitution/delta.js +154 -0
  16. package/dist/constitution/disposition.js +141 -0
  17. package/dist/constitution/evaluator.js +435 -0
  18. package/dist/constitution/experiment.js +948 -0
  19. package/dist/constitution/experimentRunner.js +344 -0
  20. package/dist/constitution/g2.js +291 -0
  21. package/dist/constitution/g2BehaviorAttestation.js +209 -0
  22. package/dist/constitution/g2BehaviorCandidates.js +703 -0
  23. package/dist/constitution/g2BehaviorDependencies.js +379 -0
  24. package/dist/constitution/g2BehaviorMaterialization.js +171 -0
  25. package/dist/constitution/g2BehaviorPolicyMaterializer.js +241 -0
  26. package/dist/constitution/g2CandidateAttestation.js +179 -0
  27. package/dist/constitution/g2Candidates.js +195 -0
  28. package/dist/constitution/g2Drills.js +122 -0
  29. package/dist/constitution/g3.js +511 -0
  30. package/dist/constitution/g3Conformance.js +115 -0
  31. package/dist/constitution/lifecycle.js +189 -0
  32. package/dist/constitution/mutation.js +262 -0
  33. package/dist/constitution/nodeTestEvidence.js +47 -0
  34. package/dist/constitution/plan.js +172 -0
  35. package/dist/constitution/policyRuntime.js +8 -0
  36. package/dist/constitution/proof.js +166 -0
  37. package/dist/constitution/replay.js +361 -0
  38. package/dist/constitution/replayCache.js +89 -0
  39. package/dist/constitution/replayWorker.js +34 -0
  40. package/dist/constitution/repository.js +533 -0
  41. package/dist/constitution/schema.js +545 -0
  42. package/dist/constitution/scorecard.js +106 -0
  43. package/dist/constitution/service.js +1149 -0
  44. package/dist/constitution/shadow.js +235 -0
  45. package/dist/constitution/sourceMutation.js +316 -0
  46. package/dist/constitution/structural.js +601 -0
  47. package/dist/core/autoreview.js +27 -3
  48. package/dist/core/dupdetect.js +10 -3
  49. package/dist/core/events.js +61 -0
  50. package/dist/core/externalImports.js +24 -0
  51. package/dist/core/hookpolicy.js +3 -0
  52. package/dist/core/relativeImports.js +33 -0
  53. package/dist/core/stats.js +115 -0
  54. package/dist/extractors/git.js +81 -0
  55. package/dist/extractors/indexer.js +39 -38
  56. package/dist/extractors/nativeTreeSitter.js +108 -0
  57. package/dist/extractors/parse.js +5 -15
  58. package/dist/integrations/claudemd.js +8 -1
  59. package/dist/integrations/gitignore.js +8 -0
  60. package/dist/integrations/providers.js +32 -10
  61. package/dist/integrations/sync.js +16 -1
  62. package/dist/mcp/server.js +284 -0
  63. package/package.json +5 -1
@@ -0,0 +1,235 @@
1
+ import { shortHash } from "../core/ids.js";
2
+ import { canonicalHash } from "./canonical.js";
3
+ import { assessHistoryDispositions } from "./disposition.js";
4
+ import { HistoryDispositionClassificationSchema, ShadowDispositionSchema, ShadowEvaluationRecordSchema, } from "./schema.js";
5
+ export function policyEvaluationContentHash(evaluation) {
6
+ const { deterministic_hash: _hash, ...body } = evaluation;
7
+ return canonicalHash(body);
8
+ }
9
+ export function shadowEvaluationContentHash(record) {
10
+ const { id: _id, content_hash: _contentHash, ...body } = record;
11
+ return canonicalHash(body);
12
+ }
13
+ export function shadowEvaluationIdentityHash(record) {
14
+ return canonicalHash({
15
+ policy_id: record.policy_id,
16
+ proof_id: record.proof_id,
17
+ policy_hash: record.policy_hash,
18
+ plan_hash: record.plan_hash,
19
+ repository_head: record.evaluation.repository.head,
20
+ graph_hash: record.evaluation.repository.graph_hash,
21
+ });
22
+ }
23
+ export function shadowDispositionContentHash(record) {
24
+ const { id: _id, content_hash: _contentHash, ...body } = record;
25
+ return canonicalHash(body);
26
+ }
27
+ export function shadowDispositionJudgmentHash(record) {
28
+ const { id: _id, content_hash: _contentHash, created_at: _createdAt, ...body } = record;
29
+ return canonicalHash(body);
30
+ }
31
+ export function compileShadowEvaluation(policy, proof, expectedPolicyHash, evaluation, alsoDetectedBy, latencyMs, now = new Date().toISOString()) {
32
+ if (policy.proof !== proof.id)
33
+ throw new Error(`policy ${policy.id} does not link proof ${proof.id}`);
34
+ if (proof.policy_hash !== expectedPolicyHash)
35
+ throw new Error(`proof ${proof.id} does not match current policy semantics`);
36
+ if (proof.data_class !== policy.data_class)
37
+ throw new Error(`proof ${proof.id} does not match policy ${policy.id} data class`);
38
+ if (evaluation.policy_id !== policy.id)
39
+ throw new Error(`shadow evaluation does not belong to policy ${policy.id}`);
40
+ if (evaluation.evaluator.name !== proof.evaluator.name || evaluation.evaluator.version !== proof.evaluator.version) {
41
+ throw new Error(`shadow evaluation does not use proof ${proof.id} evaluator`);
42
+ }
43
+ if (evaluation.deterministic_hash !== policyEvaluationContentHash(evaluation))
44
+ throw new Error("shadow evaluation deterministic hash mismatch");
45
+ if (!Number.isFinite(latencyMs) || latencyMs < 0)
46
+ throw new Error("shadow evaluation latency must be a non-negative finite number");
47
+ const body = {
48
+ record_type: "evaluation",
49
+ policy_id: policy.id,
50
+ proof_id: proof.id,
51
+ policy_hash: proof.policy_hash,
52
+ plan_hash: proof.plan_hash,
53
+ evaluation,
54
+ also_detected_by: [...new Set(alsoDetectedBy.filter((id) => id !== policy.id))].sort(),
55
+ latency_ms: latencyMs,
56
+ data_class: policy.data_class,
57
+ observed_at: now,
58
+ };
59
+ const contentHash = canonicalHash(body);
60
+ return ShadowEvaluationRecordSchema.parse({
61
+ id: `shadow_${shortHash(contentHash)}`,
62
+ content_hash: contentHash,
63
+ ...body,
64
+ });
65
+ }
66
+ export function compileShadowDisposition(evaluation, classification, actor, reason, opts = {}) {
67
+ if (evaluation.evaluation.result !== "violated")
68
+ throw new Error("shadow dispositions apply only to violated shadow evaluations");
69
+ const body = {
70
+ record_type: "disposition",
71
+ shadow_id: evaluation.id,
72
+ policy_id: evaluation.policy_id,
73
+ proof_id: evaluation.proof_id,
74
+ policy_hash: evaluation.policy_hash,
75
+ evaluation_hash: evaluation.evaluation.deterministic_hash,
76
+ classification: HistoryDispositionClassificationSchema.parse(classification),
77
+ actor,
78
+ reason: reason.trim(),
79
+ supersedes: opts.supersedes ?? null,
80
+ data_class: evaluation.data_class,
81
+ created_at: opts.now ?? new Date().toISOString(),
82
+ };
83
+ const contentHash = canonicalHash(body);
84
+ return ShadowDispositionSchema.parse({
85
+ id: `sdisp_${shortHash(contentHash)}`,
86
+ content_hash: contentHash,
87
+ ...body,
88
+ });
89
+ }
90
+ /** Resolve append-only correction chains without trusting timestamps. */
91
+ export function currentShadowDispositions(records) {
92
+ const byId = new Map(records.map((record) => [record.id, record]));
93
+ if (byId.size !== records.length)
94
+ throw new Error("duplicate shadow disposition id");
95
+ const childCount = new Map();
96
+ for (const record of records) {
97
+ if (!record.supersedes)
98
+ continue;
99
+ const parent = byId.get(record.supersedes);
100
+ if (!parent)
101
+ throw new Error(`shadow disposition ${record.id} supersedes missing ${record.supersedes}`);
102
+ if (parent.policy_id !== record.policy_id || parent.proof_id !== record.proof_id || parent.shadow_id !== record.shadow_id) {
103
+ throw new Error(`shadow disposition ${record.id} supersedes a different policy/proof/evaluation`);
104
+ }
105
+ childCount.set(parent.id, (childCount.get(parent.id) ?? 0) + 1);
106
+ if (childCount.get(parent.id) > 1)
107
+ throw new Error(`shadow disposition ${parent.id} has a branched supersession chain`);
108
+ }
109
+ for (const record of records) {
110
+ const visited = new Set();
111
+ let cursor = record;
112
+ while (cursor?.supersedes) {
113
+ if (visited.has(cursor.id))
114
+ throw new Error(`shadow disposition chain contains a cycle at ${cursor.id}`);
115
+ visited.add(cursor.id);
116
+ cursor = byId.get(cursor.supersedes);
117
+ }
118
+ }
119
+ const current = records
120
+ .filter((record) => !childCount.has(record.id))
121
+ .sort((left, right) => left.policy_id.localeCompare(right.policy_id) || left.shadow_id.localeCompare(right.shadow_id));
122
+ const currentByEvaluation = new Set();
123
+ for (const record of current) {
124
+ const key = `${record.policy_id}:${record.proof_id}:${record.shadow_id}`;
125
+ if (currentByEvaluation.has(key))
126
+ throw new Error(`shadow evaluation ${record.shadow_id} has multiple current dispositions`);
127
+ currentByEvaluation.add(key);
128
+ }
129
+ return current;
130
+ }
131
+ export const DEFAULT_SHADOW_THRESHOLDS = {
132
+ minApplicable: 20,
133
+ recentApplicable: 100,
134
+ maxUnknownErrorRate: 0.01,
135
+ minMutationSensitivity: 0.95,
136
+ };
137
+ const proofRank = { P0: 0, P1: 1, P2: 2, P3: 3, P4: 4, P5: 5 };
138
+ export function scoreShadowPrecision(policy, proof, evaluations, dispositions, historyDispositions, thresholdOverrides = {}) {
139
+ const thresholds = { ...DEFAULT_SHADOW_THRESHOLDS, ...thresholdOverrides };
140
+ if (!Number.isInteger(thresholds.minApplicable) || thresholds.minApplicable < 0)
141
+ throw new Error("shadow minApplicable must be a non-negative integer");
142
+ if (!Number.isInteger(thresholds.recentApplicable) || thresholds.recentApplicable < 1)
143
+ throw new Error("shadow recentApplicable must be a positive integer");
144
+ if (thresholds.maxUnknownErrorRate < 0 || thresholds.maxUnknownErrorRate > 1)
145
+ throw new Error("shadow maxUnknownErrorRate must be between 0 and 1");
146
+ if (thresholds.minMutationSensitivity < 0 || thresholds.minMutationSensitivity > 1)
147
+ throw new Error("shadow minMutationSensitivity must be between 0 and 1");
148
+ const exact = evaluations.filter((record) => record.proof_id === proof.id && record.policy_hash === proof.policy_hash);
149
+ const allApplicable = exact.filter((record) => record.evaluation.result !== "not_applicable");
150
+ const applicable = allApplicable
151
+ .sort((left, right) => right.observed_at.localeCompare(left.observed_at) || right.id.localeCompare(left.id))
152
+ .slice(0, thresholds.recentApplicable);
153
+ const currentDispositions = currentShadowDispositions(dispositions);
154
+ const classificationCounts = {
155
+ true_positive_actionable: 0,
156
+ true_positive_accepted_exception: 0,
157
+ false_positive_selector: 0,
158
+ false_positive_semantics: 0,
159
+ false_positive_stale: 0,
160
+ unknown_insufficient_parser: 0,
161
+ unclassified: 0,
162
+ };
163
+ for (const record of applicable.filter((candidate) => candidate.evaluation.result === "violated")) {
164
+ const disposition = currentDispositions.find((candidate) => candidate.shadow_id === record.id
165
+ && candidate.proof_id === proof.id
166
+ && candidate.policy_hash === proof.policy_hash
167
+ && candidate.evaluation_hash === record.evaluation.deterministic_hash);
168
+ if (disposition)
169
+ classificationCounts[disposition.classification] += 1;
170
+ else
171
+ classificationCounts.unclassified += 1;
172
+ }
173
+ const truePositives = classificationCounts.true_positive_actionable + classificationCounts.true_positive_accepted_exception;
174
+ const falsePositives = classificationCounts.false_positive_selector + classificationCounts.false_positive_semantics + classificationCounts.false_positive_stale;
175
+ const confirmedDenominator = truePositives + falsePositives;
176
+ const violationCount = applicable.filter((record) => record.evaluation.result === "violated").length;
177
+ const unknownErrors = applicable.filter((record) => record.evaluation.result === "unknown" || record.evaluation.result === "error").length;
178
+ const unknownErrorRate = applicable.length ? unknownErrors / applicable.length : 0;
179
+ const requiredMutations = proof.mutation_receipts.filter((receipt) => receipt.required);
180
+ const mutationSensitivity = requiredMutations.length
181
+ ? requiredMutations.filter((receipt) => receipt.passed).length / requiredMutations.length
182
+ : 0;
183
+ const historyAssessment = assessHistoryDispositions(proof, historyDispositions);
184
+ const reasons = [];
185
+ if (proofRank[proof.proof_class] < proofRank.P3)
186
+ reasons.push(`Proof ${proof.id} is ${proof.proof_class}; P3+ is required.`);
187
+ if (proof.current.satisfied !== 1 || proof.current.unknown || proof.current.error)
188
+ reasons.push("Proof has no clean satisfied baseline.");
189
+ if (historyAssessment.blocking_error)
190
+ reasons.push(historyAssessment.blocking_error);
191
+ if (mutationSensitivity < thresholds.minMutationSensitivity)
192
+ reasons.push(`Required mutation sensitivity ${mutationSensitivity.toFixed(3)} is below ${thresholds.minMutationSensitivity.toFixed(3)}.`);
193
+ if (applicable.length < thresholds.minApplicable)
194
+ reasons.push(`Shadow window has ${applicable.length} applicable change(s); ${thresholds.minApplicable} required.`);
195
+ if (falsePositives)
196
+ reasons.push(`Shadow window has ${falsePositives} confirmed false positive(s).`);
197
+ if (classificationCounts.unclassified)
198
+ reasons.push(`Shadow window has ${classificationCounts.unclassified} unclassified violation(s).`);
199
+ if (classificationCounts.unknown_insufficient_parser)
200
+ reasons.push(`Shadow window has ${classificationCounts.unknown_insufficient_parser} disposition(s) unresolved for parser support.`);
201
+ if (classificationCounts.true_positive_accepted_exception)
202
+ reasons.push(`Shadow window has ${classificationCounts.true_positive_accepted_exception} accepted exception(s) requiring policy composition repair and re-proof.`);
203
+ if (unknownErrorRate >= thresholds.maxUnknownErrorRate && unknownErrors)
204
+ reasons.push(`Shadow unknown/error rate ${unknownErrorRate.toFixed(3)} is not below ${thresholds.maxUnknownErrorRate.toFixed(3)}.`);
205
+ return {
206
+ policy_id: policy.id,
207
+ proof_id: proof.id,
208
+ policy_hash: proof.policy_hash,
209
+ thresholds,
210
+ counts: {
211
+ total: exact.length,
212
+ applicable: allApplicable.length,
213
+ satisfied: exact.filter((record) => record.evaluation.result === "satisfied").length,
214
+ violated: exact.filter((record) => record.evaluation.result === "violated").length,
215
+ not_applicable: exact.filter((record) => record.evaluation.result === "not_applicable").length,
216
+ unknown: exact.filter((record) => record.evaluation.result === "unknown").length,
217
+ error: exact.filter((record) => record.evaluation.result === "error").length,
218
+ stale_excluded: evaluations.length - exact.length,
219
+ },
220
+ window: { recent_limit: thresholds.recentApplicable, applicable: applicable.length, violated: violationCount },
221
+ dispositions: classificationCounts,
222
+ precision: {
223
+ true_positives: truePositives,
224
+ false_positives: falsePositives,
225
+ confirmed_denominator: confirmedDenominator,
226
+ confirmed: confirmedDenominator ? truePositives / confirmedDenominator : null,
227
+ lower_bound: violationCount ? truePositives / violationCount : null,
228
+ },
229
+ unknown_error_rate: unknownErrorRate,
230
+ mutation_sensitivity: mutationSensitivity,
231
+ recommendation: reasons.length ? "not_ready" : "eligible_for_p4_review",
232
+ reasons,
233
+ };
234
+ }
235
+ //# sourceMappingURL=shadow.js.map
@@ -0,0 +1,316 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, posix, relative } from "node:path";
4
+ import { hunchPathsForDir } from "../core/paths.js";
5
+ import { symbolId } from "../core/ids.js";
6
+ import { externalPackage } from "../core/externalImports.js";
7
+ import { pathMatchesGlob } from "../core/glob.js";
8
+ import { resolveRelativeImport } from "../core/relativeImports.js";
9
+ import { indexRepo } from "../extractors/indexer.js";
10
+ import { parseSource } from "../extractors/parse.js";
11
+ import { HunchStore } from "../store/hunchStore.js";
12
+ import { canonicalHash } from "./canonical.js";
13
+ import { evaluatePolicyOnSnapshot, graphSnapshot } from "./evaluator.js";
14
+ function safeEnvironment(home, gitConfig) {
15
+ const env = {};
16
+ for (const key of ["PATH", "SystemRoot", "WINDIR", "TMPDIR", "TMP", "TEMP"]) {
17
+ if (process.env[key])
18
+ env[key] = process.env[key];
19
+ }
20
+ return {
21
+ ...env,
22
+ HOME: home,
23
+ GIT_CONFIG_GLOBAL: gitConfig,
24
+ GIT_CONFIG_NOSYSTEM: "1",
25
+ GIT_TERMINAL_PROMPT: "0",
26
+ GIT_LFS_SKIP_SMUDGE: "1",
27
+ HUNCH_PRIVATE_DIR: "",
28
+ HUNCH_SYNTH_PROVIDER: "deterministic",
29
+ };
30
+ }
31
+ function gitArgs(root, hooks, args) {
32
+ return [
33
+ "-C", root,
34
+ "-c", `core.hooksPath=${hooks}`,
35
+ "-c", "core.fsmonitor=false",
36
+ "-c", "credential.helper=",
37
+ "-c", "filter.lfs.required=false",
38
+ "-c", "filter.lfs.smudge=",
39
+ "-c", "filter.lfs.process=",
40
+ ...args,
41
+ ];
42
+ }
43
+ function unsafeLocalFilter(root, env) {
44
+ try {
45
+ const raw = execFileSync("git", ["-C", root, "config", "--local", "--name-only", "--get-regexp", "^filter\\."], {
46
+ env,
47
+ encoding: "utf8",
48
+ stdio: ["ignore", "pipe", "ignore"],
49
+ });
50
+ return raw.trim().split("\n").some((key) => key && !key.startsWith("filter.lfs."));
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ function symbolForSelector(snapshot, selector) {
57
+ const raw = selector.selector;
58
+ if (raw.startsWith("symbol-id:"))
59
+ return snapshot.symbols.find((symbol) => symbol.id === raw.slice("symbol-id:".length)) ?? null;
60
+ if (!raw.startsWith("symbol:"))
61
+ return null;
62
+ const target = raw.slice("symbol:".length);
63
+ const split = target.lastIndexOf(":");
64
+ const matches = split > 0
65
+ ? snapshot.symbols.filter((symbol) => symbol.name === target.slice(split + 1) && (symbol.file === target.slice(0, split) || symbol.file.endsWith(`/${target.slice(0, split)}`)))
66
+ : snapshot.symbols.filter((symbol) => symbol.name === target);
67
+ return matches.length === 1 ? matches[0] : null;
68
+ }
69
+ function componentForSelector(snapshot, selector) {
70
+ const raw = selector.selector;
71
+ if (raw.startsWith("component-id:"))
72
+ return snapshot.components.find((component) => component.id === raw.slice("component-id:".length)) ?? null;
73
+ if (!raw.startsWith("component:"))
74
+ return null;
75
+ const name = raw.slice("component:".length);
76
+ const matches = snapshot.components.filter((component) => component.name === name);
77
+ return matches.length === 1 ? matches[0] : null;
78
+ }
79
+ function componentFiles(snapshot, component) {
80
+ return [...new Set(snapshot.symbols
81
+ .map((symbol) => symbol.file)
82
+ .filter((file) => component.paths.some((glob) => pathMatchesGlob(file, glob))))].sort();
83
+ }
84
+ function relativeSpecifier(fromFile, toFile) {
85
+ let specifier = relative(dirname(fromFile), toFile).split(/[\\/]/).join(posix.sep);
86
+ specifier = specifier.replace(/\.(?:tsx?|jsx?)$/, ".js");
87
+ return specifier.startsWith(".") ? specifier : `./${specifier}`;
88
+ }
89
+ function parsedSymbolFor(graphSymbol, parsed) {
90
+ const matches = parsed.symbols.filter((symbol) => symbol.name === graphSymbol.name && symbol.kind === graphSymbol.kind);
91
+ const base = symbolId(graphSymbol.file, graphSymbol.name, graphSymbol.kind);
92
+ return matches.find((_symbol, index) => (index === 0 ? base : `${base}_${index}`) === graphSymbol.id) ?? null;
93
+ }
94
+ function spliceBytes(source, replacements) {
95
+ let bytes = Buffer.from(source, "utf8");
96
+ for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
97
+ bytes = Buffer.concat([
98
+ bytes.subarray(0, replacement.start),
99
+ Buffer.from(replacement.text, "utf8"),
100
+ bytes.subarray(replacement.end),
101
+ ]);
102
+ }
103
+ return bytes.toString("utf8");
104
+ }
105
+ function mutateSource(policy, base, sourceFile, source) {
106
+ const assertion = policy.assertion;
107
+ if (assertion.kind === "executable-behavior")
108
+ return { error: "mutation-executable-behavior-unsupported" };
109
+ if (assertion.kind !== "exists"
110
+ && assertion.relation.edges.length === 1
111
+ && assertion.relation.edges[0] === "depends_on") {
112
+ const subjectComponent = componentForSelector(base, assertion.subject);
113
+ const objectComponent = componentForSelector(base, assertion.object);
114
+ if (!subjectComponent)
115
+ return { error: "mutation-subject-component-unresolved" };
116
+ if (!objectComponent)
117
+ return { error: "mutation-object-component-unresolved" };
118
+ const targetFile = componentFiles(base, objectComponent)[0];
119
+ if (!targetFile)
120
+ return { error: "mutation-object-component-empty" };
121
+ const parsed = parseSource(sourceFile, source);
122
+ if (!parsed?.parseable)
123
+ return { error: "mutation-source-unparseable" };
124
+ if (assertion.kind === "reaches") {
125
+ const available = base.symbols.map((symbol) => symbol.file);
126
+ const targets = new Set(componentFiles(base, objectComponent));
127
+ const matching = new Set(parsed.imports.filter((specifier) => {
128
+ const resolved = resolveRelativeImport(sourceFile, specifier, available).path;
129
+ return !!resolved && targets.has(resolved);
130
+ }));
131
+ if (!matching.size)
132
+ return { error: "mutation-required-component-import-unresolved" };
133
+ const lines = source.split(/(?<=\n)/);
134
+ const next = lines.filter((line) => {
135
+ if (!/^\s*(?:import|export)\b/.test(line))
136
+ return true;
137
+ return ![...matching].some((specifier) => line.includes(JSON.stringify(specifier)) || line.includes(`'${specifier}'`));
138
+ }).join("");
139
+ if (next === source)
140
+ return { error: "mutation-required-component-import-unresolved" };
141
+ return { file: sourceFile, source: next };
142
+ }
143
+ const specifier = relativeSpecifier(sourceFile, targetFile);
144
+ if (parsed.imports.some((candidate) => candidate === specifier))
145
+ return { error: "mutation-component-import-already-present" };
146
+ const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
147
+ return {
148
+ file: sourceFile,
149
+ source: spliceBytes(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
150
+ };
151
+ }
152
+ const subject = symbolForSelector(base, assertion.subject);
153
+ if (!subject)
154
+ return { error: "mutation-subject-unresolved" };
155
+ const parsed = parseSource(subject.file, source);
156
+ if (!parsed?.parseable)
157
+ return { error: "mutation-source-unparseable" };
158
+ const definition = parsedSymbolFor(subject, parsed);
159
+ if (!definition)
160
+ return { error: "mutation-subject-definition-unresolved" };
161
+ if (assertion.kind === "exists") {
162
+ return { file: subject.file, source: spliceBytes(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
163
+ }
164
+ if (assertion.kind === "not-reaches"
165
+ && assertion.relation.edges.length === 1
166
+ && assertion.relation.edges[0] === "imports"
167
+ && assertion.object.selector.startsWith("external:")) {
168
+ const dependency = externalPackage(assertion.object.selector.slice("external:".length));
169
+ if (!dependency)
170
+ return { error: "mutation-external-import-unsupported" };
171
+ if (parsed.imports.some((specifier) => externalPackage(specifier) === dependency)) {
172
+ return { error: "mutation-forbidden-import-already-present" };
173
+ }
174
+ const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
175
+ return {
176
+ file: subject.file,
177
+ source: spliceBytes(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
178
+ };
179
+ }
180
+ const object = symbolForSelector(base, assertion.object);
181
+ if (!object)
182
+ return { error: "mutation-object-unresolved" };
183
+ if (assertion.kind === "reaches") {
184
+ const allowed = new Set(assertion.relation.edges);
185
+ const targetNames = new Set(base.edges
186
+ .filter((edge) => edge.from === subject.id && allowed.has(edge.type))
187
+ .map((edge) => base.symbols.find((symbol) => symbol.id === edge.to)?.name)
188
+ .filter((name) => !!name));
189
+ const replacements = parsed.calls
190
+ .filter((call) => call.atByte >= definition.startByte && call.atByte < definition.endByte && targetNames.has(call.callee))
191
+ .map((call) => ({ start: call.atByte, end: call.endByte, text: "hunchMutationRemovedCall" }));
192
+ if (!replacements.length)
193
+ return { error: "mutation-required-call-unresolved" };
194
+ return { file: subject.file, source: spliceBytes(source, replacements) };
195
+ }
196
+ if (!assertion.relation.edges.includes("calls"))
197
+ return { error: "mutation-call-edge-not-supported" };
198
+ const bytes = Buffer.from(source, "utf8");
199
+ const open = bytes.indexOf("{".charCodeAt(0), definition.startByte);
200
+ if (open < 0 || open >= definition.endByte)
201
+ return { error: "mutation-subject-body-unsupported" };
202
+ const replacements = [{ start: open + 1, end: open + 1, text: `\n ${object.name}(); // hunch deterministic source mutation\n` }];
203
+ if (object.file !== subject.file) {
204
+ const specifier = relativeSpecifier(subject.file, object.file);
205
+ if (!parsed.imports.includes(specifier)) {
206
+ const insertion = source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
207
+ replacements.push({
208
+ start: insertion,
209
+ end: insertion,
210
+ text: `import { ${object.name} } from ${JSON.stringify(specifier)}; // hunch deterministic source mutation\n`,
211
+ });
212
+ }
213
+ }
214
+ return {
215
+ file: subject.file,
216
+ source: spliceBytes(source, replacements),
217
+ };
218
+ }
219
+ function removeWorktree(root, hooks, env, checkout) {
220
+ try {
221
+ execFileSync("git", gitArgs(root, hooks, ["worktree", "remove", "--force", checkout]), { env, timeout: 10_000, stdio: "ignore" });
222
+ return true;
223
+ }
224
+ catch {
225
+ rmSync(checkout, { recursive: true, force: true });
226
+ try {
227
+ execFileSync("git", gitArgs(root, hooks, ["worktree", "remove", "--force", checkout]), { env, timeout: 10_000, stdio: "ignore" });
228
+ return true;
229
+ }
230
+ catch {
231
+ return false;
232
+ }
233
+ }
234
+ }
235
+ /** Apply one primary mutation to an immutable disposable source checkout. No
236
+ * project script, build, test, provider, model, or repository hook executes. */
237
+ export function runSourceMutation(root, policy, base) {
238
+ if (policy.assertion.kind === "executable-behavior")
239
+ return { error_code: "mutation-executable-behavior-unsupported" };
240
+ if (!/^[a-f0-9]{40}$/.test(base.head))
241
+ return { error_code: "mutation-base-not-immutable" };
242
+ const cacheBase = join(root, ".hunch-cache", "mutations");
243
+ mkdirSync(cacheBase, { recursive: true });
244
+ const session = mkdtempSync(join(cacheBase, "mutation-"));
245
+ const hooks = join(session, "hooks-disabled");
246
+ const gitConfig = join(session, "global.gitconfig");
247
+ const checkout = join(session, "checkout");
248
+ const graph = join(session, "graph");
249
+ mkdirSync(hooks, { recursive: true });
250
+ writeFileSync(gitConfig, "");
251
+ const env = safeEnvironment(session, gitConfig);
252
+ let added = false;
253
+ let store;
254
+ let outcome = { error_code: "source-mutation-failed" };
255
+ try {
256
+ if (unsafeLocalFilter(root, env))
257
+ throw new Error("unsafe-local-filter-config");
258
+ execFileSync("git", gitArgs(root, hooks, ["worktree", "add", "--detach", "--force", checkout, base.head]), {
259
+ env,
260
+ timeout: 30_000,
261
+ stdio: "ignore",
262
+ });
263
+ added = true;
264
+ const subject = symbolForSelector(base, policy.assertion.subject);
265
+ const subjectComponent = componentForSelector(base, policy.assertion.subject);
266
+ const sourceFile = subject?.file ?? (subjectComponent ? componentFiles(base, subjectComponent)[0] : undefined);
267
+ if (!sourceFile)
268
+ throw new Error("mutation-subject-unresolved");
269
+ const file = join(checkout, sourceFile);
270
+ const mutation = mutateSource(policy, base, sourceFile, readFileSync(file, "utf8"));
271
+ if ("error" in mutation)
272
+ throw new Error(mutation.error);
273
+ const parsed = parseSource(mutation.file, mutation.source);
274
+ if (!parsed?.parseable)
275
+ throw new Error("mutation-source-unparseable");
276
+ writeFileSync(file, mutation.source);
277
+ const diff = execFileSync("git", gitArgs(checkout, hooks, ["diff", "--no-ext-diff", "--", mutation.file]), {
278
+ env,
279
+ encoding: "utf8",
280
+ timeout: 10_000,
281
+ stdio: ["ignore", "pipe", "ignore"],
282
+ });
283
+ if (!diff.trim())
284
+ throw new Error("mutation-source-diff-empty");
285
+ if (Buffer.byteLength(diff, "utf8") > 65_536)
286
+ throw new Error("mutation-source-diff-too-large");
287
+ store = new HunchStore(hunchPathsForDir(graph));
288
+ store.json.ensureDirs();
289
+ indexRepo(store, checkout, { churn: false });
290
+ const snapshot = graphSnapshot(store, root, { publicOnly: true, head: base.head });
291
+ outcome = {
292
+ snapshot,
293
+ evaluation: evaluatePolicyOnSnapshot(policy, snapshot),
294
+ source_patch: { files: [mutation.file], diff, diff_hash: canonicalHash(diff) },
295
+ };
296
+ }
297
+ catch (error) {
298
+ const message = error.message;
299
+ outcome = {
300
+ error_code: /^[a-z0-9-]+$/.test(message)
301
+ ? message
302
+ : added ? "source-mutation-index-failed" : "source-mutation-worktree-failed",
303
+ };
304
+ }
305
+ finally {
306
+ try {
307
+ store?.close();
308
+ }
309
+ catch { /* cleanup continues */ }
310
+ if (added && !removeWorktree(root, hooks, env, checkout))
311
+ outcome = { error_code: "worktree-cleanup-failed" };
312
+ rmSync(session, { recursive: true, force: true });
313
+ }
314
+ return outcome;
315
+ }
316
+ //# sourceMappingURL=sourceMutation.js.map