@davesheffer/hunch 1.17.0 → 1.18.1

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,13 @@ 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.
25
+
26
+ Read the [v1.18 release story](https://hunch-pi.vercel.app/blog/post?slug=configuration-joins-the-graph)
27
+ for the graph semantics, chart boundaries, honest limits, and Oliver Sampson's contribution.
24
28
 
25
29
  See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
26
30
 
@@ -50,8 +54,8 @@ to the same graph. It merges into existing configuration instead of replacing it
50
54
  - **Change receipts** — review a working tree, commit, or branch against recorded intent and get a
51
55
  cited PASS / WARN / BLOCK result.
52
56
  - **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.
57
+ - **Code awareness** — TypeScript, JavaScript, Python, Go, YAML, and chart-scoped Helm templates
58
+ feed dependency, blast-radius, and redundancy checks. The reasoning layer works with any language.
55
59
  - **ADR interop** — `hunch import-adr` populates the graph from an existing MADR/Nygard corpus;
56
60
  `hunch export-adr` projects it back as standard MADR any ADR reader understands, and the
57
61
  projection then tracks the graph automatically and reports its own drift.
@@ -88,7 +92,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
88
92
  have one maintainer run:
89
93
 
90
94
  ```bash
91
- npm i -g @davesheffer/hunch@1.13.1
95
+ npm i -g @davesheffer/hunch@1.18.1
92
96
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
93
97
  git add .gitignore .hunch/team.json
94
98
  git commit -m "chore: connect shared Hunch memory"
@@ -103,7 +107,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
103
107
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
104
108
 
105
109
  ```bash
106
- npm i -g @davesheffer/hunch@1.13.1
110
+ npm i -g @davesheffer/hunch@1.18.1
107
111
  git pull
108
112
  hunch init
109
113
  hunch doctor
@@ -121,6 +125,9 @@ authentication stays in SSH or the normal Git credential helper. Shared memory r
121
125
  `.hunch/local.json`, and `.hunch-private/` stay out of code history. Use
122
126
  `hunch check --base origin/main --strict --public-only --format markdown` for output that may be
123
127
  posted publicly; omit `--public-only` for an internal check that should enforce team memory.
128
+ `HUNCH_PRIVATE_DIR` remains an explicit process-level override for CI and portability. When it
129
+ redirects a repo away from `.hunch/local.json` or bypasses an advertised team store, CLI and MCP
130
+ startup warn on stderr and `hunch doctor` labels the effective source.
124
131
 
125
132
  For a correction that Hunch can express as a deterministic policy, create and inspect its
126
133
  proof-backed proposal:
@@ -148,7 +155,7 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
148
155
  commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
149
156
  local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
150
157
  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
158
+ `npm i -g @davesheffer/hunch@1.16.0`; the release receipt resolves and records the verified rollback
152
159
  target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
153
160
  and keep every team client on the same release before resuming Matrix policy workflows.
154
161
 
@@ -201,6 +208,8 @@ downloaded public VSIX has the same digest as the credential-free release candid
201
208
  - [VS Code extension guide](vscode-extension/README.md)
202
209
  - [Contributing](CONTRIBUTING.md)
203
210
  - [Architecture benchmark](bench/architectural-conformance.md)
211
+ - [Engineering Landscape Graph and ORC boundary](docs/engineering-landscape.md)
212
+ - [ORC outcome/experience protocol](docs/outcome-experience-protocol.md)
204
213
  - [Competitive landscape (dated; re-verify before quoting)](docs/competitive-landscape.md)
205
214
 
206
215
  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,
@@ -7,7 +7,10 @@
7
7
  * accepted -> status accepted (live)
8
8
  * proposed/draft -> status proposed
9
9
  * rejected -> status rejected
10
- * superseded/deprecated -> status superseded, valid_to closed (bi-temporal)
10
+ * superseded -> status superseded, valid_to closed (bi-temporal)
11
+ * deprecated + successor -> status superseded, closed at the successor date
12
+ * deprecated, bare -> status accepted with a loud review warning; the raw
13
+ * lifecycle remains in provenance evidence
11
14
  * "Considered Options" minus the chosen one -> alternatives_rejected
12
15
  * file slug -> topic `adr.<slug>` (namespaced so an import can never
13
16
  * collide with a live hand-captured topic; the CLI still
@@ -102,7 +105,14 @@ function refNumbers(text) {
102
105
  }
103
106
  function mapStatus(raw) {
104
107
  const s = raw.toLowerCase();
105
- if (/supersed|deprecat/.test(s))
108
+ // MADR permits a bare `deprecated` lifecycle with no replacement relation.
109
+ // Hunch has no deprecated Decision status, and inventing `superseded` would
110
+ // silently fabricate a history edge and remove the record from live recall.
111
+ // Keep it advisory/live unless an explicit successor below proves closure;
112
+ // provenance retains the source status and the mapper emits a warning.
113
+ if (/deprecat/.test(s))
114
+ return "accepted";
115
+ if (/supersed/.test(s))
106
116
  return "superseded";
107
117
  if (/reject/.test(s))
108
118
  return "rejected";
@@ -225,6 +235,9 @@ export function mapAdrCorpus(sources) {
225
235
  }
226
236
  const decisions = parsed.map((p) => {
227
237
  const successor = p.supersededByNumbers.map((n) => byNumber.get(n)).find(Boolean) ?? null;
238
+ if (/deprecat/i.test(p.statusRaw) && !successor) {
239
+ warnings.push(`${p.relPath}: deprecated status names no resolvable successor — kept in force as accepted; raw status remains in provenance evidence, review and record an explicit successor or rejection`);
240
+ }
228
241
  const superseded = p.status === "superseded" || !!successor;
229
242
  const validTo = superseded ? (successor?.date ?? p.date) : null;
230
243
  const alternatives = p.consideredOptions.filter((o) => !p.chosenOption || o.toLowerCase() !== p.chosenOption.toLowerCase());
@@ -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