@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.
@@ -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
- export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON, GO];
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 = ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python", "tree-sitter-go"];
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
- // Both binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node,
68
- // while from-source builds are named after the binding.gyp target with
69
- // underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
70
- // …). Missing the underscore names let an already-loaded source-built addon slip
71
- // past this guard and defeat the file-lock isolation entirely (issue #52).
72
- const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python|-go)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
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
- runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python, go };
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 {