@davesheffer/hunch 1.16.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 +22 -10
- package/dist/cli/index.js +49 -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/drift.js +12 -0
- package/dist/core/ids.js +16 -0
- package/dist/core/migrate.js +20 -1
- package/dist/core/types.js +207 -1
- 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/integrations/madrManifest.js +251 -0
- package/dist/mcp/server.js +18 -4
- package/dist/store/hunchStore.js +96 -2
- 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,9 +6,11 @@
|
|
|
6
6
|
[](https://github.com/davesheffer/hunch)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
12
14
|
|
|
13
15
|
Memory starts **advisory**. Nothing blocks until you explicitly trust a precise rule and choose
|
|
14
16
|
strict enforcement.
|
|
@@ -16,8 +18,10 @@ strict enforcement.
|
|
|
16
18
|
**Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
|
|
17
19
|
then a deterministic check of the change against the rules your team has explicitly trusted.
|
|
18
20
|
|
|
19
|
-
> **New in v1.
|
|
20
|
-
>
|
|
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.
|
|
21
25
|
|
|
22
26
|
See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
|
|
23
27
|
|
|
@@ -47,8 +51,11 @@ to the same graph. It merges into existing configuration instead of replacing it
|
|
|
47
51
|
- **Change receipts** — review a working tree, commit, or branch against recorded intent and get a
|
|
48
52
|
cited PASS / WARN / BLOCK result.
|
|
49
53
|
- **Bug lineage** — understand which old incident a line fixed before accidentally undoing it.
|
|
50
|
-
- **Code awareness** — TypeScript, JavaScript,
|
|
51
|
-
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.
|
|
56
|
+
- **ADR interop** — `hunch import-adr` populates the graph from an existing MADR/Nygard corpus;
|
|
57
|
+
`hunch export-adr` projects it back as standard MADR any ADR reader understands, and the
|
|
58
|
+
projection then tracks the graph automatically and reports its own drift.
|
|
52
59
|
|
|
53
60
|
The source of truth is readable JSON in `.hunch/`. A local SQLite index makes retrieval fast but
|
|
54
61
|
is always rebuildable.
|
|
@@ -82,7 +89,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
|
|
|
82
89
|
have one maintainer run:
|
|
83
90
|
|
|
84
91
|
```bash
|
|
85
|
-
npm i -g @davesheffer/hunch@1.
|
|
92
|
+
npm i -g @davesheffer/hunch@1.18.0
|
|
86
93
|
hunch shared --repo git@github.com:acme/project-hunch-memory.git
|
|
87
94
|
git add .gitignore .hunch/team.json
|
|
88
95
|
git commit -m "chore: connect shared Hunch memory"
|
|
@@ -97,7 +104,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
|
|
|
97
104
|
After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
|
|
98
105
|
|
|
99
106
|
```bash
|
|
100
|
-
npm i -g @davesheffer/hunch@1.
|
|
107
|
+
npm i -g @davesheffer/hunch@1.18.0
|
|
101
108
|
git pull
|
|
102
109
|
hunch init
|
|
103
110
|
hunch doctor
|
|
@@ -115,6 +122,9 @@ authentication stays in SSH or the normal Git credential helper. Shared memory r
|
|
|
115
122
|
`.hunch/local.json`, and `.hunch-private/` stay out of code history. Use
|
|
116
123
|
`hunch check --base origin/main --strict --public-only --format markdown` for output that may be
|
|
117
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.
|
|
118
128
|
|
|
119
129
|
For a correction that Hunch can express as a deterministic policy, create and inspect its
|
|
120
130
|
proof-backed proposal:
|
|
@@ -142,7 +152,7 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
|
|
|
142
152
|
commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
|
|
143
153
|
local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
|
|
144
154
|
rollback. For this rollout, reinstall the previous published package with
|
|
145
|
-
`npm i -g @davesheffer/hunch@1.
|
|
155
|
+
`npm i -g @davesheffer/hunch@1.16.0`; the release receipt resolves and records the verified rollback
|
|
146
156
|
target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
|
|
147
157
|
and keep every team client on the same release before resuming Matrix policy workflows.
|
|
148
158
|
|
|
@@ -195,6 +205,8 @@ downloaded public VSIX has the same digest as the credential-free release candid
|
|
|
195
205
|
- [VS Code extension guide](vscode-extension/README.md)
|
|
196
206
|
- [Contributing](CONTRIBUTING.md)
|
|
197
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)
|
|
198
210
|
- [Competitive landscape (dated; re-verify before quoting)](docs/competitive-landscape.md)
|
|
199
211
|
|
|
200
212
|
Apache-2.0
|
package/dist/cli/index.js
CHANGED
|
@@ -73,6 +73,7 @@ import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
|
73
73
|
import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
|
|
74
74
|
import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
|
|
75
75
|
import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
|
|
76
|
+
import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
|
|
76
77
|
import { pendingEscalations, policyEscalations } from "../core/escalations.js";
|
|
77
78
|
import { premiseEscalations } from "../core/premises.js";
|
|
78
79
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
@@ -113,6 +114,9 @@ function openTeamStore(root, opts = {}) {
|
|
|
113
114
|
const teamWired = ensureTeamOverlay(root);
|
|
114
115
|
const store = new HunchStore(hunchPaths(root));
|
|
115
116
|
openStore = store;
|
|
117
|
+
const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
|
|
118
|
+
if (overlayWarning)
|
|
119
|
+
console.error(`[hunch] ⚠ ${overlayWarning}`);
|
|
116
120
|
if (teamAdvertised && (store.mode !== "shared"
|
|
117
121
|
|| !store.privateDir
|
|
118
122
|
|| !existsSync(store.privateDir)
|
|
@@ -500,6 +504,21 @@ program
|
|
|
500
504
|
else if (!opts.quiet) {
|
|
501
505
|
console.log(`· skipped: ${r.reason}`);
|
|
502
506
|
}
|
|
507
|
+
// The MADR projection tracks the graph automatically once adopted, the way the
|
|
508
|
+
// SQLite index does — a user who ran `hunch export-adr` once never runs it again.
|
|
509
|
+
// Best-effort and last-write-wins-free: a hand-edited file is skipped, not
|
|
510
|
+
// clobbered, and any failure here must never affect the capture that preceded it.
|
|
511
|
+
try {
|
|
512
|
+
const refreshed = refreshMadrCorpus(store.json.loadAll("decisions"), root, new Date().toISOString());
|
|
513
|
+
if (refreshed && !opts.quiet && (refreshed.written || refreshed.removed || refreshed.skippedEdited.length)) {
|
|
514
|
+
const skipped = refreshed.skippedEdited.length ? `, ${refreshed.skippedEdited.length} hand-edited file(s) left alone` : "";
|
|
515
|
+
console.log(` ↳ ADR corpus refreshed: ${refreshed.written} written, ${refreshed.removed} removed${skipped} (${refreshed.dir}/)`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
if (!opts.quiet)
|
|
520
|
+
console.log(` ↳ ADR corpus refresh skipped safely: ${e.message}`);
|
|
521
|
+
}
|
|
503
522
|
let graphRefreshed = false;
|
|
504
523
|
let publicCorrectionQueued = false;
|
|
505
524
|
let privateCorrectionQueued = false;
|
|
@@ -3097,6 +3116,10 @@ program
|
|
|
3097
3116
|
// are OURS (marker-verified) and stale — remove so the corpus stays coherent.
|
|
3098
3117
|
for (const f of stale)
|
|
3099
3118
|
rmSync(join(outDir, f));
|
|
3119
|
+
// Adopt the corpus: the manifest is what makes `hunch drift` able to notice
|
|
3120
|
+
// this projection going stale, being hand-edited, or outliving its decision.
|
|
3121
|
+
// Written after the files land, so a failed write never claims freshness.
|
|
3122
|
+
writeMadrManifest(root, buildMadrManifest(dir, files, new Date().toISOString()));
|
|
3100
3123
|
console.log(`✓ exported ${files.length} ADR(s) to ${dir}${stale.length ? `; removed ${stale.length} stale generated file(s)` : ""}`);
|
|
3101
3124
|
console.log(` ↳ Backstage: add to catalog-info.yaml metadata.annotations → ${backstageAnnotation}`);
|
|
3102
3125
|
}
|
|
@@ -5223,6 +5246,30 @@ program
|
|
|
5223
5246
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
5224
5247
|
console.log(`\nHeal: run \`hunch wiki --heal\` — regenerates only the stale pages (the wiki is a derived view; never edit it by hand).\n`);
|
|
5225
5248
|
}
|
|
5249
|
+
// The MADR projection: three kinds, three different human actions — which is
|
|
5250
|
+
// why they are separate sections rather than one "run export-adr" line.
|
|
5251
|
+
const madrStale = kind("madr-stale");
|
|
5252
|
+
if (madrStale.length) {
|
|
5253
|
+
console.log(`${madrStale.length} exported ADR(s) drifted from the graph:\n`);
|
|
5254
|
+
for (const f of madrStale)
|
|
5255
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
5256
|
+
console.log(`\nHeal: run \`hunch export-adr\` — the corpus is a disposable projection (the graph stays the source of truth). Normally this never appears: the projection refreshes automatically on every capture.\n`);
|
|
5257
|
+
}
|
|
5258
|
+
const madrEdited = kind("madr-edited");
|
|
5259
|
+
if (madrEdited.length) {
|
|
5260
|
+
console.log(`${madrEdited.length} generated ADR(s) were hand-edited — the next export would overwrite them:\n`);
|
|
5261
|
+
for (const f of madrEdited)
|
|
5262
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
5263
|
+
console.log(`\nHeal A (the DECISION is what changed): move the edit into the decision via /capture, then let the projection regenerate — the edit survives because it now lives in the graph.`);
|
|
5264
|
+
console.log(`Heal B (you want to own this file): delete the hunch:generated marker. The export refuses it from then on and it becomes a hand-written ADR.\n`);
|
|
5265
|
+
}
|
|
5266
|
+
const madrOrphan = kind("madr-orphan");
|
|
5267
|
+
if (madrOrphan.length) {
|
|
5268
|
+
console.log(`${madrOrphan.length} generated ADR(s) have no decision behind them any more:\n`);
|
|
5269
|
+
for (const f of madrOrphan)
|
|
5270
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
5271
|
+
console.log(`\nHeal: delete the file, or run \`hunch export-adr\` to regenerate the corpus without it. If the decision moved to the private overlay, the file is a PUBLIC artifact of a now-private record — delete it.\n`);
|
|
5272
|
+
}
|
|
5226
5273
|
// Every drift kind heals here — see bug_drift_heal_asymmetry above. premise-stale
|
|
5227
5274
|
// shipped in the drift report without a section here, so a repo whose ONLY drift
|
|
5228
5275
|
// was a dead premise got "N findings" from `hunch drift` and a bare closing line
|
|
@@ -5387,8 +5434,8 @@ program
|
|
|
5387
5434
|
}
|
|
5388
5435
|
else if (store.privateDir) {
|
|
5389
5436
|
console.log(store.mode === "shared"
|
|
5390
|
-
? `shared: on → ${store.privateDir} (UNIFIED — every capture routes here; one source of truth across branches, worktrees, teammates, agents)`
|
|
5391
|
-
: `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})`);
|
|
5392
5439
|
}
|
|
5393
5440
|
else {
|
|
5394
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/drift.js
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* (hash-compared via .hunch/wiki-manifest.json; only when a wiki
|
|
12
12
|
* was adopted — see src/wiki/wiki.ts). Advisory, healed by
|
|
13
13
|
* `hunch wiki --heal`, never a gate.
|
|
14
|
+
* - madr-*: the exported MADR corpus drifted from the graph — stale (the
|
|
15
|
+
* decision moved), edited (a human changed a generated file the
|
|
16
|
+
* next export would overwrite), or orphan (the decision left the
|
|
17
|
+
* public graph). Only when a corpus was exported; healed by
|
|
18
|
+
* `hunch export-adr`. See src/integrations/madrManifest.ts.
|
|
14
19
|
*/
|
|
15
20
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
21
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -20,6 +25,7 @@ import { evaluatePremises } from "./premises.js";
|
|
|
20
25
|
import { parseDocAnchors } from "./docanchors.js";
|
|
21
26
|
import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
|
|
22
27
|
import { computeWikiDrift } from "../wiki/wiki.js";
|
|
28
|
+
import { computeMadrDrift } from "../integrations/madrManifest.js";
|
|
23
29
|
export function computeDrift(store, root) {
|
|
24
30
|
const findings = [];
|
|
25
31
|
const decisions = store.recs("decisions");
|
|
@@ -142,6 +148,12 @@ export function computeDrift(store, root) {
|
|
|
142
148
|
// component vanished). Deterministic hash comparison against the manifest;
|
|
143
149
|
// fires only when a wiki was adopted. Advisory like every other kind here.
|
|
144
150
|
findings.push(...computeWikiDrift(store, root));
|
|
151
|
+
// 6b. MADR-* — the exported ADR corpus drifted from the graph. PUBLIC decisions
|
|
152
|
+
// only: the corpus is a committable artifact, so its freshness must be
|
|
153
|
+
// computed from exactly the records allowed to reach it (an overlay record
|
|
154
|
+
// leaking into a public drift report is the same class of bug as one
|
|
155
|
+
// leaking into the export itself). Fires only where a corpus was exported.
|
|
156
|
+
findings.push(...computeMadrDrift(store.json.loadAll("decisions"), root));
|
|
145
157
|
// 7. FINDING-STALE — a LIVE finding (observation, no diff) whose anchor evaporated:
|
|
146
158
|
// an affected file that no longer exists, or a violates_constraint pointing at a
|
|
147
159
|
// retired/missing rule. Deterministic + advisory (never the exit-code class):
|
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,
|
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)
|