@davesheffer/hunch 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -0
- package/bench/constitution-exp03-v1.json +70 -0
- package/dist/cli/index.js +1630 -107
- 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 +1007 -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 +132 -0
- package/dist/constitution/lifecycle.js +224 -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/repairPolicies.js +78 -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 +1211 -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/escalations.js +65 -0
- package/dist/core/events.js +61 -0
- package/dist/core/externalImports.js +24 -0
- package/dist/core/hookpolicy.js +3 -0
- package/dist/core/memorylog.js +69 -0
- package/dist/core/relativeImports.js +33 -0
- package/dist/core/repair.js +71 -0
- package/dist/core/reviewqueue.js +11 -0
- package/dist/core/stats.js +115 -0
- package/dist/extractors/git.js +120 -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 +317 -1
- package/dist/synthesis/synthesize.js +8 -1
- package/dist/wiki/graph.js +301 -0
- package/dist/wiki/wiki.js +31 -3
- package/package.json +5 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { canonicalHash } from "./canonical.js";
|
|
7
|
+
import { compileAdapterConformanceReceipt } from "./g3.js";
|
|
8
|
+
import { NODE_TEST_REPORTER_SOURCE, exactNodeTestPattern, nodeTestIsolationFlag, nodeTestReporterEvents } from "./nodeTestEvidence.js";
|
|
9
|
+
export const G3_CONFORMANCE_CLIENTS = ["ci", "cli", "mcp"];
|
|
10
|
+
export const G3_CONFORMANCE_TEST = {
|
|
11
|
+
file: "test/behavior-workspace.test.ts",
|
|
12
|
+
name: "CLI, MCP, and check share one non-blocking working-snapshot receipt without public leakage",
|
|
13
|
+
};
|
|
14
|
+
/** The certifiable client profiles. Each maps a HUMAN-SELECTED client set to the
|
|
15
|
+
* ONE executable fixture that exercises every named surface end-to-end and
|
|
16
|
+
* asserts receipt equality + zero private leaks. The vscode profile executes the
|
|
17
|
+
* extension's real spawn seam (vscode-extension/src/spawnCore.ts) against an
|
|
18
|
+
* npm-style shim — naming a client never certifies it; only its fixture does. */
|
|
19
|
+
export const G3_CONFORMANCE_PROFILES = [
|
|
20
|
+
{ clients: G3_CONFORMANCE_CLIENTS, test: G3_CONFORMANCE_TEST },
|
|
21
|
+
{
|
|
22
|
+
clients: ["ci", "cli", "mcp", "vscode"],
|
|
23
|
+
test: {
|
|
24
|
+
file: "test/behavior-workspace.test.ts",
|
|
25
|
+
name: "CLI, MCP, check, and the VS Code seam share one non-blocking working-snapshot receipt without public leakage",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
export function g3ConformanceSourceHash(root) {
|
|
30
|
+
return canonicalHash(readFileSync(join(root, G3_CONFORMANCE_TEST.file), "utf8"));
|
|
31
|
+
}
|
|
32
|
+
function sameClients(left, right) {
|
|
33
|
+
return canonicalHash([...left].sort()) === canonicalHash([...right].sort());
|
|
34
|
+
}
|
|
35
|
+
/** Execute the real end-to-end fixture matching the plan's human-selected client
|
|
36
|
+
* profile. Unsupported client sets return an error receipt rather than being
|
|
37
|
+
* silently treated as equivalent to a certified profile. */
|
|
38
|
+
export function executeG3AdapterConformance(root, plan, opts = {}) {
|
|
39
|
+
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
40
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000)
|
|
41
|
+
throw new Error("G3 conformance timeout must be a positive integer no greater than 300000");
|
|
42
|
+
const sourceHash = g3ConformanceSourceHash(root);
|
|
43
|
+
const isolationFlag = nodeTestIsolationFlag();
|
|
44
|
+
const profile = G3_CONFORMANCE_PROFILES.find((candidate) => sameClients(plan.clients, candidate.clients));
|
|
45
|
+
if (!profile) {
|
|
46
|
+
return compileAdapterConformanceReceipt({
|
|
47
|
+
plan_id: plan.id,
|
|
48
|
+
clients: plan.clients,
|
|
49
|
+
test: { ...G3_CONFORMANCE_TEST, source_hash: sourceHash },
|
|
50
|
+
runner: { name: "node-test-tsx", isolation_flag: isolationFlag },
|
|
51
|
+
result: "error",
|
|
52
|
+
exit_code: null,
|
|
53
|
+
selected_event: null,
|
|
54
|
+
verdict_agreement: null,
|
|
55
|
+
confirmed_private_leaks: null,
|
|
56
|
+
error_code: "unsupported-client-profile",
|
|
57
|
+
log_hash: canonicalHash({ supported: G3_CONFORMANCE_PROFILES.map((candidate) => candidate.clients), selected: plan.clients }),
|
|
58
|
+
supersedes: opts.supersedes ?? null,
|
|
59
|
+
}, { now: opts.now });
|
|
60
|
+
}
|
|
61
|
+
const selectedTest = profile.test;
|
|
62
|
+
const session = mkdtempSync(join(tmpdir(), "hunch-g3-conformance-"));
|
|
63
|
+
const reporter = join(session, "reporter.mjs");
|
|
64
|
+
writeFileSync(reporter, NODE_TEST_REPORTER_SOURCE);
|
|
65
|
+
const tsx = join(root, "node_modules/tsx/dist/cli.mjs");
|
|
66
|
+
const env = {};
|
|
67
|
+
for (const key of ["PATH", "SystemRoot", "WINDIR", "TMPDIR", "TMP", "TEMP"]) {
|
|
68
|
+
if (process.env[key])
|
|
69
|
+
env[key] = process.env[key];
|
|
70
|
+
}
|
|
71
|
+
Object.assign(env, {
|
|
72
|
+
HOME: session,
|
|
73
|
+
HUNCH_PRIVATE_DIR: "",
|
|
74
|
+
HUNCH_SYNTH_PROVIDER: "deterministic",
|
|
75
|
+
CI: "1",
|
|
76
|
+
NO_COLOR: "1",
|
|
77
|
+
FORCE_COLOR: "0",
|
|
78
|
+
});
|
|
79
|
+
const run = spawnSync(process.execPath, [
|
|
80
|
+
tsx,
|
|
81
|
+
"--test",
|
|
82
|
+
isolationFlag,
|
|
83
|
+
`--test-name-pattern=${exactNodeTestPattern(selectedTest.name)}`,
|
|
84
|
+
`--test-reporter=${pathToFileURL(reporter).href}`,
|
|
85
|
+
"--test-reporter-destination=stdout",
|
|
86
|
+
selectedTest.file,
|
|
87
|
+
], {
|
|
88
|
+
cwd: root,
|
|
89
|
+
env,
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
timeout: timeoutMs,
|
|
92
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
93
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
+
shell: false,
|
|
95
|
+
});
|
|
96
|
+
rmSync(session, { recursive: true, force: true });
|
|
97
|
+
const stdout = run.stdout ?? "";
|
|
98
|
+
const stderr = run.stderr ?? "";
|
|
99
|
+
const events = nodeTestReporterEvents(stdout).filter((event) => event.name === selectedTest.name && !event.skip && !event.todo);
|
|
100
|
+
const selectedEvent = events.length === 1 ? (events[0].type === "test:pass" ? "passed" : "failed") : null;
|
|
101
|
+
let result = "error";
|
|
102
|
+
let errorCode;
|
|
103
|
+
if (run.error)
|
|
104
|
+
errorCode = run.error.code === "ETIMEDOUT" ? "timeout" : "runner-failed";
|
|
105
|
+
else if (run.signal)
|
|
106
|
+
errorCode = "runner-signaled";
|
|
107
|
+
else if (events.length === 0)
|
|
108
|
+
errorCode = "selected-test-not-executed";
|
|
109
|
+
else if (events.length > 1)
|
|
110
|
+
errorCode = "selected-test-ambiguous";
|
|
111
|
+
else if (selectedEvent === "passed" && run.status === 0)
|
|
112
|
+
result = "passed";
|
|
113
|
+
else if (selectedEvent === "failed" && run.status !== 0)
|
|
114
|
+
result = "failed";
|
|
115
|
+
else
|
|
116
|
+
errorCode = "runner-outcome-inconsistent";
|
|
117
|
+
return compileAdapterConformanceReceipt({
|
|
118
|
+
plan_id: plan.id,
|
|
119
|
+
clients: plan.clients,
|
|
120
|
+
test: { ...selectedTest, source_hash: sourceHash },
|
|
121
|
+
runner: { name: "node-test-tsx", isolation_flag: isolationFlag },
|
|
122
|
+
result,
|
|
123
|
+
exit_code: run.status ?? null,
|
|
124
|
+
selected_event: selectedEvent,
|
|
125
|
+
verdict_agreement: result === "passed" ? 1 : null,
|
|
126
|
+
confirmed_private_leaks: result === "passed" ? 0 : null,
|
|
127
|
+
...(errorCode ? { error_code: errorCode } : {}),
|
|
128
|
+
log_hash: canonicalHash({ stdout, stderr }),
|
|
129
|
+
supersedes: opts.supersedes ?? null,
|
|
130
|
+
}, { now: opts.now });
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=g3Conformance.js.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { assertCompositionBinding, exceptionScopeIsNarrower, oppositeExceptionAssertions, policyProofHash } from "./composition.js";
|
|
2
|
+
import { assessHistoryDispositions } from "./disposition.js";
|
|
3
|
+
import { evaluatorForPolicy, mutationEngineForPolicy } from "./policyRuntime.js";
|
|
4
|
+
import { executableBehaviorAttestationError } from "./behaviorAttestationBinding.js";
|
|
5
|
+
function isHumanActor(actor) {
|
|
6
|
+
return /^(human|github|git):[^\s]+$/i.test(actor);
|
|
7
|
+
}
|
|
8
|
+
export function blockingEvidenceError(proof, dispositions = []) {
|
|
9
|
+
if (proof.mutation_receipts.some((receipt) => receipt.required && !receipt.passed)) {
|
|
10
|
+
return "blocking proof has a failed required mutation receipt";
|
|
11
|
+
}
|
|
12
|
+
if (proof.mutation_controls.failed > 0) {
|
|
13
|
+
return "blocking proof has failed required mutation controls";
|
|
14
|
+
}
|
|
15
|
+
if (proof.known_bad.total > 0 && proof.known_bad.violated !== proof.known_bad.total) {
|
|
16
|
+
return "blocking proof did not catch every declared known-bad fixture";
|
|
17
|
+
}
|
|
18
|
+
if (proof.known_good.total > 0 && proof.known_good.satisfied !== proof.known_good.total) {
|
|
19
|
+
return "blocking proof did not satisfy every declared known-good fixture";
|
|
20
|
+
}
|
|
21
|
+
if (proof.accepted_history.error || proof.accepted_history.unknown) {
|
|
22
|
+
return "blocking proof has unresolved accepted-history unknown/error results";
|
|
23
|
+
}
|
|
24
|
+
return assessHistoryDispositions(proof, dispositions).blocking_error;
|
|
25
|
+
}
|
|
26
|
+
const proofRank = { P0: 0, P1: 1, P2: 2, P3: 3, P4: 4, P5: 5 };
|
|
27
|
+
/** Rechecked on every blocking evaluation; a hand-edited lifecycle flag without
|
|
28
|
+
* a current P3 proof is a configuration error, never authority. */
|
|
29
|
+
export function blockingProofError(policy, proof, dispositions = [], composition = [], currentBehaviorAttestations = []) {
|
|
30
|
+
if (policy.state !== "active_blocking")
|
|
31
|
+
return null;
|
|
32
|
+
if (policy.authority?.kind !== "human")
|
|
33
|
+
return "active blocking policy has no human authority event";
|
|
34
|
+
const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
|
|
35
|
+
if (behaviorAttestationError)
|
|
36
|
+
return behaviorAttestationError;
|
|
37
|
+
if (!policy.proof || !proof)
|
|
38
|
+
return "active blocking policy has no readable proof artifact";
|
|
39
|
+
if (proofRank[proof.proof_class] < proofRank.P3)
|
|
40
|
+
return `blocking proof is ${proof.proof_class}; P3+ is required`;
|
|
41
|
+
const evaluator = evaluatorForPolicy(policy);
|
|
42
|
+
const mutationEngine = mutationEngineForPolicy(policy);
|
|
43
|
+
if (proof.evaluator.name !== evaluator.name || proof.evaluator.version !== evaluator.version)
|
|
44
|
+
return "blocking proof evaluator version is stale";
|
|
45
|
+
if (proof.mutation_engine?.name !== mutationEngine.name || proof.mutation_engine.version !== mutationEngine.version)
|
|
46
|
+
return "blocking proof mutation engine version is stale";
|
|
47
|
+
try {
|
|
48
|
+
assertCompositionBinding(policy, composition, proof.composition);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "blocking proof does not match the current parent/exception composition";
|
|
52
|
+
}
|
|
53
|
+
if (proof.policy_hash !== policyProofHash(policy, composition))
|
|
54
|
+
return "blocking proof does not match current policy semantics";
|
|
55
|
+
if (proof.current.satisfied !== 1 || proof.current.error || proof.current.unknown)
|
|
56
|
+
return "blocking proof has no clean satisfied baseline";
|
|
57
|
+
const replayError = blockingEvidenceError(proof, dispositions);
|
|
58
|
+
if (replayError)
|
|
59
|
+
return replayError;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function requireHuman(actor) {
|
|
63
|
+
if (!isHumanActor(actor))
|
|
64
|
+
throw new Error("policy authority requires an explicit human actor (human:, github:, or git:); machine/model identities cannot activate policy");
|
|
65
|
+
}
|
|
66
|
+
function requireCurrentProof(policy, proof, composition = []) {
|
|
67
|
+
const evaluator = evaluatorForPolicy(policy);
|
|
68
|
+
const mutationEngine = mutationEngineForPolicy(policy);
|
|
69
|
+
if (proof.evaluator.name !== evaluator.name || proof.evaluator.version !== evaluator.version)
|
|
70
|
+
throw new Error(`proof ${proof.id} evaluator version is stale`);
|
|
71
|
+
if (proof.mutation_engine?.name !== mutationEngine.name || proof.mutation_engine.version !== mutationEngine.version)
|
|
72
|
+
throw new Error(`proof ${proof.id} mutation engine version is stale`);
|
|
73
|
+
assertCompositionBinding(policy, composition, proof.composition);
|
|
74
|
+
if (proof.policy_hash !== policyProofHash(policy, composition))
|
|
75
|
+
throw new Error(`proof ${proof.id} does not match the current policy semantics`);
|
|
76
|
+
if (proof.current.satisfied !== 1 || proof.current.error || proof.current.unknown)
|
|
77
|
+
throw new Error(`proof ${proof.id} has no clean satisfied baseline`);
|
|
78
|
+
}
|
|
79
|
+
export function proposeProvedPolicy(policy, proof, at, composition = [], currentBehaviorAttestations = []) {
|
|
80
|
+
const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
|
|
81
|
+
if (behaviorAttestationError)
|
|
82
|
+
throw new Error(behaviorAttestationError);
|
|
83
|
+
if (policy.state !== "compiled" && policy.state !== "validating" && policy.state !== "proposed")
|
|
84
|
+
throw new Error(`cannot attach proof while policy is ${policy.state}`);
|
|
85
|
+
if (proofRank[proof.proof_class] < proofRank.P1)
|
|
86
|
+
throw new Error(`proof ${proof.id} is ${proof.proof_class}; a clean current baseline is required`);
|
|
87
|
+
requireCurrentProof(policy, proof, composition);
|
|
88
|
+
return {
|
|
89
|
+
...policy,
|
|
90
|
+
revision: policy.revision + 1,
|
|
91
|
+
state: "proposed",
|
|
92
|
+
proof: proof.id,
|
|
93
|
+
updated_at: at,
|
|
94
|
+
audit: [...policy.audit, { action: "proved", actor_kind: "system", actor: "hunch:evidence-harness", at, reason: `Proof ${proof.id} reached ${proof.proof_class}.`, proof: proof.id }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function approvePolicy(policy, proof, mode, actor, at, dispositions = [], composition = [], currentBehaviorAttestations = []) {
|
|
98
|
+
requireHuman(actor);
|
|
99
|
+
const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
|
|
100
|
+
if (behaviorAttestationError)
|
|
101
|
+
throw new Error(behaviorAttestationError);
|
|
102
|
+
if (policy.exception_of && mode === "blocking")
|
|
103
|
+
throw new Error(`exception policy ${policy.id} cannot block independently; activate its broad parent only after parent/exception composition is proved`);
|
|
104
|
+
if (policy.state !== "proposed")
|
|
105
|
+
throw new Error(`policy ${policy.id} is ${policy.state}; only a proposed policy can be activated`);
|
|
106
|
+
requireCurrentProof(policy, proof, composition);
|
|
107
|
+
if (mode === "blocking" && proofRank[proof.proof_class] < proofRank.P3) {
|
|
108
|
+
throw new Error(`blocking activation requires P3+ proof; ${proof.id} is ${proof.proof_class}`);
|
|
109
|
+
}
|
|
110
|
+
if (mode === "blocking") {
|
|
111
|
+
const replayError = blockingEvidenceError(proof, dispositions);
|
|
112
|
+
if (replayError)
|
|
113
|
+
throw new Error(replayError);
|
|
114
|
+
}
|
|
115
|
+
const event = `${mode === "blocking" ? "approval-blocking" : "approval-advisory"}:${at}`;
|
|
116
|
+
return {
|
|
117
|
+
...policy,
|
|
118
|
+
revision: policy.revision + 1,
|
|
119
|
+
state: mode === "blocking" ? "active_blocking" : "active_advisory",
|
|
120
|
+
severity: mode === "blocking" ? "blocking" : "warning",
|
|
121
|
+
surfaces: policy.assertion.kind === "executable-behavior"
|
|
122
|
+
? ["pre_commit", "ci", "mcp", "cli"]
|
|
123
|
+
: mode === "blocking" ? ["pre_commit", "ci", "mcp", "cli"] : ["pre_edit", "pre_commit", "ci", "mcp", "cli"],
|
|
124
|
+
authority: { kind: "human", actor, event, at },
|
|
125
|
+
valid_from: at,
|
|
126
|
+
valid_to: null,
|
|
127
|
+
updated_at: at,
|
|
128
|
+
audit: [...policy.audit, {
|
|
129
|
+
action: mode === "blocking" ? "approved_blocking" : "approved_advisory",
|
|
130
|
+
actor_kind: "human",
|
|
131
|
+
actor,
|
|
132
|
+
at,
|
|
133
|
+
reason: `Human activated policy as ${mode}.`,
|
|
134
|
+
proof: proof.id,
|
|
135
|
+
}],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Human-authored exception relationship only. It invalidates the child's old
|
|
139
|
+
* proof/authority; the broad parent remains the separately activated unit. */
|
|
140
|
+
export function linkPolicyException(child, parent, actor, reason, at) {
|
|
141
|
+
requireHuman(actor);
|
|
142
|
+
if (child.id === parent.id)
|
|
143
|
+
throw new Error("a policy cannot be its own exception parent");
|
|
144
|
+
if (child.data_class !== parent.data_class)
|
|
145
|
+
throw new Error("exception and parent must have the same data class");
|
|
146
|
+
if (child.exception_of && child.exception_of !== parent.id)
|
|
147
|
+
throw new Error(`policy ${child.id} is already linked to exception parent ${child.exception_of}`);
|
|
148
|
+
if (!oppositeExceptionAssertions(child, parent))
|
|
149
|
+
throw new Error("exception must be the exact opposite reaches/not-reaches assertion over the same bindings and relation");
|
|
150
|
+
if (!exceptionScopeIsNarrower(child, parent))
|
|
151
|
+
throw new Error("exception scope must be strictly narrower than and contained by its parent scope");
|
|
152
|
+
if (!reason.trim())
|
|
153
|
+
throw new Error("exception link requires an audited human reason");
|
|
154
|
+
if (child.exception_of === parent.id)
|
|
155
|
+
return child;
|
|
156
|
+
return {
|
|
157
|
+
...child,
|
|
158
|
+
revision: child.revision + 1,
|
|
159
|
+
state: "compiled",
|
|
160
|
+
severity: "warning",
|
|
161
|
+
authority: null,
|
|
162
|
+
proof: null,
|
|
163
|
+
exception_of: parent.id,
|
|
164
|
+
updated_at: at,
|
|
165
|
+
audit: [...child.audit, {
|
|
166
|
+
action: "linked_exception",
|
|
167
|
+
actor_kind: "human",
|
|
168
|
+
actor,
|
|
169
|
+
at,
|
|
170
|
+
reason,
|
|
171
|
+
proof: null,
|
|
172
|
+
}],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export function demotePolicy(policy, actor, reason, at) {
|
|
176
|
+
requireHuman(actor);
|
|
177
|
+
if (policy.state !== "active_blocking")
|
|
178
|
+
throw new Error(`policy ${policy.id} is ${policy.state}; only active blocking policy can be demoted`);
|
|
179
|
+
return {
|
|
180
|
+
...policy,
|
|
181
|
+
revision: policy.revision + 1,
|
|
182
|
+
state: "active_advisory",
|
|
183
|
+
severity: "warning",
|
|
184
|
+
authority: { kind: "human", actor, event: `demotion:${at}`, at },
|
|
185
|
+
updated_at: at,
|
|
186
|
+
audit: [...policy.audit, { action: "demoted", actor_kind: "human", actor, at, reason, proof: policy.proof }],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Targeted advisory withdrawal (the §57 gap): pull the human authority back and
|
|
190
|
+
* return the policy to `proposed`. It stops surfacing as an active rule and
|
|
191
|
+
* RE-ENTERS the inline escalation loop ("activate or reject?") — the reversible
|
|
192
|
+
* half of retirement. History is append-only; nothing is erased. */
|
|
193
|
+
export function withdrawPolicy(policy, actor, reason, at) {
|
|
194
|
+
requireHuman(actor);
|
|
195
|
+
if (policy.state !== "active_advisory")
|
|
196
|
+
throw new Error(`policy ${policy.id} is ${policy.state}; only active advisory policy can be withdrawn to proposed`);
|
|
197
|
+
return {
|
|
198
|
+
...policy,
|
|
199
|
+
revision: policy.revision + 1,
|
|
200
|
+
state: "proposed",
|
|
201
|
+
authority: null,
|
|
202
|
+
updated_at: at,
|
|
203
|
+
audit: [...policy.audit, { action: "withdrawn", actor_kind: "human", actor, at, reason, proof: policy.proof }],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Permanent retirement: the rule is deliberately DONE — it stops surfacing
|
|
207
|
+
* anywhere (active rules, escalations) but its full history and audit trail stay
|
|
208
|
+
* (supersede-never-erase, HC-08). Closes the valid-time window. */
|
|
209
|
+
export function retirePolicy(policy, actor, reason, at) {
|
|
210
|
+
requireHuman(actor);
|
|
211
|
+
if (policy.state !== "active_advisory" && policy.state !== "active_blocking" && policy.state !== "proposed") {
|
|
212
|
+
throw new Error(`policy ${policy.id} is ${policy.state}; only an active or proposed policy can be retired`);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
...policy,
|
|
216
|
+
revision: policy.revision + 1,
|
|
217
|
+
state: "retired",
|
|
218
|
+
authority: null,
|
|
219
|
+
valid_to: at,
|
|
220
|
+
updated_at: at,
|
|
221
|
+
audit: [...policy.audit, { action: "retired", actor_kind: "human", actor, at, reason, proof: policy.proof }],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=lifecycle.js.map
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { shortHash } from "../core/ids.js";
|
|
2
|
+
import { externalPackage } from "../core/externalImports.js";
|
|
3
|
+
import { parseSource } from "../extractors/parse.js";
|
|
4
|
+
import { canonicalHash, canonicalJson, policySemanticHash, proofEvaluationHash } from "./canonical.js";
|
|
5
|
+
import { evaluatePolicyOnSnapshot, graphSnapshotFromRecords, mutateSnapshotForPolicy, mutationOperatorForPolicy, } from "./evaluator.js";
|
|
6
|
+
import { MutationReceiptSchema, MUTATION_ENGINE, } from "./schema.js";
|
|
7
|
+
import { runSourceMutation } from "./sourceMutation.js";
|
|
8
|
+
function policySelectors(policy) {
|
|
9
|
+
const assertion = policy.assertion;
|
|
10
|
+
if (assertion.kind === "executable-behavior")
|
|
11
|
+
return [];
|
|
12
|
+
return [
|
|
13
|
+
assertion.subject,
|
|
14
|
+
...(assertion.kind === "exists" ? [] : [assertion.object]),
|
|
15
|
+
...(assertion.kind === "must-pass-through" ? [assertion.via] : []),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
function symbolForSelector(snapshot, selector) {
|
|
19
|
+
const raw = selector.selector;
|
|
20
|
+
if (raw.startsWith("symbol-id:")) {
|
|
21
|
+
return snapshot.symbols.find((symbol) => symbol.id === raw.slice("symbol-id:".length)) ?? null;
|
|
22
|
+
}
|
|
23
|
+
if (!raw.startsWith("symbol:"))
|
|
24
|
+
return null;
|
|
25
|
+
const target = raw.slice("symbol:".length);
|
|
26
|
+
const split = target.lastIndexOf(":");
|
|
27
|
+
const matches = split > 0
|
|
28
|
+
? snapshot.symbols.filter((symbol) => symbol.name === target.slice(split + 1) && (symbol.file === target.slice(0, split) || symbol.file.endsWith(`/${target.slice(0, split)}`)))
|
|
29
|
+
: snapshot.symbols.filter((symbol) => symbol.name === target);
|
|
30
|
+
return matches.length === 1 ? matches[0] : null;
|
|
31
|
+
}
|
|
32
|
+
function nameForSelector(snapshot, selector) {
|
|
33
|
+
const symbol = symbolForSelector(snapshot, selector);
|
|
34
|
+
if (symbol)
|
|
35
|
+
return symbol.name;
|
|
36
|
+
const component = componentForSelector(snapshot, selector);
|
|
37
|
+
if (component)
|
|
38
|
+
return component.name;
|
|
39
|
+
return selector.selector.startsWith("external:")
|
|
40
|
+
? externalPackage(selector.selector.slice("external:".length))
|
|
41
|
+
: null;
|
|
42
|
+
}
|
|
43
|
+
function componentForSelector(snapshot, selector) {
|
|
44
|
+
const raw = selector.selector;
|
|
45
|
+
if (raw.startsWith("component-id:"))
|
|
46
|
+
return snapshot.components.find((component) => component.id === raw.slice("component-id:".length)) ?? null;
|
|
47
|
+
if (!raw.startsWith("component:"))
|
|
48
|
+
return null;
|
|
49
|
+
const name = raw.slice("component:".length);
|
|
50
|
+
const matches = snapshot.components.filter((component) => component.name === name);
|
|
51
|
+
return matches.length === 1 ? matches[0] : null;
|
|
52
|
+
}
|
|
53
|
+
function graphDiff(base, mutated) {
|
|
54
|
+
const baseSymbols = new Set(base.symbols.map((symbol) => symbol.id));
|
|
55
|
+
const mutatedSymbols = new Set(mutated.symbols.map((symbol) => symbol.id));
|
|
56
|
+
const baseEdges = new Set(base.edges.map((edge) => edge.id));
|
|
57
|
+
const mutatedEdges = new Set(mutated.edges.map((edge) => edge.id));
|
|
58
|
+
return {
|
|
59
|
+
added_symbols: [...mutatedSymbols].filter((id) => !baseSymbols.has(id)).sort(),
|
|
60
|
+
removed_symbols: [...baseSymbols].filter((id) => !mutatedSymbols.has(id)).sort(),
|
|
61
|
+
added_edges: [...mutatedEdges].filter((id) => !baseEdges.has(id)).sort(),
|
|
62
|
+
removed_edges: [...baseEdges].filter((id) => !mutatedEdges.has(id)).sort(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function receipt(policy, base, input, policyHash = policySemanticHash(policy)) {
|
|
66
|
+
const seed = {
|
|
67
|
+
engine: MUTATION_ENGINE,
|
|
68
|
+
policy_hash: policyHash,
|
|
69
|
+
base_commit: base.head,
|
|
70
|
+
base_graph_hash: base.graph_hash,
|
|
71
|
+
kind: input.kind,
|
|
72
|
+
operator: input.operator,
|
|
73
|
+
};
|
|
74
|
+
const body = {
|
|
75
|
+
id: `mut_${shortHash(canonicalJson(seed))}`,
|
|
76
|
+
engine: { ...MUTATION_ENGINE },
|
|
77
|
+
policy_hash: seed.policy_hash,
|
|
78
|
+
base_commit: seed.base_commit,
|
|
79
|
+
base_graph_hash: seed.base_graph_hash,
|
|
80
|
+
...input,
|
|
81
|
+
};
|
|
82
|
+
return MutationReceiptSchema.parse({ ...body, deterministic_hash: canonicalHash(body) });
|
|
83
|
+
}
|
|
84
|
+
function errorReceipt(policy, base, planned, kind, code, parseability = "not_applicable", policyHash = policySemanticHash(policy)) {
|
|
85
|
+
return receipt(policy, base, {
|
|
86
|
+
kind,
|
|
87
|
+
operator: planned.operator,
|
|
88
|
+
required: planned.required,
|
|
89
|
+
expected: planned.expected,
|
|
90
|
+
result: "error",
|
|
91
|
+
passed: false,
|
|
92
|
+
parseability,
|
|
93
|
+
graph_diff: { added_symbols: [], removed_symbols: [], added_edges: [], removed_edges: [] },
|
|
94
|
+
error_code: code,
|
|
95
|
+
}, policyHash);
|
|
96
|
+
}
|
|
97
|
+
function primaryMutation(root, policy, base, planned, context) {
|
|
98
|
+
const expectedOperator = mutationOperatorForPolicy(context.mutation_policy);
|
|
99
|
+
if (planned.operator !== expectedOperator) {
|
|
100
|
+
throw new Error(`proof plan mutation ${planned.operator} does not match evaluator operator ${expectedOperator}`);
|
|
101
|
+
}
|
|
102
|
+
if (/^[a-f0-9]{40}$/.test(base.head)) {
|
|
103
|
+
const source = runSourceMutation(root, context.mutation_policy, base);
|
|
104
|
+
if (!source.snapshot || !source.evaluation || !source.source_patch) {
|
|
105
|
+
const code = source.error_code ?? "source-mutation-failed";
|
|
106
|
+
return {
|
|
107
|
+
receipt: errorReceipt(policy, base, planned, "primary", code, code === "mutation-source-unparseable" ? "unparseable" : "not_applicable", context.policy_hash),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const evaluation = context.evaluate(source.snapshot);
|
|
111
|
+
return {
|
|
112
|
+
evaluation,
|
|
113
|
+
receipt: receipt(policy, base, {
|
|
114
|
+
kind: "primary",
|
|
115
|
+
operator: planned.operator,
|
|
116
|
+
required: planned.required,
|
|
117
|
+
mutated_graph_hash: source.snapshot.graph_hash,
|
|
118
|
+
expected: planned.expected,
|
|
119
|
+
result: evaluation.result,
|
|
120
|
+
passed: evaluation.result === planned.expected,
|
|
121
|
+
parseability: "parseable",
|
|
122
|
+
graph_diff: graphDiff(base, source.snapshot),
|
|
123
|
+
source_patch: source.source_patch,
|
|
124
|
+
evaluation_hash: proofEvaluationHash(evaluation),
|
|
125
|
+
}, context.policy_hash),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const mutation = mutateSnapshotForPolicy(context.mutation_policy, base);
|
|
129
|
+
if (!mutation)
|
|
130
|
+
return { receipt: errorReceipt(policy, base, planned, "primary", "mutation-unavailable", "not_applicable", context.policy_hash) };
|
|
131
|
+
const evaluation = context.evaluate(mutation.snapshot);
|
|
132
|
+
return {
|
|
133
|
+
evaluation,
|
|
134
|
+
receipt: receipt(policy, base, {
|
|
135
|
+
kind: "primary",
|
|
136
|
+
operator: planned.operator,
|
|
137
|
+
required: planned.required,
|
|
138
|
+
mutated_graph_hash: mutation.snapshot.graph_hash,
|
|
139
|
+
expected: planned.expected,
|
|
140
|
+
result: evaluation.result,
|
|
141
|
+
passed: evaluation.result === planned.expected,
|
|
142
|
+
parseability: "not_applicable",
|
|
143
|
+
graph_diff: graphDiff(base, mutation.snapshot),
|
|
144
|
+
evaluation_hash: proofEvaluationHash(evaluation),
|
|
145
|
+
}, context.policy_hash),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function commentStringControl(policy, base, planned, context) {
|
|
149
|
+
const baseline = context.evaluate(base);
|
|
150
|
+
const targets = [...new Set(policySelectors(policy)
|
|
151
|
+
.map((selector) => nameForSelector(base, selector))
|
|
152
|
+
.filter((name) => !!name))].sort();
|
|
153
|
+
if (!targets.length)
|
|
154
|
+
return errorReceipt(policy, base, planned, "control", "control-selector-unresolved", "not_applicable", context.policy_hash);
|
|
155
|
+
const marker = targets.map((name) => `${name}()`).join(" ");
|
|
156
|
+
const source = [
|
|
157
|
+
"export function hunchMutationCommentStringControl(){",
|
|
158
|
+
` const marker = ${JSON.stringify(marker)};`,
|
|
159
|
+
` // ${marker}`,
|
|
160
|
+
" return marker;",
|
|
161
|
+
"}",
|
|
162
|
+
"",
|
|
163
|
+
].join("\n");
|
|
164
|
+
const parsed = parseSource("hunch-mutation-comment-string-control.ts", source);
|
|
165
|
+
const observedCalls = parsed?.calls.map((call) => call.callee).filter((name) => targets.includes(name)).sort() ?? [];
|
|
166
|
+
const observedImports = parsed?.imports.filter((specifier) => targets.includes(specifier)).sort() ?? [];
|
|
167
|
+
const parserPassed = !!parsed?.parseable && observedCalls.length === 0 && observedImports.length === 0;
|
|
168
|
+
const result = parserPassed ? baseline.result : "error";
|
|
169
|
+
return receipt(policy, base, {
|
|
170
|
+
kind: "control",
|
|
171
|
+
operator: planned.operator,
|
|
172
|
+
required: planned.required,
|
|
173
|
+
mutated_graph_hash: base.graph_hash,
|
|
174
|
+
expected: planned.expected,
|
|
175
|
+
result,
|
|
176
|
+
passed: parserPassed && result === planned.expected,
|
|
177
|
+
parseability: parsed?.parseable ? "parseable" : "unparseable",
|
|
178
|
+
graph_diff: { added_symbols: [], removed_symbols: [], added_edges: [], removed_edges: [] },
|
|
179
|
+
parser_control: {
|
|
180
|
+
source_hash: canonicalHash(source),
|
|
181
|
+
observed_target_calls: observedCalls,
|
|
182
|
+
observed_target_imports: observedImports,
|
|
183
|
+
},
|
|
184
|
+
...(parserPassed ? { evaluation_hash: proofEvaluationHash(baseline) } : { error_code: "comment-string-parser-control-failed" }),
|
|
185
|
+
}, context.policy_hash);
|
|
186
|
+
}
|
|
187
|
+
function sameNameControl(policy, base, planned, context) {
|
|
188
|
+
const selected = policySelectors(policy).map((selector) => symbolForSelector(base, selector)).filter((symbol) => !!symbol);
|
|
189
|
+
if (!selected.length) {
|
|
190
|
+
const selectedComponents = policySelectors(policy).map((selector) => componentForSelector(base, selector)).filter((component) => !!component);
|
|
191
|
+
if (!selectedComponents.length)
|
|
192
|
+
return errorReceipt(policy, base, planned, "control", "control-selector-unresolved", "not_applicable", context.policy_hash);
|
|
193
|
+
const clones = [...new Map(selectedComponents.map((component) => [component.id, component])).values()].map((component, index) => ({
|
|
194
|
+
...component,
|
|
195
|
+
id: `${component.id}_mutation_control_${index + 1}`,
|
|
196
|
+
paths: [`__hunch_controls__/same-name-component-${index + 1}/**`],
|
|
197
|
+
}));
|
|
198
|
+
const controlled = graphSnapshotFromRecords(base.root, base.head, base.symbols, base.edges, [...base.components, ...clones]);
|
|
199
|
+
const evaluation = context.evaluate(controlled);
|
|
200
|
+
return receipt(policy, base, {
|
|
201
|
+
kind: "control",
|
|
202
|
+
operator: planned.operator,
|
|
203
|
+
required: planned.required,
|
|
204
|
+
mutated_graph_hash: controlled.graph_hash,
|
|
205
|
+
expected: planned.expected,
|
|
206
|
+
result: evaluation.result,
|
|
207
|
+
passed: evaluation.result === planned.expected,
|
|
208
|
+
parseability: "not_applicable",
|
|
209
|
+
graph_diff: graphDiff(base, controlled),
|
|
210
|
+
evaluation_hash: proofEvaluationHash(evaluation),
|
|
211
|
+
}, context.policy_hash);
|
|
212
|
+
}
|
|
213
|
+
const unique = [...new Map(selected.map((symbol) => [symbol.id, symbol])).values()];
|
|
214
|
+
const clones = unique.map((symbol, index) => ({
|
|
215
|
+
...symbol,
|
|
216
|
+
id: `${symbol.id}_mutation_control_${index + 1}`,
|
|
217
|
+
file: `__hunch_controls__/same-name-${index + 1}.ts`,
|
|
218
|
+
calls: [],
|
|
219
|
+
called_by: [],
|
|
220
|
+
metrics: { ...symbol.metrics, fan_in: 0, fan_out: 0, churn_90d: 0, bug_count: 0 },
|
|
221
|
+
last_changed: "",
|
|
222
|
+
}));
|
|
223
|
+
const controlled = graphSnapshotFromRecords(base.root, base.head, [...base.symbols, ...clones], base.edges, base.components);
|
|
224
|
+
const evaluation = context.evaluate(controlled);
|
|
225
|
+
return receipt(policy, base, {
|
|
226
|
+
kind: "control",
|
|
227
|
+
operator: planned.operator,
|
|
228
|
+
required: planned.required,
|
|
229
|
+
mutated_graph_hash: controlled.graph_hash,
|
|
230
|
+
expected: planned.expected,
|
|
231
|
+
result: evaluation.result,
|
|
232
|
+
passed: evaluation.result === planned.expected,
|
|
233
|
+
parseability: "not_applicable",
|
|
234
|
+
graph_diff: graphDiff(base, controlled),
|
|
235
|
+
evaluation_hash: proofEvaluationHash(evaluation),
|
|
236
|
+
}, context.policy_hash);
|
|
237
|
+
}
|
|
238
|
+
/** Execute only canonical evaluator/parser mutations. Project scripts, builds,
|
|
239
|
+
* tests, models, and providers are deliberately outside this evidence path. */
|
|
240
|
+
export function runMutationHarness(root, policy, base, planned, opts = {}) {
|
|
241
|
+
const context = {
|
|
242
|
+
policy_hash: opts.policyHash ?? policySemanticHash(policy),
|
|
243
|
+
mutation_policy: opts.mutationPolicy ?? policy,
|
|
244
|
+
evaluate: opts.evaluate ?? ((snapshot) => evaluatePolicyOnSnapshot(policy, snapshot)),
|
|
245
|
+
};
|
|
246
|
+
const receipts = [];
|
|
247
|
+
const primaryEvaluations = [];
|
|
248
|
+
for (const mutation of planned) {
|
|
249
|
+
if (mutation.operator === "comment-string-control")
|
|
250
|
+
receipts.push(commentStringControl(policy, base, mutation, context));
|
|
251
|
+
else if (mutation.operator === "same-name-ambiguity-control")
|
|
252
|
+
receipts.push(sameNameControl(policy, base, mutation, context));
|
|
253
|
+
else {
|
|
254
|
+
const primary = primaryMutation(root, policy, base, mutation, context);
|
|
255
|
+
receipts.push(primary.receipt);
|
|
256
|
+
if (primary.evaluation)
|
|
257
|
+
primaryEvaluations.push(primary.evaluation);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { receipts, primary_evaluations: primaryEvaluations };
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=mutation.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const NODE_TEST_REPORTER_PROTOCOL = "hunch-node-test-reporter-v1";
|
|
2
|
+
export const NODE_TEST_REPORTER_SOURCE = `export default async function* hunchReporter(source) {
|
|
3
|
+
for await (const event of source) {
|
|
4
|
+
if (event.type !== "test:pass" && event.type !== "test:fail") continue;
|
|
5
|
+
yield JSON.stringify({
|
|
6
|
+
protocol: "${NODE_TEST_REPORTER_PROTOCOL}",
|
|
7
|
+
type: event.type,
|
|
8
|
+
name: event.data.name,
|
|
9
|
+
nesting: event.data.nesting,
|
|
10
|
+
skip: event.data.skip ?? null,
|
|
11
|
+
todo: event.data.todo ?? null
|
|
12
|
+
}) + "\\n";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
export function exactNodeTestPattern(name) {
|
|
17
|
+
return `^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`;
|
|
18
|
+
}
|
|
19
|
+
export function nodeTestIsolationFlag() {
|
|
20
|
+
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
|
|
21
|
+
return major > 23 || (major === 23 && minor >= 6)
|
|
22
|
+
? "--test-isolation=none"
|
|
23
|
+
: "--experimental-test-isolation=none";
|
|
24
|
+
}
|
|
25
|
+
export function nodeTestReporterEvents(output) {
|
|
26
|
+
const events = [];
|
|
27
|
+
for (const line of output.split("\n").filter(Boolean)) {
|
|
28
|
+
try {
|
|
29
|
+
const event = JSON.parse(line);
|
|
30
|
+
if (event.protocol !== NODE_TEST_REPORTER_PROTOCOL
|
|
31
|
+
|| (event.type !== "test:pass" && event.type !== "test:fail")
|
|
32
|
+
|| typeof event.name !== "string")
|
|
33
|
+
continue;
|
|
34
|
+
events.push({
|
|
35
|
+
protocol: NODE_TEST_REPORTER_PROTOCOL,
|
|
36
|
+
type: event.type,
|
|
37
|
+
name: event.name,
|
|
38
|
+
...(typeof event.nesting === "number" ? { nesting: event.nesting } : {}),
|
|
39
|
+
skip: event.skip ?? null,
|
|
40
|
+
todo: event.todo ?? null,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch { /* non-protocol output cannot become evidence */ }
|
|
44
|
+
}
|
|
45
|
+
return events;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=nodeTestEvidence.js.map
|