@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 +22 -13
- package/dist/cli/index.js +5 -2
- package/dist/constitution/delta.js +3 -3
- package/dist/constitution/evaluator.js +3 -0
- package/dist/constitution/schema.js +1 -1
- package/dist/core/ids.js +16 -0
- package/dist/core/migrate.js +20 -1
- package/dist/core/types.js +207 -1
- package/dist/extractors/adrImport.js +15 -2
- package/dist/extractors/git.js +7 -0
- package/dist/extractors/helm.js +77 -0
- package/dist/extractors/indexer.js +100 -24
- package/dist/extractors/landscapeDiscovery.js +492 -0
- package/dist/extractors/languages.js +36 -1
- package/dist/extractors/nativeTreeSitter.js +17 -8
- package/dist/extractors/parse.js +58 -14
- package/dist/mcp/server.js +18 -4
- package/dist/store/hunchStore.js +79 -1
- package/dist/store/jsonStore.js +8 -1
- package/dist/store/schema.js +25 -2
- package/package.json +4 -3
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
[](https://github.com/davesheffer/hunch)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
21
|
-
>
|
|
22
|
-
>
|
|
23
|
-
>
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
30
|
+
const byIndex = new Map(parsed.symbols.map((s, i) => [i, s]));
|
|
31
31
|
const calls = new Map();
|
|
32
|
-
for (const [
|
|
33
|
-
const caller =
|
|
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. */
|
package/dist/core/migrate.js
CHANGED
|
@@ -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 =
|
|
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. */
|
package/dist/core/types.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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());
|
package/dist/extractors/git.js
CHANGED
|
@@ -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
|