@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,601 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
3
|
+
import { pathMatchesGlob } from "../core/glob.js";
|
|
4
|
+
import { shortHash } from "../core/ids.js";
|
|
5
|
+
import { resolveRelativeImport } from "../core/relativeImports.js";
|
|
6
|
+
import { commitMeta } from "../extractors/git.js";
|
|
7
|
+
import { canonicalHash } from "./canonical.js";
|
|
8
|
+
import { clampCandidateLimit, durationCutoff } from "./bootstrap.js";
|
|
9
|
+
import { compileStructuralPolicy } from "./compiler.js";
|
|
10
|
+
import { extractStructuralDelta } from "./delta.js";
|
|
11
|
+
import { EvidenceEventSchema, PolicySpecSchema, } from "./schema.js";
|
|
12
|
+
const exactSelector = (symbol) => ({ selector: `symbol:${symbol.file}:${symbol.name}` });
|
|
13
|
+
function scopeFor(store, file, publicOnly) {
|
|
14
|
+
const components = (publicOnly ? store.json.loadAll("components") : store.recs("components"))
|
|
15
|
+
.filter((c) => c.paths.some((glob) => pathMatchesGlob(file, glob)))
|
|
16
|
+
.map((c) => c.id)
|
|
17
|
+
.sort();
|
|
18
|
+
return { repos: [], paths: [file], components };
|
|
19
|
+
}
|
|
20
|
+
function candidateId(assertion, scope) {
|
|
21
|
+
return `cand_${shortHash(canonicalHash({ assertion, scope }))}`;
|
|
22
|
+
}
|
|
23
|
+
function alternativeFor(candidate) {
|
|
24
|
+
return {
|
|
25
|
+
id: candidate.id,
|
|
26
|
+
basis: candidate.basis,
|
|
27
|
+
reason: candidate.reason,
|
|
28
|
+
assertion_hash: canonicalHash(candidate.assertion),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function candidateContext(enumerated, selected, conflicts = [], incumbent = null, scopeSuggestion = null, counterexamples = []) {
|
|
32
|
+
const alternatives = enumerated.candidates.map(alternativeFor).sort((left, right) => {
|
|
33
|
+
if (selected && left.id === selected.id)
|
|
34
|
+
return -1;
|
|
35
|
+
if (selected && right.id === selected.id)
|
|
36
|
+
return 1;
|
|
37
|
+
return left.id.localeCompare(right.id);
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
alternatives,
|
|
41
|
+
uncertainty: [...new Set(enumerated.unsupported)].sort(),
|
|
42
|
+
conflicts: [...new Set(conflicts)].sort(),
|
|
43
|
+
incumbent,
|
|
44
|
+
scope_suggestion: scopeSuggestion,
|
|
45
|
+
counterexamples: [...new Set(counterexamples)].sort(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function structuralKey(policy) {
|
|
49
|
+
return canonicalHash({ assertion: policy.assertion, scope: policy.scope, data_class: policy.data_class });
|
|
50
|
+
}
|
|
51
|
+
function scopesOverlap(left, right) {
|
|
52
|
+
const repoOverlap = !left.repos.length || !right.repos.length || left.repos.some((repo) => right.repos.includes(repo));
|
|
53
|
+
const pathOverlap = !left.paths.length || !right.paths.length || left.paths.some((path) => right.paths.includes(path));
|
|
54
|
+
const componentOverlap = !left.components.length || !right.components.length || left.components.some((component) => right.components.includes(component));
|
|
55
|
+
return repoOverlap && pathOverlap && componentOverlap;
|
|
56
|
+
}
|
|
57
|
+
function directConflict(candidate, incumbent) {
|
|
58
|
+
const left = candidate.assertion;
|
|
59
|
+
const right = incumbent.assertion;
|
|
60
|
+
if (!((left.kind === "reaches" && right.kind === "not-reaches") || (left.kind === "not-reaches" && right.kind === "reaches")))
|
|
61
|
+
return false;
|
|
62
|
+
return left.subject.selector === right.subject.selector
|
|
63
|
+
&& left.object.selector === right.object.selector
|
|
64
|
+
&& canonicalHash(left.relation) === canonicalHash(right.relation)
|
|
65
|
+
&& scopesOverlap(candidate.scope, incumbent.scope);
|
|
66
|
+
}
|
|
67
|
+
function counterexampleSignals(store, candidate, publicOnly) {
|
|
68
|
+
const assertion = candidate.assertion;
|
|
69
|
+
if (assertion.kind === "exists" || assertion.kind === "executable-behavior" || assertion.relation.transitive)
|
|
70
|
+
return [];
|
|
71
|
+
const parseSymbol = (raw) => {
|
|
72
|
+
if (!raw.startsWith("symbol:"))
|
|
73
|
+
return null;
|
|
74
|
+
const target = raw.slice("symbol:".length);
|
|
75
|
+
const split = target.lastIndexOf(":");
|
|
76
|
+
return split > 0 ? { file: target.slice(0, split), name: target.slice(split + 1) } : null;
|
|
77
|
+
};
|
|
78
|
+
const subject = parseSymbol(assertion.subject.selector);
|
|
79
|
+
const object = parseSymbol(assertion.object.selector);
|
|
80
|
+
if (!subject || !object)
|
|
81
|
+
return [];
|
|
82
|
+
const symbols = publicOnly ? store.json.loadAll("symbols") : store.recs("symbols");
|
|
83
|
+
const edges = publicOnly ? store.json.loadAll("edges") : store.recs("edges");
|
|
84
|
+
const objectMatches = symbols.filter((symbol) => symbol.file === object.file && symbol.name === object.name);
|
|
85
|
+
if (objectMatches.length !== 1)
|
|
86
|
+
return [];
|
|
87
|
+
const objectId = objectMatches[0].id;
|
|
88
|
+
const allowed = new Set(assertion.relation.edges);
|
|
89
|
+
return symbols
|
|
90
|
+
.filter((symbol) => symbol.name === subject.name && symbol.file !== subject.file && !candidate.scope.paths.includes(symbol.file))
|
|
91
|
+
.flatMap((symbol) => {
|
|
92
|
+
const reaches = edges.some((edge) => edge.from === symbol.id && edge.to === objectId && allowed.has(edge.type));
|
|
93
|
+
const contradictsBroadening = assertion.kind === "reaches" ? !reaches : reaches;
|
|
94
|
+
if (!contradictsBroadening)
|
|
95
|
+
return [];
|
|
96
|
+
return [`${symbol.file}:${symbol.name} is a counterexample outside the narrow scope: it ${reaches ? "does" : "does not"} satisfy ${assertion.kind} ${assertion.object.selector}`];
|
|
97
|
+
})
|
|
98
|
+
.sort()
|
|
99
|
+
.slice(0, 10);
|
|
100
|
+
}
|
|
101
|
+
function commonPathGlob(paths) {
|
|
102
|
+
const unique = [...new Set(paths)].sort();
|
|
103
|
+
if (unique.length < 3)
|
|
104
|
+
return null;
|
|
105
|
+
const directories = unique.map((path) => path.split("/").slice(0, -1));
|
|
106
|
+
const common = [];
|
|
107
|
+
for (let index = 0;; index++) {
|
|
108
|
+
const segment = directories[0]?.[index];
|
|
109
|
+
if (!segment || directories.some((parts) => parts[index] !== segment))
|
|
110
|
+
break;
|
|
111
|
+
common.push(segment);
|
|
112
|
+
}
|
|
113
|
+
return common.length ? `${common.join("/")}/**` : null;
|
|
114
|
+
}
|
|
115
|
+
/** Repetition may suggest a broader review scope but never mutates the compiled
|
|
116
|
+
* scope. Three independently grounded component-policy sources are required. */
|
|
117
|
+
function repeatedScopeSuggestion(candidate, policies) {
|
|
118
|
+
if (candidate.assertion.kind === "exists" || candidate.assertion.kind === "executable-behavior" || !candidate.assertion.subject.selector.startsWith("component"))
|
|
119
|
+
return null;
|
|
120
|
+
const assertionHash = canonicalHash(candidate.assertion);
|
|
121
|
+
const related = [...policies, candidate].filter((policy) => policy.data_class === candidate.data_class && canonicalHash(policy.assertion) === assertionHash);
|
|
122
|
+
const sources = new Set(related.flatMap((policy) => policy.legacy_refs.filter((ref) => ref.startsWith("dec_"))));
|
|
123
|
+
const paths = related.flatMap((policy) => policy.scope.paths);
|
|
124
|
+
const path = commonPathGlob(paths);
|
|
125
|
+
if (sources.size < 3 || !path)
|
|
126
|
+
return null;
|
|
127
|
+
const repoShapes = new Set(related.map((policy) => canonicalHash([...policy.scope.repos].sort())));
|
|
128
|
+
if (repoShapes.size !== 1)
|
|
129
|
+
return null;
|
|
130
|
+
return {
|
|
131
|
+
repos: [...candidate.scope.repos].sort(),
|
|
132
|
+
paths: [path],
|
|
133
|
+
components: [...new Set(related.flatMap((policy) => policy.scope.components))].sort(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function enrichIncumbent(repository, incumbent, incoming, context, now, isPrivate) {
|
|
137
|
+
const evidence = [...new Set([...incumbent.evidence, ...incoming.evidence])].sort();
|
|
138
|
+
const legacyRefs = [...new Set([...incumbent.legacy_refs, ...incoming.legacy_refs])].sort();
|
|
139
|
+
const alternatives = [...new Map([...incumbent.candidate.alternatives, ...context.alternatives].map((alternative) => [alternative.id, alternative])).values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
140
|
+
const candidate = {
|
|
141
|
+
alternatives,
|
|
142
|
+
uncertainty: [...new Set([...incumbent.candidate.uncertainty, ...context.uncertainty])].sort(),
|
|
143
|
+
conflicts: [...new Set([...incumbent.candidate.conflicts, ...context.conflicts])].sort(),
|
|
144
|
+
incumbent: incumbent.id,
|
|
145
|
+
scope_suggestion: context.scope_suggestion ?? incumbent.candidate.scope_suggestion,
|
|
146
|
+
counterexamples: [...new Set([...incumbent.candidate.counterexamples, ...context.counterexamples])].sort(),
|
|
147
|
+
};
|
|
148
|
+
if (canonicalHash({ evidence, legacyRefs, candidate }) === canonicalHash({ evidence: incumbent.evidence, legacyRefs: incumbent.legacy_refs, candidate: incumbent.candidate }))
|
|
149
|
+
return incumbent;
|
|
150
|
+
const enriched = PolicySpecSchema.parse({
|
|
151
|
+
...incumbent,
|
|
152
|
+
revision: incumbent.revision + 1,
|
|
153
|
+
evidence,
|
|
154
|
+
legacy_refs: legacyRefs,
|
|
155
|
+
candidate,
|
|
156
|
+
updated_at: now,
|
|
157
|
+
audit: [...incumbent.audit, {
|
|
158
|
+
action: "enriched",
|
|
159
|
+
actor_kind: "system",
|
|
160
|
+
actor: "hunch:structural-delta-compiler",
|
|
161
|
+
at: now,
|
|
162
|
+
reason: `Linked equivalent evidence without changing assertion, scope, proof, lifecycle, or authority.`,
|
|
163
|
+
proof: incumbent.proof,
|
|
164
|
+
}],
|
|
165
|
+
});
|
|
166
|
+
return repository.putPolicy(enriched, { private: isPrivate });
|
|
167
|
+
}
|
|
168
|
+
function judgmentNames(source, identifier) {
|
|
169
|
+
if (!source)
|
|
170
|
+
return true;
|
|
171
|
+
const text = [
|
|
172
|
+
source.title,
|
|
173
|
+
source.context,
|
|
174
|
+
source.decision,
|
|
175
|
+
...source.consequences,
|
|
176
|
+
...source.alternatives_rejected,
|
|
177
|
+
].join("\n");
|
|
178
|
+
const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
179
|
+
return new RegExp(`(^|[^A-Za-z0-9_$])${escaped}([^A-Za-z0-9_$]|$)`, "i").test(text);
|
|
180
|
+
}
|
|
181
|
+
/** Enumerate every supported assertion that the current graph can bind exactly.
|
|
182
|
+
* Ambiguous or missing bindings are reported; they are never guessed through. */
|
|
183
|
+
export function enumerateStructuralCandidates(store, delta, opts = {}) {
|
|
184
|
+
const symbols = opts.publicOnly ? store.json.loadAll("symbols") : store.recs("symbols");
|
|
185
|
+
const edges = opts.publicOnly ? store.json.loadAll("edges") : store.recs("edges");
|
|
186
|
+
const components = opts.publicOnly ? store.json.loadAll("components") : store.recs("components");
|
|
187
|
+
const callsFrom = new Map();
|
|
188
|
+
for (const edge of edges.filter((e) => e.type === "calls")) {
|
|
189
|
+
const targets = callsFrom.get(edge.from) ?? new Set();
|
|
190
|
+
targets.add(edge.to);
|
|
191
|
+
callsFrom.set(edge.from, targets);
|
|
192
|
+
}
|
|
193
|
+
const candidates = [];
|
|
194
|
+
const unsupported = [];
|
|
195
|
+
const files = new Set(symbols.map((symbol) => symbol.file));
|
|
196
|
+
const componentForFile = (file, role) => {
|
|
197
|
+
const matches = components.filter((component) => component.paths.some((glob) => pathMatchesGlob(file, glob)));
|
|
198
|
+
if (matches.length !== 1) {
|
|
199
|
+
unsupported.push(`${role} file ${file} maps to ${matches.length ? `${matches.length} components` : "no component"}`);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return matches[0];
|
|
203
|
+
};
|
|
204
|
+
const subjectFor = (file, name) => {
|
|
205
|
+
const matches = symbols.filter((s) => s.file === file && s.name === name);
|
|
206
|
+
if (matches.length !== 1) {
|
|
207
|
+
unsupported.push(`${file}:${name} subject is ${matches.length ? "ambiguous" : "missing"} in the current graph`);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return matches[0];
|
|
211
|
+
};
|
|
212
|
+
const anchorForFile = (file) => {
|
|
213
|
+
const added = new Set(delta.symbols.added.map((symbol) => `${symbol.file}\0${symbol.kind}\0${symbol.name}`));
|
|
214
|
+
const matches = symbols
|
|
215
|
+
.filter((symbol) => symbol.file === file && !added.has(`${symbol.file}\0${symbol.kind}\0${symbol.name}`))
|
|
216
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
217
|
+
if (!matches.length)
|
|
218
|
+
unsupported.push(`${file} has no symbol present before and after the delta to anchor a file-scoped import policy`);
|
|
219
|
+
return matches[0] ?? null;
|
|
220
|
+
};
|
|
221
|
+
const addCallCandidate = (call, basis) => {
|
|
222
|
+
if (!judgmentNames(opts.judgment, call.caller) && !judgmentNames(opts.judgment, call.callee)) {
|
|
223
|
+
unsupported.push(`${call.file}:${call.caller} -> ${call.callee} is structural coincidence not explicitly named by the human judgment`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const subject = subjectFor(call.file, call.caller);
|
|
227
|
+
if (!subject)
|
|
228
|
+
return;
|
|
229
|
+
const namedTargets = symbols.filter((s) => s.name === call.callee && s.id !== subject.id);
|
|
230
|
+
const currentTargets = callsFrom.get(subject.id) ?? new Set();
|
|
231
|
+
if (basis === "removed-call" && namedTargets.length !== 1) {
|
|
232
|
+
unsupported.push(`${call.file}:${call.caller} -> ${call.callee} removed-call target is ${namedTargets.length ? "ambiguous" : "missing"}`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// A present current edge can disambiguate an added call. A removed edge has
|
|
236
|
+
// no current edge to identify its historical target, so duplicate names
|
|
237
|
+
// must remain ambiguous instead of selecting whichever target is unlinked.
|
|
238
|
+
const matches = basis === "added-call"
|
|
239
|
+
? namedTargets.filter((s) => currentTargets.has(s.id))
|
|
240
|
+
: !currentTargets.has(namedTargets[0].id)
|
|
241
|
+
? namedTargets
|
|
242
|
+
: [];
|
|
243
|
+
if (matches.length !== 1) {
|
|
244
|
+
unsupported.push(`${call.file}:${call.caller} -> ${call.callee} ${basis} target is ${matches.length ? "ambiguous" : "not exactly bindable"}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const assertion = {
|
|
248
|
+
kind: basis === "added-call" ? "reaches" : "not-reaches",
|
|
249
|
+
subject: exactSelector(subject),
|
|
250
|
+
relation: { edges: ["calls"], transitive: false, max_depth: 1 },
|
|
251
|
+
object: exactSelector(matches[0]),
|
|
252
|
+
};
|
|
253
|
+
const scope = scopeFor(store, call.file, !!opts.publicOnly);
|
|
254
|
+
candidates.push({
|
|
255
|
+
id: candidateId(assertion, scope),
|
|
256
|
+
assertion,
|
|
257
|
+
scope,
|
|
258
|
+
basis,
|
|
259
|
+
reason: basis === "added-call"
|
|
260
|
+
? `fix/revert added exact current call ${call.caller} -> ${call.callee}`
|
|
261
|
+
: `fix/revert removed exact call ${call.caller} -> ${call.callee}`,
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
for (const call of delta.calls.added)
|
|
265
|
+
addCallCandidate(call, "added-call");
|
|
266
|
+
for (const call of delta.calls.removed)
|
|
267
|
+
addCallCandidate(call, "removed-call");
|
|
268
|
+
for (const added of delta.symbols.added) {
|
|
269
|
+
if (!judgmentNames(opts.judgment, added.name)) {
|
|
270
|
+
unsupported.push(`added symbol ${added.file}:${added.name} is not explicitly named by the human judgment`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const subject = subjectFor(added.file, added.name);
|
|
274
|
+
if (!subject)
|
|
275
|
+
continue;
|
|
276
|
+
const assertion = { kind: "exists", subject: exactSelector(subject) };
|
|
277
|
+
const scope = scopeFor(store, added.file, !!opts.publicOnly);
|
|
278
|
+
candidates.push({
|
|
279
|
+
id: candidateId(assertion, scope), assertion, scope, basis: "added-symbol",
|
|
280
|
+
reason: `fix/revert introduced exact current symbol ${added.file}:${added.name}`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
for (const removed of delta.symbols.removed)
|
|
284
|
+
unsupported.push(`removed symbol ${removed.file}:${removed.name} has no enforceable current binding`);
|
|
285
|
+
for (const moved of delta.symbols.moved)
|
|
286
|
+
unsupported.push(`moved symbol ${moved.from}:${moved.name} -> ${moved.to} needs repair semantics, not a new policy`);
|
|
287
|
+
const addRelativeImportCandidate = (ref, basis) => {
|
|
288
|
+
const resolved = resolveRelativeImport(ref.file, ref.specifier, files);
|
|
289
|
+
if (resolved.matches.length !== 1 || !resolved.path) {
|
|
290
|
+
unsupported.push(`relative import ${ref.file}:${ref.specifier} target is ${resolved.matches.length ? "ambiguous" : "missing"} in the current graph`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const from = componentForFile(ref.file, "source");
|
|
294
|
+
const to = componentForFile(resolved.path, "target");
|
|
295
|
+
if (!from || !to)
|
|
296
|
+
return;
|
|
297
|
+
if (from.id === to.id) {
|
|
298
|
+
unsupported.push(`relative import ${ref.file}:${ref.specifier} stays inside component ${from.name}; the component evaluator cannot represent an internal file edge`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const relationNamed = judgmentNames(opts.judgment, ref.specifier)
|
|
302
|
+
|| (judgmentNames(opts.judgment, from.name) && judgmentNames(opts.judgment, to.name));
|
|
303
|
+
if (!relationNamed) {
|
|
304
|
+
unsupported.push(`relative import ${from.name} -> ${to.name} is structural coincidence not explicitly named by the human judgment`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const currentEdge = edges.some((edge) => edge.type === "depends_on" && edge.from === from.id && edge.to === to.id);
|
|
308
|
+
if (basis === "added-relative-import" && !currentEdge) {
|
|
309
|
+
unsupported.push(`added relative import ${from.name} -> ${to.name} has no exact current component edge`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (basis === "removed-relative-import" && currentEdge) {
|
|
313
|
+
unsupported.push(`removed relative import ${from.name} -> ${to.name} leaves the component dependency present through another import`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const assertion = {
|
|
317
|
+
kind: basis === "added-relative-import" ? "reaches" : "not-reaches",
|
|
318
|
+
subject: { selector: `component-id:${from.id}` },
|
|
319
|
+
relation: { edges: ["depends_on"], transitive: false, max_depth: 1 },
|
|
320
|
+
object: { selector: `component-id:${to.id}` },
|
|
321
|
+
};
|
|
322
|
+
const scope = { repos: [], paths: [ref.file], components: [from.id, to.id].sort() };
|
|
323
|
+
candidates.push({
|
|
324
|
+
id: candidateId(assertion, scope),
|
|
325
|
+
assertion,
|
|
326
|
+
scope,
|
|
327
|
+
basis,
|
|
328
|
+
reason: basis === "added-relative-import"
|
|
329
|
+
? `fix/revert added exact relative component dependency ${from.name} -> ${to.name}`
|
|
330
|
+
: `fix/revert removed exact relative component dependency ${from.name} -> ${to.name}`,
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
for (const added of delta.imports.added) {
|
|
334
|
+
if (externalPackage(added.specifier))
|
|
335
|
+
unsupported.push(`added external import ${added.file}:${added.specifier} awaits an import assertion evaluator for positive package requirements`);
|
|
336
|
+
else
|
|
337
|
+
addRelativeImportCandidate(added, "added-relative-import");
|
|
338
|
+
}
|
|
339
|
+
for (const removed of delta.imports.removed) {
|
|
340
|
+
const dependency = externalPackage(removed.specifier);
|
|
341
|
+
const external = externalImportNodeId(removed.specifier);
|
|
342
|
+
if (!dependency || !external) {
|
|
343
|
+
addRelativeImportCandidate(removed, "removed-relative-import");
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (!judgmentNames(opts.judgment, dependency) && !judgmentNames(opts.judgment, removed.specifier)) {
|
|
347
|
+
unsupported.push(`removed external import ${removed.file}:${dependency} is not explicitly named by the human judgment`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (opts.judgment?.retired.deps.length
|
|
351
|
+
&& !opts.judgment.retired.deps.some((specifier) => externalPackage(specifier) === dependency)) {
|
|
352
|
+
unsupported.push(`removed external import ${removed.file}:${dependency} is not listed in the human judgment's retired dependencies`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const subject = anchorForFile(removed.file);
|
|
356
|
+
if (!subject)
|
|
357
|
+
continue;
|
|
358
|
+
if (edges.some((edge) => edge.type === "imports" && edge.from === subject.id && edge.to === external)) {
|
|
359
|
+
unsupported.push(`removed external import ${removed.file}:${dependency} remains present through another package subpath`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const assertion = {
|
|
363
|
+
kind: "not-reaches",
|
|
364
|
+
subject: exactSelector(subject),
|
|
365
|
+
relation: { edges: ["imports"], transitive: false, max_depth: 1 },
|
|
366
|
+
object: { selector: `external:${dependency}` },
|
|
367
|
+
};
|
|
368
|
+
const scope = scopeFor(store, removed.file, !!opts.publicOnly);
|
|
369
|
+
candidates.push({
|
|
370
|
+
id: candidateId(assertion, scope), assertion, scope, basis: "removed-import",
|
|
371
|
+
reason: `fix/revert removed human-named external package ${dependency} from ${removed.file}`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
const eligibleCandidates = opts.retiredDependenciesOnly
|
|
375
|
+
? candidates.filter((candidate) => candidate.basis === "removed-import")
|
|
376
|
+
: candidates;
|
|
377
|
+
if (opts.retiredDependenciesOnly && eligibleCandidates.length !== candidates.length) {
|
|
378
|
+
unsupported.push(`${candidates.length - eligibleCandidates.length} call/symbol candidate(s) excluded because the human judgment's structured signal is dependency retirement`);
|
|
379
|
+
}
|
|
380
|
+
const unique = new Map(eligibleCandidates.map((c) => [canonicalHash({ assertion: c.assertion, scope: c.scope }), c]));
|
|
381
|
+
return {
|
|
382
|
+
candidates: [...unique.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
383
|
+
unsupported: [...new Set(unsupported)].sort(),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function sourceInHome(store, decisionId, opts) {
|
|
387
|
+
if (opts.publicOnly && opts.privateOnly)
|
|
388
|
+
throw new Error("choose only one of publicOnly or privateOnly");
|
|
389
|
+
if (opts.privateOnly) {
|
|
390
|
+
if (!store.hasPrivate)
|
|
391
|
+
throw new Error("private structural inspection needs a configured Hunch private overlay");
|
|
392
|
+
const source = store.getPrivateRec("decisions", decisionId);
|
|
393
|
+
if (!source)
|
|
394
|
+
throw new Error(`private decision ${decisionId} not found`);
|
|
395
|
+
return { source, private: true };
|
|
396
|
+
}
|
|
397
|
+
if (opts.publicOnly) {
|
|
398
|
+
const source = store.json.get("decisions", decisionId);
|
|
399
|
+
if (!source)
|
|
400
|
+
throw new Error(`public decision ${decisionId} not found`);
|
|
401
|
+
return { source, private: false };
|
|
402
|
+
}
|
|
403
|
+
const source = store.getRec("decisions", decisionId);
|
|
404
|
+
if (!source)
|
|
405
|
+
throw new Error(`decision ${decisionId} not found`);
|
|
406
|
+
return { source, private: !!store.getPrivateRec("decisions", decisionId) };
|
|
407
|
+
}
|
|
408
|
+
function historyKind(source, meta) {
|
|
409
|
+
const text = `${meta.subject}\n${meta.body}`;
|
|
410
|
+
if (/^revert\b/i.test(meta.subject.trim()))
|
|
411
|
+
return "revert";
|
|
412
|
+
if (source.caused_by_bug || /\b(fix(?:e[ds]|ing)?|bug(?:fix)?|hotfix|regression|restore[ds]?)\b/i.test(text))
|
|
413
|
+
return "bug_fix";
|
|
414
|
+
if (source.retired.deps.length > 0)
|
|
415
|
+
return "decision";
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
export function inspectStructuralDecision(store, root, decisionId, opts = {}) {
|
|
419
|
+
const { source, private: isPrivate } = sourceInHome(store, decisionId, opts);
|
|
420
|
+
if (!source.commit)
|
|
421
|
+
throw new Error(`decision ${decisionId} has no commit anchor`);
|
|
422
|
+
const meta = commitMeta(source.commit, root);
|
|
423
|
+
if (!meta)
|
|
424
|
+
throw new Error(`decision ${decisionId} commit ${source.commit} does not resolve in this repository`);
|
|
425
|
+
const kind = historyKind(source, meta);
|
|
426
|
+
if (!kind)
|
|
427
|
+
throw new Error(`decision ${decisionId} is not linked to a fixing or revert commit`);
|
|
428
|
+
const delta = extractStructuralDelta(root, meta.sha);
|
|
429
|
+
const enumerated = enumerateStructuralCandidates(store, delta, {
|
|
430
|
+
publicOnly: opts.publicOnly,
|
|
431
|
+
judgment: source,
|
|
432
|
+
retiredDependenciesOnly: kind === "decision",
|
|
433
|
+
});
|
|
434
|
+
return { decision: source.id, commit: meta.sha, kind, delta, ...enumerated, private: isPrivate };
|
|
435
|
+
}
|
|
436
|
+
function eventFor(root, source, meta, kind, isPrivate, delta) {
|
|
437
|
+
const contentHash = canonicalHash({ decision: source.id, commit: meta.sha, kind });
|
|
438
|
+
const symbols = delta
|
|
439
|
+
? [...new Set([
|
|
440
|
+
...delta.calls.added.flatMap((c) => [c.caller, c.callee]),
|
|
441
|
+
...delta.calls.removed.flatMap((c) => [c.caller, c.callee]),
|
|
442
|
+
...delta.symbols.added.map((s) => s.name),
|
|
443
|
+
])].sort()
|
|
444
|
+
: [];
|
|
445
|
+
return EvidenceEventSchema.parse({
|
|
446
|
+
id: `ev_${shortHash(`history:${contentHash}`)}`,
|
|
447
|
+
kind,
|
|
448
|
+
occurred_at: meta.date,
|
|
449
|
+
actor: meta.author,
|
|
450
|
+
repository: basename(root),
|
|
451
|
+
commit: meta.sha,
|
|
452
|
+
files: meta.files,
|
|
453
|
+
symbols,
|
|
454
|
+
text_ref: source.id,
|
|
455
|
+
diff_ref: delta ? `git:${delta.before_commit}..${delta.after_commit}` : `git:${meta.sha}`,
|
|
456
|
+
related_records: [source.id],
|
|
457
|
+
data_class: isPrivate ? "private" : "public",
|
|
458
|
+
content_hash: contentHash,
|
|
459
|
+
...(delta ? { structural_delta: delta } : {}),
|
|
460
|
+
compiler: { status: "eligible", policy: null, reason: "Human-confirmed fix/revert or explicit dependency-retirement decision with an exact first-parent structural delta." },
|
|
461
|
+
provenance: {
|
|
462
|
+
source: "derived",
|
|
463
|
+
confidence: 1,
|
|
464
|
+
evidence: [source.id, `commit:${meta.sha}`],
|
|
465
|
+
last_verified: source.provenance.last_verified,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
function canReclassify(event) {
|
|
470
|
+
const status = event?.compiler?.status;
|
|
471
|
+
return !event || status === "eligible" || status === "uncompilable" || status === "conflicted";
|
|
472
|
+
}
|
|
473
|
+
/** Phase-2B opt-in bootstrap: only human-confirmed fix/revert or explicit
|
|
474
|
+
* dependency-retirement decisions, exact
|
|
475
|
+
* first-parent deltas, and exactly one bindable candidate may compile. */
|
|
476
|
+
export function bootstrapStructuralPolicies(store, root, repository, opts = {}) {
|
|
477
|
+
if (opts.publicOnly && opts.privateOnly)
|
|
478
|
+
throw new Error("choose only one of publicOnly or privateOnly");
|
|
479
|
+
if (opts.privateOnly && !store.hasPrivate)
|
|
480
|
+
throw new Error("private history bootstrap needs a configured Hunch private overlay");
|
|
481
|
+
const now = opts.now ?? new Date().toISOString();
|
|
482
|
+
const minDate = durationCutoff(opts.since ?? "90d", now);
|
|
483
|
+
const decisions = opts.privateOnly
|
|
484
|
+
? store.recsInHome("decisions", "private")
|
|
485
|
+
: opts.publicOnly
|
|
486
|
+
? store.json.loadAll("decisions")
|
|
487
|
+
: store.recs("decisions");
|
|
488
|
+
const eligible = decisions.flatMap((source) => {
|
|
489
|
+
const at = Date.parse(source.valid_from ?? source.date);
|
|
490
|
+
if (source.status !== "accepted" || source.superseded_by || source.valid_to || source.conformance?.length
|
|
491
|
+
|| !source.commit || !source.provenance.source.includes("human_confirmed") || !Number.isFinite(at) || at < minDate)
|
|
492
|
+
return [];
|
|
493
|
+
const meta = commitMeta(source.commit, root);
|
|
494
|
+
if (!meta)
|
|
495
|
+
return [];
|
|
496
|
+
const kind = historyKind(source, meta);
|
|
497
|
+
return kind ? [{ source, meta, kind }] : [];
|
|
498
|
+
}).sort((a, b) => (b.source.valid_from ?? b.source.date).localeCompare(a.source.valid_from ?? a.source.date) || a.source.id.localeCompare(b.source.id));
|
|
499
|
+
const homeView = { publicOnly: opts.publicOnly, privateOnly: opts.privateOnly };
|
|
500
|
+
const maxCandidates = clampCandidateLimit(opts.maxCandidates);
|
|
501
|
+
const open = repository.listPolicies(homeView).filter((p) => p.state === "compiled" || p.state === "validating" || p.state === "proposed").length;
|
|
502
|
+
const available = Math.max(0, maxCandidates - Math.min(maxCandidates, open));
|
|
503
|
+
const report = { scanned: decisions.length, eligible: eligible.length, compiled: [], covered: 0, deferred: 0, uncompilable: 0, conflicted: 0 };
|
|
504
|
+
for (const { source, meta, kind } of eligible) {
|
|
505
|
+
const isPrivate = opts.privateOnly ? true : opts.publicOnly ? false : !!store.getPrivateRec("decisions", source.id);
|
|
506
|
+
let baseEvent = eventFor(root, source, meta, kind, isPrivate);
|
|
507
|
+
const prior = repository.getEvidence(baseEvent.id, homeView);
|
|
508
|
+
try {
|
|
509
|
+
const delta = extractStructuralDelta(root, meta.sha);
|
|
510
|
+
const enumerated = enumerateStructuralCandidates(store, delta, {
|
|
511
|
+
publicOnly: opts.publicOnly,
|
|
512
|
+
judgment: source,
|
|
513
|
+
retiredDependenciesOnly: kind === "decision",
|
|
514
|
+
});
|
|
515
|
+
baseEvent = eventFor(root, source, meta, kind, isPrivate, delta);
|
|
516
|
+
if (enumerated.candidates.length !== 1) {
|
|
517
|
+
report.uncompilable++;
|
|
518
|
+
const reason = enumerated.candidates.length > 1
|
|
519
|
+
? `Ambiguous structural delta enumerated ${enumerated.candidates.length} exact supported candidates; Hunch refused to choose semantics.`
|
|
520
|
+
: `No exact supported structural assertion. ${enumerated.unsupported.slice(0, 4).join("; ") || "The delta contains no supported call/symbol/import fact."}`;
|
|
521
|
+
if (canReclassify(prior) && (prior?.compiler?.status !== "uncompilable" || prior.compiler.reason !== reason)) {
|
|
522
|
+
const context = candidateContext(enumerated);
|
|
523
|
+
repository.putEvidence({ ...baseEvent, compiler: { status: "uncompilable", policy: null, reason, ...context } }, { private: isPrivate });
|
|
524
|
+
}
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const candidate = enumerated.candidates[0];
|
|
528
|
+
const policies = repository.listPolicies(homeView);
|
|
529
|
+
const counterexamples = counterexampleSignals(store, candidate, !!opts.publicOnly);
|
|
530
|
+
let context = candidateContext(enumerated, candidate, [], null, null, counterexamples);
|
|
531
|
+
let compiled = compileStructuralPolicy(store, {
|
|
532
|
+
source,
|
|
533
|
+
evidenceId: baseEvent.id,
|
|
534
|
+
commit: meta.sha,
|
|
535
|
+
assertion: candidate.assertion,
|
|
536
|
+
scope: candidate.scope,
|
|
537
|
+
dataClass: (isPrivate ? "private" : "public"),
|
|
538
|
+
candidate: context,
|
|
539
|
+
now,
|
|
540
|
+
});
|
|
541
|
+
const scopeSuggestion = repeatedScopeSuggestion(compiled.policy, policies);
|
|
542
|
+
if (scopeSuggestion) {
|
|
543
|
+
context = candidateContext(enumerated, candidate, [], null, scopeSuggestion, counterexamples);
|
|
544
|
+
compiled = { ...compiled, policy: PolicySpecSchema.parse({ ...compiled.policy, candidate: context }) };
|
|
545
|
+
}
|
|
546
|
+
const key = structuralKey(compiled.policy);
|
|
547
|
+
const incumbent = policies.find((p) => structuralKey(p) === key);
|
|
548
|
+
if (incumbent) {
|
|
549
|
+
report.covered++;
|
|
550
|
+
const incumbentSuggestion = repeatedScopeSuggestion(compiled.policy, policies);
|
|
551
|
+
context = candidateContext(enumerated, candidate, [], incumbent.id, incumbentSuggestion, counterexamples);
|
|
552
|
+
const enriched = enrichIncumbent(repository, incumbent, compiled.policy, context, now, isPrivate);
|
|
553
|
+
if (canReclassify(prior))
|
|
554
|
+
repository.putEvidence({
|
|
555
|
+
...baseEvent,
|
|
556
|
+
related_records: [...baseEvent.related_records, enriched.id],
|
|
557
|
+
compiler: { status: "covered", policy: enriched.id, reason: "Equivalent assertion and scope already exist; incumbent lifecycle and authority were preserved while evidence was enriched idempotently.", ...context },
|
|
558
|
+
}, { private: isPrivate });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const conflicts = policies.filter((policy) => directConflict(compiled.policy, policy)).map((policy) => policy.id).sort();
|
|
562
|
+
if (conflicts.length) {
|
|
563
|
+
report.conflicted++;
|
|
564
|
+
context = candidateContext(enumerated, candidate, conflicts, null, scopeSuggestion, counterexamples);
|
|
565
|
+
repository.putEvidence({
|
|
566
|
+
...baseEvent,
|
|
567
|
+
related_records: [...baseEvent.related_records, ...conflicts],
|
|
568
|
+
compiler: {
|
|
569
|
+
status: "conflicted",
|
|
570
|
+
policy: null,
|
|
571
|
+
reason: `Candidate directly contradicts ${conflicts.join(", ")}; no policy was minted and no authority changed.`,
|
|
572
|
+
...context,
|
|
573
|
+
},
|
|
574
|
+
}, { private: isPrivate });
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (report.compiled.length >= available) {
|
|
578
|
+
report.deferred++;
|
|
579
|
+
if (canReclassify(prior) && prior?.compiler?.status !== "eligible")
|
|
580
|
+
repository.putEvidence(baseEvent, { private: isPrivate });
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const policy = repository.putPolicy(compiled.policy, { private: compiled.private });
|
|
584
|
+
const event = repository.putEvidence({
|
|
585
|
+
...baseEvent,
|
|
586
|
+
related_records: [...baseEvent.related_records, policy.id],
|
|
587
|
+
compiler: { status: "compiled", policy: policy.id, reason: `Exactly one supported candidate: ${candidate.reason}.`, ...context },
|
|
588
|
+
}, { private: isPrivate });
|
|
589
|
+
report.compiled.push({ evidence: event, policy });
|
|
590
|
+
}
|
|
591
|
+
catch (e) {
|
|
592
|
+
report.uncompilable++;
|
|
593
|
+
const reason = e.message;
|
|
594
|
+
if (canReclassify(prior) && (prior?.compiler?.status !== "uncompilable" || prior.compiler.reason !== reason)) {
|
|
595
|
+
repository.putEvidence({ ...baseEvent, compiler: { status: "uncompilable", policy: null, reason, alternatives: [], uncertainty: [reason], conflicts: [], incumbent: null } }, { private: isPrivate });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return report;
|
|
600
|
+
}
|
|
601
|
+
//# sourceMappingURL=structural.js.map
|
package/dist/core/autoreview.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { draftDuplicateOf } from "./dupdetect.js";
|
|
1
|
+
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "./dupdetect.js";
|
|
2
2
|
import { parseSynth, isReady, READY_MIN_GROUNDED } from "./reviewqueue.js";
|
|
3
3
|
const DEFAULT_MIN_REJECT_CONFIDENCE = 0.7;
|
|
4
4
|
/** Build the plan. `verdicts` maps draft id → harness verdict (absent → the draft
|
|
5
|
-
* was not judged, e.g. no CLI available;
|
|
5
|
+
* was not judged, e.g. no CLI available; the pure plan can still flag a
|
|
6
|
+
* deterministic duplicate or keep it). The CLI refuses `--apply` on an incomplete
|
|
7
|
+
* requested harness batch; explicit `--no-llm` triage intentionally has no batch. */
|
|
6
8
|
export function planAutoReview(drafts, allDecisions, verdicts, cfg = {}) {
|
|
7
9
|
const minGrounded = cfg.minGrounded ?? READY_MIN_GROUNDED;
|
|
8
10
|
const minReject = cfg.minRejectConfidence ?? DEFAULT_MIN_REJECT_CONFIDENCE;
|
|
@@ -19,7 +21,8 @@ export function planAutoReview(drafts, allDecisions, verdicts, cfg = {}) {
|
|
|
19
21
|
plan.rejectDuplicate.push({ ...base, action: "rejectDuplicate", reason: `near-duplicate of ${detDup.of.id} "${detDup.of.title}" (${Math.round(detDup.score * 100)}%)` });
|
|
20
22
|
continue;
|
|
21
23
|
}
|
|
22
|
-
if (verdict?.duplicate_of && verdict.duplicate_of !== d.id
|
|
24
|
+
if (verdict?.duplicate_of && verdict.duplicate_of !== d.id
|
|
25
|
+
&& allDecisions.some((x) => x.id === verdict.duplicate_of && isAcceptedDuplicateAnchor(x))) {
|
|
23
26
|
plan.rejectDuplicate.push({ ...base, action: "rejectDuplicate", reason: `harness: restates ${verdict.duplicate_of} — ${verdict.reason}` });
|
|
24
27
|
continue;
|
|
25
28
|
}
|
|
@@ -49,4 +52,25 @@ export function planAutoReview(drafts, allDecisions, verdicts, cfg = {}) {
|
|
|
49
52
|
export function planMutations(plan) {
|
|
50
53
|
return plan.accept.length + plan.rejectDuplicate.length + plan.rejectIrrelevant.length;
|
|
51
54
|
}
|
|
55
|
+
export function resolveSelection(drafts, acceptIds, deleteIds) {
|
|
56
|
+
const byId = new Map(drafts.map((d) => [d.id, d]));
|
|
57
|
+
const claimed = new Set();
|
|
58
|
+
const pick = (ids) => {
|
|
59
|
+
const out = [];
|
|
60
|
+
const unknown = [];
|
|
61
|
+
for (const id of ids) {
|
|
62
|
+
const d = byId.get(id);
|
|
63
|
+
if (!d || claimed.has(id))
|
|
64
|
+
unknown.push(id);
|
|
65
|
+
else {
|
|
66
|
+
claimed.add(id);
|
|
67
|
+
out.push(d);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { out, unknown };
|
|
71
|
+
};
|
|
72
|
+
const a = pick(acceptIds); // accept wins an id contested with delete
|
|
73
|
+
const del = pick(deleteIds);
|
|
74
|
+
return { accept: a.out, delete: del.out, unknown: [...a.unknown, ...del.unknown] };
|
|
75
|
+
}
|
|
52
76
|
//# sourceMappingURL=autoreview.js.map
|