@davesheffer/hunch 1.38.0 → 1.39.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.
Files changed (48) hide show
  1. package/dist/cli/index.js +255 -9
  2. package/dist/cli/serve.js +1 -0
  3. package/dist/client/readOrCompute.d.ts +77 -0
  4. package/dist/client/readOrCompute.js +85 -0
  5. package/dist/client/state.d.ts +1 -0
  6. package/dist/client/state.js +1 -0
  7. package/dist/constitution/g2.d.ts +1 -0
  8. package/dist/constitution/service.js +8 -0
  9. package/dist/constitution/sourceMutation.js +23 -18
  10. package/dist/core/config.d.ts +16 -0
  11. package/dist/core/config.js +13 -0
  12. package/dist/core/machine.d.ts +20 -0
  13. package/dist/core/machine.js +101 -0
  14. package/dist/core/taskRecord.js +6 -3
  15. package/dist/core/taskReport.d.ts +25 -2
  16. package/dist/core/taskReport.js +92 -18
  17. package/dist/core/taskReportEvidence.d.ts +4 -1
  18. package/dist/core/taskReportEvidence.js +5 -2
  19. package/dist/core/taskReportHook.d.ts +16 -3
  20. package/dist/core/taskReportHook.js +65 -13
  21. package/dist/core/taskTouched.d.ts +4 -0
  22. package/dist/core/taskTouched.js +30 -10
  23. package/dist/core/types.d.ts +66 -1
  24. package/dist/core/types.js +3 -0
  25. package/dist/core/workspace.d.ts +234 -0
  26. package/dist/core/workspace.js +335 -0
  27. package/dist/extractors/helm.d.ts +17 -28
  28. package/dist/extractors/helm.js +12 -12
  29. package/dist/extractors/indexer.js +171 -7
  30. package/dist/extractors/k8sManifest.d.ts +59 -0
  31. package/dist/extractors/k8sManifest.js +507 -0
  32. package/dist/extractors/workspaces.d.ts +18 -0
  33. package/dist/extractors/workspaces.js +350 -0
  34. package/dist/integrations/claudemd.js +1 -0
  35. package/dist/integrations/hooks.d.ts +2 -0
  36. package/dist/integrations/hooks.js +25 -0
  37. package/dist/integrations/scaffold.js +11 -0
  38. package/dist/integrations/workspaceLedger.d.ts +73 -0
  39. package/dist/integrations/workspaceLedger.js +201 -0
  40. package/dist/mcp/server.js +54 -0
  41. package/dist/mcp/taskReportTools.d.ts +9 -0
  42. package/dist/mcp/taskReportTools.js +13 -5
  43. package/dist/serve/app.d.ts +2 -0
  44. package/dist/serve/app.js +107 -92
  45. package/dist/serve/mcpHttp.d.ts +27 -0
  46. package/dist/serve/mcpHttp.js +95 -0
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -10,8 +10,9 @@
10
10
  */
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join, posix } from "node:path";
13
- import { parseSource, attributeCalls, attributeRelations } from "./parse.js";
13
+ import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
14
14
  import { extractHelmDirectives } from "./helm.js";
15
+ import { extractK8sManifest } from "./k8sManifest.js";
15
16
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
16
17
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
17
18
  import { resolveRelativeImport } from "../core/relativeImports.js";
@@ -100,6 +101,10 @@ export function scanRepo(store, root, opts = {}) {
100
101
  const perFileCalls = [];
101
102
  const perFileImports = [];
102
103
  const perFileRelations = [];
104
+ const k8sResourceIndex = [];
105
+ const k8sReferenceCandidates = [];
106
+ const k8sSelectors = [];
107
+ const k8sWorkloadLabels = [];
103
108
  const phpNamespaces = new Map();
104
109
  const phpUseDeclarations = new Map();
105
110
  // Batched per-file git metrics (churn + last commit) in TWO `git log` spawns
@@ -113,7 +118,7 @@ export function scanRepo(store, root, opts = {}) {
113
118
  const chartRoot = chartRootFor(path);
114
119
  if (chartRoot === null)
115
120
  continue;
116
- (chartFiles.get(chartRoot) ?? chartFiles.set(chartRoot, []).get(chartRoot)).push(path);
121
+ pushInto(chartFiles, chartRoot, path);
117
122
  }
118
123
  const gitMeta = useGit ? fileGitMetrics(root, rels, opts.churn === false ? 0 : 90) : null;
119
124
  let skipped = 0;
@@ -165,8 +170,50 @@ export function scanRepo(store, root, opts = {}) {
165
170
  // JS-falsy — `if (chartRoot)` would silently skip every repo-root chart.
166
171
  if (chartRoot !== null) {
167
172
  const helm = extractHelmDirectives(src);
168
- parsed.symbols = [...parsed.symbols, ...helm.symbols].sort((a, b) => a.startByte - b.startByte);
169
- parsed.calls = [...parsed.calls, ...helm.calls];
173
+ // helm.ts's own offsets are named *Char (they're JS char indices, not
174
+ // UTF-8 bytes -- see its module doc comment); mapped here into
175
+ // parsed.symbols/calls's startByte/endByte/atByte fields, which carry
176
+ // the same char-index values under the shared ParsedSymbol/ParsedCall
177
+ // naming this merge target already uses.
178
+ const helmSymbols = helm.symbols.map((s) => ({
179
+ name: s.name, kind: s.kind, startByte: s.startChar, endByte: s.endChar, loc: s.loc, bodyText: s.bodyText,
180
+ }));
181
+ const helmCalls = helm.calls.map((c) => ({
182
+ callee: c.callee, atByte: c.atChar, endByte: c.endChar, member: c.member,
183
+ }));
184
+ parsed.symbols = [...parsed.symbols, ...helmSymbols].sort((a, b) => a.startByte - b.startByte);
185
+ parsed.calls = [...parsed.calls, ...helmCalls];
186
+ }
187
+ // Runs for EVERY yaml file, chart or not (unlike the Helm merge above) --
188
+ // raw manifests with no Chart.yaml are still in scope; what varies below is
189
+ // resolution SCOPE (chartRoot ?? this file), not whether extraction runs.
190
+ let k8sDocs = [];
191
+ // THIS file's K8s resource symbol OBJECTS (identity, not their byte
192
+ // offsets) -- scopes the id lookup below to exactly the symbols this pass
193
+ // creates. Identity, not a Set<number> of startBytes, because a byte
194
+ // value is not a reliable per-symbol key: a Helm `define` symbol that
195
+ // happens to share a startByte with a K8s doc symbol in the same .yaml
196
+ // file (both legitimately synthetic, both can start at byte 0) would
197
+ // otherwise be indistinguishable by offset alone, and the wrong (Helm)
198
+ // symbol id could get recorded instead of the K8s one.
199
+ const k8sSymbolObjects = new Set();
200
+ if (languageFor(rel)?.id === "yaml") {
201
+ k8sDocs = extractK8sManifest(src);
202
+ const k8sSymbols = k8sDocs
203
+ .filter((d) => d.resource !== null)
204
+ // k8sManifest.ts's own offsets are named *Char; mapped here into the
205
+ // shared startByte/endByte fields, same as the Helm merge above.
206
+ .map((d) => ({
207
+ name: `${d.resource.kind}/${displayNameText(d.resource.name)}`,
208
+ kind: "variable",
209
+ startByte: d.resource.startChar,
210
+ endByte: d.resource.endChar,
211
+ loc: src.slice(d.resource.startChar, d.resource.endChar).split("\n").length,
212
+ bodyText: src.slice(d.resource.startChar, d.resource.endChar).slice(0, MAX_BODY_TEXT_CHARS),
213
+ }));
214
+ for (const s of k8sSymbols)
215
+ k8sSymbolObjects.add(s);
216
+ parsed.symbols = [...parsed.symbols, ...k8sSymbols].sort((a, b) => a.startByte - b.startByte);
170
217
  }
171
218
  const m = gitMeta?.get(rel);
172
219
  const churn = opts.churn === false ? (preservedChurn.get(rel) ?? 0) : (m?.churn ?? 0);
@@ -174,6 +221,7 @@ export function scanRepo(store, root, opts = {}) {
174
221
  const idsInFile = [];
175
222
  const symbolIndexId = new Map();
176
223
  const idCounts = new Map(); // disambiguate same (file,name,kind)
224
+ const k8sSymbolIdByStartByte = new Map();
177
225
  for (const [index, ps] of parsed.symbols.entries()) {
178
226
  const base = symbolId(rel, ps.name, ps.kind);
179
227
  const n = idCounts.get(base) ?? 0;
@@ -182,7 +230,7 @@ export function scanRepo(store, root, opts = {}) {
182
230
  const id = n === 0 ? base : `${base}_${n}`;
183
231
  idsInFile.push(id);
184
232
  symbolIndexId.set(index, id);
185
- (nameIndex.get(ps.name) ?? nameIndex.set(ps.name, []).get(ps.name)).push(id);
233
+ pushInto(nameIndex, ps.name, id);
186
234
  symbols.push({
187
235
  id, file: rel, name: ps.name, kind: ps.kind,
188
236
  signature_hash: sha1(ps.bodyText).slice(0, 16),
@@ -190,6 +238,27 @@ export function scanRepo(store, root, opts = {}) {
190
238
  metrics: { loc: ps.loc, churn_90d: churn, bug_count: 0, fan_in: 0, fan_out: 0 },
191
239
  last_changed: last,
192
240
  });
241
+ if (k8sSymbolObjects.has(ps))
242
+ k8sSymbolIdByStartByte.set(ps.startByte, id);
243
+ }
244
+ for (const doc of k8sDocs) {
245
+ if (!doc.resource)
246
+ continue;
247
+ const fromId = k8sSymbolIdByStartByte.get(doc.resource.startChar);
248
+ if (!fromId)
249
+ continue;
250
+ const scope = chartRoot ?? rel;
251
+ k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name) });
252
+ for (const ref of doc.references) {
253
+ k8sReferenceCandidates.push({
254
+ fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name),
255
+ reason: `${doc.resource.kind}/${displayNameText(doc.resource.name)} references ${ref.refKind}/${displayNameText(ref.name)}`,
256
+ });
257
+ }
258
+ if (doc.selector)
259
+ k8sSelectors.push({ symbolId: fromId, scope, selector: doc.selector });
260
+ if (doc.labels)
261
+ k8sWorkloadLabels.push({ symbolId: fromId, scope, labels: doc.labels });
193
262
  }
194
263
  fileSymbols.set(rel, idsInFile);
195
264
  fileSymbolIndexId.set(rel, symbolIndexId);
@@ -285,6 +354,77 @@ export function scanRepo(store, root, opts = {}) {
285
354
  }
286
355
  }
287
356
  }
357
+ // ---- K8s manifest cross-resource references (Phase 1: name-keyed) --------
358
+ // Own resolver, not resolveName(): resolveName() indexes by bare symbol name
359
+ // only, with no concept of Kubernetes kind -- a ConfigMap and a Secret that
360
+ // happen to share a name would incorrectly conflate. Same ambiguity contract
361
+ // as resolveName() though: 0 matches or 2+ matches -> no edge, never guess.
362
+ const kindNameIndex = new Map();
363
+ for (const r of k8sResourceIndex) {
364
+ const key = `${r.scope}:${r.kind}:${r.nameKey}`;
365
+ pushInto(kindNameIndex, key, r.symbolId);
366
+ }
367
+ for (const ref of k8sReferenceCandidates) {
368
+ const candidates = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
369
+ if (candidates.length !== 1)
370
+ continue; // 0 or 2+ -> ambiguous or absent, don't guess
371
+ const toId = candidates[0];
372
+ if (toId === ref.fromSymbolId)
373
+ continue;
374
+ addEdge({
375
+ schema: "hunch.edge/1",
376
+ id: edgeId(ref.fromSymbolId, toId, "references"),
377
+ from: ref.fromSymbolId, to: toId, type: "references",
378
+ reason: ref.reason, strength: 0.7,
379
+ provenance: extracted(0.7, [ref.scope]),
380
+ environment: null,
381
+ metadata: {},
382
+ });
383
+ }
384
+ // ---- K8s manifest cross-resource references (Phase 2: label-selector) ----
385
+ // Structurally different from Phase 1: no name to look up, a SUBSET match
386
+ // between a Service's selector and a workload's pod-template labels, within
387
+ // the same scope. Only ever fires on LITERAL selector/labels (k8sSelectors/
388
+ // k8sWorkloadLabels are already filtered to literal-only by k8sManifest.ts --
389
+ // a block-form templated value is never guessed at).
390
+ //
391
+ // Deliberately NO ambiguity guard here, unlike Phase 1's "0 or 2+ candidates
392
+ // -> no edge": a Service legitimately fronting multiple workloads (blue/green,
393
+ // canary, a shared-label pair of Deployments) is normal, intentional
394
+ // Kubernetes usage, not an ambiguous match to decline -- Phase 1's guard
395
+ // exists because a ConfigMap named X is exactly one resource by definition,
396
+ // which has no analogue here. Fan-out is the correct behavior, not a gap.
397
+ const selectorsByScope = new Map();
398
+ for (const s of k8sSelectors)
399
+ pushInto(selectorsByScope, s.scope, s);
400
+ const labelsByScope = new Map();
401
+ for (const l of k8sWorkloadLabels)
402
+ pushInto(labelsByScope, l.scope, l);
403
+ for (const [scope, selectors] of selectorsByScope) {
404
+ const workloads = labelsByScope.get(scope) ?? [];
405
+ for (const svc of selectors) {
406
+ for (const wl of workloads) {
407
+ // Defensive, not currently reachable: k8sSelectors only ever holds
408
+ // Service symbols and k8sWorkloadLabels only ever holds symbols for
409
+ // kinds in LABELS_PATH_BY_KIND, which excludes Service -- so the two
410
+ // ids can never collide today.
411
+ if (svc.symbolId === wl.symbolId)
412
+ continue;
413
+ const isSubset = Object.entries(svc.selector).every(([k, v]) => wl.labels[k] === v);
414
+ if (!isSubset)
415
+ continue;
416
+ addEdge({
417
+ schema: "hunch.edge/1",
418
+ id: edgeId(svc.symbolId, wl.symbolId, "references"),
419
+ from: svc.symbolId, to: wl.symbolId, type: "references",
420
+ reason: "Service selector matches workload pod-template labels", strength: 0.6,
421
+ provenance: extracted(0.6, [scope]),
422
+ environment: null,
423
+ metadata: {},
424
+ });
425
+ }
426
+ }
427
+ }
288
428
  // PHP's static type relationships use the same symbol graph and conservative
289
429
  // resolver as calls. Ambiguous or dynamic targets produce no edge.
290
430
  for (const { file, bySym } of perFileRelations) {
@@ -441,6 +581,16 @@ export function indexRepo(store, root, opts = {}) {
441
581
  return scan.result;
442
582
  }
443
583
  // ---- helpers --------------------------------------------------------------
584
+ /** Append `value` to the array at `key`, creating the array on first use.
585
+ * Function declaration (not `const`) so it's usable from pass-1 code above
586
+ * this section via hoisting, without reordering. */
587
+ function pushInto(map, key, value) {
588
+ const bucket = map.get(key);
589
+ if (bucket)
590
+ bucket.push(value);
591
+ else
592
+ map.set(key, [value]);
593
+ }
444
594
  /** Nearest-ancestor Chart.yaml lookup, memoized per directory: walks a file's
445
595
  * own directory upward through the tracked-file set until it finds
446
596
  * `<dir>/Chart.yaml`, or returns null if the file isn't under any chart.
@@ -449,8 +599,9 @@ export function indexRepo(store, root, opts = {}) {
449
599
  * approximation, not full Helm semantics: Helm's template namespace is
450
600
  * actually release-global, so a parent chart can legitimately include a
451
601
  * subchart's define — nearest-ancestor scoping will miss that edge rather
452
- * than fabricate a wrong one. No test currently covers the nested
453
- * charts/<sub>/Chart.yaml case — tracked as issue #42. */
602
+ * than fabricate a wrong one (test coverage: indexer.test.ts's nested-subchart
603
+ * and subchart-miss cases). Modeling the release-global namespace itself
604
+ * remains open — issue #42. */
454
605
  function nearestChartRoot(rels) {
455
606
  const tracked = new Set(rels);
456
607
  const cache = new Map();
@@ -466,6 +617,19 @@ function nearestChartRoot(rels) {
466
617
  };
467
618
  return (file) => resolveDir(file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "");
468
619
  }
620
+ /** Human-readable text for a name/kind field -- a literal value as-is, or a
621
+ * template's exact raw `{{ }}` source text (never evaluated). Used for
622
+ * display (symbol names, edge reasons); NOT for resolution-key equality --
623
+ * see nameKeyText below for that. */
624
+ function displayNameText(ref) {
625
+ return ref.form === "literal" ? ref.value : ref.sourceText;
626
+ }
627
+ /** Normalized resolution-key text for a name/kind field: a literal value or a
628
+ * template's exact raw source text, EACH PREFIXED so a literal "foo" can
629
+ * never collide with a template whose source text happens to read "foo". */
630
+ function nameKeyText(ref) {
631
+ return ref.form === "literal" ? `L:${ref.value}` : `T:${ref.sourceText}`;
632
+ }
469
633
  /** Resolve a callee name to a symbol id: prefer same-file, otherwise require a
470
634
  * unique symbol in a statically imported local file. A unique repository-wide
471
635
  * name is not evidence of a binding: callback parameters and built-ins often
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Deterministic text scan for Kubernetes manifest cross-resource references —
3
+ * NOT a tree-sitter walk. Confirmed directly (not assumed): parsing a realistic
4
+ * templated Deployment with this repo's own tree-sitter-yaml bundle showed a
5
+ * same-line templated scalar (`name: {{ include "x" . }}`) parses cleanly, but
6
+ * a block-form injection on its own line (`labels:\n {{- include ... }}` — the
7
+ * pattern real charts use for metadata.labels and spec.selector) collapses the
8
+ * WHOLE REST OF THE DOCUMENT'S error recovery into a flat, unstructured ERROR
9
+ * node. Real charts place such an injection early (right after metadata.name),
10
+ * so it would poison parsing for every field-path this module needs further
11
+ * down the same document. parseSource() (parse.ts) also never exposes its
12
+ * tree-sitter tree to callers at all -- helm.ts's own text-scan precedent
13
+ * exists for the same reason.
14
+ *
15
+ * This is a line-oriented, indentation-tracking scanner (not flat regex, unlike
16
+ * helm.ts's `{{ }}`-action scan) because K8s field-paths are nested block
17
+ * structure (`spec.template.spec.containers[].env[].valueFrom.secretKeyRef.name`)
18
+ * that a flat scan can't reconstruct. It never interprets `{{ }}` as YAML syntax
19
+ * at all -- a `{{ ... }}` on a line's value side is just that line's raw value
20
+ * text, same-line or block-form alike -- so it is immune to the exact failure
21
+ * mode that broke tree-sitter.
22
+ */
23
+ export type ManifestNameRef = {
24
+ form: "literal";
25
+ value: string;
26
+ atChar: number;
27
+ endChar: number;
28
+ } | {
29
+ form: "template";
30
+ sourceText: string;
31
+ atChar: number;
32
+ endChar: number;
33
+ };
34
+ interface K8sReferenceCandidate {
35
+ /** Fixed literal for most kinds ("ConfigMap", "Secret", "PersistentVolumeClaim",
36
+ * "Service"); for an ownerReferences entry, the reference's OWN `kind` field
37
+ * value (data-dependent, not statically known). */
38
+ refKind: string;
39
+ name: ManifestNameRef;
40
+ }
41
+ type ManifestLabelMap = Record<string, string>;
42
+ interface K8sResourceDoc {
43
+ kind: string;
44
+ name: ManifestNameRef;
45
+ startChar: number;
46
+ endChar: number;
47
+ }
48
+ export interface K8sManifestDocument {
49
+ resource: K8sResourceDoc | null;
50
+ references: K8sReferenceCandidate[];
51
+ selector: ManifestLabelMap | null;
52
+ labels: ManifestLabelMap | null;
53
+ }
54
+ /** Where a kind's pod spec lives -- factored once so container/volume field
55
+ * paths below aren't hand-duplicated per kind. */
56
+ export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;
57
+ export declare const LABELS_PATH_BY_KIND: Record<string, string>;
58
+ export declare function extractK8sManifest(source: string): K8sManifestDocument[];
59
+ export {};