@davesheffer/hunch 1.8.3 → 1.9.2
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 +92 -1
- package/dist/cli/index.js +1222 -397
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +6 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +53 -10
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +7 -2
- package/tooling/md1-benchmark.mjs +628 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { headSha } from "../extractors/git.js";
|
|
3
3
|
import { canonicalHash, canonicalJson } from "./canonical.js";
|
|
4
|
+
import { replacementFreeGitEnvironment } from "./replacementFreeGit.js";
|
|
4
5
|
import { shortHash } from "../core/ids.js";
|
|
5
6
|
import { compileDecisionPolicy } from "./compiler.js";
|
|
6
|
-
import { evaluatePolicy, policyBlocks
|
|
7
|
-
import { approvePolicy, blockingProofError, demotePolicy, linkPolicyException, proposeProvedPolicy, retirePolicy, withdrawPolicy } from "./lifecycle.js";
|
|
7
|
+
import { evaluatePolicy, policyBlocks } from "./evaluator.js";
|
|
8
|
+
import { activationGateError, approvePolicy, blockingProofError, demotePolicy, linkPolicyException, proposeProvedPolicy, retirePolicy, withdrawPolicy } from "./lifecycle.js";
|
|
8
9
|
import { provePolicy } from "./proof.js";
|
|
9
10
|
import { PolicyRepository } from "./repository.js";
|
|
10
11
|
import { bootstrapPolicies } from "./bootstrap.js";
|
|
@@ -33,6 +34,17 @@ import { G3_REQUIRED_EXPERIMENTS, G3EvidenceRepository, compileExperimentPreregi
|
|
|
33
34
|
import { executeG3AdapterConformance, g3ConformanceSourceHash } from "./g3Conformance.js";
|
|
34
35
|
import { ExperimentRepository, assignmentTreatment, buildExperimentReport, compileExperimentCaseBank, compileExperimentFollowup, compileExperimentOutcome, compileExperimentReviewStart, compileExperimentReviewerQualification, compileExperimentRun, compileExperimentStop, compileExp03ReviewResponse, currentExperimentOutcomes, experimentReviewGuide, normalizedEditDistance, } from "./experiment.js";
|
|
35
36
|
import { executeExp01Assignment } from "./experimentRunner.js";
|
|
37
|
+
import { materializeCorrectionPolicies, materializeCorrectionPolicy, } from "./correctionPolicyMaterializer.js";
|
|
38
|
+
export function policyEvaluationEnvelope(result) {
|
|
39
|
+
return {
|
|
40
|
+
...result.evaluation,
|
|
41
|
+
enforcement: {
|
|
42
|
+
blocks: result.blocks,
|
|
43
|
+
strict_error: result.strict_error,
|
|
44
|
+
gate_error: result.gate_error ?? null,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
36
48
|
function relationSummary(policy) {
|
|
37
49
|
return {
|
|
38
50
|
id: policy.id,
|
|
@@ -76,6 +88,7 @@ export function shadowCommitEligible(root, policy, commit) {
|
|
|
76
88
|
const sourceCommit = policy.assertion.test.source_commit;
|
|
77
89
|
const check = spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", sourceCommit, commit], {
|
|
78
90
|
encoding: "utf8",
|
|
91
|
+
env: replacementFreeGitEnvironment(),
|
|
79
92
|
stdio: ["ignore", "ignore", "pipe"],
|
|
80
93
|
});
|
|
81
94
|
if (check.status === 0)
|
|
@@ -693,6 +706,7 @@ export class ConstitutionService {
|
|
|
693
706
|
const manifest = this.g2Repository.currentPlan();
|
|
694
707
|
const commits = manifest ? execFileSync("git", ["-C", this.root, "rev-list", "--first-parent", `--max-count=${maxCommits}`, "HEAD"], {
|
|
695
708
|
encoding: "utf8",
|
|
709
|
+
env: replacementFreeGitEnvironment(),
|
|
696
710
|
stdio: ["ignore", "pipe", "ignore"],
|
|
697
711
|
}).trim().split("\n").filter((commit) => /^[a-f0-9]{40}$/.test(commit)) : [];
|
|
698
712
|
const attempted = (manifest?.policy_ids.length ?? 0) * commits.length;
|
|
@@ -944,6 +958,12 @@ export class ConstitutionService {
|
|
|
944
958
|
ingest(opts = {}) {
|
|
945
959
|
return ingestLocalEvidence(this.store, this.root, this.repository, opts);
|
|
946
960
|
}
|
|
961
|
+
upgradeCorrection(id, opts = {}) {
|
|
962
|
+
return materializeCorrectionPolicy(this.store, this.root, this.repository, id, opts);
|
|
963
|
+
}
|
|
964
|
+
upgradeCorrections(opts = {}) {
|
|
965
|
+
return materializeCorrectionPolicies(this.store, this.root, this.repository, opts);
|
|
966
|
+
}
|
|
947
967
|
plan(id, opts = {}) {
|
|
948
968
|
const policy = this.get(id, opts);
|
|
949
969
|
const home = opts.publicOnly ? "public" : opts.privateOnly ? "private" : this.repository.homeOfPolicy(id);
|
|
@@ -961,7 +981,7 @@ export class ConstitutionService {
|
|
|
961
981
|
const publicOnly = policy.data_class === "public" && this.repository.homeOfPolicy(id) === "public";
|
|
962
982
|
const homeOpts = publicOnly ? { publicOnly: true } : { privateOnly: true };
|
|
963
983
|
const composition = this.composition(policy, homeOpts);
|
|
964
|
-
const plan = this.plan(id, { publicOnly, now: opts.now });
|
|
984
|
+
const plan = this.plan(id, { publicOnly, now: opts.now, repositoryName: opts.repositoryName });
|
|
965
985
|
const proof = provePolicy(this.store, this.root, policy, { publicOnly, now: opts.now, plan, composition });
|
|
966
986
|
this.repository.putProof(proof, policy.id);
|
|
967
987
|
const proposed = proposeProvedPolicy(policy, proof, opts.now ?? proof.generated_at, composition, this.g2BehaviorAttestationRepository.current());
|
|
@@ -1037,13 +1057,18 @@ export class ConstitutionService {
|
|
|
1037
1057
|
throw new Error(`historical shadow backfill supports executable-behavior policies only; ${policy.id} is ${policy.assertion.kind}`);
|
|
1038
1058
|
}
|
|
1039
1059
|
else {
|
|
1040
|
-
evaluation = evaluatePolicy(this.store, this.root, policy, {
|
|
1060
|
+
evaluation = evaluatePolicy(this.store, this.root, policy, {
|
|
1061
|
+
publicOnly: home === "public",
|
|
1062
|
+
composition,
|
|
1063
|
+
snapshot: opts.snapshot,
|
|
1064
|
+
behavior: opts.behavior,
|
|
1065
|
+
});
|
|
1041
1066
|
}
|
|
1042
1067
|
const latencyMs = opts.latencyMs ?? performance.now() - started;
|
|
1043
1068
|
const sameMatches = canonicalJson(evaluation.matches);
|
|
1044
1069
|
const alsoDetectedBy = evaluation.result === "violated"
|
|
1045
1070
|
&& (!opts.commit || opts.commit === currentHead)
|
|
1046
|
-
? this.evaluate({ activeOnly: true, publicOnly: home === "public" })
|
|
1071
|
+
? this.evaluate({ activeOnly: true, publicOnly: home === "public", snapshot: opts.snapshot, behavior: opts.behavior })
|
|
1047
1072
|
.filter((candidate) => candidate.policy.id !== id
|
|
1048
1073
|
&& candidate.evaluation.result === "violated"
|
|
1049
1074
|
&& canonicalJson(candidate.evaluation.matches) === sameMatches)
|
|
@@ -1074,7 +1099,10 @@ export class ConstitutionService {
|
|
|
1074
1099
|
const audit = this.repository.listShadowDispositions(opts).filter((record) => record.policy_id === id);
|
|
1075
1100
|
const current = currentShadowDispositions(audit);
|
|
1076
1101
|
const history = this.repository.listDispositions(opts).filter((record) => record.policy_id === id && record.proof_id === proof.id);
|
|
1077
|
-
const scoringRecords = records.filter((record) => shadowCommitEligible(this.root, policy,
|
|
1102
|
+
const scoringRecords = records.filter((record) => shadowCommitEligible(this.root, policy,
|
|
1103
|
+
// Workspace receipts retain their content-addressed pseudo-head for
|
|
1104
|
+
// dedupe, but ancestry eligibility is anchored to the real base commit.
|
|
1105
|
+
record.evaluation.repository.base ?? record.evaluation.repository.head));
|
|
1078
1106
|
const report = scoreShadowPrecision(policy, proof, scoringRecords, audit, history, thresholds);
|
|
1079
1107
|
return {
|
|
1080
1108
|
...report,
|
|
@@ -1200,11 +1228,21 @@ export class ConstitutionService {
|
|
|
1200
1228
|
}
|
|
1201
1229
|
evaluate(opts = {}) {
|
|
1202
1230
|
let policies = opts.id ? [this.get(opts.id, opts)] : this.list(opts);
|
|
1203
|
-
|
|
1204
|
-
|
|
1231
|
+
// Select the persisted active lifecycle states, including invalid/tampered
|
|
1232
|
+
// ones. The evaluator below must surface their gate/proof error so strict CI
|
|
1233
|
+
// fails visibly; filtering through policyIsActive would silently erase the
|
|
1234
|
+
// exact fail-closed configuration errors this seam is responsible for.
|
|
1235
|
+
if (opts.activeOnly) {
|
|
1236
|
+
policies = policies.filter((policy) => policy.state === "active_advisory" || policy.state === "active_blocking");
|
|
1237
|
+
}
|
|
1205
1238
|
return policies.map((policy) => {
|
|
1206
1239
|
const composition = this.composition(policy, opts);
|
|
1207
|
-
const evaluation = evaluatePolicy(this.store, this.root, policy, {
|
|
1240
|
+
const evaluation = evaluatePolicy(this.store, this.root, policy, {
|
|
1241
|
+
publicOnly: opts.publicOnly,
|
|
1242
|
+
composition,
|
|
1243
|
+
behavior: opts.behavior,
|
|
1244
|
+
snapshot: opts.snapshot,
|
|
1245
|
+
});
|
|
1208
1246
|
let proof;
|
|
1209
1247
|
let dispositions = [];
|
|
1210
1248
|
if (policy.state === "active_blocking" && policy.proof) {
|
|
@@ -1214,7 +1252,12 @@ export class ConstitutionService {
|
|
|
1214
1252
|
dispositions = this.repository.listDispositions(homeOpts).filter((record) => record.policy_id === policy.id && record.proof_id === policy.proof);
|
|
1215
1253
|
}
|
|
1216
1254
|
const behaviorBindingError = executableBehaviorAttestationError(policy, this.g2BehaviorAttestationRepository.current());
|
|
1217
|
-
const
|
|
1255
|
+
const activeActivationError = policy.state === "active_advisory" || policy.state === "active_blocking"
|
|
1256
|
+
? activationGateError(policy)
|
|
1257
|
+
: null;
|
|
1258
|
+
const gateError = behaviorBindingError
|
|
1259
|
+
?? activeActivationError
|
|
1260
|
+
?? blockingProofError(policy, proof, dispositions, composition, this.g2BehaviorAttestationRepository.current());
|
|
1218
1261
|
return {
|
|
1219
1262
|
policy,
|
|
1220
1263
|
evaluation,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join, posix, relative } from "node:path";
|
|
2
|
+
import { closeSync, constants, fstatSync, ftruncateSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync, writeSync, } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { hunchPathsForDir } from "../core/paths.js";
|
|
5
5
|
import { symbolId } from "../core/ids.js";
|
|
6
6
|
import { externalPackage } from "../core/externalImports.js";
|
|
@@ -11,6 +11,7 @@ import { parseSource } from "../extractors/parse.js";
|
|
|
11
11
|
import { HunchStore } from "../store/hunchStore.js";
|
|
12
12
|
import { canonicalHash } from "./canonical.js";
|
|
13
13
|
import { evaluatePolicyOnSnapshot, graphSnapshot } from "./evaluator.js";
|
|
14
|
+
import { hasUnsafeCheckoutAttributes } from "./safeCheckout.js";
|
|
14
15
|
function safeEnvironment(home, gitConfig) {
|
|
15
16
|
const env = {};
|
|
16
17
|
for (const key of ["PATH", "SystemRoot", "WINDIR", "TMPDIR", "TMP", "TEMP"]) {
|
|
@@ -22,6 +23,7 @@ function safeEnvironment(home, gitConfig) {
|
|
|
22
23
|
HOME: home,
|
|
23
24
|
GIT_CONFIG_GLOBAL: gitConfig,
|
|
24
25
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
26
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
25
27
|
GIT_TERMINAL_PROMPT: "0",
|
|
26
28
|
GIT_LFS_SKIP_SMUDGE: "1",
|
|
27
29
|
HUNCH_PRIVATE_DIR: "",
|
|
@@ -232,6 +234,37 @@ function removeWorktree(root, hooks, env, checkout) {
|
|
|
232
234
|
}
|
|
233
235
|
}
|
|
234
236
|
}
|
|
237
|
+
function openRegularSourceNoFollow(checkout, sourceFile) {
|
|
238
|
+
const file = join(checkout, sourceFile);
|
|
239
|
+
const before = lstatSync(file);
|
|
240
|
+
if (before.isSymbolicLink())
|
|
241
|
+
throw new Error("mutation-source-symlink-unsupported");
|
|
242
|
+
if (!before.isFile())
|
|
243
|
+
throw new Error("mutation-source-not-regular");
|
|
244
|
+
const canonicalCheckout = realpathSync(checkout);
|
|
245
|
+
const canonicalFile = realpathSync(file);
|
|
246
|
+
const fromCheckout = relative(canonicalCheckout, canonicalFile);
|
|
247
|
+
if (!fromCheckout || fromCheckout === ".." || fromCheckout.startsWith(`..${sep}`) || isAbsolute(fromCheckout)
|
|
248
|
+
|| canonicalFile !== resolve(canonicalCheckout, sourceFile)) {
|
|
249
|
+
throw new Error("mutation-source-outside-checkout");
|
|
250
|
+
}
|
|
251
|
+
let descriptor;
|
|
252
|
+
try {
|
|
253
|
+
descriptor = openSync(file, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
254
|
+
const opened = fstatSync(descriptor);
|
|
255
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
256
|
+
throw new Error("mutation-source-changed-before-open");
|
|
257
|
+
}
|
|
258
|
+
return descriptor;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (descriptor !== undefined)
|
|
262
|
+
closeSync(descriptor);
|
|
263
|
+
if (error.code === "ELOOP")
|
|
264
|
+
throw new Error("mutation-source-symlink-unsupported");
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
235
268
|
/** Apply one primary mutation to an immutable disposable source checkout. No
|
|
236
269
|
* project script, build, test, provider, model, or repository hook executes. */
|
|
237
270
|
export function runSourceMutation(root, policy, base) {
|
|
@@ -255,6 +288,9 @@ export function runSourceMutation(root, policy, base) {
|
|
|
255
288
|
try {
|
|
256
289
|
if (unsafeLocalFilter(root, env))
|
|
257
290
|
throw new Error("unsafe-local-filter-config");
|
|
291
|
+
if (hasUnsafeCheckoutAttributes(root, base.head, env, { allowDisabledLfs: true })) {
|
|
292
|
+
throw new Error("unsafe-checkout-attributes");
|
|
293
|
+
}
|
|
258
294
|
execFileSync("git", gitArgs(root, hooks, ["worktree", "add", "--detach", "--force", checkout, base.head]), {
|
|
259
295
|
env,
|
|
260
296
|
timeout: 30_000,
|
|
@@ -266,15 +302,32 @@ export function runSourceMutation(root, policy, base) {
|
|
|
266
302
|
const sourceFile = subject?.file ?? (subjectComponent ? componentFiles(base, subjectComponent)[0] : undefined);
|
|
267
303
|
if (!sourceFile)
|
|
268
304
|
throw new Error("mutation-subject-unresolved");
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
305
|
+
const descriptor = openRegularSourceNoFollow(checkout, sourceFile);
|
|
306
|
+
let mutation;
|
|
307
|
+
try {
|
|
308
|
+
const attempted = mutateSource(policy, base, sourceFile, readFileSync(descriptor, "utf8"));
|
|
309
|
+
if ("error" in attempted)
|
|
310
|
+
throw new Error(attempted.error);
|
|
311
|
+
mutation = attempted;
|
|
312
|
+
if (mutation.file !== sourceFile)
|
|
313
|
+
throw new Error("mutation-source-target-changed");
|
|
314
|
+
const parsed = parseSource(mutation.file, mutation.source);
|
|
315
|
+
if (!parsed?.parseable)
|
|
316
|
+
throw new Error("mutation-source-unparseable");
|
|
317
|
+
ftruncateSync(descriptor, 0);
|
|
318
|
+
const bytes = Buffer.from(mutation.source, "utf8");
|
|
319
|
+
let written = 0;
|
|
320
|
+
while (written < bytes.length) {
|
|
321
|
+
const count = writeSync(descriptor, bytes, written, bytes.length - written, written);
|
|
322
|
+
if (!count)
|
|
323
|
+
throw new Error("mutation-source-write-incomplete");
|
|
324
|
+
written += count;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
closeSync(descriptor);
|
|
329
|
+
}
|
|
330
|
+
const diff = execFileSync("git", gitArgs(checkout, hooks, ["diff", "--no-ext-diff", "--no-textconv", "--", mutation.file]), {
|
|
278
331
|
env,
|
|
279
332
|
encoding: "utf8",
|
|
280
333
|
timeout: 10_000,
|
|
@@ -286,7 +339,7 @@ export function runSourceMutation(root, policy, base) {
|
|
|
286
339
|
throw new Error("mutation-source-diff-too-large");
|
|
287
340
|
store = new HunchStore(hunchPathsForDir(graph));
|
|
288
341
|
store.json.ensureDirs();
|
|
289
|
-
indexRepo(store, checkout, { churn: false });
|
|
342
|
+
indexRepo(store, checkout, { churn: false, requireComplete: true });
|
|
290
343
|
const snapshot = graphSnapshot(store, root, { publicOnly: true, head: base.head });
|
|
291
344
|
outcome = {
|
|
292
345
|
snapshot,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CODE_EXTENSIONS } from "../extractors/languages.js";
|
|
2
|
+
import { replacementFreeCommitFiles, replacementFreeExactCommit, replacementFreeIsAncestorOrSame, } from "./replacementFreeGit.js";
|
|
3
|
+
const touchesGraphOrCheckoutInputs = (files) => files.some((file) => CODE_EXTENSIONS.some((extension) => file.endsWith(extension))
|
|
4
|
+
|| file === ".gitattributes"
|
|
5
|
+
|| file.endsWith("/.gitattributes"));
|
|
6
|
+
/** Canonical committed identity for the deterministic static graph.
|
|
7
|
+
*
|
|
8
|
+
* Public Hunch pumping creates clone-local commits containing `.hunch/` JSON and
|
|
9
|
+
* sometimes refreshed grounding docs. Those commits do not change anything the
|
|
10
|
+
* indexer parses, so binding a shared static receipt to their local SHA makes the
|
|
11
|
+
* same graph produce different artifact ids in otherwise identical clones. Walk
|
|
12
|
+
* first-parent until the newest indexed-code or checkout-attribute change instead.
|
|
13
|
+
* Attributes are a boundary because proof replay validates them before materializing
|
|
14
|
+
* exact source bytes. A merge is always a boundary: its resolution can change the
|
|
15
|
+
* effective tree even when a simple diff listing is incomplete. Reverts touch code
|
|
16
|
+
* and therefore remain distinct. */
|
|
17
|
+
export function canonicalStaticGraphBaseline(root, ref = "HEAD") {
|
|
18
|
+
const repositoryRef = replacementFreeExactCommit(root, ref);
|
|
19
|
+
if (!repositoryRef)
|
|
20
|
+
throw new Error(`static graph baseline needs a resolvable Git commit, got ${ref}`);
|
|
21
|
+
let current = repositoryRef;
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
while (!seen.has(current)) {
|
|
24
|
+
seen.add(current);
|
|
25
|
+
if (replacementFreeExactCommit(root, `${current}^2`))
|
|
26
|
+
return current;
|
|
27
|
+
if (touchesGraphOrCheckoutInputs(replacementFreeCommitFiles(root, current)))
|
|
28
|
+
return current;
|
|
29
|
+
// With no indexed-code commit at all, the repository root is the one
|
|
30
|
+
// cross-clone-resolvable anchor for the empty static graph. Returning the
|
|
31
|
+
// caller's docs/memory-only HEAD would reintroduce clone-local receipt churn.
|
|
32
|
+
const parent = replacementFreeExactCommit(root, `${current}^1`);
|
|
33
|
+
if (!parent)
|
|
34
|
+
return current;
|
|
35
|
+
if (parent === current)
|
|
36
|
+
return repositoryRef;
|
|
37
|
+
current = parent;
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
export function isAncestorOrSame(root, ancestor, descendant) {
|
|
42
|
+
return replacementFreeIsAncestorOrSame(root, ancestor, descendant);
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=staticGraphBaseline.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { builtinModules } from "node:module";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
2
3
|
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
3
4
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
4
5
|
import { shortHash } from "../core/ids.js";
|
|
@@ -9,6 +10,7 @@ import { clampCandidateLimit, durationCutoff } from "./bootstrap.js";
|
|
|
9
10
|
import { compileStructuralPolicy } from "./compiler.js";
|
|
10
11
|
import { extractStructuralDelta } from "./delta.js";
|
|
11
12
|
import { EvidenceEventSchema, PolicySpecSchema, } from "./schema.js";
|
|
13
|
+
const builtinModuleSpecifiers = new Set(builtinModules);
|
|
12
14
|
const exactSelector = (symbol) => ({ selector: `symbol:${symbol.file}:${symbol.name}` });
|
|
13
15
|
function scopeFor(store, file, publicOnly) {
|
|
14
16
|
const components = (publicOnly ? store.json.loadAll("components") : store.recs("components"))
|
|
@@ -20,6 +22,58 @@ function scopeFor(store, file, publicOnly) {
|
|
|
20
22
|
function candidateId(assertion, scope) {
|
|
21
23
|
return `cand_${shortHash(canonicalHash({ assertion, scope }))}`;
|
|
22
24
|
}
|
|
25
|
+
/** Bind one exact file/package boundary to the existing static import graph.
|
|
26
|
+
* The file-level fact is anchored to the first stable symbol because the
|
|
27
|
+
* indexer attaches every external import in a file to every symbol in it. */
|
|
28
|
+
export function inspectExternalImportBoundary(store, file, specifier, opts = {}) {
|
|
29
|
+
const supportedExtensions = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
30
|
+
if (!supportedExtensions.has(extname(file).toLowerCase())) {
|
|
31
|
+
return {
|
|
32
|
+
candidate: null,
|
|
33
|
+
reason: `${file} is outside MD-1a's TypeScript/JavaScript static ESM import-declaration projection`,
|
|
34
|
+
code: "unsupported_file",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const exactSpecifier = specifier.trim();
|
|
38
|
+
const dependency = externalPackage(exactSpecifier);
|
|
39
|
+
const external = externalImportNodeId(exactSpecifier);
|
|
40
|
+
const exactNpmPackage = /^(?:@[A-Za-z0-9][A-Za-z0-9._~-]*\/)?[A-Za-z0-9][A-Za-z0-9._~-]*$/;
|
|
41
|
+
if (!dependency || !external || dependency !== exactSpecifier || builtinModuleSpecifiers.has(exactSpecifier)
|
|
42
|
+
|| !exactNpmPackage.test(exactSpecifier)) {
|
|
43
|
+
return {
|
|
44
|
+
candidate: null,
|
|
45
|
+
reason: `${specifier} is not one exact top-level npm package identity; package subpaths, built-ins, and scheme specifiers are unsupported`,
|
|
46
|
+
code: "unsupported_dependency",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const symbols = (opts.publicOnly ? store.json.loadAll("symbols") : store.recs("symbols"))
|
|
50
|
+
.filter((symbol) => symbol.file === file)
|
|
51
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
52
|
+
if (!symbols.length)
|
|
53
|
+
return { candidate: null, reason: `${file} has no exact current symbol to anchor a file-scoped import policy`, code: "missing_anchor" };
|
|
54
|
+
const edges = opts.publicOnly ? store.json.loadAll("edges") : store.recs("edges");
|
|
55
|
+
const symbolIds = new Set(symbols.map((symbol) => symbol.id));
|
|
56
|
+
if (edges.some((edge) => edge.type === "imports" && symbolIds.has(edge.from) && edge.to === external)) {
|
|
57
|
+
return { candidate: null, reason: `${file} currently imports ${dependency}; fix the baseline before Hunch builds a proof packet`, code: "baseline_violated" };
|
|
58
|
+
}
|
|
59
|
+
const assertion = {
|
|
60
|
+
kind: "not-reaches",
|
|
61
|
+
subject: exactSelector(symbols[0]),
|
|
62
|
+
relation: { edges: ["imports"], transitive: false, max_depth: 1 },
|
|
63
|
+
object: { selector: `external:${dependency}` },
|
|
64
|
+
};
|
|
65
|
+
const scope = scopeFor(store, file, !!opts.publicOnly);
|
|
66
|
+
return {
|
|
67
|
+
candidate: {
|
|
68
|
+
id: candidateId(assertion, scope),
|
|
69
|
+
assertion,
|
|
70
|
+
scope,
|
|
71
|
+
basis: "correction-forbidden-import",
|
|
72
|
+
reason: `human correction forbids direct external package ${dependency} in ${file}`,
|
|
73
|
+
},
|
|
74
|
+
reason: null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
23
77
|
function alternativeFor(candidate) {
|
|
24
78
|
return {
|
|
25
79
|
id: candidate.id,
|
|
@@ -45,16 +99,18 @@ function candidateContext(enumerated, selected, conflicts = [], incumbent = null
|
|
|
45
99
|
counterexamples: [...new Set(counterexamples)].sort(),
|
|
46
100
|
};
|
|
47
101
|
}
|
|
48
|
-
function structuralKey(policy) {
|
|
102
|
+
export function structuralKey(policy) {
|
|
49
103
|
return canonicalHash({ assertion: policy.assertion, scope: policy.scope, data_class: policy.data_class });
|
|
50
104
|
}
|
|
51
105
|
function scopesOverlap(left, right) {
|
|
52
106
|
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((
|
|
107
|
+
const pathOverlap = !left.paths.length || !right.paths.length || left.paths.some((leftPath) => right.paths.some((rightPath) => leftPath === rightPath
|
|
108
|
+
|| pathMatchesGlob(leftPath, rightPath)
|
|
109
|
+
|| pathMatchesGlob(rightPath, leftPath)));
|
|
54
110
|
const componentOverlap = !left.components.length || !right.components.length || left.components.some((component) => right.components.includes(component));
|
|
55
111
|
return repoOverlap && pathOverlap && componentOverlap;
|
|
56
112
|
}
|
|
57
|
-
function directConflict(candidate, incumbent) {
|
|
113
|
+
export function directConflict(candidate, incumbent) {
|
|
58
114
|
const left = candidate.assertion;
|
|
59
115
|
const right = incumbent.assertion;
|
|
60
116
|
if (!((left.kind === "reaches" && right.kind === "not-reaches") || (left.kind === "not-reaches" && right.kind === "reaches")))
|
package/dist/core/autoreview.js
CHANGED
|
@@ -48,7 +48,7 @@ export function planAutoReview(drafts, allDecisions, verdicts, cfg = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
return plan;
|
|
50
50
|
}
|
|
51
|
-
/** Total drafts the plan would mutate (accept + both
|
|
51
|
+
/** Total drafts the plan would mutate (accept + both rejection buckets). */
|
|
52
52
|
export function planMutations(plan) {
|
|
53
53
|
return plan.accept.length + plan.rejectDuplicate.length + plan.rejectIrrelevant.length;
|
|
54
54
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Locale-free UTF-16 code-unit ordering for content-addressed artifacts.
|
|
2
|
+
* Never use localeCompare where ordering contributes to a hash or selection. */
|
|
3
|
+
export function compareCodeUnits(left, right) {
|
|
4
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
5
|
+
}
|
|
6
|
+
//# sourceMappingURL=canonicalOrder.js.map
|
package/dist/core/conformance.js
CHANGED
|
@@ -1,56 +1,97 @@
|
|
|
1
|
-
function
|
|
2
|
-
const syms =
|
|
1
|
+
function resolveSymbols(graph, ref) {
|
|
2
|
+
const syms = graph.symbols;
|
|
3
3
|
if (ref.startsWith("sym_"))
|
|
4
|
-
return syms.
|
|
4
|
+
return syms.filter((s) => s.id === ref);
|
|
5
5
|
if (ref.includes(":")) {
|
|
6
|
-
const
|
|
7
|
-
|
|
6
|
+
const split = ref.lastIndexOf(":");
|
|
7
|
+
const file = ref.slice(0, split);
|
|
8
|
+
const name = ref.slice(split + 1);
|
|
9
|
+
return syms.filter((s) => s.name === name && (s.file === file || s.file.endsWith("/" + file)));
|
|
8
10
|
}
|
|
9
|
-
return syms.
|
|
11
|
+
return syms.filter((s) => s.name === ref);
|
|
10
12
|
}
|
|
11
|
-
function reaches(
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
function reaches(graph, id, transitive) {
|
|
14
|
+
const reached = new Set();
|
|
15
|
+
const seen = new Set([id]);
|
|
16
|
+
const queue = [{ id, depth: 0 }];
|
|
17
|
+
const maxDepth = transitive ? 6 : 1;
|
|
18
|
+
const traversable = new Set(["calls", "depends_on", "imports", "contains"]);
|
|
19
|
+
const outgoing = new Map();
|
|
20
|
+
for (const edge of graph.edges) {
|
|
21
|
+
if (!traversable.has(edge.type))
|
|
22
|
+
continue;
|
|
23
|
+
const targets = outgoing.get(edge.from) ?? [];
|
|
24
|
+
targets.push(edge.to);
|
|
25
|
+
outgoing.set(edge.from, targets);
|
|
16
26
|
}
|
|
17
|
-
|
|
27
|
+
while (queue.length) {
|
|
28
|
+
const current = queue.shift();
|
|
29
|
+
if (current.depth >= maxDepth)
|
|
30
|
+
continue;
|
|
31
|
+
for (const next of outgoing.get(current.id) ?? []) {
|
|
32
|
+
reached.add(next);
|
|
33
|
+
if (seen.has(next))
|
|
34
|
+
continue;
|
|
35
|
+
seen.add(next);
|
|
36
|
+
queue.push({ id: next, depth: current.depth + 1 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return reached;
|
|
18
40
|
}
|
|
19
|
-
function evalPredicate(
|
|
41
|
+
function evalPredicate(graph, d, p) {
|
|
20
42
|
const base = { decision: d.id, title: d.title, assert: p.assert, subject: p.subject, object: p.object };
|
|
21
|
-
const
|
|
43
|
+
const subjects = resolveSymbols(graph, p.subject);
|
|
22
44
|
if (p.assert === "exists") {
|
|
23
|
-
return { ...base, satisfied:
|
|
45
|
+
return { ...base, satisfied: subjects.length > 0, detail: subjects.length ? `${p.subject} exists (${subjects.map((subject) => subject.file).join(", ")})` : `${p.subject} no longer exists in the graph` };
|
|
24
46
|
}
|
|
25
|
-
if (!
|
|
47
|
+
if (!subjects.length)
|
|
26
48
|
return { ...base, satisfied: false, detail: `subject "${p.subject}" not found in the graph — intent's subject is gone` };
|
|
27
49
|
const wantReach = p.assert === "calls" || p.assert === "imports";
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
50
|
+
const objects = p.object ? resolveSymbols(graph, p.object) : [];
|
|
51
|
+
if (!objects.length) {
|
|
30
52
|
// a required target gone ⇒ the link can't hold (violated); a forbidden one trivially holds.
|
|
31
53
|
return { ...base, satisfied: !wantReach, detail: `target "${p.object ?? ""}" not found in the graph` };
|
|
32
54
|
}
|
|
33
|
-
|
|
55
|
+
// A required relation cannot guess which same-name symbol carries the intent.
|
|
56
|
+
// Force qualification instead of accidentally proving a different binding.
|
|
57
|
+
if (wantReach && (subjects.length !== 1 || objects.length !== 1)) {
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
satisfied: false,
|
|
61
|
+
detail: `ambiguous required binding (${subjects.length} subject, ${objects.length} target matches) — qualify as file:symbol; intent VIOLATED`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// A forbidden relation is conservative in the other direction: ANY matching
|
|
65
|
+
// subject reaching ANY same-name target is a real counterexample. Looking at
|
|
66
|
+
// only the first target lets a duplicate symbol hide a violation.
|
|
67
|
+
const linked = subjects.some((subject) => {
|
|
68
|
+
const reached = reaches(graph, subject.id, p.transitive);
|
|
69
|
+
return objects.some((object) => reached.has(object.id));
|
|
70
|
+
});
|
|
34
71
|
const satisfied = wantReach ? linked : !linked;
|
|
35
72
|
const via = p.transitive ? " (transitively)" : "";
|
|
36
73
|
const detail = satisfied
|
|
37
74
|
? wantReach
|
|
38
|
-
? `${
|
|
39
|
-
: `${
|
|
75
|
+
? `${p.subject} →${via} ${p.object} ✓`
|
|
76
|
+
: `${p.subject} does not reach ${p.object} ✓`
|
|
40
77
|
: wantReach
|
|
41
|
-
? `${
|
|
42
|
-
: `${
|
|
78
|
+
? `${p.subject} no longer reaches${via} ${p.object} — intent VIOLATED`
|
|
79
|
+
: `${p.subject} now reaches${via} ${p.object} — intent VIOLATED`;
|
|
43
80
|
return { ...base, satisfied, detail };
|
|
44
81
|
}
|
|
45
82
|
/** Check every in-force decision's conformance predicates against the CURRENT graph.
|
|
46
|
-
* `.satisfied === false` means the code drifted from the recorded intent. Deterministic.
|
|
47
|
-
|
|
83
|
+
* `.satisfied === false` means the code drifted from the recorded intent. Deterministic.
|
|
84
|
+
* `publicOnly` selects every input at the JSON read boundary so a private decision,
|
|
85
|
+
* symbol, or edge can never influence (or be rendered into) a public CI receipt. */
|
|
86
|
+
export function checkConformance(store, opts = {}) {
|
|
87
|
+
const load = (kind) => opts.publicOnly ? store.json.loadAll(kind) : store.recs(kind);
|
|
88
|
+
const graph = opts.graph ?? { symbols: load("symbols"), edges: load("edges") };
|
|
48
89
|
const out = [];
|
|
49
|
-
for (const d of
|
|
90
|
+
for (const d of load("decisions")) {
|
|
50
91
|
if (d.status === "superseded" || d.superseded_by)
|
|
51
92
|
continue; // in-force decisions only
|
|
52
93
|
for (const p of d.conformance ?? [])
|
|
53
|
-
out.push(evalPredicate(
|
|
94
|
+
out.push(evalPredicate(graph, d, p));
|
|
54
95
|
}
|
|
55
96
|
return out;
|
|
56
97
|
}
|
package/dist/core/docscan.js
CHANGED
|
@@ -19,6 +19,7 @@ import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
|
19
19
|
import { join, extname } from "node:path";
|
|
20
20
|
import { parseDocAnchors } from "./docanchors.js";
|
|
21
21
|
import { currentForTopic } from "./topics.js";
|
|
22
|
+
import { compareCodeUnits } from "./canonicalOrder.js";
|
|
22
23
|
export const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
|
|
23
24
|
export const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
|
|
24
25
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "vscode-extension", "site"]);
|
|
@@ -111,6 +112,6 @@ export function scanRepoDocs(decisions, root) {
|
|
|
111
112
|
const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? doc.rel;
|
|
112
113
|
out.push({ rel: doc.rel, title, topics, srcRefs, status, issues });
|
|
113
114
|
}
|
|
114
|
-
return out.sort((a, b) => a.rel
|
|
115
|
+
return out.sort((a, b) => compareCodeUnits(a.rel, b.rel));
|
|
115
116
|
}
|
|
116
117
|
//# sourceMappingURL=docscan.js.map
|
package/dist/core/escalations.js
CHANGED
|
@@ -46,6 +46,17 @@ export function policyEscalations(policies) {
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
else if (p.state === "proposed") {
|
|
49
|
+
if (p.activation_gate?.status === "blocked") {
|
|
50
|
+
out.push({
|
|
51
|
+
kind: "policy-proposal",
|
|
52
|
+
topic: p.id,
|
|
53
|
+
decisionIds: [p.id],
|
|
54
|
+
question: `Proposed rule "${clip(p.statement)}" (${p.id}) is ready for review but mechanically blocked from activation — keep it as evidence?`,
|
|
55
|
+
detail: `state proposed · ${p.proof ? `proof ${p.proof}` : "no proof"} · authority none · activation gate ${p.activation_gate.kind}`,
|
|
56
|
+
resolution: `inspect: hunch policy card ${p.id} — activation remains unavailable until the source-currentness gate is implemented and cleared`,
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
49
60
|
out.push({
|
|
50
61
|
kind: "policy-proposal",
|
|
51
62
|
topic: p.id,
|