@davesheffer/hunch 1.17.0 → 1.18.0
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 +19 -13
- package/dist/cli/index.js +5 -2
- package/dist/constitution/delta.js +3 -3
- package/dist/constitution/evaluator.js +3 -0
- package/dist/constitution/schema.js +1 -1
- package/dist/core/ids.js +16 -0
- package/dist/core/migrate.js +20 -1
- package/dist/core/types.js +207 -1
- package/dist/extractors/git.js +7 -0
- package/dist/extractors/helm.js +77 -0
- package/dist/extractors/indexer.js +100 -24
- package/dist/extractors/landscapeDiscovery.js +492 -0
- package/dist/extractors/languages.js +36 -1
- package/dist/extractors/nativeTreeSitter.js +17 -8
- package/dist/extractors/parse.js +58 -14
- package/dist/mcp/server.js +18 -4
- package/dist/store/hunchStore.js +79 -1
- package/dist/store/jsonStore.js +8 -1
- package/dist/store/schema.js +25 -2
- package/package.json +4 -3
- package/server.json +2 -2
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { posix } from "node:path";
|
|
4
|
+
import { TextDecoder } from "node:util";
|
|
5
|
+
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
6
|
+
import { resourceId, resourceRelationshipId } from "../core/ids.js";
|
|
7
|
+
import { EdgeSchema, ResourceSchema, isCredentialFreeText, } from "../core/types.js";
|
|
8
|
+
import { canonicalRemoteRepositoryIdentity, foreignRepoEnv, gitNullDevice, isGitRepo, } from "./git.js";
|
|
9
|
+
export const LANDSCAPE_DISCOVERY_SCHEMA_VERSION = "hunch.landscape-discovery/1";
|
|
10
|
+
export const LANDSCAPE_CANDIDATE_SCHEMA_VERSION = "hunch.landscape-candidate/1";
|
|
11
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
12
|
+
const MAX_MANIFESTS = 128;
|
|
13
|
+
const ORDINARY_BLOB_MODES = new Set(["100644", "100755"]);
|
|
14
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
15
|
+
function gitEnv() {
|
|
16
|
+
return {
|
|
17
|
+
...foreignRepoEnv(process.env),
|
|
18
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
19
|
+
GIT_CONFIG_GLOBAL: gitNullDevice(),
|
|
20
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function gitBuffer(root, args, maxBuffer = 8 * 1024 * 1024) {
|
|
24
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
25
|
+
encoding: "buffer",
|
|
26
|
+
env: gitEnv(),
|
|
27
|
+
maxBuffer,
|
|
28
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
29
|
+
timeout: 15_000,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function gitText(root, args, maxBuffer = 8 * 1024 * 1024) {
|
|
33
|
+
return gitBuffer(root, args, maxBuffer).toString("utf8").trim();
|
|
34
|
+
}
|
|
35
|
+
function sha256Bytes(bytes) {
|
|
36
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
37
|
+
}
|
|
38
|
+
function canonical(value) {
|
|
39
|
+
if (Array.isArray(value))
|
|
40
|
+
return value.map(canonical);
|
|
41
|
+
if (!value || typeof value !== "object")
|
|
42
|
+
return value;
|
|
43
|
+
return Object.fromEntries(Object.entries(value)
|
|
44
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
45
|
+
.map(([key, child]) => [key, canonical(child)]));
|
|
46
|
+
}
|
|
47
|
+
function contentHash(value) {
|
|
48
|
+
return sha256Bytes(JSON.stringify(canonical(value)));
|
|
49
|
+
}
|
|
50
|
+
function exactRevision(root, ref) {
|
|
51
|
+
const revision = gitText(root, ["rev-parse", "--verify", `${ref}^{commit}`]).toLowerCase();
|
|
52
|
+
if (!/^[0-9a-f]{40,64}$/.test(revision))
|
|
53
|
+
throw new Error("landscape discovery requires an exact Git commit");
|
|
54
|
+
return revision;
|
|
55
|
+
}
|
|
56
|
+
function revisionTime(root, revision) {
|
|
57
|
+
const value = gitText(root, ["show", "-s", "--format=%cI", revision], 1024 * 1024);
|
|
58
|
+
if (!Number.isFinite(Date.parse(value)))
|
|
59
|
+
throw new Error("landscape discovery commit timestamp is invalid");
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function nulRecords(bytes) {
|
|
63
|
+
const records = [];
|
|
64
|
+
let start = 0;
|
|
65
|
+
for (let end = bytes.indexOf(0, start); end !== -1; end = bytes.indexOf(0, start)) {
|
|
66
|
+
if (end > start)
|
|
67
|
+
records.push(bytes.subarray(start, end));
|
|
68
|
+
start = end + 1;
|
|
69
|
+
}
|
|
70
|
+
if (start < bytes.length)
|
|
71
|
+
records.push(bytes.subarray(start));
|
|
72
|
+
return records;
|
|
73
|
+
}
|
|
74
|
+
function manifestBlobs(root, revision) {
|
|
75
|
+
const raw = gitBuffer(root, ["ls-tree", "--full-tree", "-r", "-z", revision], 64 * 1024 * 1024);
|
|
76
|
+
const manifests = [];
|
|
77
|
+
for (const record of nulRecords(raw)) {
|
|
78
|
+
const tab = record.indexOf(0x09);
|
|
79
|
+
if (tab < 0)
|
|
80
|
+
continue;
|
|
81
|
+
const head = record.subarray(0, tab).toString("ascii").match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40,64})$/i);
|
|
82
|
+
if (!head)
|
|
83
|
+
continue;
|
|
84
|
+
const pathBytes = record.subarray(tab + 1);
|
|
85
|
+
let path;
|
|
86
|
+
try {
|
|
87
|
+
path = UTF8_DECODER.decode(pathBytes);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
const suffix = Buffer.from("package.json", "utf8");
|
|
91
|
+
if (pathBytes.length >= suffix.length && pathBytes.subarray(pathBytes.length - suffix.length).equals(suffix)) {
|
|
92
|
+
manifests.push({
|
|
93
|
+
path: `<non-utf8-package-manifest:sha256:${createHash("sha256").update(pathBytes).digest("hex")}>`,
|
|
94
|
+
mode: "unsafe-path",
|
|
95
|
+
oid: head[3].toLowerCase(),
|
|
96
|
+
bytes: null,
|
|
97
|
+
contentHash: null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (path !== "package.json" && !path.endsWith("/package.json"))
|
|
103
|
+
continue;
|
|
104
|
+
const mode = head[1];
|
|
105
|
+
const oid = head[3].toLowerCase();
|
|
106
|
+
const segments = path.split("/");
|
|
107
|
+
if (path.length > 1024 || path.startsWith("/") || path.includes("\\")
|
|
108
|
+
|| segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
109
|
+
|| /[\u0000-\u001f\u007f]/.test(path) || !isCredentialFreeText(path)) {
|
|
110
|
+
manifests.push({ path: "<unsafe-package-manifest>", mode: "unsafe-path", oid, bytes: null, contentHash: null });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
manifests.push({ path, mode: head[2] === "blob" ? mode : `${head[2]}:${mode}`, oid, bytes: null, contentHash: null });
|
|
114
|
+
}
|
|
115
|
+
return manifests.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
116
|
+
}
|
|
117
|
+
function boundedManifestBlobs(root, manifests) {
|
|
118
|
+
const rootManifest = manifests.find((manifest) => manifest.path === "package.json");
|
|
119
|
+
const selected = [
|
|
120
|
+
...(rootManifest ? [rootManifest] : []),
|
|
121
|
+
...manifests.filter((manifest) => manifest !== rootManifest).slice(0, MAX_MANIFESTS - (rootManifest ? 1 : 0)),
|
|
122
|
+
];
|
|
123
|
+
return selected.map((manifest) => {
|
|
124
|
+
if (manifest.mode === "unsafe-path" || !ORDINARY_BLOB_MODES.has(manifest.mode))
|
|
125
|
+
return manifest;
|
|
126
|
+
const size = Number(gitText(root, ["cat-file", "-s", manifest.oid], 1024 * 1024));
|
|
127
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_MANIFEST_BYTES) {
|
|
128
|
+
return { ...manifest, contentHash: size > MAX_MANIFEST_BYTES ? "oversized" : null };
|
|
129
|
+
}
|
|
130
|
+
const bytes = gitBuffer(root, ["cat-file", "blob", manifest.oid], MAX_MANIFEST_BYTES + 1);
|
|
131
|
+
return { ...manifest, bytes, contentHash: sha256Bytes(bytes) };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function workspacePatterns(value, issues) {
|
|
135
|
+
const raw = Array.isArray(value)
|
|
136
|
+
? value
|
|
137
|
+
: value && typeof value === "object" && Array.isArray(value.packages)
|
|
138
|
+
? value.packages
|
|
139
|
+
: [];
|
|
140
|
+
const patterns = [];
|
|
141
|
+
for (const [index, entry] of raw.entries()) {
|
|
142
|
+
if (typeof entry !== "string") {
|
|
143
|
+
issues.push({
|
|
144
|
+
code: "workspace_pattern_invalid",
|
|
145
|
+
sourcePath: "package.json",
|
|
146
|
+
sourceField: "workspaces",
|
|
147
|
+
detail: "workspace entries must be repository-relative string patterns",
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const pattern = entry.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
152
|
+
const segments = pattern.split("/");
|
|
153
|
+
if (!pattern || pattern.startsWith("/") || /^[A-Za-z]:/.test(pattern)
|
|
154
|
+
|| segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
155
|
+
issues.push({
|
|
156
|
+
code: "workspace_pattern_invalid",
|
|
157
|
+
sourcePath: "package.json",
|
|
158
|
+
sourceField: "workspaces",
|
|
159
|
+
detail: `workspace pattern at index ${index} is not a safe repository-relative pattern`,
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
patterns.push(pattern);
|
|
164
|
+
}
|
|
165
|
+
return [...new Set(patterns)].sort(compareCodeUnits);
|
|
166
|
+
}
|
|
167
|
+
function globRegex(pattern) {
|
|
168
|
+
let expression = "^";
|
|
169
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
170
|
+
const char = pattern[index];
|
|
171
|
+
if (char === "*") {
|
|
172
|
+
if (pattern[index + 1] === "*") {
|
|
173
|
+
expression += ".*";
|
|
174
|
+
index += 1;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
expression += "[^/]*";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else if (char === "?") {
|
|
181
|
+
expression += "[^/]";
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
expression += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return new RegExp(`${expression}$`);
|
|
188
|
+
}
|
|
189
|
+
function isWorkspaceManifest(path, patterns) {
|
|
190
|
+
if (path === "package.json")
|
|
191
|
+
return true;
|
|
192
|
+
const directory = posix.dirname(path);
|
|
193
|
+
return patterns.some((pattern) => globRegex(pattern).test(directory));
|
|
194
|
+
}
|
|
195
|
+
function parseManifests(blobs, issues) {
|
|
196
|
+
const parsed = [];
|
|
197
|
+
for (const blob of blobs) {
|
|
198
|
+
if (!blob.bytes) {
|
|
199
|
+
issues.push({
|
|
200
|
+
code: blob.contentHash === "oversized" ? "manifest_oversized" : blob.mode === "unsafe-path" ? "manifest_path" : "manifest_mode",
|
|
201
|
+
sourcePath: blob.path,
|
|
202
|
+
sourceField: "",
|
|
203
|
+
detail: blob.contentHash === "oversized"
|
|
204
|
+
? `${blob.path} exceeds the ${MAX_MANIFEST_BYTES}-byte manifest limit`
|
|
205
|
+
: blob.mode === "unsafe-path" ? "a package manifest uses an unsafe path" : `${blob.path} uses unsupported Git mode ${blob.mode}`,
|
|
206
|
+
});
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const value = JSON.parse(blob.bytes.toString("utf8"));
|
|
211
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
212
|
+
throw new Error("manifest root is not an object");
|
|
213
|
+
parsed.push({ path: blob.path, value: value, contentHash: blob.contentHash });
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
issues.push({ code: "manifest_invalid", sourcePath: blob.path, sourceField: "", detail: `${blob.path} is not valid JSON object data` });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
function repositoryKey(identity) {
|
|
222
|
+
const safe = (key, locator) => {
|
|
223
|
+
if (key.length > 1900 || /[\u0000-\u001f\u007f]/.test(key) || !isCredentialFreeText(key)) {
|
|
224
|
+
return { key: `opaque/sha256/${createHash("sha256").update(identity).digest("hex")}`, locator: null };
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
key,
|
|
228
|
+
locator: locator && locator.length <= 2048 && isCredentialFreeText(locator) ? locator : null,
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
if (identity.startsWith("provider:github:")) {
|
|
232
|
+
const path = identity.slice("provider:github:".length);
|
|
233
|
+
return safe(`github.com/${path}`, `https://github.com/${path}`);
|
|
234
|
+
}
|
|
235
|
+
if (identity.startsWith("file:")) {
|
|
236
|
+
return { key: `local/sha256/${createHash("sha256").update(identity).digest("hex")}`, locator: null };
|
|
237
|
+
}
|
|
238
|
+
if (identity.startsWith("net:any://")) {
|
|
239
|
+
return safe(identity.slice("net:any://".length), null);
|
|
240
|
+
}
|
|
241
|
+
return safe(identity, null);
|
|
242
|
+
}
|
|
243
|
+
function packageRepositoryValue(value) {
|
|
244
|
+
if (typeof value === "string")
|
|
245
|
+
return value;
|
|
246
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
247
|
+
return null;
|
|
248
|
+
const url = value.url;
|
|
249
|
+
return typeof url === "string" ? url : null;
|
|
250
|
+
}
|
|
251
|
+
function validPackageName(value) {
|
|
252
|
+
if (typeof value !== "string")
|
|
253
|
+
return null;
|
|
254
|
+
const name = value.trim();
|
|
255
|
+
return name
|
|
256
|
+
&& name.length <= 214
|
|
257
|
+
&& !/[\u0000-\u001f\u007f\s]/.test(name)
|
|
258
|
+
&& /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i.test(name)
|
|
259
|
+
? name
|
|
260
|
+
: null;
|
|
261
|
+
}
|
|
262
|
+
function configuredRemotes(root, revision) {
|
|
263
|
+
let output = "";
|
|
264
|
+
try {
|
|
265
|
+
output = gitText(root, ["config", "--local", "--get-regexp", "^remote\\..*\\.url$"], 4 * 1024 * 1024);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
const declarations = [];
|
|
271
|
+
for (const line of output.split(/\r?\n/)) {
|
|
272
|
+
const match = line.match(/^(remote\.(.+)\.url)\s+(.+)$/);
|
|
273
|
+
if (!match)
|
|
274
|
+
continue;
|
|
275
|
+
const identity = canonicalRemoteRepositoryIdentity(match[3], root);
|
|
276
|
+
if (!identity)
|
|
277
|
+
continue;
|
|
278
|
+
const normalized = repositoryKey(identity);
|
|
279
|
+
const remoteField = match[1].length <= 256 && isCredentialFreeText(match[1])
|
|
280
|
+
? match[1]
|
|
281
|
+
: `remote[sha256:${createHash("sha256").update(match[1]).digest("hex")}].url`;
|
|
282
|
+
declarations.push({
|
|
283
|
+
identity,
|
|
284
|
+
...normalized,
|
|
285
|
+
evidence: {
|
|
286
|
+
kind: "git_remote",
|
|
287
|
+
sourcePath: ".git/config",
|
|
288
|
+
sourceField: remoteField,
|
|
289
|
+
sourceRevision: revision,
|
|
290
|
+
sourceContentHash: contentHash({ field: remoteField, identity }),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return declarations;
|
|
295
|
+
}
|
|
296
|
+
function repositoryDeclarations(root, revision, rootManifest) {
|
|
297
|
+
const declarations = configuredRemotes(root, revision);
|
|
298
|
+
const manifestRemote = packageRepositoryValue(rootManifest?.value.repository);
|
|
299
|
+
if (manifestRemote && rootManifest) {
|
|
300
|
+
const identity = canonicalRemoteRepositoryIdentity(manifestRemote, root);
|
|
301
|
+
if (identity) {
|
|
302
|
+
declarations.push({
|
|
303
|
+
identity,
|
|
304
|
+
...repositoryKey(identity),
|
|
305
|
+
evidence: {
|
|
306
|
+
kind: "package_manifest",
|
|
307
|
+
sourcePath: rootManifest.path,
|
|
308
|
+
sourceField: "repository",
|
|
309
|
+
sourceRevision: revision,
|
|
310
|
+
sourceContentHash: rootManifest.contentHash,
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return declarations.sort((left, right) => compareCodeUnits(`${left.identity}:${left.evidence.sourcePath}:${left.evidence.sourceField}`, `${right.identity}:${right.evidence.sourcePath}:${right.evidence.sourceField}`));
|
|
316
|
+
}
|
|
317
|
+
function provenanceEvidence(evidence) {
|
|
318
|
+
return `${evidence.sourcePath}#${evidence.sourceField}@${evidence.sourceRevision}:${evidence.sourceContentHash}`;
|
|
319
|
+
}
|
|
320
|
+
function candidate(record, evidence) {
|
|
321
|
+
const orderedEvidence = [...evidence].sort((left, right) => compareCodeUnits(`${left.kind}:${left.sourcePath}:${left.sourceField}:${left.sourceContentHash}`, `${right.kind}:${right.sourcePath}:${right.sourceField}:${right.sourceContentHash}`));
|
|
322
|
+
const unsigned = {
|
|
323
|
+
schema: LANDSCAPE_CANDIDATE_SCHEMA_VERSION,
|
|
324
|
+
authority: "candidate",
|
|
325
|
+
record,
|
|
326
|
+
evidence: orderedEvidence,
|
|
327
|
+
};
|
|
328
|
+
return { ...unsigned, candidateHash: contentHash(unsigned) };
|
|
329
|
+
}
|
|
330
|
+
function resourceCurrentness(revision, hashes) {
|
|
331
|
+
return {
|
|
332
|
+
status: "unverified",
|
|
333
|
+
source_revision: revision,
|
|
334
|
+
source_content_hash: contentHash([...hashes].sort(compareCodeUnits)),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function rootHistoryIdentity(root, revision) {
|
|
338
|
+
const roots = gitText(root, ["rev-list", "--max-parents=0", revision], 4 * 1024 * 1024)
|
|
339
|
+
.split(/\s+/).filter((value) => /^[0-9a-f]{40,64}$/i.test(value)).sort(compareCodeUnits);
|
|
340
|
+
const identity = `git:${roots.join("+") || revision}`;
|
|
341
|
+
return {
|
|
342
|
+
identity,
|
|
343
|
+
...repositoryKey(identity),
|
|
344
|
+
evidence: {
|
|
345
|
+
kind: "git_history",
|
|
346
|
+
sourcePath: ".git/objects",
|
|
347
|
+
sourceField: "root_commits",
|
|
348
|
+
sourceRevision: revision,
|
|
349
|
+
sourceContentHash: contentHash(roots),
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/** Discover an exact, reviewable repository candidate fragment. This function
|
|
354
|
+
* never writes Hunch graph state: candidate authority remains explicit until a
|
|
355
|
+
* normal review/capture path accepts the records. */
|
|
356
|
+
export function discoverRepositoryLandscape(root, ref = "HEAD") {
|
|
357
|
+
if (!isGitRepo(root))
|
|
358
|
+
throw new Error("landscape discovery requires a Git repository");
|
|
359
|
+
const revision = exactRevision(root, ref);
|
|
360
|
+
const timestamp = revisionTime(root, revision);
|
|
361
|
+
const issues = [];
|
|
362
|
+
const blobs = manifestBlobs(root, revision);
|
|
363
|
+
if (blobs.length > MAX_MANIFESTS) {
|
|
364
|
+
issues.push({
|
|
365
|
+
code: "manifest_limit",
|
|
366
|
+
sourcePath: "package.json",
|
|
367
|
+
sourceField: "workspaces",
|
|
368
|
+
detail: `repository exposes ${blobs.length} package manifests; bounded discovery accepts at most ${MAX_MANIFESTS}`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const parsed = parseManifests(boundedManifestBlobs(root, blobs), issues);
|
|
372
|
+
const rootManifest = parsed.find((manifest) => manifest.path === "package.json");
|
|
373
|
+
if (!rootManifest) {
|
|
374
|
+
issues.push({ code: "manifest_missing", sourcePath: "package.json", sourceField: "", detail: "root package.json was not found at the exact revision" });
|
|
375
|
+
}
|
|
376
|
+
const patterns = workspacePatterns(rootManifest?.value.workspaces, issues);
|
|
377
|
+
const manifests = parsed.filter((manifest) => isWorkspaceManifest(manifest.path, patterns));
|
|
378
|
+
const declarations = repositoryDeclarations(root, revision, rootManifest);
|
|
379
|
+
const identities = [...new Set(declarations.map((declaration) => declaration.identity))].sort(compareCodeUnits);
|
|
380
|
+
let selected = null;
|
|
381
|
+
if (identities.length > 1) {
|
|
382
|
+
issues.push({
|
|
383
|
+
code: "repository_identity_conflict",
|
|
384
|
+
sourcePath: "package.json",
|
|
385
|
+
sourceField: "repository",
|
|
386
|
+
detail: `repository declarations disagree across ${identities.length} canonical identities; package relationships remain unbound`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
else if (identities.length === 1) {
|
|
390
|
+
selected = declarations.find((declaration) => declaration.identity === identities[0]);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
selected = rootHistoryIdentity(root, revision);
|
|
394
|
+
}
|
|
395
|
+
const resources = [];
|
|
396
|
+
const relationships = [];
|
|
397
|
+
let repositoryRecord = null;
|
|
398
|
+
if (selected) {
|
|
399
|
+
const repositoryEvidence = declarations.length
|
|
400
|
+
? declarations.filter((declaration) => declaration.identity === selected.identity).map((declaration) => declaration.evidence)
|
|
401
|
+
: [selected.evidence];
|
|
402
|
+
const packageName = validPackageName(rootManifest?.value.name);
|
|
403
|
+
repositoryRecord = ResourceSchema.parse({
|
|
404
|
+
schema: "hunch.resource/1",
|
|
405
|
+
id: resourceId("repository", selected.key),
|
|
406
|
+
kind: "repository",
|
|
407
|
+
name: packageName ?? selected.key.slice(0, 256),
|
|
408
|
+
scope: [],
|
|
409
|
+
locator: selected.locator,
|
|
410
|
+
lifecycle: "active",
|
|
411
|
+
provenance: {
|
|
412
|
+
source: "extracted:repository-declaration",
|
|
413
|
+
confidence: 0.75,
|
|
414
|
+
evidence: repositoryEvidence.map(provenanceEvidence),
|
|
415
|
+
},
|
|
416
|
+
currentness: resourceCurrentness(revision, repositoryEvidence.map((item) => item.sourceContentHash)),
|
|
417
|
+
metadata: { discovery_authority: "candidate" },
|
|
418
|
+
created_at: timestamp,
|
|
419
|
+
updated_at: timestamp,
|
|
420
|
+
});
|
|
421
|
+
resources.push(candidate(repositoryRecord, repositoryEvidence));
|
|
422
|
+
}
|
|
423
|
+
for (const manifest of manifests) {
|
|
424
|
+
const rawName = typeof manifest.value.name === "string" ? manifest.value.name.trim() : "";
|
|
425
|
+
if (!rawName) {
|
|
426
|
+
issues.push({ code: "package_name_missing", sourcePath: manifest.path, sourceField: "name", detail: `${manifest.path} has no package name` });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const name = validPackageName(rawName);
|
|
430
|
+
if (!name) {
|
|
431
|
+
issues.push({ code: "package_name_invalid", sourcePath: manifest.path, sourceField: "name", detail: `${manifest.path} has an invalid package name` });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const evidence = {
|
|
435
|
+
kind: "package_manifest",
|
|
436
|
+
sourcePath: manifest.path,
|
|
437
|
+
sourceField: "name",
|
|
438
|
+
sourceRevision: revision,
|
|
439
|
+
sourceContentHash: manifest.contentHash,
|
|
440
|
+
};
|
|
441
|
+
const packageRecord = ResourceSchema.parse({
|
|
442
|
+
schema: "hunch.resource/1",
|
|
443
|
+
id: resourceId("package", `npm/${name}`),
|
|
444
|
+
kind: "package",
|
|
445
|
+
name,
|
|
446
|
+
scope: repositoryRecord ? [repositoryRecord.id] : [],
|
|
447
|
+
locator: `${manifest.path}#name`,
|
|
448
|
+
lifecycle: "active",
|
|
449
|
+
contract_version: typeof manifest.value.version === "string"
|
|
450
|
+
&& manifest.value.version.length <= 128
|
|
451
|
+
&& !/[\u0000-\u001f\u007f\s]/.test(manifest.value.version)
|
|
452
|
+
? manifest.value.version
|
|
453
|
+
: undefined,
|
|
454
|
+
provenance: { source: "extracted:package-manifest", confidence: 0.9, evidence: [provenanceEvidence(evidence)] },
|
|
455
|
+
currentness: resourceCurrentness(revision, [manifest.contentHash]),
|
|
456
|
+
metadata: { discovery_authority: "candidate", manifest_path: manifest.path, workspace: manifest.path !== "package.json" },
|
|
457
|
+
created_at: timestamp,
|
|
458
|
+
updated_at: timestamp,
|
|
459
|
+
});
|
|
460
|
+
resources.push(candidate(packageRecord, [evidence]));
|
|
461
|
+
if (!repositoryRecord)
|
|
462
|
+
continue;
|
|
463
|
+
const relationship = EdgeSchema.parse({
|
|
464
|
+
schema: "hunch.resource-relationship/1",
|
|
465
|
+
id: resourceRelationshipId(repositoryRecord.id, packageRecord.id, "contains"),
|
|
466
|
+
from: repositoryRecord.id,
|
|
467
|
+
to: packageRecord.id,
|
|
468
|
+
type: "contains",
|
|
469
|
+
reason: `${manifest.path} declares package ${name}`,
|
|
470
|
+
strength: 1,
|
|
471
|
+
provenance: { source: "extracted:package-workspace", confidence: 0.9, evidence: [provenanceEvidence(evidence)] },
|
|
472
|
+
currentness: resourceCurrentness(revision, [manifest.contentHash]),
|
|
473
|
+
environment: null,
|
|
474
|
+
metadata: { discovery_authority: "candidate", manifest_path: manifest.path },
|
|
475
|
+
});
|
|
476
|
+
relationships.push(candidate(relationship, [evidence]));
|
|
477
|
+
}
|
|
478
|
+
resources.sort((left, right) => compareCodeUnits(left.record.id, right.record.id));
|
|
479
|
+
relationships.sort((left, right) => compareCodeUnits(left.record.id, right.record.id));
|
|
480
|
+
issues.sort((left, right) => compareCodeUnits(`${left.code}:${left.sourcePath}:${left.sourceField}:${left.detail}`, `${right.code}:${right.sourcePath}:${right.sourceField}:${right.detail}`));
|
|
481
|
+
const unsigned = {
|
|
482
|
+
schema: LANDSCAPE_DISCOVERY_SCHEMA_VERSION,
|
|
483
|
+
authority: "candidate",
|
|
484
|
+
sourceRevision: revision,
|
|
485
|
+
repositoryRootIdentity: selected?.key ?? `conflict:${contentHash(identities)}`,
|
|
486
|
+
resources,
|
|
487
|
+
relationships,
|
|
488
|
+
issues,
|
|
489
|
+
};
|
|
490
|
+
return { ...unsigned, discoveryHash: contentHash(unsigned) };
|
|
491
|
+
}
|
|
492
|
+
//# sourceMappingURL=landscapeDiscovery.js.map
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* here (+ a new tree-sitter-* dependency), not edits scattered across those
|
|
6
6
|
* four files.
|
|
7
7
|
*/
|
|
8
|
+
import { basename } from "node:path";
|
|
8
9
|
import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
|
|
9
10
|
const TS_QUERY = `
|
|
10
11
|
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
@@ -173,7 +174,41 @@ const GO = {
|
|
|
173
174
|
},
|
|
174
175
|
builtinMethods: GO_BUILTIN_METHODS,
|
|
175
176
|
};
|
|
176
|
-
|
|
177
|
+
const YAML_QUERY = `
|
|
178
|
+
(block_node (anchor (anchor_name) @anchor.name)) @anchor.def
|
|
179
|
+
(flow_node (anchor (anchor_name) @anchor.name)) @anchor.def
|
|
180
|
+
(alias (alias_name) @call.id)
|
|
181
|
+
(stream) @doc.def
|
|
182
|
+
`;
|
|
183
|
+
const YAML = {
|
|
184
|
+
id: "yaml",
|
|
185
|
+
extensions: [".yml", ".yaml", ".tpl"],
|
|
186
|
+
grammarKey: "yaml",
|
|
187
|
+
loadGrammar: () => loadNativeTreeSitter().yaml,
|
|
188
|
+
query: YAML_QUERY,
|
|
189
|
+
// Both block-style (`key: &x\n ...`) and flow-style (`key: &x {...}`) anchors
|
|
190
|
+
// wrap only the sigil+name; ascendToDef climbs from anchor_name -> anchor ->
|
|
191
|
+
// this enclosing node to find the def whose byte range covers the full value.
|
|
192
|
+
defNodeTypes: new Set(["block_node", "flow_node"]),
|
|
193
|
+
defKindOf: { "anchor.def": "variable", "doc.def": "file" },
|
|
194
|
+
nameToDef: { "anchor.name": "anchor.def" },
|
|
195
|
+
builtinMethods: new Set(),
|
|
196
|
+
// Alias references aren't calls; label the edge accordingly (Task 5).
|
|
197
|
+
referenceEdgeType: "references",
|
|
198
|
+
// Most aliases reference an anchor from OUTSIDE that anchor's own byte range
|
|
199
|
+
// (a sibling key, not a nested value) — without a whole-file fallback symbol,
|
|
200
|
+
// attributeCalls's containment check would silently drop them. See Task 4.
|
|
201
|
+
fallbackDefName: (file) => basename(file),
|
|
202
|
+
// Helm charts / templated CI configs are common .yaml content that only
|
|
203
|
+
// becomes valid YAML after a render step (issue #33). The negative lookbehind
|
|
204
|
+
// on "{{" excludes GitHub Actions' `${{ expression }}` syntax, which is
|
|
205
|
+
// ordinary, always-valid YAML — every workflow file in this repo uses it, so
|
|
206
|
+
// treating it as a templating marker would blanket-disable the fail-closed
|
|
207
|
+
// gate for .github/workflows/**.
|
|
208
|
+
templatingMarkers: [/(?<!\$)\{\{/, /\{%/],
|
|
209
|
+
alwaysTemplatedExtensions: [".tpl"],
|
|
210
|
+
};
|
|
211
|
+
export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON, GO, YAML];
|
|
177
212
|
export const CODE_EXTENSIONS = [...new Set(LANGUAGES.flatMap((l) => l.extensions))];
|
|
178
213
|
export function languageFor(file) {
|
|
179
214
|
for (const lang of LANGUAGES) {
|
|
@@ -4,7 +4,13 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
const runtimeRequire = createRequire(import.meta.url);
|
|
6
6
|
const COPY_PREFIX = "hunch-tree-sitter-";
|
|
7
|
-
const NATIVE_PACKAGES = [
|
|
7
|
+
const NATIVE_PACKAGES = [
|
|
8
|
+
"tree-sitter",
|
|
9
|
+
"tree-sitter-typescript",
|
|
10
|
+
"tree-sitter-python",
|
|
11
|
+
"tree-sitter-go",
|
|
12
|
+
"@tree-sitter-grammars/tree-sitter-yaml",
|
|
13
|
+
];
|
|
8
14
|
let runtime = null;
|
|
9
15
|
function processIsAlive(pid) {
|
|
10
16
|
if (pid === process.pid)
|
|
@@ -64,12 +70,14 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
|
|
|
64
70
|
export function loadNativeTreeSitter() {
|
|
65
71
|
if (runtime)
|
|
66
72
|
return runtime;
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
+
// Three binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node
|
|
74
|
+
// or, for a scoped package like @tree-sitter-grammars/tree-sitter-yaml, as
|
|
75
|
+
// @scope+name.node; from-source builds are named after the binding.gyp target
|
|
76
|
+
// with underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
|
|
77
|
+
// tree_sitter_yaml_binding.node, …). Missing the underscore names let an
|
|
78
|
+
// already-loaded source-built addon slip past this guard and defeat the
|
|
79
|
+
// file-lock isolation entirely (issue #52).
|
|
80
|
+
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python|-go|-yaml)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
|
|
73
81
|
&& !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
|
|
74
82
|
if (preloaded.length) {
|
|
75
83
|
throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
|
|
@@ -88,7 +96,8 @@ export function loadNativeTreeSitter() {
|
|
|
88
96
|
const languages = runtimeRequire("tree-sitter-typescript");
|
|
89
97
|
const python = runtimeRequire("tree-sitter-python");
|
|
90
98
|
const go = runtimeRequire("tree-sitter-go");
|
|
91
|
-
|
|
99
|
+
const yaml = runtimeRequire("@tree-sitter-grammars/tree-sitter-yaml");
|
|
100
|
+
runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python, go, yaml };
|
|
92
101
|
}
|
|
93
102
|
catch (error) {
|
|
94
103
|
try {
|