@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 CHANGED
@@ -6,10 +6,11 @@
6
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
7
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
8
8
 
9
- Hunch is a guarantee: **your agents never re-make a decided decision, and never re-introduce a
10
- fixed bug.** The mechanism behind it is an engineering-memory and architectural-conformance layer
11
- the decisions, constraints, rejected approaches, and bug history behind your code, delivered as
12
- evidence before an assistant changes anything, with the result checked deterministically after.
9
+ For the precise rules your team explicitly trusts and proves, Hunch gives a concrete guarantee:
10
+ **your agents do not re-make a decided decision or re-introduce a fixed bug without a deterministic
11
+ violation being surfaced.** The mechanism is an engineering-memory and architectural-conformance
12
+ layer the decisions, constraints, rejected approaches, and bug history behind your code, delivered
13
+ as evidence before an assistant changes anything, with the result checked deterministically after.
13
14
 
14
15
  Memory starts **advisory**. Nothing blocks until you explicitly trust a precise rule and choose
15
16
  strict enforcement.
@@ -17,10 +18,10 @@ strict enforcement.
17
18
  **Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
18
19
  then a deterministic check of the change against the rules your team has explicitly trusted.
19
20
 
20
- > **New in v1.17.0:** an exported ADR corpus now tracks the graph automatically and reports its
21
- > own drift (`madr-stale` / `madr-edited` / `madr-orphan`), and retrieval ranks recorded intent
22
- > above code symbols that merely share the query's vocabulary — Recall@10 70% → 90% on the
23
- > curated benchmark.
21
+ > **New in v1.18.0:** YAML and Helm now enter the same dependency graph as application code.
22
+ > Anchors and aliases become reference edges; chart-scoped `define`, `include`, and `template`
23
+ > relationships survive pre-render syntax without weakening fail-closed handling for ordinary
24
+ > invalid YAML.
24
25
 
25
26
  See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
26
27
 
@@ -50,8 +51,8 @@ to the same graph. It merges into existing configuration instead of replacing it
50
51
  - **Change receipts** — review a working tree, commit, or branch against recorded intent and get a
51
52
  cited PASS / WARN / BLOCK result.
52
53
  - **Bug lineage** — understand which old incident a line fixed before accidentally undoing it.
53
- - **Code awareness** — TypeScript, JavaScript, Python, and Go structure feed dependency,
54
- blast-radius, and redundancy checks. The reasoning layer works with any language.
54
+ - **Code awareness** — TypeScript, JavaScript, Python, Go, YAML, and chart-scoped Helm templates
55
+ feed dependency, blast-radius, and redundancy checks. The reasoning layer works with any language.
55
56
  - **ADR interop** — `hunch import-adr` populates the graph from an existing MADR/Nygard corpus;
56
57
  `hunch export-adr` projects it back as standard MADR any ADR reader understands, and the
57
58
  projection then tracks the graph automatically and reports its own drift.
@@ -88,7 +89,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
88
89
  have one maintainer run:
89
90
 
90
91
  ```bash
91
- npm i -g @davesheffer/hunch@1.13.1
92
+ npm i -g @davesheffer/hunch@1.18.0
92
93
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
93
94
  git add .gitignore .hunch/team.json
94
95
  git commit -m "chore: connect shared Hunch memory"
@@ -103,7 +104,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
103
104
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
104
105
 
105
106
  ```bash
106
- npm i -g @davesheffer/hunch@1.13.1
107
+ npm i -g @davesheffer/hunch@1.18.0
107
108
  git pull
108
109
  hunch init
109
110
  hunch doctor
@@ -121,6 +122,9 @@ authentication stays in SSH or the normal Git credential helper. Shared memory r
121
122
  `.hunch/local.json`, and `.hunch-private/` stay out of code history. Use
122
123
  `hunch check --base origin/main --strict --public-only --format markdown` for output that may be
123
124
  posted publicly; omit `--public-only` for an internal check that should enforce team memory.
125
+ `HUNCH_PRIVATE_DIR` remains an explicit process-level override for CI and portability. When it
126
+ redirects a repo away from `.hunch/local.json` or bypasses an advertised team store, CLI and MCP
127
+ startup warn on stderr and `hunch doctor` labels the effective source.
124
128
 
125
129
  For a correction that Hunch can express as a deterministic policy, create and inspect its
126
130
  proof-backed proposal:
@@ -148,7 +152,7 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
148
152
  commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
149
153
  local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
150
154
  rollback. For this rollout, reinstall the previous published package with
151
- `npm i -g @davesheffer/hunch@1.12.1`; the release receipt resolves and records the verified rollback
155
+ `npm i -g @davesheffer/hunch@1.16.0`; the release receipt resolves and records the verified rollback
152
156
  target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
153
157
  and keep every team client on the same release before resuming Matrix policy workflows.
154
158
 
@@ -201,6 +205,8 @@ downloaded public VSIX has the same digest as the credential-free release candid
201
205
  - [VS Code extension guide](vscode-extension/README.md)
202
206
  - [Contributing](CONTRIBUTING.md)
203
207
  - [Architecture benchmark](bench/architectural-conformance.md)
208
+ - [Engineering Landscape Graph and ORC boundary](docs/engineering-landscape.md)
209
+ - [ORC outcome/experience protocol](docs/outcome-experience-protocol.md)
204
210
  - [Competitive landscape (dated; re-verify before quoting)](docs/competitive-landscape.md)
205
211
 
206
212
  Apache-2.0
package/dist/cli/index.js CHANGED
@@ -114,6 +114,9 @@ function openTeamStore(root, opts = {}) {
114
114
  const teamWired = ensureTeamOverlay(root);
115
115
  const store = new HunchStore(hunchPaths(root));
116
116
  openStore = store;
117
+ const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
118
+ if (overlayWarning)
119
+ console.error(`[hunch] ⚠ ${overlayWarning}`);
117
120
  if (teamAdvertised && (store.mode !== "shared"
118
121
  || !store.privateDir
119
122
  || !existsSync(store.privateDir)
@@ -5431,8 +5434,8 @@ program
5431
5434
  }
5432
5435
  else if (store.privateDir) {
5433
5436
  console.log(store.mode === "shared"
5434
- ? `shared: on → ${store.privateDir} (UNIFIED — every capture routes here; one source of truth across branches, worktrees, teammates, agents)`
5435
- : `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly)`);
5437
+ ? `shared: on → ${store.privateDir} (UNIFIED — every capture routes here; one source of truth across branches, worktrees, teammates, agents; source: ${store.overlaySource})`
5438
+ : `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly; source: ${store.overlaySource})`);
5436
5439
  }
5437
5440
  else {
5438
5441
  const team = readTeamConfig(root);
@@ -27,10 +27,10 @@ function view(file, source) {
27
27
  }
28
28
  function viewOfParsed(parsed) {
29
29
  const symbols = new Map(parsed.symbols.map((s) => [symbolKey(s), s]));
30
- const byStart = new Map(parsed.symbols.map((s) => [s.startByte, s]));
30
+ const byIndex = new Map(parsed.symbols.map((s, i) => [i, s]));
31
31
  const calls = new Map();
32
- for (const [start, callees] of attributeCalls(parsed)) {
33
- const caller = byStart.get(start);
32
+ for (const [index, callees] of attributeCalls(parsed)) {
33
+ const caller = byIndex.get(index);
34
34
  if (!caller)
35
35
  continue;
36
36
  for (const [callee, member] of callees) {
@@ -436,6 +436,7 @@ export function mutateSnapshotForPolicy(policy, snapshot) {
436
436
  }
437
437
  else {
438
438
  edges.push({
439
+ schema: "hunch.edge/1",
439
440
  id: `edge_policy_mutation_${policy.id}`,
440
441
  from: subjectId,
441
442
  to: objectId,
@@ -443,6 +444,8 @@ export function mutateSnapshotForPolicy(policy, snapshot) {
443
444
  reason: "deterministic proof mutation",
444
445
  strength: 1,
445
446
  provenance: { source: "derived", confidence: 1, evidence: [policy.id] },
447
+ environment: null,
448
+ metadata: {},
446
449
  });
447
450
  operator = mutationOperatorForPolicy(policy);
448
451
  }
@@ -14,7 +14,7 @@ export const DataClassSchema = z.enum(["public", "private", "secret"]);
14
14
  export const StructuralSymbolRefSchema = z.object({
15
15
  file: z.string().min(1),
16
16
  name: z.string().min(1),
17
- kind: z.enum(["function", "method", "class", "interface", "type"]),
17
+ kind: z.enum(["function", "method", "class", "interface", "type", "variable", "file"]),
18
18
  });
19
19
  export const StructuralCallRefSchema = z.object({
20
20
  file: z.string().min(1),
package/dist/core/ids.js CHANGED
@@ -22,6 +22,22 @@ export function componentId(name) {
22
22
  export function edgeId(from, to, type) {
23
23
  return "edge_" + shortHash(`${from}->${to}:${type}`);
24
24
  }
25
+ /** Stable Engineering Landscape resource identity. The kind remains visible so
26
+ * fragments stay useful without a lookup table; the natural key is normalized
27
+ * only where spelling cannot carry meaning (outer whitespace, path separators,
28
+ * and a trailing slash). Kind-specific discovery may apply stricter canonical
29
+ * rules before calling this helper. */
30
+ export function resourceId(kind, naturalKey) {
31
+ const normalizedKind = kind.trim().toLowerCase();
32
+ const normalizedKey = naturalKey.trim().replace(/\\/g, "/").replace(/\/+$/, "");
33
+ return `${normalizedKind}:${normalizedKey}`;
34
+ }
35
+ /** Resource relationships ride the existing edge graph and therefore share its
36
+ * endpoint/type identity rule. Keeping one helper prevents a parallel graph from
37
+ * minting incompatible relationship ids. */
38
+ export function resourceRelationshipId(from, to, type) {
39
+ return edgeId(from, to, type);
40
+ }
25
41
  /** Decision id. Seed with the CANONICAL full commit sha (the auto-sync and MCP
26
42
  * commit paths both do this, so a recorded decision upgrades the auto-draft for
27
43
  * the same commit), or with "manual:<title>" for an ad-hoc MCP decision. */
@@ -18,7 +18,7 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
  import { writeFileAtomic } from "./io.js";
20
20
  /** The schema generation this build writes and reads. Bump on any breaking change. */
21
- export const SCHEMA_VERSION = 2;
21
+ export const SCHEMA_VERSION = 3;
22
22
  /** A repo whose `.hunch/` predates manifests is treated as v1. Migrations are
23
23
  * numbered from 2 (each `version` is the number it PRODUCES), so a baseline repo
24
24
  * runs every migration with version >= 2 — never author a no-op version:1 one. */
@@ -58,6 +58,25 @@ export const MIGRATIONS = [
58
58
  return raw;
59
59
  },
60
60
  },
61
+ {
62
+ // v3: Engineering Landscape resources reuse the existing graph. Existing
63
+ // edges are explicitly identified as legacy graph edges before the expanded
64
+ // relationship schema is validated; newly written resource relationships use
65
+ // hunch.resource-relationship/1 and keep their currentness/metadata.
66
+ version: 3,
67
+ description: "Version existing graph edges before adding Engineering Landscape resources and relationships",
68
+ up(kind, raw) {
69
+ if (kind === "edges") {
70
+ if (raw.schema === undefined)
71
+ raw.schema = "hunch.edge/1";
72
+ if (raw.environment === undefined)
73
+ raw.environment = null;
74
+ if (raw.metadata === undefined)
75
+ raw.metadata = {};
76
+ }
77
+ return raw;
78
+ },
79
+ },
61
80
  ];
62
81
  /** Read `.hunch/manifest.json`. A missing/corrupt manifest is treated as the
63
82
  * BASELINE version (a pre-manifest `.hunch/`), so future builds still migrate it. */
@@ -6,6 +6,7 @@
6
6
  * tool inputs. Every record carries `provenance` so nothing is a blind assertion.
7
7
  */
8
8
  import { z } from "zod";
9
+ import { resourceId, resourceRelationshipId } from "./ids.js";
9
10
  /** Where a fact came from and how much to trust it. Confidence tiers (DESIGN §4):
10
11
  * inferred < extracted < llm_draft < llm_draft+human_confirmed/derived. */
11
12
  export const ProvenanceSchema = z.object({
@@ -29,6 +30,140 @@ export const ComponentSchema = z.object({
29
30
  created_at: z.string(),
30
31
  updated_at: z.string(),
31
32
  });
33
+ export const RESOURCE_SCHEMA_VERSION = "hunch.resource/1";
34
+ export const RESOURCE_RELATIONSHIP_SCHEMA_VERSION = "hunch.resource-relationship/1";
35
+ /** Resource kinds are deliberately extensible: the initial vocabulary is
36
+ * documented, while repositories may add a stable snake_case kind without a
37
+ * schema release. */
38
+ export const ResourceKindSchema = z.string().regex(/^[a-z][a-z0-9_]{0,63}$/);
39
+ export const ResourceCurrentnessSchema = z.object({
40
+ status: z.enum(["current", "unverified", "stale"]),
41
+ verified_at: z.string().max(64).optional().describe("ISO timestamp at which the declaration was checked"),
42
+ source_revision: z.string().min(1).max(512).optional().describe("immutable source/Git revision backing the declaration"),
43
+ source_content_hash: z.string().min(1).max(512).optional().describe("content hash when revision alone is insufficient"),
44
+ }).strict().superRefine((currentness, ctx) => {
45
+ if (currentness.verified_at !== undefined && !Number.isFinite(Date.parse(currentness.verified_at))) {
46
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["verified_at"], message: "resource currentness timestamp must be ISO-compatible" });
47
+ }
48
+ if (currentness.status !== "unverified"
49
+ && (!currentness.verified_at || (!currentness.source_revision && !currentness.source_content_hash))) {
50
+ ctx.addIssue({
51
+ code: z.ZodIssueCode.custom,
52
+ message: "current or stale resource evidence requires a verification timestamp and source revision or content hash",
53
+ });
54
+ }
55
+ });
56
+ const MetadataValueSchema = z.union([
57
+ z.string().max(1024),
58
+ z.number().finite(),
59
+ z.boolean(),
60
+ z.null(),
61
+ z.array(z.union([z.string().max(1024), z.number().finite(), z.boolean(), z.null()])).max(32),
62
+ ]);
63
+ const SENSITIVE_METADATA_KEY = /(^|[_-])(authorization|bearer|credential|password|passwd|private[_-]?key|secret|token|api[_-]?key)($|[_-])/i;
64
+ const SENSITIVE_ASSIGNMENT = /\b(authorization|password|passwd|private[_-]?key|secret|access[_-]?token|refresh[_-]?token|api[_-]?key)\s*[:=]\s*[^\s,;]{4,}/i;
65
+ const PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/i;
66
+ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]{12,}/i;
67
+ /** Reject credential material while allowing ordinary architecture prose such as
68
+ * "authentication service" or "secrets are managed externally". */
69
+ export function isCredentialFreeText(value) {
70
+ if (PRIVATE_KEY_BLOCK.test(value) || BEARER_VALUE.test(value) || SENSITIVE_ASSIGNMENT.test(value))
71
+ return false;
72
+ try {
73
+ const url = new URL(value);
74
+ if (url.username || url.password)
75
+ return false;
76
+ for (const [key] of url.searchParams)
77
+ if (SENSITIVE_METADATA_KEY.test(key))
78
+ return false;
79
+ }
80
+ catch { /* credential-free canonical locators need not be absolute URLs */ }
81
+ return true;
82
+ }
83
+ function isCanonicalResourceIdentity(value) {
84
+ const separator = value.indexOf(":");
85
+ if (separator <= 0 || separator === value.length - 1)
86
+ return false;
87
+ const kind = value.slice(0, separator);
88
+ const naturalKey = value.slice(separator + 1);
89
+ return ResourceKindSchema.safeParse(kind).success && value === resourceId(kind, naturalKey);
90
+ }
91
+ export const ResourceMetadataSchema = z.record(z.string().min(1).max(64), MetadataValueSchema)
92
+ .superRefine((metadata, ctx) => {
93
+ if (Object.keys(metadata).length > 64) {
94
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "resource metadata is limited to 64 fields" });
95
+ }
96
+ for (const [key, raw] of Object.entries(metadata)) {
97
+ if (SENSITIVE_METADATA_KEY.test(key)) {
98
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "credential-bearing metadata keys are forbidden" });
99
+ continue;
100
+ }
101
+ const values = Array.isArray(raw) ? raw : [raw];
102
+ for (const value of values) {
103
+ if (typeof value === "string" && !isCredentialFreeText(value)) {
104
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "credential material is forbidden in resource metadata" });
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ });
110
+ /** Durable Engineering Landscape node. Runtime health/readiness intentionally has
111
+ * no field here: those expiring observations belong to ORC. */
112
+ export const ResourceSchema = z.object({
113
+ schema: z.literal(RESOURCE_SCHEMA_VERSION),
114
+ id: z.string().min(3).max(2048).describe("stable kind-qualified resource identity"),
115
+ kind: ResourceKindSchema,
116
+ name: z.string().min(1).max(256),
117
+ scope: z.array(z.string().min(1).max(512)).max(16).default([]),
118
+ locator: z.string().min(1).max(2048).nullable().default(null),
119
+ lifecycle: z.enum(["planned", "active", "deprecated", "retired"]).default("active"),
120
+ criticality: z.enum(["low", "medium", "high", "critical"]).optional(),
121
+ contract_version: z.string().max(256).optional(),
122
+ provenance: ProvenanceSchema,
123
+ currentness: ResourceCurrentnessSchema,
124
+ metadata: ResourceMetadataSchema.default({}),
125
+ created_at: z.string(),
126
+ updated_at: z.string(),
127
+ }).strict().superRefine((resource, ctx) => {
128
+ const prefix = `${resource.kind}:`;
129
+ const naturalKey = resource.id.startsWith(prefix) ? resource.id.slice(prefix.length) : "";
130
+ if (!naturalKey.trim() || resource.id !== resourceId(resource.kind, naturalKey)) {
131
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "resource id must be a canonical kind-qualified identity" });
132
+ }
133
+ const credentialFreeFields = [
134
+ ["id", resource.id],
135
+ ["name", resource.name],
136
+ ...resource.scope.map((scope, index) => [`scope.${index}`, scope]),
137
+ ...(resource.locator === null ? [] : [["locator", resource.locator]]),
138
+ ...(resource.contract_version === undefined ? [] : [["contract_version", resource.contract_version]]),
139
+ ["provenance.source", resource.provenance.source],
140
+ ...resource.provenance.evidence.map((evidence, index) => [`provenance.evidence.${index}`, evidence]),
141
+ ...(resource.currentness.source_revision === undefined
142
+ ? []
143
+ : [["currentness.source_revision", resource.currentness.source_revision]]),
144
+ ...(resource.currentness.source_content_hash === undefined
145
+ ? []
146
+ : [["currentness.source_content_hash", resource.currentness.source_content_hash]]),
147
+ ];
148
+ for (const [field, value] of credentialFreeFields) {
149
+ if (!isCredentialFreeText(value)) {
150
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: String(field).split("."), message: "credential material is forbidden in resource records" });
151
+ }
152
+ }
153
+ if (new Set(resource.scope).size !== resource.scope.length) {
154
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["scope"], message: "resource scope entries must be unique" });
155
+ }
156
+ if (resource.provenance.source.length > 256 || resource.provenance.evidence.length > 64
157
+ || resource.provenance.evidence.some((evidence) => evidence.length > 2048)) {
158
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance"], message: "resource provenance must remain bounded" });
159
+ }
160
+ if (resource.created_at.length > 64 || !Number.isFinite(Date.parse(resource.created_at))) {
161
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["created_at"], message: "resource created_at must be ISO-compatible" });
162
+ }
163
+ if (resource.updated_at.length > 64 || !Number.isFinite(Date.parse(resource.updated_at))) {
164
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["updated_at"], message: "resource updated_at must be ISO-compatible" });
165
+ }
166
+ });
32
167
  export const EdgeType = z.enum([
33
168
  "depends_on",
34
169
  "calls",
@@ -37,9 +172,36 @@ export const EdgeType = z.enum([
37
172
  "implements",
38
173
  "supersedes",
39
174
  "related_to",
175
+ "references",
176
+ "provides",
177
+ "belongs_to",
178
+ "implemented_by",
179
+ "invokes",
180
+ "exposes",
181
+ "publishes",
182
+ "consumes",
183
+ "reads_from",
184
+ "writes_to",
185
+ "builds",
186
+ "tests",
187
+ "deploys",
188
+ "deployed_on",
189
+ "owned_by",
190
+ "monitored_by",
191
+ "governed_by",
192
+ "source_of_truth_for",
193
+ "compatible_with",
194
+ "replaces",
195
+ ]);
196
+ export const ResourceRelationshipType = z.enum([
197
+ "provides", "belongs_to", "implemented_by", "contains", "depends_on", "invokes",
198
+ "exposes", "publishes", "consumes", "reads_from", "writes_to", "builds", "tests",
199
+ "deploys", "deployed_on", "owned_by", "monitored_by", "governed_by",
200
+ "source_of_truth_for", "compatible_with", "replaces", "implements",
40
201
  ]);
41
202
  /** Typed relationship between components or symbols. */
42
203
  export const EdgeSchema = z.object({
204
+ schema: z.enum(["hunch.edge/1", RESOURCE_RELATIONSHIP_SCHEMA_VERSION]).default("hunch.edge/1"),
43
205
  id: z.string().describe("edge_*"),
44
206
  from: z.string(),
45
207
  to: z.string(),
@@ -47,6 +209,49 @@ export const EdgeSchema = z.object({
47
209
  reason: z.string().default(""),
48
210
  strength: z.number().min(0).max(1).default(0.5),
49
211
  provenance: ProvenanceSchema,
212
+ currentness: ResourceCurrentnessSchema.optional(),
213
+ environment: z.string().max(256).nullable().default(null),
214
+ criticality: z.enum(["low", "medium", "high", "critical"]).optional(),
215
+ contract_version: z.string().max(256).optional(),
216
+ metadata: ResourceMetadataSchema.default({}),
217
+ }).strict().superRefine((edge, ctx) => {
218
+ if (edge.schema !== RESOURCE_RELATIONSHIP_SCHEMA_VERSION)
219
+ return;
220
+ const credentialFreeFields = [
221
+ ["from", edge.from], ["to", edge.to], ["reason", edge.reason],
222
+ ["provenance.source", edge.provenance.source],
223
+ ...edge.provenance.evidence.map((evidence, index) => [`provenance.evidence.${index}`, evidence]),
224
+ ...(edge.environment === null ? [] : [["environment", edge.environment]]),
225
+ ...(edge.contract_version === undefined ? [] : [["contract_version", edge.contract_version]]),
226
+ ...(edge.currentness?.source_revision === undefined
227
+ ? []
228
+ : [["currentness.source_revision", edge.currentness.source_revision]]),
229
+ ...(edge.currentness?.source_content_hash === undefined
230
+ ? []
231
+ : [["currentness.source_content_hash", edge.currentness.source_content_hash]]),
232
+ ];
233
+ for (const [field, value] of credentialFreeFields) {
234
+ if (!isCredentialFreeText(value)) {
235
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: field.split("."), message: "credential material is forbidden in graph relationships" });
236
+ }
237
+ }
238
+ if (!ResourceRelationshipType.options.includes(edge.type)) {
239
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["type"], message: "unsupported resource relationship type" });
240
+ }
241
+ if (!isCanonicalResourceIdentity(edge.from) || !isCanonicalResourceIdentity(edge.to)) {
242
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["from"], message: "resource relationships require kind-qualified endpoints" });
243
+ }
244
+ if (edge.id !== resourceRelationshipId(edge.from, edge.to, edge.type)) {
245
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "resource relationship id must be deterministic from endpoints and type" });
246
+ }
247
+ if (!edge.currentness) {
248
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["currentness"], message: "resource relationships require currentness evidence" });
249
+ }
250
+ if (edge.from.length > 2048 || edge.to.length > 2048 || edge.reason.length > 2048
251
+ || edge.provenance.source.length > 256 || edge.provenance.evidence.length > 64
252
+ || edge.provenance.evidence.some((evidence) => evidence.length > 2048)) {
253
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance"], message: "resource relationship fields must remain bounded" });
254
+ }
50
255
  });
51
256
  export const SymbolKind = z.enum(["function", "method", "class", "interface", "type", "variable", "file"]);
52
257
  export const SymbolMetricsSchema = z.object({
@@ -281,9 +486,10 @@ export const FindingSchema = z.object({
281
486
  provenance: ProvenanceSchema,
282
487
  });
283
488
  /** The entity collections, keyed by their on-disk directory name. */
284
- export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings"];
489
+ export const ENTITY_KINDS = ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings"];
285
490
  export const SCHEMAS = {
286
491
  components: ComponentSchema,
492
+ resources: ResourceSchema,
287
493
  edges: EdgeSchema,
288
494
  symbols: SymbolSchema,
289
495
  decisions: DecisionSchema,
@@ -311,6 +311,13 @@ function remoteIdentity(raw, cwd, purpose = "route") {
311
311
  return `literal:${trimRepoSuffix(value)}`;
312
312
  }
313
313
  }
314
+ /** Canonical repository identity for deterministic, credential-free discovery.
315
+ * The returned value is an internal identity: callers exposing it must hash
316
+ * local `file:` identities so host paths never enter graph records. Network
317
+ * credentials, URL query strings and fragments are deliberately excluded. */
318
+ export function canonicalRemoteRepositoryIdentity(raw, cwd) {
319
+ return remoteIdentity(raw, cwd, "publication");
320
+ }
314
321
  function localRemotePath(raw, cwd) {
315
322
  const value = raw.trim();
316
323
  if (!value)
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Deterministic text scan for Helm's `define`/`include`/`template` Go-template
3
+ * actions — NOT a Go-template parser, and NOT a tree-sitter query. tree-sitter-yaml
4
+ * has no notion of `{{ }}` content: parsing `{{ include "x" . }}` inside a real
5
+ * YAML mapping value produces only stray `{` flow-mapping-open tokens, with the
6
+ * enclosed text lost to ERROR recovery (verified directly against this repo's
7
+ * tree-sitter-yaml bundle). There is nothing for a tree-sitter query to capture,
8
+ * so this runs as a sidecar text scan over the raw source — invoked only for
9
+ * files under a Helm chart (indexer.ts's chart-root detection), never for
10
+ * arbitrary YAML.
11
+ *
12
+ * Known bounded limitation: this is a token scan, not a full parser. A literal
13
+ * `}}` inside a quoted argument, or an include/define-shaped string inside a
14
+ * `{{/* comment *}}`, can misattribute a byte range or produce a phantom call.
15
+ * Both are bounded failure modes (a stray reference to a real symbol name, or a
16
+ * slightly-long symbol range) — the same class of accepted limitation
17
+ * `toleratedErrorScopes` documents for the tree-sitter grammars, not a silent gap.
18
+ * The chart-wide `importedFiles` widening this module's output flows through
19
+ * (indexer.ts) also lets a YAML alias resolve to an anchor in a sibling chart
20
+ * file, even though YAML anchors are properly document-scoped; this only fires
21
+ * when the alias has no matching anchor in its own file (i.e. only on input
22
+ * that's already invalid YAML on its own terms), so it's bounded, but it's a
23
+ * real, disclosed side effect of the chart-scoping mechanism, not something to
24
+ * silently rely on.
25
+ */
26
+ import { MAX_BODY_TEXT_CHARS } from "./parse.js";
27
+ // Matches one `{{ ... }}` action, including the `{{-`/`-}}` whitespace-trim
28
+ // markers. Non-greedy so a multi-action line matches each action separately.
29
+ const ACTION = /\{\{-?([\s\S]*?)-?\}\}/g;
30
+ const BLOCK_OPEN = new Set(["if", "range", "with", "define", "block"]);
31
+ const NAME_ARG = /^"([^"]*)"/;
32
+ // Matched against an action's RAW inner text (not just a leading keyword) so
33
+ // `{{ $labels := include "x" . }}` and `{{ if include "x" . }}` are caught,
34
+ // not only the standalone `{{ include "x" . }}` form.
35
+ const CALL_SITE = /\b(?:include|template)\s+"([^"]+)"/g;
36
+ export function extractHelmDirectives(source) {
37
+ const symbols = [];
38
+ const calls = [];
39
+ const stack = [];
40
+ for (const m of source.matchAll(ACTION)) {
41
+ const raw = m[1];
42
+ // "{{" is 2 chars; a trim-marker "{{-" is 3 — this is the byte offset of
43
+ // `raw`'s first character within `source`, needed for call-site atByte math.
44
+ const innerStart = m.index + (source[m.index + 2] === "-" ? 3 : 2);
45
+ const endByte = m.index + m[0].length;
46
+ const body = raw.trim();
47
+ const spaceIdx = body.search(/\s/);
48
+ const keyword = spaceIdx === -1 ? body : body.slice(0, spaceIdx);
49
+ if (keyword === "define") {
50
+ const name = NAME_ARG.exec(body.slice(spaceIdx + 1).trim())?.[1];
51
+ stack.push({ name, startByte: m.index });
52
+ }
53
+ else if (BLOCK_OPEN.has(keyword)) {
54
+ // if/range/with/block: depth marker only, no symbol on its own.
55
+ stack.push({ startByte: m.index });
56
+ }
57
+ else if (keyword === "end") {
58
+ const open = stack.pop();
59
+ if (open?.name) {
60
+ symbols.push({
61
+ name: open.name,
62
+ kind: "variable",
63
+ startByte: open.startByte,
64
+ endByte,
65
+ loc: source.slice(open.startByte, endByte).split("\n").length,
66
+ bodyText: source.slice(open.startByte, endByte).slice(0, MAX_BODY_TEXT_CHARS),
67
+ });
68
+ }
69
+ }
70
+ for (const call of raw.matchAll(CALL_SITE)) {
71
+ const atByte = innerStart + call.index;
72
+ calls.push({ callee: call[1], atByte, endByte: atByte + call[0].length, member: false });
73
+ }
74
+ }
75
+ return { symbols, calls };
76
+ }
77
+ //# sourceMappingURL=helm.js.map