@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.
Files changed (58) hide show
  1. package/README.md +92 -1
  2. package/dist/cli/index.js +1222 -397
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +6 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +53 -10
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +7 -2
  58. 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, policyIsActive } from "./evaluator.js";
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, { publicOnly: home === "public", composition });
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, record.evaluation.repository.head));
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
- if (opts.activeOnly)
1204
- policies = policies.filter(policyIsActive);
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, { publicOnly: opts.publicOnly, composition, behavior: opts.behavior });
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 gateError = behaviorBindingError ?? blockingProofError(policy, proof, dispositions, composition, this.g2BehaviorAttestationRepository.current());
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 file = join(checkout, sourceFile);
270
- const mutation = mutateSource(policy, base, sourceFile, readFileSync(file, "utf8"));
271
- if ("error" in mutation)
272
- throw new Error(mutation.error);
273
- const parsed = parseSource(mutation.file, mutation.source);
274
- if (!parsed?.parseable)
275
- throw new Error("mutation-source-unparseable");
276
- writeFileSync(file, mutation.source);
277
- const diff = execFileSync("git", gitArgs(checkout, hooks, ["diff", "--no-ext-diff", "--", mutation.file]), {
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 { basename } from "node:path";
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((path) => right.paths.includes(path));
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")))
@@ -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 delete buckets). */
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
@@ -1,56 +1,97 @@
1
- function resolveSymbol(store, ref) {
2
- const syms = store.recs("symbols");
1
+ function resolveSymbols(graph, ref) {
2
+ const syms = graph.symbols;
3
3
  if (ref.startsWith("sym_"))
4
- return syms.find((s) => s.id === ref) ?? null;
4
+ return syms.filter((s) => s.id === ref);
5
5
  if (ref.includes(":")) {
6
- const [f, n] = ref.split(":");
7
- return syms.find((s) => s.name === n && (s.file === f || s.file.endsWith("/" + (f ?? "")))) ?? null;
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.find((s) => s.name === ref) ?? null;
11
+ return syms.filter((s) => s.name === ref);
10
12
  }
11
- function reaches(store, id, transitive) {
12
- const set = new Set();
13
- for (const d of store.getDependencies(id, transitive ? 6 : 1)) {
14
- if (transitive || d.depth === 1)
15
- set.add(d.id);
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
- return set;
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(store, d, p) {
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 subj = resolveSymbol(store, p.subject);
43
+ const subjects = resolveSymbols(graph, p.subject);
22
44
  if (p.assert === "exists") {
23
- return { ...base, satisfied: !!subj, detail: subj ? `${p.subject} exists (${subj.file})` : `${p.subject} no longer exists in the graph` };
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 (!subj)
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 obj = p.object ? resolveSymbol(store, p.object) : null;
29
- if (!obj) {
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
- const linked = reaches(store, subj.id, p.transitive).has(obj.id);
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
- ? `${subj.name} →${via} ${obj.name} ✓`
39
- : `${subj.name} does not reach ${obj.name} ✓`
75
+ ? `${p.subject} →${via} ${p.object} ✓`
76
+ : `${p.subject} does not reach ${p.object} ✓`
40
77
  : wantReach
41
- ? `${subj.name} no longer reaches${via} ${obj.name} — intent VIOLATED`
42
- : `${subj.name} now reaches${via} ${obj.name} — intent VIOLATED`;
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
- export function checkConformance(store) {
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 store.recs("decisions")) {
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(store, d, p));
94
+ out.push(evalPredicate(graph, d, p));
54
95
  }
55
96
  return out;
56
97
  }
@@ -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.localeCompare(b.rel));
115
+ return out.sort((a, b) => compareCodeUnits(a.rel, b.rel));
115
116
  }
116
117
  //# sourceMappingURL=docscan.js.map
@@ -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,