@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.
- package/README.md +214 -0
- package/bench/constitution-exp03-v1.json +70 -0
- package/dist/cli/index.js +1203 -24
- 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/package.json +5 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
3
|
+
import { pathMatchesGlob } from "../core/glob.js";
|
|
4
|
+
import { headSha } from "../extractors/git.js";
|
|
5
|
+
import { canonicalHash, proofEvaluationHash } from "./canonical.js";
|
|
6
|
+
import { evaluateExecutableBehaviorPolicy } from "./behaviorEvaluator.js";
|
|
7
|
+
import { policyCompositionBinding } from "./composition.js";
|
|
8
|
+
import { POLICY_EVALUATOR, PolicyEvaluationSchema, } from "./schema.js";
|
|
9
|
+
function snapshotHash(symbols, edges, components) {
|
|
10
|
+
return canonicalHash({
|
|
11
|
+
symbols: symbols.map((s) => ({ id: s.id, file: s.file, name: s.name, kind: s.kind })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
12
|
+
edges: edges.map((e) => ({ id: e.id, from: e.from, to: e.to, type: e.type })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
13
|
+
components: components.map((c) => ({ id: c.id, name: c.name, kind: c.kind, paths: [...c.paths].sort(), status: c.status })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function graphSnapshotFromRecords(root, head, symbols, edges, components = []) {
|
|
17
|
+
return { root, head, symbols, edges, components, graph_hash: snapshotHash(symbols, edges, components) };
|
|
18
|
+
}
|
|
19
|
+
export function graphSnapshot(store, root, opts = {}) {
|
|
20
|
+
const symbols = opts.publicOnly ? store.json.loadAll("symbols") : store.recs("symbols");
|
|
21
|
+
const edges = opts.publicOnly ? store.json.loadAll("edges") : store.recs("edges");
|
|
22
|
+
const components = opts.publicOnly ? store.json.loadAll("components") : store.recs("components");
|
|
23
|
+
return graphSnapshotFromRecords(root, opts.head ?? (headSha(root) || "working-tree"), symbols, edges, components);
|
|
24
|
+
}
|
|
25
|
+
function resolveSelector(snapshot, selector) {
|
|
26
|
+
const raw = selector.selector;
|
|
27
|
+
if (raw.startsWith("external:")) {
|
|
28
|
+
const dependency = externalPackage(raw.slice("external:".length));
|
|
29
|
+
const id = externalImportNodeId(raw.slice("external:".length));
|
|
30
|
+
return dependency && id
|
|
31
|
+
? { resolution: "exact", ids: [id], explanation: `${raw} resolved to canonical external package ${dependency}` }
|
|
32
|
+
: { resolution: "unsupported", ids: [], explanation: `${raw} is not a supported external package selector` };
|
|
33
|
+
}
|
|
34
|
+
if (raw.startsWith("symbol-id:")) {
|
|
35
|
+
const id = raw.slice("symbol-id:".length);
|
|
36
|
+
const found = snapshot.symbols.some((s) => s.id === id);
|
|
37
|
+
return found
|
|
38
|
+
? { resolution: "exact", ids: [id], explanation: `${raw} resolved exactly` }
|
|
39
|
+
: { resolution: "missing", ids: [], explanation: `${raw} does not exist in the graph` };
|
|
40
|
+
}
|
|
41
|
+
if (raw.startsWith("symbol:")) {
|
|
42
|
+
const target = raw.slice("symbol:".length);
|
|
43
|
+
const split = target.lastIndexOf(":");
|
|
44
|
+
const matches = split > 0
|
|
45
|
+
? snapshot.symbols.filter((s) => s.name === target.slice(split + 1) && (s.file === target.slice(0, split) || s.file.endsWith(`/${target.slice(0, split)}`)))
|
|
46
|
+
: snapshot.symbols.filter((s) => s.name === target);
|
|
47
|
+
if (matches.length === 1)
|
|
48
|
+
return { resolution: "exact", ids: [matches[0].id], explanation: `${raw} resolved exactly` };
|
|
49
|
+
if (matches.length > 1)
|
|
50
|
+
return { resolution: "ambiguous", ids: matches.map((s) => s.id).sort(), explanation: `${raw} resolves to ${matches.length} symbols` };
|
|
51
|
+
return { resolution: "missing", ids: [], explanation: `${raw} does not exist in the graph` };
|
|
52
|
+
}
|
|
53
|
+
if (raw.startsWith("component-id:")) {
|
|
54
|
+
const id = raw.slice("component-id:".length);
|
|
55
|
+
const found = snapshot.components.some((component) => component.id === id);
|
|
56
|
+
return found
|
|
57
|
+
? { resolution: "exact", ids: [id], explanation: `${raw} resolved exactly` }
|
|
58
|
+
: { resolution: "missing", ids: [], explanation: `${raw} does not exist in the component graph` };
|
|
59
|
+
}
|
|
60
|
+
if (raw.startsWith("component:")) {
|
|
61
|
+
const name = raw.slice("component:".length);
|
|
62
|
+
const matches = snapshot.components.filter((component) => component.name === name);
|
|
63
|
+
if (matches.length === 1)
|
|
64
|
+
return { resolution: "exact", ids: [matches[0].id], explanation: `${raw} resolved exactly` };
|
|
65
|
+
if (matches.length > 1)
|
|
66
|
+
return { resolution: "ambiguous", ids: matches.map((component) => component.id).sort(), explanation: `${raw} resolves to ${matches.length} components` };
|
|
67
|
+
return { resolution: "missing", ids: [], explanation: `${raw} does not exist in the component graph` };
|
|
68
|
+
}
|
|
69
|
+
return { resolution: "unsupported", ids: [], explanation: `selector form "${raw}" is not supported by evaluator ${POLICY_EVALUATOR.version}` };
|
|
70
|
+
}
|
|
71
|
+
function adjacency(snapshot, relation) {
|
|
72
|
+
const allowed = new Set(relation.edges);
|
|
73
|
+
const out = new Map();
|
|
74
|
+
for (const edge of snapshot.edges) {
|
|
75
|
+
if (!allowed.has(edge.type))
|
|
76
|
+
continue;
|
|
77
|
+
const neighbors = out.get(edge.from) ?? new Set();
|
|
78
|
+
neighbors.add(edge.to);
|
|
79
|
+
out.set(edge.from, neighbors);
|
|
80
|
+
}
|
|
81
|
+
return new Map([...out].map(([id, ids]) => [id, [...ids].sort()]));
|
|
82
|
+
}
|
|
83
|
+
function findPath(snapshot, start, target, relation, blocked) {
|
|
84
|
+
if (blocked === start || blocked === target)
|
|
85
|
+
return null;
|
|
86
|
+
if (start === target)
|
|
87
|
+
return [start];
|
|
88
|
+
const graph = adjacency(snapshot, relation);
|
|
89
|
+
const maxDepth = relation.transitive ? relation.max_depth : 1;
|
|
90
|
+
const queue = [{ id: start, path: [start] }];
|
|
91
|
+
const seen = new Set([start]);
|
|
92
|
+
while (queue.length) {
|
|
93
|
+
const current = queue.shift();
|
|
94
|
+
const depth = current.path.length - 1;
|
|
95
|
+
if (depth >= maxDepth)
|
|
96
|
+
continue;
|
|
97
|
+
for (const next of graph.get(current.id) ?? []) {
|
|
98
|
+
if (next === blocked || seen.has(next))
|
|
99
|
+
continue;
|
|
100
|
+
const path = [...current.path, next];
|
|
101
|
+
if (next === target)
|
|
102
|
+
return path;
|
|
103
|
+
seen.add(next);
|
|
104
|
+
queue.push({ id: next, path });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function matchFor(snapshot, id, relationPath) {
|
|
110
|
+
const sym = snapshot.symbols.find((s) => s.id === id);
|
|
111
|
+
if (sym)
|
|
112
|
+
return { file: sym.file, symbol: sym.name, ...(relationPath ? { relation_path: relationPath } : {}) };
|
|
113
|
+
const component = snapshot.components.find((candidate) => candidate.id === id);
|
|
114
|
+
if (component)
|
|
115
|
+
return { file: component.paths[0] ?? "", symbol: component.name, ...(relationPath ? { relation_path: relationPath } : {}) };
|
|
116
|
+
const edge = snapshot.edges.find((candidate) => candidate.from === id || candidate.to === id);
|
|
117
|
+
const evidence = edge?.provenance.evidence.find((item) => item.includes(":imports:"));
|
|
118
|
+
const file = evidence?.slice(0, evidence.indexOf(":imports:")) ?? "";
|
|
119
|
+
return { file, symbol: id, ...(relationPath ? { relation_path: relationPath } : {}) };
|
|
120
|
+
}
|
|
121
|
+
function unfinished(policy, snapshot, result, explanation) {
|
|
122
|
+
return finish(policy, snapshot, result, [], explanation);
|
|
123
|
+
}
|
|
124
|
+
function finish(policy, snapshot, result, matches, explanation) {
|
|
125
|
+
const body = {
|
|
126
|
+
policy_id: policy.id,
|
|
127
|
+
policy_revision: policy.revision,
|
|
128
|
+
result,
|
|
129
|
+
evaluator: { ...POLICY_EVALUATOR },
|
|
130
|
+
repository: { head: snapshot.head, graph_hash: snapshot.graph_hash },
|
|
131
|
+
matches,
|
|
132
|
+
explanation,
|
|
133
|
+
evidence_refs: [...policy.evidence],
|
|
134
|
+
};
|
|
135
|
+
return PolicyEvaluationSchema.parse({ ...body, deterministic_hash: canonicalHash(body) });
|
|
136
|
+
}
|
|
137
|
+
function requiredBinding(policy, snapshot, selector, role) {
|
|
138
|
+
const binding = resolveSelector(snapshot, selector);
|
|
139
|
+
if (binding.resolution === "exact")
|
|
140
|
+
return binding;
|
|
141
|
+
return unfinished(policy, snapshot, "unknown", `${role} binding is ${binding.resolution}: ${binding.explanation}`);
|
|
142
|
+
}
|
|
143
|
+
export function evaluatePolicyOnSnapshot(policy, snapshot) {
|
|
144
|
+
try {
|
|
145
|
+
if (policy.scope.repos.length) {
|
|
146
|
+
const repo = basename(snapshot.root);
|
|
147
|
+
const applicable = policy.scope.repos.some((r) => r === snapshot.root || r === repo || r.endsWith(`/${repo}`));
|
|
148
|
+
if (!applicable)
|
|
149
|
+
return finish(policy, snapshot, "not_applicable", [], `repository ${repo} is outside the policy scope`);
|
|
150
|
+
}
|
|
151
|
+
const assertion = policy.assertion;
|
|
152
|
+
if (assertion.kind === "executable-behavior") {
|
|
153
|
+
return unfinished(policy, snapshot, "error", "executable-behavior requires an isolated repository checkout, not a graph snapshot");
|
|
154
|
+
}
|
|
155
|
+
const subject = resolveSelector(snapshot, assertion.subject);
|
|
156
|
+
if (assertion.kind === "exists") {
|
|
157
|
+
if (subject.resolution === "exact") {
|
|
158
|
+
return finish(policy, snapshot, "satisfied", [matchFor(snapshot, subject.ids[0])], `${assertion.subject.selector} exists exactly once`);
|
|
159
|
+
}
|
|
160
|
+
if (subject.resolution === "missing") {
|
|
161
|
+
return finish(policy, snapshot, "violated", [], `${assertion.subject.selector} does not exist in the graph`);
|
|
162
|
+
}
|
|
163
|
+
return unfinished(policy, snapshot, "unknown", `subject binding is ${subject.resolution}: ${subject.explanation}`);
|
|
164
|
+
}
|
|
165
|
+
const subjectExact = requiredBinding(policy, snapshot, assertion.subject, "subject");
|
|
166
|
+
if ("result" in subjectExact)
|
|
167
|
+
return subjectExact;
|
|
168
|
+
const objectExact = requiredBinding(policy, snapshot, assertion.object, "object");
|
|
169
|
+
if ("result" in objectExact)
|
|
170
|
+
return objectExact;
|
|
171
|
+
const subjectId = subjectExact.ids[0];
|
|
172
|
+
const objectId = objectExact.ids[0];
|
|
173
|
+
if (assertion.kind === "must-pass-through") {
|
|
174
|
+
const viaExact = requiredBinding(policy, snapshot, assertion.via, "via");
|
|
175
|
+
if ("result" in viaExact)
|
|
176
|
+
return viaExact;
|
|
177
|
+
const viaId = viaExact.ids[0];
|
|
178
|
+
if (viaId === subjectId || viaId === objectId) {
|
|
179
|
+
return unfinished(policy, snapshot, "error", "must-pass-through requires three distinct bindings");
|
|
180
|
+
}
|
|
181
|
+
const anyPath = findPath(snapshot, subjectId, objectId, assertion.relation);
|
|
182
|
+
if (!anyPath) {
|
|
183
|
+
return finish(policy, snapshot, "satisfied", [matchFor(snapshot, subjectId)], `${assertion.subject.selector} does not reach ${assertion.object.selector}; no bypass exists`);
|
|
184
|
+
}
|
|
185
|
+
const bypass = findPath(snapshot, subjectId, objectId, assertion.relation, viaId);
|
|
186
|
+
if (bypass) {
|
|
187
|
+
return finish(policy, snapshot, "violated", [matchFor(snapshot, subjectId, bypass)], `${assertion.subject.selector} reaches ${assertion.object.selector} without passing through ${assertion.via.selector}`);
|
|
188
|
+
}
|
|
189
|
+
return finish(policy, snapshot, "satisfied", [matchFor(snapshot, subjectId, anyPath)], `every discovered path from ${assertion.subject.selector} to ${assertion.object.selector} passes through ${assertion.via.selector}`);
|
|
190
|
+
}
|
|
191
|
+
const path = findPath(snapshot, subjectId, objectId, assertion.relation);
|
|
192
|
+
const reaches = !!path;
|
|
193
|
+
const satisfied = assertion.kind === "reaches" ? reaches : !reaches;
|
|
194
|
+
const result = satisfied ? "satisfied" : "violated";
|
|
195
|
+
const explanation = assertion.kind === "reaches"
|
|
196
|
+
? reaches
|
|
197
|
+
? `${assertion.subject.selector} reaches ${assertion.object.selector}`
|
|
198
|
+
: `${assertion.subject.selector} does not reach ${assertion.object.selector}`
|
|
199
|
+
: reaches
|
|
200
|
+
? `${assertion.subject.selector} reaches forbidden target ${assertion.object.selector}`
|
|
201
|
+
: `${assertion.subject.selector} does not reach ${assertion.object.selector}`;
|
|
202
|
+
return finish(policy, snapshot, result, [matchFor(snapshot, subjectId, path ?? undefined)], explanation);
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
return unfinished(policy, snapshot, "error", `deterministic evaluator failed: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function scopeApplicability(policy, snapshot) {
|
|
209
|
+
if (policy.scope.repos.length) {
|
|
210
|
+
const repo = basename(snapshot.root);
|
|
211
|
+
const applies = policy.scope.repos.some((candidate) => candidate === snapshot.root || candidate === repo || candidate.endsWith(`/${repo}`));
|
|
212
|
+
if (!applies)
|
|
213
|
+
return { kind: "not_applicable", explanation: `repository ${repo} is outside ${policy.id} scope` };
|
|
214
|
+
}
|
|
215
|
+
if (policy.assertion.kind === "executable-behavior")
|
|
216
|
+
return { kind: "applicable", explanation: "executable behavior repository scope applies" };
|
|
217
|
+
const subject = resolveSelector(snapshot, policy.assertion.subject);
|
|
218
|
+
if (subject.resolution !== "exact") {
|
|
219
|
+
return { kind: "unknown", explanation: `cannot choose scoped policy ${policy.id}: subject binding is ${subject.resolution}` };
|
|
220
|
+
}
|
|
221
|
+
const id = subject.ids[0];
|
|
222
|
+
const symbol = snapshot.symbols.find((candidate) => candidate.id === id);
|
|
223
|
+
const component = snapshot.components.find((candidate) => candidate.id === id);
|
|
224
|
+
if (policy.scope.paths.length) {
|
|
225
|
+
if (symbol) {
|
|
226
|
+
if (!policy.scope.paths.some((glob) => pathMatchesGlob(symbol.file, glob))) {
|
|
227
|
+
return { kind: "not_applicable", explanation: `${symbol.file} is outside ${policy.id} path scope` };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (component) {
|
|
231
|
+
const concrete = component.paths.filter((path) => !/[*?]/.test(path));
|
|
232
|
+
if (!concrete.length) {
|
|
233
|
+
return { kind: "unknown", explanation: `component ${component.id} has no concrete path binding for ${policy.id} scope precedence` };
|
|
234
|
+
}
|
|
235
|
+
if (!concrete.some((path) => policy.scope.paths.some((glob) => pathMatchesGlob(path, glob)))) {
|
|
236
|
+
return { kind: "not_applicable", explanation: `component ${component.id} is outside ${policy.id} path scope` };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
return { kind: "unknown", explanation: `subject ${id} has no concrete file binding for ${policy.id} path scope` };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (policy.scope.components.length) {
|
|
244
|
+
const subjectComponents = component
|
|
245
|
+
? [component.id]
|
|
246
|
+
: symbol
|
|
247
|
+
? snapshot.components.filter((candidate) => candidate.paths.some((glob) => pathMatchesGlob(symbol.file, glob))).map((candidate) => candidate.id)
|
|
248
|
+
: [];
|
|
249
|
+
if (!subjectComponents.length) {
|
|
250
|
+
return { kind: "unknown", explanation: `subject ${id} has no exact component binding for ${policy.id} scope precedence` };
|
|
251
|
+
}
|
|
252
|
+
if (!subjectComponents.some((candidate) => policy.scope.components.includes(candidate))) {
|
|
253
|
+
return { kind: "not_applicable", explanation: `subject ${id} is outside ${policy.id} component scope` };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { kind: "applicable", explanation: `${policy.id} scope applies to the exact subject binding` };
|
|
257
|
+
}
|
|
258
|
+
class CompositionOutcome extends Error {
|
|
259
|
+
result;
|
|
260
|
+
constructor(result, message) {
|
|
261
|
+
super(message);
|
|
262
|
+
this.result = result;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function compositionSelection(root, members, snapshot) {
|
|
266
|
+
const binding = policyCompositionBinding(root, members);
|
|
267
|
+
if (!binding)
|
|
268
|
+
throw new Error(`policy ${root.id} has no exception members to compose`);
|
|
269
|
+
const policies = [root, ...members];
|
|
270
|
+
const byId = new Map(policies.map((policy) => [policy.id, policy]));
|
|
271
|
+
const evaluations = new Map(policies.map((policy) => [policy.id, evaluatePolicyOnSnapshot(policy, snapshot)]));
|
|
272
|
+
const rootScope = scopeApplicability(root, snapshot);
|
|
273
|
+
if (rootScope.kind !== "applicable")
|
|
274
|
+
throw new CompositionOutcome(rootScope.kind, rootScope.explanation);
|
|
275
|
+
const depth = (policy) => {
|
|
276
|
+
let current = policy;
|
|
277
|
+
let value = 0;
|
|
278
|
+
const visited = new Set();
|
|
279
|
+
while (current.exception_of) {
|
|
280
|
+
if (visited.has(current.id))
|
|
281
|
+
throw new Error(`exception composition contains a cycle at ${current.id}`);
|
|
282
|
+
visited.add(current.id);
|
|
283
|
+
const parent = byId.get(current.exception_of);
|
|
284
|
+
if (!parent)
|
|
285
|
+
throw new Error(`exception policy ${current.id} has missing composition parent ${current.exception_of}`);
|
|
286
|
+
current = parent;
|
|
287
|
+
value++;
|
|
288
|
+
}
|
|
289
|
+
if (current.id !== root.id)
|
|
290
|
+
throw new Error(`exception policy ${policy.id} is not rooted at ${root.id}`);
|
|
291
|
+
return value;
|
|
292
|
+
};
|
|
293
|
+
const applicable = [root];
|
|
294
|
+
const applicableIds = new Set([root.id]);
|
|
295
|
+
for (const member of [...members].sort((left, right) => depth(left) - depth(right) || left.id.localeCompare(right.id))) {
|
|
296
|
+
if (!member.exception_of || !applicableIds.has(member.exception_of))
|
|
297
|
+
continue;
|
|
298
|
+
const scope = scopeApplicability(member, snapshot);
|
|
299
|
+
if (scope.kind === "unknown")
|
|
300
|
+
throw new CompositionOutcome("unknown", scope.explanation);
|
|
301
|
+
if (scope.kind === "applicable") {
|
|
302
|
+
applicable.push(member);
|
|
303
|
+
applicableIds.add(member.id);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const maxDepth = Math.max(...applicable.map(depth));
|
|
307
|
+
const deepest = applicable.filter((policy) => depth(policy) === maxDepth).sort((a, b) => a.id.localeCompare(b.id));
|
|
308
|
+
const results = new Set(deepest.map((policy) => evaluations.get(policy.id).result));
|
|
309
|
+
if (results.size !== 1)
|
|
310
|
+
throw new Error(`equally specific exception policies disagree: ${deepest.map((policy) => policy.id).join(", ")}`);
|
|
311
|
+
return { binding, evaluations, applicable, selected: deepest[0] };
|
|
312
|
+
}
|
|
313
|
+
export function selectedPolicyForComposition(root, members, snapshot) {
|
|
314
|
+
return compositionSelection(root, members, snapshot).selected;
|
|
315
|
+
}
|
|
316
|
+
/** Evaluate explicit scope precedence as one receipt. The deepest applicable
|
|
317
|
+
* linked exception wins; severity and adapter order never participate. */
|
|
318
|
+
export function evaluateCompositePolicyOnSnapshot(root, members, snapshot) {
|
|
319
|
+
try {
|
|
320
|
+
const selection = compositionSelection(root, members, snapshot);
|
|
321
|
+
const selectedEvaluation = selection.evaluations.get(selection.selected.id);
|
|
322
|
+
const memberHashes = Object.fromEntries([root, ...members]
|
|
323
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
324
|
+
.map((policy) => [policy.id, proofEvaluationHash(selection.evaluations.get(policy.id))]));
|
|
325
|
+
const composition = {
|
|
326
|
+
...selection.binding,
|
|
327
|
+
selected_policy_id: selection.selected.id,
|
|
328
|
+
applicable_policy_ids: selection.applicable.map((policy) => policy.id),
|
|
329
|
+
member_evaluation_hashes: memberHashes,
|
|
330
|
+
};
|
|
331
|
+
const body = {
|
|
332
|
+
policy_id: root.id,
|
|
333
|
+
policy_revision: root.revision,
|
|
334
|
+
result: selectedEvaluation.result,
|
|
335
|
+
evaluator: { ...POLICY_EVALUATOR },
|
|
336
|
+
repository: { head: snapshot.head, graph_hash: snapshot.graph_hash },
|
|
337
|
+
matches: selectedEvaluation.matches,
|
|
338
|
+
explanation: `composite scope selected ${selection.selected.id}: ${selectedEvaluation.explanation}`,
|
|
339
|
+
evidence_refs: [...new Set([root, ...members].flatMap((policy) => policy.evidence))].sort(),
|
|
340
|
+
composition,
|
|
341
|
+
};
|
|
342
|
+
return PolicyEvaluationSchema.parse({ ...body, deterministic_hash: canonicalHash(body) });
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
const result = error instanceof CompositionOutcome ? error.result : "error";
|
|
346
|
+
return finish(root, snapshot, result, [], `parent/exception composition failed: ${error.message}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
export function evaluatePolicy(store, root, policy, opts = {}) {
|
|
350
|
+
if (policy.assertion.kind === "executable-behavior") {
|
|
351
|
+
if (opts.composition?.length)
|
|
352
|
+
throw new Error("executable-behavior policies cannot participate in parent/exception composition");
|
|
353
|
+
return evaluateExecutableBehaviorPolicy(root, policy, opts.behavior);
|
|
354
|
+
}
|
|
355
|
+
const snapshot = graphSnapshot(store, root, opts);
|
|
356
|
+
return opts.composition?.length
|
|
357
|
+
? evaluateCompositePolicyOnSnapshot(policy, opts.composition, snapshot)
|
|
358
|
+
: evaluatePolicyOnSnapshot(policy, snapshot);
|
|
359
|
+
}
|
|
360
|
+
export function policyIsActive(policy) {
|
|
361
|
+
return policy.state === "active_advisory" || policy.state === "active_blocking";
|
|
362
|
+
}
|
|
363
|
+
export function policyBlocks(policy, evaluation) {
|
|
364
|
+
return policy.state === "active_blocking"
|
|
365
|
+
&& !policy.exception_of
|
|
366
|
+
&& policy.severity === "blocking"
|
|
367
|
+
&& policy.authority?.kind === "human"
|
|
368
|
+
&& evaluation.result === "violated";
|
|
369
|
+
}
|
|
370
|
+
export function mutationOperatorForPolicy(policy) {
|
|
371
|
+
if (policy.assertion.kind === "executable-behavior")
|
|
372
|
+
return "known-bad-regression";
|
|
373
|
+
if (policy.assertion.kind === "exists")
|
|
374
|
+
return "delete-required-symbol";
|
|
375
|
+
if (policy.assertion.kind === "reaches")
|
|
376
|
+
return "remove-required-path";
|
|
377
|
+
if (policy.assertion.kind === "must-pass-through")
|
|
378
|
+
return "add-bypass-edge";
|
|
379
|
+
if (policy.assertion.relation.edges.length === 1
|
|
380
|
+
&& policy.assertion.relation.edges[0] === "imports"
|
|
381
|
+
&& policy.assertion.object.selector.startsWith("external:"))
|
|
382
|
+
return "add-forbidden-import";
|
|
383
|
+
return "add-forbidden-edge";
|
|
384
|
+
}
|
|
385
|
+
export function mutateSnapshotForPolicy(policy, snapshot) {
|
|
386
|
+
const assertion = policy.assertion;
|
|
387
|
+
if (assertion.kind === "executable-behavior")
|
|
388
|
+
return null;
|
|
389
|
+
const subject = resolveSelector(snapshot, assertion.subject);
|
|
390
|
+
if (subject.resolution !== "exact")
|
|
391
|
+
return null;
|
|
392
|
+
const subjectId = subject.ids[0];
|
|
393
|
+
let symbols = [...snapshot.symbols];
|
|
394
|
+
let edges = [...snapshot.edges];
|
|
395
|
+
let operator = "";
|
|
396
|
+
if (assertion.kind === "exists") {
|
|
397
|
+
symbols = symbols.filter((s) => s.id !== subjectId);
|
|
398
|
+
edges = edges.filter((e) => e.from !== subjectId && e.to !== subjectId);
|
|
399
|
+
operator = mutationOperatorForPolicy(policy);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
const object = resolveSelector(snapshot, assertion.object);
|
|
403
|
+
if (object.resolution !== "exact")
|
|
404
|
+
return null;
|
|
405
|
+
const objectId = object.ids[0];
|
|
406
|
+
if (assertion.kind === "reaches") {
|
|
407
|
+
const allowed = new Set(assertion.relation.edges);
|
|
408
|
+
edges = edges.filter((e) => e.from !== subjectId || !allowed.has(e.type));
|
|
409
|
+
operator = mutationOperatorForPolicy(policy);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
edges.push({
|
|
413
|
+
id: `edge_policy_mutation_${policy.id}`,
|
|
414
|
+
from: subjectId,
|
|
415
|
+
to: objectId,
|
|
416
|
+
type: assertion.relation.edges[0],
|
|
417
|
+
reason: "deterministic proof mutation",
|
|
418
|
+
strength: 1,
|
|
419
|
+
provenance: { source: "derived", confidence: 1, evidence: [policy.id] },
|
|
420
|
+
});
|
|
421
|
+
operator = mutationOperatorForPolicy(policy);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
operator,
|
|
426
|
+
snapshot: {
|
|
427
|
+
...snapshot,
|
|
428
|
+
symbols,
|
|
429
|
+
edges,
|
|
430
|
+
graph_hash: snapshotHash(symbols, edges, snapshot.components),
|
|
431
|
+
components: snapshot.components,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
//# sourceMappingURL=evaluator.js.map
|