@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.
Files changed (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  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 +96 -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 +74 -14
  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 +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. package/tooling/md1-benchmark.mjs +628 -0
@@ -4,22 +4,51 @@
4
4
  * resolve a best-effort call graph + import dependency graph, derive components
5
5
  * from the directory layout, and compute churn / fan-in / fan-out metrics.
6
6
  *
7
- * Writes Symbol/Edge/Component records to the JSON source of truth. The caller
7
+ * `scanRepo` derives Symbol/Edge/Component records without mutating the store.
8
+ * `indexRepo` persists that exact scan into the JSON source of truth; its caller
8
9
  * then runs HunchStore.reindex() to refresh the SQLite index.
9
10
  */
10
- import { readFileSync, statSync, readdirSync } from "node:fs";
11
- import { join, relative, dirname, posix } from "node:path";
11
+ import { dirname, posix } from "node:path";
12
12
  import { parseSource, attributeCalls } from "./parse.js";
13
13
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
14
14
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
15
15
  import { resolveRelativeImport } from "../core/relativeImports.js";
16
16
  import { extracted, inferred } from "../core/types.js";
17
- import { isGitRepo, trackedFiles, fileGitMetrics } from "./git.js";
18
- import { CODE_EXTENSIONS, languageFor } from "./languages.js";
19
- const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".hunch", "coverage", ".next", "out"]);
20
- export function indexRepo(store, root, opts = {}) {
21
- const files = listCodeFiles(root);
17
+ import { isGitRepo, fileGitMetrics, revExists } from "./git.js";
18
+ import { languageFor } from "./languages.js";
19
+ import { assertCleanIndexedCode, repoSourceInventory, } from "./repoSource.js";
20
+ /** Read-only policy evaluators must never turn an omitted source file into a
21
+ * false satisfied receipt. Call this after scanRepo when the consumer cannot
22
+ * represent partial-graph uncertainty directly. */
23
+ export function assertCompleteRepoScan(scan) {
24
+ if (!scan.issues.length)
25
+ return;
26
+ const sample = scan.issues.slice(0, 5)
27
+ .map((issue) => `${issue.path} [${issue.code}]`)
28
+ .join(", ");
29
+ const more = scan.issues.length > 5 ? ` (+${scan.issues.length - 5} more)` : "";
30
+ throw new Error(`incomplete semantic source scan rejected ${scan.issues.length} file(s): ${sample}${more}`);
31
+ }
32
+ /** Derive the current repository graph without writing JSON or rebuilding SQLite.
33
+ * Read-only gates use this so checking changed code can never rewrite or publish
34
+ * the durable graph merely by inspecting it. Existing public graph records remain
35
+ * inputs for the same churn/component enrichment semantics as a persisted index. */
36
+ export function scanRepo(store, root, opts = {}) {
37
+ const inventory = repoSourceInventory(root, opts.source);
38
+ const files = inventory.entries;
22
39
  const useGit = isGitRepo(root);
40
+ // Fast scans intentionally skip the expensive 90-day history walk. Zero is
41
+ // not a fresh measurement, though: overwriting a previously measured value
42
+ // makes full and fast scans ping-pong symbols/index.json and can create an
43
+ // endless stream of memory-only commits. Churn is file-level, so retain the
44
+ // latest durable value for every still-indexed file; genuinely new files use
45
+ // the conservative zero default until the next full scan.
46
+ const preservedChurn = new Map();
47
+ if (opts.churn === false) {
48
+ for (const symbol of store.json.loadAll("symbols")) {
49
+ preservedChurn.set(symbol.file, Math.max(preservedChurn.get(symbol.file) ?? 0, symbol.metrics.churn_90d));
50
+ }
51
+ }
23
52
  // ---- pass 1: parse files -> symbols, remember per-file calls & imports ----
24
53
  const symbols = [];
25
54
  const nameIndex = new Map(); // symbol name -> [symbol ids]
@@ -29,19 +58,27 @@ export function indexRepo(store, root, opts = {}) {
29
58
  const perFileImports = [];
30
59
  // Batched per-file git metrics (churn + last commit) in TWO `git log` spawns
31
60
  // total, instead of two per file — the dominant cost of indexing a large repo.
32
- const rels = files.map((abs) => toPosix(relative(root, abs)));
61
+ const rels = files.map((file) => file.path);
33
62
  const gitMeta = useGit ? fileGitMetrics(root, rels, opts.churn === false ? 0 : 90) : null;
34
63
  let skipped = 0;
35
- for (const abs of files) {
36
- const rel = toPosix(relative(root, abs));
37
- let src;
38
- try {
39
- src = readFileSync(abs, "utf8");
64
+ const issues = [];
65
+ const sourceFingerprint = [];
66
+ for (const file of files) {
67
+ const rel = file.path;
68
+ const read = file.read();
69
+ if (read.absent) {
70
+ sourceFingerprint.push({ path: rel, mode: read.mode, content: "absent" });
71
+ continue;
40
72
  }
41
- catch {
73
+ if (read.source === null) {
42
74
  skipped++;
75
+ const issue = read.issue ?? { path: rel, code: "read_failed", detail: `${rel} could not be read` };
76
+ issues.push(issue);
77
+ sourceFingerprint.push({ path: rel, mode: read.mode, content: read.contentHash ?? `skipped:${issue.code}` });
43
78
  continue;
44
79
  }
80
+ const src = read.source;
81
+ sourceFingerprint.push({ path: rel, mode: read.mode, content: read.contentHash });
45
82
  // one bad/oversized file must never abort the whole index run
46
83
  let parsed;
47
84
  try {
@@ -49,14 +86,21 @@ export function indexRepo(store, root, opts = {}) {
49
86
  }
50
87
  catch {
51
88
  skipped++;
89
+ issues.push({ path: rel, code: "parse_failed", detail: `${rel} could not be parsed` });
52
90
  continue;
53
91
  }
54
92
  if (!parsed) {
55
93
  skipped++;
94
+ issues.push({ path: rel, code: "parse_failed", detail: `${rel} has no supported parser` });
95
+ continue;
96
+ }
97
+ if (!parsed.parseable) {
98
+ skipped++;
99
+ issues.push({ path: rel, code: "parse_failed", detail: `${rel} contains syntax errors and cannot prove a complete semantic graph` });
56
100
  continue;
57
101
  }
58
102
  const m = gitMeta?.get(rel);
59
- const churn = m?.churn ?? 0;
103
+ const churn = opts.churn === false ? (preservedChurn.get(rel) ?? 0) : (m?.churn ?? 0);
60
104
  const last = m?.lastCommit ?? "";
61
105
  const idsInFile = [];
62
106
  const startByteId = new Map();
@@ -189,9 +233,6 @@ export function indexRepo(store, root, opts = {}) {
189
233
  }
190
234
  }
191
235
  }
192
- // persist
193
- store.json.replaceAll("symbols", symbols);
194
- store.json.replaceAll("edges", edges);
195
236
  // Components are derived-but-ENRICHED records: layout facts (paths, kind, name)
196
237
  // come from this scan, while curation/synthesis (responsibility, owners, status,
197
238
  // fragility from raiseFragility, upgraded provenance) lives only on the stored
@@ -215,36 +256,34 @@ export function indexRepo(store, root, opts = {}) {
215
256
  };
216
257
  return stamp(merged) === stamp(prev) ? prev : { ...merged, updated_at: draft.updated_at };
217
258
  });
218
- store.json.replaceAll("components", compsOut);
219
- return { files: files.length, symbols: symbols.length, edges: edges.length, components: compsOut.length, skipped };
220
- }
221
- // ---- helpers --------------------------------------------------------------
222
- function listCodeFiles(root) {
223
- if (isGitRepo(root)) {
224
- // Apply SKIP_DIRS to the git-tracked list too: a repo that (accidentally)
225
- // tracks node_modules/ or dist/ must not flood the graph with vendored symbols.
226
- const tracked = trackedFiles(root, CODE_EXTENSIONS)
227
- .filter((f) => !f.split(/[\\/]/).some((seg) => SKIP_DIRS.has(seg)))
228
- .map((f) => join(root, f));
229
- if (tracked.length > 0)
230
- return tracked; // else fall through (nothing committed yet)
231
- }
232
- const out = [];
233
- const walk = (dir) => {
234
- for (const name of readdirSync(dir)) {
235
- if (SKIP_DIRS.has(name))
236
- continue;
237
- const abs = join(dir, name);
238
- const st = statSync(abs);
239
- if (st.isDirectory())
240
- walk(abs);
241
- else if (languageFor(name) !== null)
242
- out.push(abs);
243
- }
259
+ return {
260
+ result: { files: files.length, symbols: symbols.length, edges: edges.length, components: compsOut.length, skipped },
261
+ symbols,
262
+ edges,
263
+ components: compsOut,
264
+ source: { ...inventory.identity, content_hash: sha1(JSON.stringify(sourceFingerprint)) },
265
+ issues,
244
266
  };
245
- walk(root);
246
- return out;
247
267
  }
268
+ /** Persist one pure scan into the Git-native source of truth. */
269
+ export function indexRepo(store, root, opts = {}) {
270
+ if (opts.requireClean)
271
+ assertCleanIndexedCode(root);
272
+ // A clean preflight followed by a filesystem read still has a TOCTOU seam.
273
+ // Durable Git-backed publication therefore derives from immutable HEAD blobs;
274
+ // unborn and non-Git repositories retain the historical safe-filesystem path.
275
+ const source = opts.requireClean && isGitRepo(root) && revExists("HEAD", root)
276
+ ? { kind: "commit", ref: "HEAD" }
277
+ : opts.source;
278
+ const scan = scanRepo(store, root, { churn: opts.churn, source });
279
+ if (opts.requireComplete)
280
+ assertCompleteRepoScan(scan);
281
+ store.json.replaceAll("symbols", scan.symbols);
282
+ store.json.replaceAll("edges", scan.edges);
283
+ store.json.replaceAll("components", scan.components);
284
+ return scan.result;
285
+ }
286
+ // ---- helpers --------------------------------------------------------------
248
287
  /** Resolve a callee name to a symbol id: prefer same-file, otherwise require a
249
288
  * unique symbol in a statically imported local file. A unique repository-wide
250
289
  * name is not evidence of a binding: callback parameters and built-ins often
@@ -0,0 +1,390 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { lstatSync, readdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { TextDecoder } from "node:util";
6
+ import { MAX_REPO_SOURCE_FILE_BYTES, createRepoFileBufferReader } from "../core/safeRepoFile.js";
7
+ import { compareCodeUnits } from "../core/canonicalOrder.js";
8
+ import { foreignRepoEnv, gitNullDevice } from "./git.js";
9
+ import { languageFor } from "./languages.js";
10
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".hunch", "coverage", ".next", "out"]);
11
+ const ORDINARY_BLOB_MODES = new Set(["100644", "100755"]);
12
+ const GIT_MAX_LISTING_BYTES = 64 * 1024 * 1024;
13
+ function rawContentHash(bytes) {
14
+ return `sha1:${createHash("sha1").update(bytes).digest("hex")}`;
15
+ }
16
+ function decodedSource(path, mode, bytes) {
17
+ const contentHash = rawContentHash(bytes);
18
+ try {
19
+ return { source: UTF8_DECODER.decode(bytes), mode, contentHash };
20
+ }
21
+ catch {
22
+ return {
23
+ source: null,
24
+ mode,
25
+ contentHash,
26
+ issue: { path, code: "invalid_encoding", detail: `${path} is not valid UTF-8 and cannot be parsed losslessly` },
27
+ };
28
+ }
29
+ }
30
+ function gitEnv(preserveInvocationIndex = false) {
31
+ const env = {
32
+ ...foreignRepoEnv(process.env),
33
+ GIT_CONFIG_NOSYSTEM: "1",
34
+ GIT_CONFIG_GLOBAL: gitNullDevice(),
35
+ GIT_NO_REPLACE_OBJECTS: "1",
36
+ };
37
+ // A partial/pre-commit workflow can intentionally select an alternate index.
38
+ // `foreignRepoEnv` must clear it for commit/replay work in another checkout,
39
+ // but staged/working source selection in the invocation repo has to use the
40
+ // same index as changed-file enumeration or the gate can inspect two worlds.
41
+ if (preserveInvocationIndex && process.env.GIT_INDEX_FILE) {
42
+ env.GIT_INDEX_FILE = process.env.GIT_INDEX_FILE;
43
+ }
44
+ return env;
45
+ }
46
+ function gitBuffer(root, args, maxBuffer = GIT_MAX_LISTING_BYTES, preserveInvocationIndex = false) {
47
+ return execFileSync("git", ["-C", root, ...args], {
48
+ encoding: "buffer",
49
+ env: gitEnv(preserveInvocationIndex),
50
+ maxBuffer,
51
+ stdio: ["ignore", "pipe", "ignore"],
52
+ timeout: 15_000,
53
+ });
54
+ }
55
+ function gitText(root, args) {
56
+ return gitBuffer(root, args, 1024 * 1024).toString("utf8").trim();
57
+ }
58
+ function isGitRepository(root) {
59
+ try {
60
+ return gitText(root, ["rev-parse", "--is-inside-work-tree"]) === "true";
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ function isSafeRelativePath(path) {
67
+ const segments = path.split("/");
68
+ return !!path
69
+ && !path.startsWith("/")
70
+ && !path.includes("\\")
71
+ && !/^[A-Za-z]:/.test(path)
72
+ && segments.every((segment) => !!segment && segment !== "." && segment !== ".." && segment.toLowerCase() !== ".git");
73
+ }
74
+ function isSkippedPath(path) {
75
+ return path.split("/").some((segment) => SKIP_DIRS.has(segment));
76
+ }
77
+ export function isIndexedCodePath(path) {
78
+ return languageFor(path) !== null && !isSkippedPath(path);
79
+ }
80
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
81
+ function nulRecords(bytes) {
82
+ const records = [];
83
+ let start = 0;
84
+ for (let end = bytes.indexOf(0, start); end !== -1; end = bytes.indexOf(0, start)) {
85
+ if (end > start)
86
+ records.push(bytes.subarray(start, end));
87
+ start = end + 1;
88
+ }
89
+ if (start < bytes.length)
90
+ records.push(bytes.subarray(start));
91
+ return records;
92
+ }
93
+ function decodeGitPath(bytes) {
94
+ try {
95
+ const path = UTF8_DECODER.decode(bytes);
96
+ return { path, invalidUtf8: false, indexedCode: isIndexedCodePath(path) };
97
+ }
98
+ catch {
99
+ // Git pathnames are arbitrary bytes on POSIX. Never collapse distinct byte
100
+ // sequences through U+FFFD: bind the raw bytes to a stable opaque label and
101
+ // make the semantic scan incomplete/fail-closed.
102
+ const fingerprint = createHash("sha256").update(bytes).digest("hex");
103
+ return { path: `<non-utf8-git-path:sha256:${fingerprint}>`, invalidUtf8: true, indexedCode: true };
104
+ }
105
+ }
106
+ function issueEntry(path, mode, code, detail) {
107
+ const issue = { path, code, detail };
108
+ return { path, mode, read: () => ({ source: null, mode, issue }) };
109
+ }
110
+ function filesystemEntry(root, path, allowMissing) {
111
+ const readFile = createRepoFileBufferReader(root);
112
+ return {
113
+ path,
114
+ mode: "filesystem",
115
+ read: () => {
116
+ const absolute = join(root, path);
117
+ let stat;
118
+ try {
119
+ stat = lstatSync(absolute);
120
+ }
121
+ catch (error) {
122
+ if (allowMissing && error.code === "ENOENT") {
123
+ return { source: null, mode: "absent", absent: true };
124
+ }
125
+ return {
126
+ source: null,
127
+ mode: "filesystem",
128
+ issue: { path, code: "read_failed", detail: `${path} disappeared or could not be inspected` },
129
+ };
130
+ }
131
+ if (stat.isSymbolicLink()) {
132
+ return { source: null, mode: "120000", issue: { path, code: "symlink", detail: `${path} is a symlink` } };
133
+ }
134
+ if (!stat.isFile()) {
135
+ return { source: null, mode: "non-regular", issue: { path, code: "non_regular", detail: `${path} is not a regular file` } };
136
+ }
137
+ const mode = stat.mode & 0o111 ? "100755" : "100644";
138
+ if (stat.size > MAX_REPO_SOURCE_FILE_BYTES) {
139
+ return { source: null, mode, issue: { path, code: "oversized", detail: `${path} exceeds the ${MAX_REPO_SOURCE_FILE_BYTES}-byte source limit` } };
140
+ }
141
+ const bytes = readFile(absolute);
142
+ return bytes === null
143
+ ? { source: null, mode, issue: { path, code: "read_failed", detail: `${path} changed identity or could not be read safely` } }
144
+ : decodedSource(path, mode, bytes);
145
+ },
146
+ };
147
+ }
148
+ function gitBlobEntry(root, path, mode, oid) {
149
+ return {
150
+ path,
151
+ mode,
152
+ read: () => {
153
+ try {
154
+ const type = gitText(root, ["cat-file", "-t", oid]);
155
+ const size = Number(gitText(root, ["cat-file", "-s", oid]));
156
+ if (type !== "blob" || !Number.isSafeInteger(size) || size < 0) {
157
+ return { source: null, mode, issue: { path, code: "read_failed", detail: `${path} does not resolve to an ordinary blob` } };
158
+ }
159
+ if (size > MAX_REPO_SOURCE_FILE_BYTES) {
160
+ return { source: null, mode, issue: { path, code: "oversized", detail: `${path} exceeds the ${MAX_REPO_SOURCE_FILE_BYTES}-byte source limit` } };
161
+ }
162
+ const bytes = gitBuffer(root, ["cat-file", "blob", oid], MAX_REPO_SOURCE_FILE_BYTES + 1);
163
+ if (bytes.length !== size) {
164
+ return { source: null, mode, issue: { path, code: "read_failed", detail: `${path} blob length changed while scanning` } };
165
+ }
166
+ return decodedSource(path, mode, bytes);
167
+ }
168
+ catch {
169
+ return { source: null, mode, issue: { path, code: "read_failed", detail: `${path} blob could not be read` } };
170
+ }
171
+ },
172
+ };
173
+ }
174
+ function indexRows(root, preserveInvocationIndex = false) {
175
+ const raw = gitBuffer(root, ["ls-files", "--cached", "--stage", "-z"], GIT_MAX_LISTING_BYTES, preserveInvocationIndex);
176
+ const rows = [];
177
+ for (const record of nulRecords(raw)) {
178
+ const tab = record.indexOf(0x09);
179
+ if (tab < 0)
180
+ throw new Error("could not parse the Git index while selecting semantic source");
181
+ const match = record.subarray(0, tab).toString("ascii").match(/^([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])$/i);
182
+ if (!match)
183
+ throw new Error("could not parse the Git index while selecting semantic source");
184
+ const decoded = decodeGitPath(record.subarray(tab + 1));
185
+ rows.push({
186
+ mode: match[1],
187
+ oid: match[2].toLowerCase(),
188
+ stage: Number(match[3]),
189
+ path: decoded.path,
190
+ invalidUtf8: decoded.invalidUtf8,
191
+ indexedCode: decoded.indexedCode,
192
+ });
193
+ }
194
+ return rows;
195
+ }
196
+ function normalizeCandidate(path, mode) {
197
+ if (!isIndexedCodePath(path))
198
+ return "skip";
199
+ if (!isSafeRelativePath(path))
200
+ return issueEntry(path, mode, "unsafe_path", `${path} is not a safe repository-relative source path`);
201
+ return null;
202
+ }
203
+ function stagedInventory(root) {
204
+ const grouped = new Map();
205
+ for (const row of indexRows(root, true)) {
206
+ const rows = grouped.get(row.path) ?? [];
207
+ rows.push(row);
208
+ grouped.set(row.path, rows);
209
+ }
210
+ const entries = [];
211
+ for (const path of [...grouped.keys()].sort(compareCodeUnits)) {
212
+ const rows = grouped.get(path);
213
+ if (rows.some((row) => row.invalidUtf8)) {
214
+ if (rows.some((row) => row.indexedCode)) {
215
+ entries.push(issueEntry(path, rows[0]?.mode ?? "index", "unsafe_path", `${path} is not valid UTF-8 and cannot be scanned losslessly`));
216
+ }
217
+ continue;
218
+ }
219
+ const preflight = normalizeCandidate(path, rows[0]?.mode ?? "index");
220
+ if (preflight === "skip")
221
+ continue;
222
+ if (preflight) {
223
+ entries.push(preflight);
224
+ continue;
225
+ }
226
+ if (rows.some((row) => row.stage !== 0)) {
227
+ entries.push(issueEntry(path, "conflicted", "conflicted", `${path} has unresolved Git index stages`));
228
+ continue;
229
+ }
230
+ const row = rows.find((candidate) => candidate.stage === 0);
231
+ if (!row) {
232
+ entries.push(issueEntry(path, "conflicted", "conflicted", `${path} has no stage-0 Git index blob`));
233
+ }
234
+ else if (!ORDINARY_BLOB_MODES.has(row.mode)) {
235
+ entries.push(issueEntry(path, row.mode, "unsafe_mode", `${path} uses unsupported Git mode ${row.mode}`));
236
+ }
237
+ else {
238
+ entries.push(gitBlobEntry(root, path, row.mode, row.oid));
239
+ }
240
+ }
241
+ return { identity: { kind: "staged" }, entries };
242
+ }
243
+ function exactCommit(root, ref) {
244
+ const revision = gitText(root, ["rev-parse", "--verify", `${ref}^{commit}`]).toLowerCase();
245
+ if (!/^[0-9a-f]{40,64}$/.test(revision))
246
+ throw new Error(`semantic source ref ${JSON.stringify(ref)} is not an exact commit`);
247
+ return revision;
248
+ }
249
+ function treeInventory(root, kind, ref) {
250
+ const revision = exactCommit(root, ref);
251
+ const raw = gitBuffer(root, ["ls-tree", "--full-tree", "-r", "-z", revision]);
252
+ const entries = [];
253
+ for (const record of nulRecords(raw)) {
254
+ const tab = record.indexOf(0x09);
255
+ if (tab < 0)
256
+ throw new Error(`could not parse exact ${kind} tree ${revision}`);
257
+ const match = record.subarray(0, tab).toString("ascii").match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40,64})$/i);
258
+ if (!match)
259
+ throw new Error(`could not parse exact ${kind} tree ${revision}`);
260
+ const mode = match[1];
261
+ const type = match[2];
262
+ const oid = match[3].toLowerCase();
263
+ const decoded = decodeGitPath(record.subarray(tab + 1));
264
+ const path = decoded.path;
265
+ if (decoded.invalidUtf8) {
266
+ if (decoded.indexedCode)
267
+ entries.push(issueEntry(path, mode, "unsafe_path", `${path} is not valid UTF-8 and cannot be scanned losslessly`));
268
+ continue;
269
+ }
270
+ const preflight = normalizeCandidate(path, mode);
271
+ if (preflight === "skip")
272
+ continue;
273
+ if (preflight) {
274
+ entries.push(preflight);
275
+ continue;
276
+ }
277
+ if (type !== "blob" || !ORDINARY_BLOB_MODES.has(mode)) {
278
+ entries.push(issueEntry(path, mode, "unsafe_mode", `${path} uses unsupported Git ${type} mode ${mode}`));
279
+ }
280
+ else {
281
+ entries.push(gitBlobEntry(root, path, mode, oid));
282
+ }
283
+ }
284
+ entries.sort((left, right) => compareCodeUnits(left.path, right.path));
285
+ return { identity: { kind, revision }, entries };
286
+ }
287
+ function nulPaths(root, args, preserveInvocationIndex = false) {
288
+ return nulRecords(gitBuffer(root, args, GIT_MAX_LISTING_BYTES, preserveInvocationIndex)).map(decodeGitPath);
289
+ }
290
+ function filesystemInventory(root, kind) {
291
+ if (!isGitRepository(root))
292
+ return walkedInventory(root, kind);
293
+ const preserveInvocationIndex = kind === "working";
294
+ const rows = indexRows(root, preserveInvocationIndex);
295
+ const tracked = new Set(rows.filter((row) => !row.invalidUtf8).map((row) => row.path));
296
+ const conflicted = new Set(rows.filter((row) => !row.invalidUtf8 && row.stage !== 0).map((row) => row.path));
297
+ const untracked = kind === "working"
298
+ ? nulPaths(root, ["ls-files", "--others", "--exclude-standard", "-z"], true)
299
+ : [];
300
+ // Preserve historical checkout-index behavior: when an unborn repository has
301
+ // no tracked source, initialization scans the safe filesystem instead.
302
+ if (kind === "checkout" && rows.length === 0)
303
+ return walkedInventory(root, kind);
304
+ const paths = [...new Set([...tracked, ...untracked.filter((item) => !item.invalidUtf8).map((item) => item.path)])].sort(compareCodeUnits);
305
+ const entries = [];
306
+ const invalid = new Map();
307
+ for (const row of rows) {
308
+ if (row.invalidUtf8 && row.indexedCode)
309
+ invalid.set(row.path, row.mode);
310
+ }
311
+ for (const item of untracked) {
312
+ if (item.invalidUtf8 && item.indexedCode)
313
+ invalid.set(item.path, "filesystem");
314
+ }
315
+ for (const [path, mode] of [...invalid].sort(([left], [right]) => compareCodeUnits(left, right))) {
316
+ entries.push(issueEntry(path, mode, "unsafe_path", `${path} is not valid UTF-8 and cannot be scanned losslessly`));
317
+ }
318
+ for (const path of paths) {
319
+ const preflight = normalizeCandidate(path, "filesystem");
320
+ if (preflight === "skip")
321
+ continue;
322
+ if (preflight) {
323
+ entries.push(preflight);
324
+ continue;
325
+ }
326
+ if (conflicted.has(path)) {
327
+ entries.push(issueEntry(path, "conflicted", "conflicted", `${path} has unresolved Git index stages`));
328
+ }
329
+ else {
330
+ entries.push(filesystemEntry(root, path, tracked.has(path)));
331
+ }
332
+ }
333
+ return { identity: { kind }, entries };
334
+ }
335
+ function walkedInventory(root, kind) {
336
+ const paths = [];
337
+ const walk = (dir, prefix = "") => {
338
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
339
+ if (SKIP_DIRS.has(entry.name))
340
+ continue;
341
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
342
+ if (entry.isDirectory())
343
+ walk(join(dir, entry.name), path);
344
+ else if (languageFor(path))
345
+ paths.push(path);
346
+ }
347
+ };
348
+ walk(root);
349
+ return {
350
+ identity: { kind },
351
+ entries: paths.sort(compareCodeUnits).map((path) => filesystemEntry(root, path, false)),
352
+ };
353
+ }
354
+ export function repoSourceInventory(root, source = { kind: "checkout" }) {
355
+ if (source.kind === "staged") {
356
+ if (!isGitRepository(root))
357
+ throw new Error("staged semantic source requires a Git repository");
358
+ return stagedInventory(root);
359
+ }
360
+ if (source.kind === "commit") {
361
+ if (!isGitRepository(root))
362
+ throw new Error("commit semantic source requires a Git repository");
363
+ return treeInventory(root, "commit", source.ref);
364
+ }
365
+ if (source.kind === "base") {
366
+ if (!isGitRepository(root))
367
+ throw new Error("base semantic source requires a Git repository");
368
+ return treeInventory(root, "base", "HEAD");
369
+ }
370
+ return filesystemInventory(root, source.kind);
371
+ }
372
+ export function dirtyIndexedCodePaths(root) {
373
+ if (!isGitRepository(root))
374
+ return [];
375
+ const paths = [
376
+ ...nulPaths(root, ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--no-renames", "--diff-filter=ACDMRTUXB", "-z", "--"], true),
377
+ ...nulPaths(root, ["diff", "--no-ext-diff", "--no-textconv", "--name-only", "--no-renames", "--diff-filter=ACDMRTUXB", "-z", "--"], true),
378
+ ...nulPaths(root, ["ls-files", "--others", "--exclude-standard", "-z"], true),
379
+ ];
380
+ return [...new Set(paths.filter((item) => item.indexedCode).map((item) => item.path))].sort(compareCodeUnits);
381
+ }
382
+ export function assertCleanIndexedCode(root) {
383
+ const dirty = dirtyIndexedCodePaths(root);
384
+ if (!dirty.length)
385
+ return;
386
+ const sample = dirty.slice(0, 5).join(", ");
387
+ const more = dirty.length > 5 ? ` (+${dirty.length - 5} more)` : "";
388
+ throw new Error(`refusing to persist a derived graph from dirty indexed code: ${sample}${more}; commit or stash code changes, then retry`);
389
+ }
390
+ //# sourceMappingURL=repoSource.js.map
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { mkdirSync, writeFileSync, existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
+ import { HUNCH_VERSION } from "../core/version.js";
11
12
  // `\${{ … }}` keeps GitHub Actions expressions literal inside this template
12
13
  // literal (a bare `${` would be JS interpolation).
13
14
  export function ciWorkflowYaml() {
@@ -29,14 +30,21 @@ jobs:
29
30
  steps:
30
31
  - uses: actions/checkout@v4
31
32
  with:
33
+ # Check the actual PR head, not GitHub's synthetic merge commit. This
34
+ # lets repository readiness checks prove the branch contains its live
35
+ # base instead of passing merely because GitHub pre-merged it.
36
+ ref: \${{ github.event.pull_request.head.sha }}
32
37
  fetch-depth: 0 # full history: the guard diffs base...head and reads git log
33
38
 
34
39
  - uses: actions/setup-node@v4
35
40
  with:
36
- node-version: 20
41
+ node-version: 22.13.0
37
42
 
38
43
  - name: Install Hunch
39
- run: npm install -g @davesheffer/hunch
44
+ # Pin the same release that generated this file so every assistant and CI
45
+ # evaluate the graph with identical semantics. Dependabot/Renovate (or a
46
+ # deliberate hunch-ci refresh) can advance this in a reviewed change.
47
+ run: npm install -g @davesheffer/hunch@${HUNCH_VERSION}
40
48
 
41
49
  - name: Fetch the PR base branch
42
50
  # checkout sets up no origin/<base> tracking ref; create it explicitly so