@davesheffer/hunch 1.8.2 → 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 +96 -1
- package/dist/cli/index.js +1238 -396
- 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 +96 -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 +74 -14
- 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 +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- package/tooling/md1-benchmark.mjs +628 -0
|
@@ -1,22 +1,207 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
|
-
import { basename, join } from "node:path";
|
|
3
|
+
import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
|
+
import { TextDecoder } from "node:util";
|
|
5
6
|
import { shortHash } from "../core/ids.js";
|
|
7
|
+
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
6
8
|
import { canonicalHash, canonicalJson } from "./canonical.js";
|
|
7
9
|
import { replaySafeEnvironment } from "./replay.js";
|
|
10
|
+
import { replacementFreeExactCommit, replacementFreeGitEnvironment } from "./replacementFreeGit.js";
|
|
8
11
|
const FULL_SHA = /^[a-f0-9]{40}$/;
|
|
9
12
|
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
|
|
10
|
-
|
|
13
|
+
// v1 bound only the installed lock and native binaries. Including the version
|
|
14
|
+
// in SnapshotInput makes those caches ineligible and provisions a fresh v2 tree.
|
|
15
|
+
const SNAPSHOT_VERSION = 2;
|
|
16
|
+
const TREE_HASH_VERSION = "hunch-node-modules-tree-v1";
|
|
17
|
+
const HASH_BUFFER_BYTES = 64 * 1024;
|
|
18
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
11
19
|
function sha256(value) {
|
|
12
20
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
13
21
|
}
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
function fileSha256(file) {
|
|
23
|
+
const hash = createHash("sha256");
|
|
24
|
+
const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES);
|
|
25
|
+
const fd = openSync(file, "r");
|
|
26
|
+
try {
|
|
27
|
+
for (let bytes = readSync(fd, buffer, 0, buffer.length, null); bytes > 0; bytes = readSync(fd, buffer, 0, buffer.length, null)) {
|
|
28
|
+
hash.update(buffer.subarray(0, bytes));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
return `sha256:${hash.digest("hex")}`;
|
|
35
|
+
}
|
|
36
|
+
function hashField(hash, value) {
|
|
37
|
+
const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
|
38
|
+
const length = Buffer.allocUnsafe(8);
|
|
39
|
+
length.writeBigUInt64BE(BigInt(bytes.length));
|
|
40
|
+
hash.update(length);
|
|
41
|
+
hash.update(bytes);
|
|
42
|
+
}
|
|
43
|
+
function safeEntryName(name) {
|
|
44
|
+
if (!name || name === "." || name === ".." || name.includes("\0") || name.includes("/") || (sep === "\\" && name.includes("\\"))) {
|
|
45
|
+
throw new Error("dependency snapshot contains an unsafe filesystem entry name");
|
|
46
|
+
}
|
|
47
|
+
return name;
|
|
48
|
+
}
|
|
49
|
+
function symlinkTarget(link) {
|
|
50
|
+
const raw = readlinkSync(link, { encoding: "buffer" });
|
|
51
|
+
try {
|
|
52
|
+
return UTF8_DECODER.decode(raw);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error("dependency snapshot contains a non-UTF-8 symlink target");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function inside(root, target) {
|
|
59
|
+
return target === root || target.startsWith(`${root}${sep}`);
|
|
60
|
+
}
|
|
61
|
+
function safeInternalSymlink(root, link, target) {
|
|
62
|
+
if (!target || target.includes("\0") || isAbsolute(target)) {
|
|
63
|
+
throw new Error("dependency snapshot contains an absolute or empty symlink target");
|
|
64
|
+
}
|
|
65
|
+
const lexicalRoot = resolve(root);
|
|
66
|
+
const lexicalTarget = resolve(dirname(link), target);
|
|
67
|
+
if (!inside(lexicalRoot, lexicalTarget)) {
|
|
68
|
+
throw new Error("dependency snapshot contains an escaping symlink target");
|
|
69
|
+
}
|
|
70
|
+
const realRoot = realpathSync(root);
|
|
71
|
+
const realTarget = realpathSync(lexicalTarget);
|
|
72
|
+
if (!inside(realRoot, realTarget)) {
|
|
73
|
+
throw new Error("dependency snapshot contains a symlink target outside node_modules");
|
|
74
|
+
}
|
|
75
|
+
const targetStat = statSync(link);
|
|
76
|
+
if (targetStat.isFile())
|
|
77
|
+
return "file";
|
|
78
|
+
if (targetStat.isDirectory())
|
|
79
|
+
return "dir";
|
|
80
|
+
throw new Error("dependency snapshot symlink resolves to a special filesystem entry");
|
|
81
|
+
}
|
|
82
|
+
function treeEntries(dir) {
|
|
83
|
+
return readdirSync(dir, { encoding: "buffer" })
|
|
84
|
+
.sort((left, right) => Buffer.compare(left, right))
|
|
85
|
+
.map((raw) => {
|
|
86
|
+
let name;
|
|
87
|
+
try {
|
|
88
|
+
name = UTF8_DECODER.decode(raw);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Error("dependency snapshot contains a non-UTF-8 filesystem entry name");
|
|
92
|
+
}
|
|
93
|
+
return safeEntryName(name);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function treeRecord(hash, relative, kind, mode, payload = "") {
|
|
97
|
+
hashField(hash, relative);
|
|
98
|
+
hashField(hash, kind);
|
|
99
|
+
hashField(hash, (mode & 0o7777).toString(8).padStart(4, "0"));
|
|
100
|
+
hashField(hash, payload);
|
|
101
|
+
}
|
|
102
|
+
/** Hash every directory, regular file, executable bit, and internal symlink in
|
|
103
|
+
* a dependency tree. Traversal uses raw UTF-8 byte ordering rather than the
|
|
104
|
+
* host locale, and rejects filesystem shapes that cannot be copied safely. */
|
|
105
|
+
export function dependencySnapshotTreeHash(nodeModules) {
|
|
106
|
+
const rootStat = lstatSync(nodeModules);
|
|
107
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
108
|
+
throw new Error("dependency snapshot node_modules root is not a real directory");
|
|
109
|
+
}
|
|
110
|
+
const hash = createHash("sha256");
|
|
111
|
+
hashField(hash, TREE_HASH_VERSION);
|
|
112
|
+
treeRecord(hash, "", "directory", rootStat.mode);
|
|
113
|
+
const walk = (dir, relative) => {
|
|
114
|
+
for (const name of treeEntries(dir)) {
|
|
115
|
+
const absolute = join(dir, name);
|
|
116
|
+
const next = relative ? `${relative}/${name}` : name;
|
|
117
|
+
const stat = lstatSync(absolute);
|
|
118
|
+
if (stat.isSymbolicLink()) {
|
|
119
|
+
const target = symlinkTarget(absolute);
|
|
120
|
+
safeInternalSymlink(nodeModules, absolute, target);
|
|
121
|
+
treeRecord(hash, next, "symlink", stat.mode, target);
|
|
122
|
+
}
|
|
123
|
+
else if (stat.isDirectory()) {
|
|
124
|
+
treeRecord(hash, next, "directory", stat.mode);
|
|
125
|
+
walk(absolute, next);
|
|
126
|
+
}
|
|
127
|
+
else if (stat.isFile()) {
|
|
128
|
+
treeRecord(hash, next, "file", stat.mode, fileSha256(absolute));
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
throw new Error(`dependency snapshot contains special filesystem entry ${next}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
walk(nodeModules, "");
|
|
136
|
+
return `sha256:${hash.digest("hex")}`;
|
|
137
|
+
}
|
|
138
|
+
function copyDependencyTree(source, destination) {
|
|
139
|
+
const copyDirectory = (sourceDir, destinationDir) => {
|
|
140
|
+
const sourceStat = lstatSync(sourceDir);
|
|
141
|
+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
142
|
+
throw new Error("dependency snapshot copy source is not a real directory");
|
|
143
|
+
}
|
|
144
|
+
mkdirSync(destinationDir, { mode: sourceStat.mode & 0o7777 });
|
|
145
|
+
for (const name of treeEntries(sourceDir)) {
|
|
146
|
+
const sourceEntry = join(sourceDir, name);
|
|
147
|
+
const destinationEntry = join(destinationDir, name);
|
|
148
|
+
const stat = lstatSync(sourceEntry);
|
|
149
|
+
if (stat.isSymbolicLink()) {
|
|
150
|
+
const target = symlinkTarget(sourceEntry);
|
|
151
|
+
const targetType = safeInternalSymlink(source, sourceEntry, target);
|
|
152
|
+
symlinkSync(target, destinationEntry, process.platform === "win32" ? targetType : undefined);
|
|
153
|
+
}
|
|
154
|
+
else if (stat.isDirectory()) {
|
|
155
|
+
copyDirectory(sourceEntry, destinationEntry);
|
|
156
|
+
}
|
|
157
|
+
else if (stat.isFile()) {
|
|
158
|
+
copyFileSync(sourceEntry, destinationEntry, fsConstants.COPYFILE_FICLONE);
|
|
159
|
+
chmodSync(destinationEntry, stat.mode & 0o7777);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
throw new Error("dependency snapshot contains a special filesystem entry");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
chmodSync(destinationDir, sourceStat.mode & 0o7777);
|
|
166
|
+
};
|
|
167
|
+
copyDirectory(source, destination);
|
|
168
|
+
}
|
|
169
|
+
/** Create one independently writable dependency tree. COPYFILE_FICLONE is a
|
|
170
|
+
* copy-on-write optimization where supported, never a shared hardlink; the
|
|
171
|
+
* byte-copy fallback has the same isolation. Pre/post hashes bind the copy. */
|
|
172
|
+
export function materializeDependencyTree(source, destination, opts = {}) {
|
|
173
|
+
if (existsSync(destination))
|
|
174
|
+
throw new Error("dependency snapshot destination already exists");
|
|
175
|
+
const sourceHash = dependencySnapshotTreeHash(source);
|
|
176
|
+
if (opts.expectedHash && sourceHash !== opts.expectedHash) {
|
|
177
|
+
throw new Error("dependency tree hash mismatch before materialization");
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
copyDependencyTree(source, destination);
|
|
181
|
+
if (dependencySnapshotTreeHash(destination) !== sourceHash) {
|
|
182
|
+
throw new Error("dependency tree hash mismatch after materialization");
|
|
183
|
+
}
|
|
184
|
+
return sourceHash;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
rmSync(destination, { recursive: true, force: true });
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Materialize a private writable dependency tree for one disposable run. */
|
|
192
|
+
export function materializeDependencySnapshot(dependency, destination) {
|
|
193
|
+
materializeDependencyTree(dependency.nodeModules, destination, {
|
|
194
|
+
expectedHash: dependency.snapshot.node_modules_hash,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function gitFile(root, commit, file, env) {
|
|
198
|
+
if (!FULL_SHA.test(commit) || replacementFreeExactCommit(root, commit) !== commit) {
|
|
199
|
+
throw new Error(`dependency snapshot commit ${commit} is not one exact commit`);
|
|
200
|
+
}
|
|
17
201
|
try {
|
|
18
202
|
return execFileSync("git", ["-C", root, "show", `${commit}:${file}`], {
|
|
19
203
|
encoding: "utf8",
|
|
204
|
+
env: replacementFreeGitEnvironment(env),
|
|
20
205
|
maxBuffer: 20 * 1024 * 1024,
|
|
21
206
|
stdio: ["ignore", "pipe", "ignore"],
|
|
22
207
|
});
|
|
@@ -62,7 +247,7 @@ function lockedPackageNames(lock) {
|
|
|
62
247
|
return names;
|
|
63
248
|
}
|
|
64
249
|
function normalizeAllowlist(values, lock) {
|
|
65
|
-
const result = [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
|
250
|
+
const result = [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort(compareCodeUnits);
|
|
66
251
|
const locked = lockedPackageNames(lock);
|
|
67
252
|
for (const name of result) {
|
|
68
253
|
if (!PACKAGE_NAME.test(name))
|
|
@@ -104,8 +289,8 @@ function runtimeIdentity(env) {
|
|
|
104
289
|
return { node: process.version, npm: result.stdout.trim(), platform: process.platform, arch: process.arch };
|
|
105
290
|
}
|
|
106
291
|
function snapshotInput(root, commit, allowInstallScripts, env) {
|
|
107
|
-
const packageJson = gitFile(root, commit, "package.json");
|
|
108
|
-
const packageLock = gitFile(root, commit, "package-lock.json");
|
|
292
|
+
const packageJson = gitFile(root, commit, "package.json", env);
|
|
293
|
+
const packageLock = gitFile(root, commit, "package-lock.json", env);
|
|
109
294
|
const pkg = parseObject(packageJson, "package.json");
|
|
110
295
|
const lock = parseObject(packageLock, "package-lock.json");
|
|
111
296
|
if (lock.lockfileVersion !== 2 && lock.lockfileVersion !== 3) {
|
|
@@ -139,7 +324,7 @@ function snapshotInput(root, commit, allowInstallScripts, env) {
|
|
|
139
324
|
function nativeInventory(nodeModules) {
|
|
140
325
|
const files = [];
|
|
141
326
|
const walk = (dir, relative) => {
|
|
142
|
-
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name
|
|
327
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => compareCodeUnits(a.name, b.name))) {
|
|
143
328
|
const absolute = join(dir, entry.name);
|
|
144
329
|
const next = relative ? `${relative}/${entry.name}` : entry.name;
|
|
145
330
|
if (entry.isSymbolicLink())
|
|
@@ -171,6 +356,8 @@ function readSnapshot(dir) {
|
|
|
171
356
|
const installedLock = join(nodeModules, ".package-lock.json");
|
|
172
357
|
if (!existsSync(nodeModules) || !lstatSync(nodeModules).isDirectory())
|
|
173
358
|
return null;
|
|
359
|
+
if (dependencySnapshotTreeHash(nodeModules) !== value.node_modules_hash)
|
|
360
|
+
return null;
|
|
174
361
|
const installedLockHash = existsSync(installedLock) ? sha256(readFileSync(installedLock)) : sha256("");
|
|
175
362
|
if (installedLockHash !== value.installed_lock_hash)
|
|
176
363
|
return null;
|
|
@@ -232,6 +419,7 @@ function buildSnapshot(base, input, env, timeoutMs) {
|
|
|
232
419
|
const nodeModules = join(work, "node_modules");
|
|
233
420
|
const installedLock = join(nodeModules, ".package-lock.json");
|
|
234
421
|
mkdirSync(nodeModules, { recursive: true });
|
|
422
|
+
const nodeModulesHash = dependencySnapshotTreeHash(nodeModules);
|
|
235
423
|
const body = {
|
|
236
424
|
input_hash: input.inputHash,
|
|
237
425
|
package_json_hash: input.packageJsonHash,
|
|
@@ -242,6 +430,7 @@ function buildSnapshot(base, input, env, timeoutMs) {
|
|
|
242
430
|
allow_install_scripts: input.allowInstallScripts,
|
|
243
431
|
installed_lock_hash: existsSync(installedLock) ? sha256(readFileSync(installedLock)) : sha256(""),
|
|
244
432
|
native_binaries: nativeInventory(nodeModules),
|
|
433
|
+
node_modules_hash: nodeModulesHash,
|
|
245
434
|
format_version: SNAPSHOT_VERSION,
|
|
246
435
|
data_class: "private",
|
|
247
436
|
authority: "none",
|
|
@@ -298,8 +487,8 @@ export function dependencySnapshotForCommit(root, commit, allowedIds) {
|
|
|
298
487
|
let packageJson;
|
|
299
488
|
let packageLock;
|
|
300
489
|
try {
|
|
301
|
-
packageJson = gitFile(root, commit, "package.json");
|
|
302
|
-
packageLock = gitFile(root, commit, "package-lock.json");
|
|
490
|
+
packageJson = gitFile(root, commit, "package.json", env);
|
|
491
|
+
packageLock = gitFile(root, commit, "package-lock.json", env);
|
|
303
492
|
}
|
|
304
493
|
catch {
|
|
305
494
|
return null;
|
|
@@ -343,7 +532,7 @@ export function provisionG2BehaviorDependencySnapshotsForCommits(root, commits,
|
|
|
343
532
|
});
|
|
344
533
|
return {
|
|
345
534
|
snapshots: [...new Map([...byInput.values()].map((snapshot) => [snapshot.id, snapshot])).values()]
|
|
346
|
-
.sort((left, right) => left.id
|
|
535
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id)),
|
|
347
536
|
commits: mapped,
|
|
348
537
|
};
|
|
349
538
|
}
|
|
@@ -367,7 +556,7 @@ export function provisionG2BehaviorDependencySnapshots(root, report, candidate,
|
|
|
367
556
|
known_bad: { commit: candidate.proposed_corpus.known_bad.ref, dependency_snapshot_id: bad.dependency_snapshot_id },
|
|
368
557
|
known_good: { commit: candidate.proposed_corpus.known_good.ref, dependency_snapshot_id: good.dependency_snapshot_id },
|
|
369
558
|
},
|
|
370
|
-
allow_install_scripts: [...new Set(allowInstallScripts.map((value) => value.trim()).filter(Boolean))].sort(),
|
|
559
|
+
allow_install_scripts: [...new Set(allowInstallScripts.map((value) => value.trim()).filter(Boolean))].sort(compareCodeUnits),
|
|
371
560
|
data_class: "private",
|
|
372
561
|
authority: "none",
|
|
373
562
|
effects: "cache_only",
|
|
@@ -77,7 +77,7 @@ export function buildG2CandidateReview(store, root, opts = {}, resolutions = [])
|
|
|
77
77
|
const graphStore = new HunchStore(hunchPathsForDir(scratchRoot));
|
|
78
78
|
try {
|
|
79
79
|
graphStore.json.ensureDirs();
|
|
80
|
-
indexRepo(graphStore, root, { churn: false });
|
|
80
|
+
indexRepo(graphStore, root, { churn: false, requireComplete: true });
|
|
81
81
|
return buildFromIndexedGraph(store, graphStore, root, opts, resolutions);
|
|
82
82
|
}
|
|
83
83
|
finally {
|
|
@@ -24,11 +24,25 @@ export function blockingEvidenceError(proof, dispositions = []) {
|
|
|
24
24
|
return assessHistoryDispositions(proof, dispositions).blocking_error;
|
|
25
25
|
}
|
|
26
26
|
const proofRank = { P0: 0, P1: 1, P2: 2, P3: 3, P4: 4, P5: 5 };
|
|
27
|
+
/** Activation is a runtime property, not merely an approval-time check. The
|
|
28
|
+
* audit fallback keeps correction policies compiled before `origin` was added
|
|
29
|
+
* fail-closed after they are loaded by the current schema. */
|
|
30
|
+
export function activationGateError(policy) {
|
|
31
|
+
const isMd1Correction = policy.origin === "correction_md1a"
|
|
32
|
+
|| policy.audit.some((event) => event.action === "compiled" && event.actor === "hunch:correction-policy-materializer");
|
|
33
|
+
if (isMd1Correction && policy.activation_gate?.status !== "blocked") {
|
|
34
|
+
return "MD-1a correction policy is missing its required source-currentness activation gate";
|
|
35
|
+
}
|
|
36
|
+
return policy.activation_gate?.status === "blocked" ? policy.activation_gate.reason : null;
|
|
37
|
+
}
|
|
27
38
|
/** Rechecked on every blocking evaluation; a hand-edited lifecycle flag without
|
|
28
39
|
* a current P3 proof is a configuration error, never authority. */
|
|
29
40
|
export function blockingProofError(policy, proof, dispositions = [], composition = [], currentBehaviorAttestations = []) {
|
|
30
41
|
if (policy.state !== "active_blocking")
|
|
31
42
|
return null;
|
|
43
|
+
const activationError = activationGateError(policy);
|
|
44
|
+
if (activationError)
|
|
45
|
+
return activationError;
|
|
32
46
|
if (policy.authority?.kind !== "human")
|
|
33
47
|
return "active blocking policy has no human authority event";
|
|
34
48
|
const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
|
|
@@ -95,6 +109,9 @@ export function proposeProvedPolicy(policy, proof, at, composition = [], current
|
|
|
95
109
|
};
|
|
96
110
|
}
|
|
97
111
|
export function approvePolicy(policy, proof, mode, actor, at, dispositions = [], composition = [], currentBehaviorAttestations = []) {
|
|
112
|
+
const activationError = activationGateError(policy);
|
|
113
|
+
if (activationError)
|
|
114
|
+
throw new Error(`policy ${policy.id} cannot activate: ${activationError}`);
|
|
98
115
|
requireHuman(actor);
|
|
99
116
|
const behaviorAttestationError = executableBehaviorAttestationError(policy, currentBehaviorAttestations);
|
|
100
117
|
if (behaviorAttestationError)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
2
1
|
import { shortHash } from "../core/ids.js";
|
|
3
|
-
import {
|
|
2
|
+
import { stableRepositoryName } from "../extractors/git.js";
|
|
4
3
|
import { canonicalHash, policySemanticHash } from "./canonical.js";
|
|
5
4
|
import { policyCompositionBinding, policyProofHash } from "./composition.js";
|
|
6
5
|
import { graphSnapshot, mutationOperatorForPolicy, selectedPolicyForComposition } from "./evaluator.js";
|
|
7
6
|
import { createExecutableBehaviorProofPlan } from "./behaviorProof.js";
|
|
7
|
+
import { canonicalStaticGraphBaseline, isAncestorOrSame } from "./staticGraphBaseline.js";
|
|
8
|
+
import { replacementFreeExactCommit, replacementFreeFirstCommitForFile } from "./replacementFreeGit.js";
|
|
9
|
+
export { canonicalStaticGraphBaseline } from "./staticGraphBaseline.js";
|
|
8
10
|
import { POLICY_EVALUATOR, MUTATION_ENGINE, ProofPlanSchema, } from "./schema.js";
|
|
9
11
|
function clamp(value, fallback, min, max) {
|
|
10
12
|
if (value == null || !Number.isFinite(value))
|
|
@@ -46,19 +48,26 @@ export function createProofPlan(store, root, repository, policy, opts = {}) {
|
|
|
46
48
|
throw new Error("executable-behavior policies cannot have exception composition");
|
|
47
49
|
return createExecutableBehaviorProofPlan(root, repository, policy, { now: opts.now, privateOnly: true });
|
|
48
50
|
}
|
|
49
|
-
const
|
|
50
|
-
if (!
|
|
51
|
+
const repositoryHead = replacementFreeExactCommit(root, "HEAD");
|
|
52
|
+
if (!repositoryHead)
|
|
51
53
|
throw new Error("proof planning needs a Git repository with a current HEAD");
|
|
54
|
+
const head = canonicalStaticGraphBaseline(root, repositoryHead);
|
|
52
55
|
const composition = opts.composition ?? [];
|
|
53
56
|
const parentHash = policySemanticHash(policy);
|
|
54
57
|
const policyHash = policyProofHash(policy, composition);
|
|
55
58
|
const compositionBinding = policyCompositionBinding(policy, composition);
|
|
56
59
|
const corpus = repository.getCorpus(policy.id, opts);
|
|
60
|
+
// Proof plans are shared memory. A clone-local directory basename would mint
|
|
61
|
+
// different plan IDs for Architect, Developer, CI, and linked worktrees even
|
|
62
|
+
// when every one of them is looking at the same repository and policy. An
|
|
63
|
+
// existing immutable corpus remains the compatibility authority for artifacts
|
|
64
|
+
// created before stable repository identities were introduced.
|
|
65
|
+
const repositoryName = opts.repositoryName ?? corpus?.repository ?? stableRepositoryName(root);
|
|
57
66
|
if (corpus) {
|
|
58
67
|
if (corpus.policy_hash !== parentHash) {
|
|
59
68
|
throw new Error(`proof corpus ${corpus.id} is stale for policy ${policy.id}; re-import it after the policy semantic change`);
|
|
60
69
|
}
|
|
61
|
-
if (corpus.repository !==
|
|
70
|
+
if (corpus.repository !== repositoryName || corpus.data_class !== policy.data_class) {
|
|
62
71
|
throw new Error(`proof corpus ${corpus.id} does not match repository/data class for policy ${policy.id}`);
|
|
63
72
|
}
|
|
64
73
|
}
|
|
@@ -73,11 +82,19 @@ export function createProofPlan(store, root, repository, policy, opts = {}) {
|
|
|
73
82
|
.filter((ref) => ref.startsWith("dec_"))
|
|
74
83
|
.map(readDecision)
|
|
75
84
|
.find((record) => !!record);
|
|
76
|
-
const policyCommit =
|
|
85
|
+
const policyCommit = replacementFreeFirstCommitForFile(root, `.hunch/policies/${policy.id}.json`);
|
|
77
86
|
const sourceRef = sourceEvent?.commit ?? decision?.commit ?? (policyCommit || head);
|
|
78
|
-
|
|
87
|
+
const rawSource = replacementFreeExactCommit(root, sourceRef);
|
|
88
|
+
if (!rawSource)
|
|
79
89
|
throw new Error(`proof-plan source commit ${sourceRef} does not resolve in this repository`);
|
|
80
|
-
|
|
90
|
+
// Canonicalize the source independently of current HEAD. A policy introduced
|
|
91
|
+
// by a Hunch-only publication commit remains anchored to the indexed-code (or
|
|
92
|
+
// merge) boundary immediately before that publication, even after later code
|
|
93
|
+
// commits advance the current baseline.
|
|
94
|
+
const sourceCommit = canonicalStaticGraphBaseline(root, rawSource);
|
|
95
|
+
if (!isAncestorOrSame(root, sourceCommit, head)) {
|
|
96
|
+
throw new Error(`proof-plan source commit ${sourceCommit} is not an ancestor of canonical graph baseline ${head}`);
|
|
97
|
+
}
|
|
81
98
|
const structural = events.find((event) => event.structural_delta && (event.kind === "bug_fix" || event.kind === "revert" || event.kind === "decision"));
|
|
82
99
|
const structuralKnownBad = structural?.structural_delta
|
|
83
100
|
? [{
|
|
@@ -112,7 +129,7 @@ export function createProofPlan(store, root, repository, policy, opts = {}) {
|
|
|
112
129
|
const body = {
|
|
113
130
|
policy_id: policy.id,
|
|
114
131
|
policy_candidate_hash: policyHash,
|
|
115
|
-
repository:
|
|
132
|
+
repository: repositoryName,
|
|
116
133
|
data_class: policy.data_class,
|
|
117
134
|
source_commit: sourceCommit,
|
|
118
135
|
valid_from_commit: sourceCommit,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { foreignRepoEnv } from "../extractors/git.js";
|
|
3
|
+
/** Constitution proof identity is the repository's real object graph, never a
|
|
4
|
+
* clone-local `refs/replace/*` or legacy graft view. Keep this environment
|
|
5
|
+
* private to proof planning so every traversal and ancestry check agrees with
|
|
6
|
+
* replay, which uses the same Git invariant. */
|
|
7
|
+
export function replacementFreeGitEnvironment(source = process.env) {
|
|
8
|
+
return {
|
|
9
|
+
...foreignRepoEnv(source),
|
|
10
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function gitText(root, args, maxBuffer = 64 * 1024 * 1024) {
|
|
14
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
env: replacementFreeGitEnvironment(),
|
|
17
|
+
maxBuffer,
|
|
18
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
+
}).trim();
|
|
20
|
+
}
|
|
21
|
+
function gitTextSafe(root, args, maxBuffer) {
|
|
22
|
+
try {
|
|
23
|
+
return gitText(root, args, maxBuffer);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Resolve one commit-ish through the real object graph. */
|
|
30
|
+
export function replacementFreeExactCommit(root, ref) {
|
|
31
|
+
const oid = gitTextSafe(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).toLowerCase();
|
|
32
|
+
return /^[0-9a-f]{40,64}$/.test(oid) ? oid : null;
|
|
33
|
+
}
|
|
34
|
+
/** Files changed by an exact commit, ignoring local replacement objects. */
|
|
35
|
+
export function replacementFreeCommitFiles(root, commit) {
|
|
36
|
+
const out = gitTextSafe(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
|
|
37
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
38
|
+
}
|
|
39
|
+
export function replacementFreeCommitMeta(root, commit) {
|
|
40
|
+
const raw = gitTextSafe(root, ["show", "-s", "--format=%H%x1f%h%x1f%s%x1f%b%x1f%an%x1f%aI", commit]);
|
|
41
|
+
if (!raw)
|
|
42
|
+
return null;
|
|
43
|
+
const [sha = "", shortSha = "", subject = "", body = "", author = "", date = ""] = raw.split("\x1f");
|
|
44
|
+
if (!/^[0-9a-f]{40,64}$/i.test(sha))
|
|
45
|
+
return null;
|
|
46
|
+
return { sha, shortSha, subject, body, author, date, files: replacementFreeCommitFiles(root, sha) };
|
|
47
|
+
}
|
|
48
|
+
/** Exact introducing commit for a Git-native policy record. */
|
|
49
|
+
export function replacementFreeFirstCommitForFile(root, file) {
|
|
50
|
+
const added = gitTextSafe(root, ["log", "--diff-filter=A", "--format=%H", "--", file])
|
|
51
|
+
.split("\n")
|
|
52
|
+
.find(Boolean);
|
|
53
|
+
if (added)
|
|
54
|
+
return added;
|
|
55
|
+
return gitTextSafe(root, ["log", "--reverse", "--format=%H", "--", file])
|
|
56
|
+
.split("\n")
|
|
57
|
+
.find(Boolean) ?? "";
|
|
58
|
+
}
|
|
59
|
+
export function replacementFreeIsAncestorOrSame(root, ancestor, descendant) {
|
|
60
|
+
if (ancestor === descendant)
|
|
61
|
+
return true;
|
|
62
|
+
return spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", ancestor, descendant], {
|
|
63
|
+
env: replacementFreeGitEnvironment(),
|
|
64
|
+
stdio: "ignore",
|
|
65
|
+
}).status === 0;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=replacementFreeGit.js.map
|
|
@@ -7,6 +7,7 @@ import { canonicalHash, proofEvaluationHash, proofPlanContentHash } from "./cano
|
|
|
7
7
|
import { assertCompositionBinding, policyProofHash } from "./composition.js";
|
|
8
8
|
import { evaluateCompositePolicyOnSnapshot, evaluatePolicyOnSnapshot } from "./evaluator.js";
|
|
9
9
|
import { loadReplaySnapshot, putReplaySnapshot } from "./replayCache.js";
|
|
10
|
+
import { hasUnsafeCheckoutAttributes } from "./safeCheckout.js";
|
|
10
11
|
import { POLICY_EVALUATOR, ProofPlanSchema, ReplayReceiptSchema, } from "./schema.js";
|
|
11
12
|
const ZERO_SHA = "0".repeat(40);
|
|
12
13
|
export const DEFAULT_REPLAY_WORKERS = 4;
|
|
@@ -26,6 +27,7 @@ export function replaySafeEnvironment(home, gitConfig) {
|
|
|
26
27
|
HOME: home,
|
|
27
28
|
GIT_CONFIG_GLOBAL: gitConfig,
|
|
28
29
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
30
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
29
31
|
GIT_TERMINAL_PROMPT: "0",
|
|
30
32
|
GIT_LFS_SKIP_SMUDGE: "1",
|
|
31
33
|
HUNCH_PRIVATE_DIR: "",
|
|
@@ -204,6 +206,10 @@ export function replayProofPlan(root, policy, inputPlan, opts = {}) {
|
|
|
204
206
|
outcomes.set(commit, { commit, error_code: "timeout" });
|
|
205
207
|
continue;
|
|
206
208
|
}
|
|
209
|
+
if (hasUnsafeCheckoutAttributes(root, commit, env, { allowDisabledLfs: true })) {
|
|
210
|
+
outcomes.set(commit, { commit, error_code: "unsafe-checkout-attributes" });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
207
213
|
const cached = loadReplaySnapshot(root, commit, plan.data_class);
|
|
208
214
|
if (cached.status === "hit" && cached.snapshot) {
|
|
209
215
|
cacheStats.hits++;
|
|
@@ -6,7 +6,7 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
6
6
|
import { canonicalHash } from "./canonical.js";
|
|
7
7
|
import { graphSnapshotFromRecords } from "./evaluator.js";
|
|
8
8
|
import { DataClassSchema, POLICY_EVALUATOR } from "./schema.js";
|
|
9
|
-
export const REPLAY_CACHE_ENGINE = { name: "hunch-tsjs-static-index", version: "
|
|
9
|
+
export const REPLAY_CACHE_ENGINE = { name: "hunch-tsjs-static-index", version: "4" };
|
|
10
10
|
const ReplayGraphCacheSchema = z.object({
|
|
11
11
|
version: z.literal(1),
|
|
12
12
|
engine: z.object({ name: z.string().min(1), version: z.string().min(1) }),
|
|
@@ -13,7 +13,7 @@ let message;
|
|
|
13
13
|
try {
|
|
14
14
|
store = new HunchStore(hunchPathsForDir(input.graph));
|
|
15
15
|
store.json.ensureDirs();
|
|
16
|
-
indexRepo(store, input.checkout, { churn: false });
|
|
16
|
+
indexRepo(store, input.checkout, { churn: false, requireComplete: true });
|
|
17
17
|
message = {
|
|
18
18
|
commit: input.commit,
|
|
19
19
|
snapshot: graphSnapshot(store, input.root, { publicOnly: true, head: input.commit }),
|