@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
|
@@ -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
|
-
*
|
|
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 {
|
|
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,
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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((
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
41
|
+
node-version: 22.13.0
|
|
37
42
|
|
|
38
43
|
- name: Install Hunch
|
|
39
|
-
|
|
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
|