@davesheffer/hunch 0.1.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/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/cli/index.js +587 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/core/format.js +46 -0
- package/dist/core/glob.js +62 -0
- package/dist/core/ids.js +39 -0
- package/dist/core/io.js +44 -0
- package/dist/core/migrate.js +59 -0
- package/dist/core/paths.js +41 -0
- package/dist/core/types.js +142 -0
- package/dist/extractors/diff.js +198 -0
- package/dist/extractors/git.js +136 -0
- package/dist/extractors/indexer.js +271 -0
- package/dist/extractors/parse.js +176 -0
- package/dist/integrations/claudemd.js +77 -0
- package/dist/integrations/hooks.js +80 -0
- package/dist/integrations/mergeDriver.js +41 -0
- package/dist/integrations/scaffold.js +74 -0
- package/dist/mcp/server.js +233 -0
- package/dist/store/compact.js +100 -0
- package/dist/store/db.js +19 -0
- package/dist/store/embedder.js +133 -0
- package/dist/store/hunchStore.js +469 -0
- package/dist/store/jsonStore.js +268 -0
- package/dist/store/merge.js +179 -0
- package/dist/store/schema.js +100 -0
- package/dist/synthesis/provider.js +488 -0
- package/dist/synthesis/synthesize.js +312 -0
- package/package.json +68 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Minimal, segment-aware glob matching for constraint `scope` / component
|
|
2
|
+
* `paths` (e.g. "src/auth/**"). Supports **, *, and ? with correct path-segment
|
|
3
|
+
* semantics (`**` spans separators; `*`/`?` stay within one segment). No dep. */
|
|
4
|
+
/** Normalize a path/glob: backslashes -> '/', strip a leading './'. */
|
|
5
|
+
function norm(p) {
|
|
6
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
7
|
+
}
|
|
8
|
+
/** Translate one path segment (no '/') to a regex fragment. */
|
|
9
|
+
function segToRe(seg) {
|
|
10
|
+
return seg
|
|
11
|
+
.replace(/[.+^${}()|[\]]/g, "\\$&")
|
|
12
|
+
.replace(/\*/g, "[^/]*")
|
|
13
|
+
.replace(/\?/g, "[^/]");
|
|
14
|
+
}
|
|
15
|
+
function globToRegExp(glob) {
|
|
16
|
+
// Collapse runs of consecutive "**" segments so "**/**", "a/**/**/b" etc.
|
|
17
|
+
// behave like a single globstar (avoids a spurious leading slash / no-match).
|
|
18
|
+
const segs = glob.split("/").filter((s, i, a) => !(s === "**" && a[i - 1] === "**"));
|
|
19
|
+
if (segs.length === 1 && segs[0] === "**")
|
|
20
|
+
return /^.*$/;
|
|
21
|
+
let re = "";
|
|
22
|
+
for (let i = 0; i < segs.length; i++) {
|
|
23
|
+
const seg = segs[i];
|
|
24
|
+
const isFirst = i === 0;
|
|
25
|
+
const isLast = i === segs.length - 1;
|
|
26
|
+
if (seg === "**") {
|
|
27
|
+
if (isLast) {
|
|
28
|
+
// trailing "/**": the directory itself OR anything beneath it
|
|
29
|
+
re += "(?:/.*)?";
|
|
30
|
+
}
|
|
31
|
+
// a leading/middle "**" contributes nothing here; the next concrete
|
|
32
|
+
// segment emits the "zero or more directories" group (see below).
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const prevGlobstar = i > 0 && segs[i - 1] === "**";
|
|
36
|
+
if (prevGlobstar) {
|
|
37
|
+
// "**/" before this segment → optional run of directories
|
|
38
|
+
re += isFirst /* unreachable */ ? "" : i - 1 === 0 ? "(?:.*/)?" : "/(?:.*/)?";
|
|
39
|
+
}
|
|
40
|
+
else if (!isFirst) {
|
|
41
|
+
re += "/";
|
|
42
|
+
}
|
|
43
|
+
re += segToRe(seg);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return new RegExp("^" + re + "$");
|
|
47
|
+
}
|
|
48
|
+
/** Does a concrete path match a glob? Also returns true when the glob is a bare
|
|
49
|
+
* directory prefix of the path (so "src/auth" matches "src/auth/x.ts"). */
|
|
50
|
+
export function pathMatchesGlob(path, glob) {
|
|
51
|
+
const p = norm(path);
|
|
52
|
+
const g = norm(glob);
|
|
53
|
+
if (g === p)
|
|
54
|
+
return true;
|
|
55
|
+
if (globToRegExp(g).test(p))
|
|
56
|
+
return true;
|
|
57
|
+
// bare-prefix convenience: "src/auth" ~ "src/auth/**"
|
|
58
|
+
if (!/[*?]/.test(g) && p.startsWith(g.endsWith("/") ? g : g + "/"))
|
|
59
|
+
return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=glob.js.map
|
package/dist/core/ids.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Stable id helpers. Symbol/component/edge ids are DETERMINISTIC (derived from
|
|
2
|
+
* their natural key) so re-indexing the same repo yields the same ids and the
|
|
3
|
+
* git diff of `.hunch/` stays minimal. Decisions/bugs use a content hash too,
|
|
4
|
+
* so the learning loop is idempotent for the same commit. */
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
function shortHash(input, len = 10) {
|
|
7
|
+
return createHash("sha1").update(input).digest("hex").slice(0, len);
|
|
8
|
+
}
|
|
9
|
+
/** Full sha1 (used for signature_hash etc.). */
|
|
10
|
+
export function sha1(input) {
|
|
11
|
+
return "sha1:" + createHash("sha1").update(input).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
/** Symbol id from file + name + kind — deterministic across re-indexes. */
|
|
14
|
+
export function symbolId(file, name, kind) {
|
|
15
|
+
return "sym_" + shortHash(`${file}::${name}::${kind}`);
|
|
16
|
+
}
|
|
17
|
+
/** Component id from a stable name. */
|
|
18
|
+
export function componentId(name) {
|
|
19
|
+
return "cmp_" + shortHash(name.toLowerCase());
|
|
20
|
+
}
|
|
21
|
+
/** Edge id from its endpoints + type — deterministic, dedupes naturally. */
|
|
22
|
+
export function edgeId(from, to, type) {
|
|
23
|
+
return "edge_" + shortHash(`${from}->${to}:${type}`);
|
|
24
|
+
}
|
|
25
|
+
/** Decision id. Seed with the CANONICAL full commit sha (the auto-sync and MCP
|
|
26
|
+
* commit paths both do this, so a recorded decision upgrades the auto-draft for
|
|
27
|
+
* the same commit), or with "manual:<title>" for an ad-hoc MCP decision. */
|
|
28
|
+
export function decisionId(seed) {
|
|
29
|
+
return "dec_" + shortHash(seed);
|
|
30
|
+
}
|
|
31
|
+
/** Bug id seeded by symptom/test so the same failure doesn't spawn duplicates. */
|
|
32
|
+
export function bugId(seed) {
|
|
33
|
+
return "bug_" + shortHash(seed);
|
|
34
|
+
}
|
|
35
|
+
/** Constraint id seeded by its statement. */
|
|
36
|
+
export function constraintId(statement) {
|
|
37
|
+
return "con_" + shortHash(statement.toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=ids.js.map
|
package/dist/core/io.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Durable file writes for the Hunch. */
|
|
2
|
+
import { writeFileSync, renameSync, rmSync } from "node:fs";
|
|
3
|
+
let counter = 0;
|
|
4
|
+
/**
|
|
5
|
+
* Write `data` to `file` via a temp file + rename, so an interrupted write can't
|
|
6
|
+
* leave the target truncated (the symbols/edges index is the worst to half-write).
|
|
7
|
+
*
|
|
8
|
+
* Windows caveat: renameSync can't REPLACE a file another process holds open (even
|
|
9
|
+
* for read) — it throws EPERM/EBUSY/EACCES, exactly when the MCP server is reading
|
|
10
|
+
* while a CLI writes. Atomicity is crash-safety insurance, not worth FAILING a write
|
|
11
|
+
* the old in-place writeFileSync would have completed — so we fall back to a direct
|
|
12
|
+
* write there. The temp file is always cleaned up; a failed write never leaks it.
|
|
13
|
+
*/
|
|
14
|
+
export function writeFileAtomic(file, data) {
|
|
15
|
+
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
16
|
+
try {
|
|
17
|
+
writeFileSync(tmp, data);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
safeRm(tmp);
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
renameSync(tmp, file);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
safeRm(tmp);
|
|
28
|
+
const code = e.code;
|
|
29
|
+
if (code === "EPERM" || code === "EBUSY" || code === "EACCES") {
|
|
30
|
+
writeFileSync(file, data); // non-atomic fallback (matches pre-hardening behavior)
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function safeRm(p) {
|
|
37
|
+
try {
|
|
38
|
+
rmSync(p, { force: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* best effort */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=io.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema versioning + migration for the JSON source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Records under `.hunch/` carry no per-record version; instead `.hunch/manifest.json`
|
|
5
|
+
* records the schema generation the on-disk data was written at. On load we migrate
|
|
6
|
+
* raw JSON UP to SCHEMA_VERSION *before* Zod validation — so a future schema change
|
|
7
|
+
* never silently drops old records (Zod would reject an old shape and the loader
|
|
8
|
+
* skips invalid records, which would be silent data loss).
|
|
9
|
+
*
|
|
10
|
+
* Adding a new schema version:
|
|
11
|
+
* 1. bump SCHEMA_VERSION,
|
|
12
|
+
* 2. append a Migration whose `version` equals the new number, transforming a raw
|
|
13
|
+
* record of the PREVIOUS shape into the new one (idempotent, defensive — the
|
|
14
|
+
* input is untrusted JSON, not a validated entity).
|
|
15
|
+
* Migrations run in ascending `version` order for every version in (from, to].
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
import { writeFileAtomic } from "./io.js";
|
|
20
|
+
/** The schema generation this build writes and reads. Bump on any breaking change. */
|
|
21
|
+
export const SCHEMA_VERSION = 1;
|
|
22
|
+
/** A repo whose `.hunch/` predates manifests is treated as v1. Migrations are
|
|
23
|
+
* numbered from 2 (each `version` is the number it PRODUCES), so a baseline repo
|
|
24
|
+
* runs every migration with version >= 2 — never author a no-op version:1 one. */
|
|
25
|
+
export const BASELINE_VERSION = 1;
|
|
26
|
+
/** Ordered, ascending by `version`. Empty at v1 (baseline); future versions append. */
|
|
27
|
+
export const MIGRATIONS = [];
|
|
28
|
+
/** Read `.hunch/manifest.json`. A missing/corrupt manifest is treated as the
|
|
29
|
+
* BASELINE version (a pre-manifest `.hunch/`), so future builds still migrate it. */
|
|
30
|
+
export function readManifest(paths) {
|
|
31
|
+
if (!existsSync(paths.manifest))
|
|
32
|
+
return { schema_version: BASELINE_VERSION };
|
|
33
|
+
try {
|
|
34
|
+
const m = JSON.parse(readFileSync(paths.manifest, "utf8"));
|
|
35
|
+
const v = typeof m.schema_version === "number" && Number.isInteger(m.schema_version) ? m.schema_version : BASELINE_VERSION;
|
|
36
|
+
return { schema_version: v };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { schema_version: BASELINE_VERSION };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Write `.hunch/manifest.json` at `version` (default: the current SCHEMA_VERSION). */
|
|
43
|
+
export function writeManifest(paths, version = SCHEMA_VERSION) {
|
|
44
|
+
mkdirSync(dirname(paths.manifest), { recursive: true });
|
|
45
|
+
writeFileAtomic(paths.manifest, JSON.stringify({ schema_version: version }, null, 2) + "\n");
|
|
46
|
+
}
|
|
47
|
+
/** Apply every migration in (fromVersion, toVersion] to a single raw record. Skips
|
|
48
|
+
* non-object input untouched (the loader's Zod pass will reject it). */
|
|
49
|
+
export function migrateRaw(kind, raw, fromVersion, migrations = MIGRATIONS, toVersion = SCHEMA_VERSION) {
|
|
50
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
51
|
+
return raw;
|
|
52
|
+
let rec = raw;
|
|
53
|
+
for (const m of [...migrations].sort((a, b) => a.version - b.version)) {
|
|
54
|
+
if (m.version > fromVersion && m.version <= toVersion)
|
|
55
|
+
rec = m.up(kind, rec);
|
|
56
|
+
}
|
|
57
|
+
return rec;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=migrate.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
export const HUNCH_DIR = ".hunch";
|
|
6
|
+
export function hunchPaths(root) {
|
|
7
|
+
const hunch = join(root, HUNCH_DIR);
|
|
8
|
+
return {
|
|
9
|
+
root,
|
|
10
|
+
hunch,
|
|
11
|
+
sqlite: join(hunch, "hunch.sqlite"),
|
|
12
|
+
manifest: join(hunch, "manifest.json"),
|
|
13
|
+
dir: (kind) => join(hunch, kind),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Walk up from `start` to find the nearest repo containing a .hunch/ dir,
|
|
17
|
+
* else the nearest git repo, else `start`. Lets `hunch` run from subdirs. */
|
|
18
|
+
export function findRoot(start = process.cwd()) {
|
|
19
|
+
let cur = resolve(start);
|
|
20
|
+
let gitFallback = null;
|
|
21
|
+
const isDir = (p) => {
|
|
22
|
+
try {
|
|
23
|
+
return statSync(p).isDirectory();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (isDir(join(cur, HUNCH_DIR)))
|
|
31
|
+
return cur; // a `.hunch` regular file is not a root
|
|
32
|
+
if (gitFallback === null && existsSync(join(cur, ".git")))
|
|
33
|
+
gitFallback = cur;
|
|
34
|
+
const parent = dirname(cur);
|
|
35
|
+
if (parent === cur)
|
|
36
|
+
break;
|
|
37
|
+
cur = parent;
|
|
38
|
+
}
|
|
39
|
+
return gitFallback ?? resolve(start);
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core entity schema for the Project Hunch (DESIGN.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Zod is the single source of truth: TypeScript types are inferred from the
|
|
5
|
+
* schemas, and the same schemas validate JSON on the write path and shape MCP
|
|
6
|
+
* tool inputs. Every record carries `provenance` so nothing is a blind assertion.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
/** Where a fact came from and how much to trust it. Confidence tiers (DESIGN §4):
|
|
10
|
+
* inferred < extracted < llm_draft < llm_draft+human_confirmed/derived. */
|
|
11
|
+
export const ProvenanceSchema = z.object({
|
|
12
|
+
source: z.string().describe("e.g. extracted | inferred | llm_draft | human_confirmed | test_failure+llm | derived"),
|
|
13
|
+
confidence: z.number().min(0).max(1),
|
|
14
|
+
evidence: z.array(z.string()).default([]).describe("file paths, commit ids, test ids backing the claim"),
|
|
15
|
+
last_verified: z.string().optional().describe("ISO timestamp of last re-validation"),
|
|
16
|
+
});
|
|
17
|
+
export const ComponentKind = z.enum(["service", "module", "layer", "external"]);
|
|
18
|
+
/** Architecture node — a service / module / layer / external dependency. */
|
|
19
|
+
export const ComponentSchema = z.object({
|
|
20
|
+
id: z.string().describe("cmp_*"),
|
|
21
|
+
kind: ComponentKind,
|
|
22
|
+
name: z.string(),
|
|
23
|
+
responsibility: z.string().default(""),
|
|
24
|
+
paths: z.array(z.string()).default([]).describe("glob(s) the component owns"),
|
|
25
|
+
status: z.enum(["active", "deprecated", "archived"]).default("active"),
|
|
26
|
+
owners: z.array(z.string()).default([]),
|
|
27
|
+
fragility: z.number().min(0).max(1).default(0),
|
|
28
|
+
provenance: ProvenanceSchema,
|
|
29
|
+
created_at: z.string(),
|
|
30
|
+
updated_at: z.string(),
|
|
31
|
+
});
|
|
32
|
+
export const EdgeType = z.enum([
|
|
33
|
+
"depends_on",
|
|
34
|
+
"calls",
|
|
35
|
+
"imports",
|
|
36
|
+
"contains",
|
|
37
|
+
"implements",
|
|
38
|
+
"supersedes",
|
|
39
|
+
"related_to",
|
|
40
|
+
]);
|
|
41
|
+
/** Typed relationship between components or symbols. */
|
|
42
|
+
export const EdgeSchema = z.object({
|
|
43
|
+
id: z.string().describe("edge_*"),
|
|
44
|
+
from: z.string(),
|
|
45
|
+
to: z.string(),
|
|
46
|
+
type: EdgeType,
|
|
47
|
+
reason: z.string().default(""),
|
|
48
|
+
strength: z.number().min(0).max(1).default(0.5),
|
|
49
|
+
provenance: ProvenanceSchema,
|
|
50
|
+
});
|
|
51
|
+
export const SymbolKind = z.enum(["function", "method", "class", "interface", "type", "variable", "file"]);
|
|
52
|
+
export const SymbolMetricsSchema = z.object({
|
|
53
|
+
loc: z.number().default(0),
|
|
54
|
+
churn_90d: z.number().default(0).describe("times changed in last 90 days"),
|
|
55
|
+
bug_count: z.number().default(0),
|
|
56
|
+
fan_in: z.number().default(0).describe("number of callers"),
|
|
57
|
+
fan_out: z.number().default(0).describe("number of callees"),
|
|
58
|
+
});
|
|
59
|
+
/** File/function-level node for the dependency map. */
|
|
60
|
+
export const SymbolSchema = z.object({
|
|
61
|
+
id: z.string().describe("sym_*"),
|
|
62
|
+
file: z.string(),
|
|
63
|
+
name: z.string(),
|
|
64
|
+
kind: SymbolKind,
|
|
65
|
+
signature_hash: z.string().default(""),
|
|
66
|
+
calls: z.array(z.string()).default([]).describe("symbol ids this calls"),
|
|
67
|
+
called_by: z.array(z.string()).default([]).describe("symbol ids that call this"),
|
|
68
|
+
metrics: SymbolMetricsSchema.default({ loc: 0, churn_90d: 0, bug_count: 0, fan_in: 0, fan_out: 0 }),
|
|
69
|
+
last_changed: z.string().default("").describe("commit:<sha> or ISO date"),
|
|
70
|
+
});
|
|
71
|
+
/** ADR-style decision record, auto-drafted and human-confirmable. */
|
|
72
|
+
export const DecisionSchema = z.object({
|
|
73
|
+
id: z.string().describe("dec_*"),
|
|
74
|
+
title: z.string(),
|
|
75
|
+
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).default("proposed"),
|
|
76
|
+
context: z.string().default(""),
|
|
77
|
+
decision: z.string().default(""),
|
|
78
|
+
consequences: z.array(z.string()).default([]),
|
|
79
|
+
alternatives_rejected: z.array(z.string()).default([]),
|
|
80
|
+
related_components: z.array(z.string()).default([]),
|
|
81
|
+
related_files: z.array(z.string()).default([]),
|
|
82
|
+
supersedes: z.string().nullable().default(null),
|
|
83
|
+
caused_by_bug: z.string().nullable().default(null),
|
|
84
|
+
commit: z.string().nullable().default(null),
|
|
85
|
+
provenance: ProvenanceSchema,
|
|
86
|
+
date: z.string(),
|
|
87
|
+
});
|
|
88
|
+
export const BugLineageSchema = z.object({
|
|
89
|
+
introduced_commit: z.string().nullable().default(null),
|
|
90
|
+
detected: z.string().nullable().default(null).describe("test id or report"),
|
|
91
|
+
fixed_commit: z.string().nullable().default(null),
|
|
92
|
+
recurrence_of: z.string().nullable().default(null).describe("bug id this recurs"),
|
|
93
|
+
spawned_decision: z.string().nullable().default(null),
|
|
94
|
+
spawned_constraint: z.string().nullable().default(null),
|
|
95
|
+
});
|
|
96
|
+
/** A bug with root cause and lineage (introduced → fixed → recurred). */
|
|
97
|
+
export const BugSchema = z.object({
|
|
98
|
+
id: z.string().describe("bug_*"),
|
|
99
|
+
title: z.string(),
|
|
100
|
+
symptom: z.string().default(""),
|
|
101
|
+
root_cause: z.string().default(""),
|
|
102
|
+
severity: z.enum(["low", "medium", "high", "critical"]).default("medium"),
|
|
103
|
+
status: z.enum(["open", "investigating", "fixed", "regressed"]).default("open"),
|
|
104
|
+
affected_files: z.array(z.string()).default([]),
|
|
105
|
+
affected_symbols: z.array(z.string()).default([]),
|
|
106
|
+
lineage: BugLineageSchema.default({
|
|
107
|
+
introduced_commit: null, detected: null, fixed_commit: null,
|
|
108
|
+
recurrence_of: null, spawned_decision: null, spawned_constraint: null,
|
|
109
|
+
}),
|
|
110
|
+
provenance: ProvenanceSchema,
|
|
111
|
+
});
|
|
112
|
+
/** An invariant the system must respect. */
|
|
113
|
+
export const ConstraintSchema = z.object({
|
|
114
|
+
id: z.string().describe("con_*"),
|
|
115
|
+
type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).default("correctness"),
|
|
116
|
+
statement: z.string(),
|
|
117
|
+
scope: z.array(z.string()).default([]).describe("glob(s) it applies to"),
|
|
118
|
+
severity: z.enum(["advisory", "warning", "blocking"]).default("warning"),
|
|
119
|
+
enforcement: z.enum(["advisory_v1", "ci", "manual"]).default("advisory_v1"),
|
|
120
|
+
rationale: z.string().default(""),
|
|
121
|
+
source_decision: z.string().nullable().default(null),
|
|
122
|
+
violations: z.array(z.string()).default([]),
|
|
123
|
+
provenance: ProvenanceSchema,
|
|
124
|
+
});
|
|
125
|
+
/** The six entity collections, keyed by their on-disk directory name. */
|
|
126
|
+
export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints"];
|
|
127
|
+
export const SCHEMAS = {
|
|
128
|
+
components: ComponentSchema,
|
|
129
|
+
edges: EdgeSchema,
|
|
130
|
+
symbols: SymbolSchema,
|
|
131
|
+
decisions: DecisionSchema,
|
|
132
|
+
bugs: BugSchema,
|
|
133
|
+
constraints: ConstraintSchema,
|
|
134
|
+
};
|
|
135
|
+
/** Default provenance helper for deterministic (extracted) records. */
|
|
136
|
+
export function extracted(confidence, evidence = []) {
|
|
137
|
+
return { source: "extracted", confidence, evidence };
|
|
138
|
+
}
|
|
139
|
+
export function inferred(confidence, evidence = []) {
|
|
140
|
+
return { source: "inferred", confidence, evidence };
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured analysis of a git unified diff (deterministic, no LLM). Turns raw
|
|
3
|
+
* patch text into "what actually changed" — added/removed/changed symbols, new
|
|
4
|
+
* and dropped dependencies, file add/delete/rename — so the synthesis layer can
|
|
5
|
+
* write an INFORMATIVE decision even with no model available.
|
|
6
|
+
*
|
|
7
|
+
* Parsing is hunk-state-aware: file headers ("--- "/"+++ ") are only honored in
|
|
8
|
+
* the pre-hunk region, so a CONTENT line like `+++counter` (source `++counter`)
|
|
9
|
+
* is never mistaken for a header. Symbol classification is PER FILE, so moving a
|
|
10
|
+
* function between files isn't misread as a signature change.
|
|
11
|
+
*/
|
|
12
|
+
const DECL_PATTERNS = [
|
|
13
|
+
{ kind: "function", re: /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/ },
|
|
14
|
+
{ kind: "class", re: /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/ },
|
|
15
|
+
{ kind: "interface", re: /^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/ },
|
|
16
|
+
{ kind: "type", re: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*[=<]/ },
|
|
17
|
+
{ kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/ },
|
|
18
|
+
{ kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?function/ },
|
|
19
|
+
];
|
|
20
|
+
const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
|
|
21
|
+
const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
|
|
22
|
+
const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
23
|
+
const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
24
|
+
const isCode = (p) => !!p && CODE_EXT.test(p);
|
|
25
|
+
function declOf(line) {
|
|
26
|
+
for (const { kind, re } of DECL_PATTERNS) {
|
|
27
|
+
const m = re.exec(line);
|
|
28
|
+
if (m)
|
|
29
|
+
return { name: m[1], kind };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function importOf(line) {
|
|
34
|
+
const m = IMPORT_RE.exec(line) ?? CONT_IMPORT_RE.exec(line) ?? REQUIRE_RE.exec(line);
|
|
35
|
+
return m ? m[1] : null;
|
|
36
|
+
}
|
|
37
|
+
function stripAB(p) {
|
|
38
|
+
return p.replace(/^[ab]\//, "");
|
|
39
|
+
}
|
|
40
|
+
export function analyzeDiff(diff) {
|
|
41
|
+
const filesAdded = new Set();
|
|
42
|
+
const filesDeleted = new Set();
|
|
43
|
+
const filesModified = new Set();
|
|
44
|
+
const filesRenamed = [];
|
|
45
|
+
const perFile = new Map();
|
|
46
|
+
const addedImports = new Set();
|
|
47
|
+
const removedImports = new Set();
|
|
48
|
+
let addedLines = 0;
|
|
49
|
+
let removedLines = 0;
|
|
50
|
+
let curFile = "";
|
|
51
|
+
let inHunk = false;
|
|
52
|
+
let curAdded = false;
|
|
53
|
+
let curDeleted = false;
|
|
54
|
+
let renameFrom = "";
|
|
55
|
+
const declsFor = (f) => {
|
|
56
|
+
if (!isCode(f))
|
|
57
|
+
return null;
|
|
58
|
+
let e = perFile.get(f);
|
|
59
|
+
if (!e) {
|
|
60
|
+
e = { added: new Map(), removed: new Map() };
|
|
61
|
+
perFile.set(f, e);
|
|
62
|
+
}
|
|
63
|
+
return e;
|
|
64
|
+
};
|
|
65
|
+
for (const raw of diff.split("\n")) {
|
|
66
|
+
if (raw.startsWith("diff --git")) {
|
|
67
|
+
curFile = "";
|
|
68
|
+
inHunk = false;
|
|
69
|
+
curAdded = curDeleted = false;
|
|
70
|
+
renameFrom = "";
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (raw.startsWith("@@")) {
|
|
74
|
+
inHunk = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!inHunk) {
|
|
78
|
+
// ---- pre-hunk header region (file metadata) ----
|
|
79
|
+
if (raw.startsWith("new file mode")) {
|
|
80
|
+
curAdded = true;
|
|
81
|
+
}
|
|
82
|
+
else if (raw.startsWith("deleted file mode")) {
|
|
83
|
+
curDeleted = true;
|
|
84
|
+
}
|
|
85
|
+
else if (raw.startsWith("rename from ")) {
|
|
86
|
+
renameFrom = raw.slice("rename from ".length).trim();
|
|
87
|
+
}
|
|
88
|
+
else if (raw.startsWith("copy from ")) {
|
|
89
|
+
renameFrom = raw.slice("copy from ".length).trim();
|
|
90
|
+
}
|
|
91
|
+
else if (raw.startsWith("rename to ") || raw.startsWith("copy to ")) {
|
|
92
|
+
const to = raw.slice(raw.indexOf(" to ") + 4).trim();
|
|
93
|
+
curFile = to;
|
|
94
|
+
if (isCode(to))
|
|
95
|
+
filesRenamed.push({ from: renameFrom, to });
|
|
96
|
+
}
|
|
97
|
+
else if (raw.startsWith("--- ")) {
|
|
98
|
+
const p = raw.slice(4).trim();
|
|
99
|
+
if (p !== "/dev/null")
|
|
100
|
+
curFile = stripAB(p); // old path (may be replaced by +++)
|
|
101
|
+
}
|
|
102
|
+
else if (raw.startsWith("+++ ")) {
|
|
103
|
+
const p = raw.slice(4).trim();
|
|
104
|
+
if (p !== "/dev/null")
|
|
105
|
+
curFile = stripAB(p); // new path preferred
|
|
106
|
+
if (isCode(curFile)) {
|
|
107
|
+
if (curAdded)
|
|
108
|
+
filesAdded.add(curFile);
|
|
109
|
+
else if (curDeleted)
|
|
110
|
+
filesDeleted.add(curFile);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// ---- inside a hunk: content lines ----
|
|
116
|
+
if (raw.startsWith("+")) {
|
|
117
|
+
if (!isCode(curFile))
|
|
118
|
+
continue;
|
|
119
|
+
addedLines++;
|
|
120
|
+
if (!curAdded && !curDeleted)
|
|
121
|
+
filesModified.add(curFile);
|
|
122
|
+
const body = raw.slice(1);
|
|
123
|
+
const d = declOf(body);
|
|
124
|
+
if (d)
|
|
125
|
+
declsFor(curFile)?.added.set(d.name, d);
|
|
126
|
+
const imp = importOf(body);
|
|
127
|
+
if (imp && !imp.startsWith("."))
|
|
128
|
+
addedImports.add(imp);
|
|
129
|
+
}
|
|
130
|
+
else if (raw.startsWith("-")) {
|
|
131
|
+
if (!isCode(curFile))
|
|
132
|
+
continue;
|
|
133
|
+
removedLines++;
|
|
134
|
+
if (!curAdded && !curDeleted)
|
|
135
|
+
filesModified.add(curFile);
|
|
136
|
+
const body = raw.slice(1);
|
|
137
|
+
const d = declOf(body);
|
|
138
|
+
if (d)
|
|
139
|
+
declsFor(curFile)?.removed.set(d.name, d);
|
|
140
|
+
const imp = importOf(body);
|
|
141
|
+
if (imp && !imp.startsWith("."))
|
|
142
|
+
removedImports.add(imp);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// per-file symbol classification (added/removed/changed within the same file)
|
|
146
|
+
const addedSymbols = [];
|
|
147
|
+
const removedSymbols = [];
|
|
148
|
+
const changedSymbols = [];
|
|
149
|
+
for (const { added, removed } of perFile.values()) {
|
|
150
|
+
for (const [name, sc] of added) {
|
|
151
|
+
if (removed.has(name))
|
|
152
|
+
changedSymbols.push(sc);
|
|
153
|
+
else
|
|
154
|
+
addedSymbols.push(sc);
|
|
155
|
+
}
|
|
156
|
+
for (const [name, sc] of removed) {
|
|
157
|
+
if (!added.has(name))
|
|
158
|
+
removedSymbols.push(sc);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const renamedSet = new Set(filesRenamed.map((r) => r.to));
|
|
162
|
+
return {
|
|
163
|
+
filesAdded: [...filesAdded],
|
|
164
|
+
filesDeleted: [...filesDeleted],
|
|
165
|
+
filesModified: [...filesModified].filter((f) => !filesAdded.has(f) && !filesDeleted.has(f) && !renamedSet.has(f)),
|
|
166
|
+
filesRenamed,
|
|
167
|
+
addedSymbols,
|
|
168
|
+
removedSymbols,
|
|
169
|
+
changedSymbols,
|
|
170
|
+
addedDeps: [...addedImports].filter((d) => !removedImports.has(d)),
|
|
171
|
+
removedDeps: [...removedImports].filter((d) => !addedImports.has(d)),
|
|
172
|
+
addedLines,
|
|
173
|
+
removedLines,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** A compact human-readable summary of a DiffAnalysis (used in decision text). */
|
|
177
|
+
export function summarizeDiff(a) {
|
|
178
|
+
const parts = [];
|
|
179
|
+
const names = (arr) => arr.map((s) => s.name).slice(0, 8).join(", ");
|
|
180
|
+
if (a.addedSymbols.length)
|
|
181
|
+
parts.push(`added ${names(a.addedSymbols)}`);
|
|
182
|
+
if (a.removedSymbols.length)
|
|
183
|
+
parts.push(`removed ${names(a.removedSymbols)}`);
|
|
184
|
+
if (a.changedSymbols.length)
|
|
185
|
+
parts.push(`changed ${names(a.changedSymbols)}`);
|
|
186
|
+
if (a.addedDeps.length)
|
|
187
|
+
parts.push(`new dep(s): ${a.addedDeps.slice(0, 6).join(", ")}`);
|
|
188
|
+
if (a.removedDeps.length)
|
|
189
|
+
parts.push(`dropped dep(s): ${a.removedDeps.slice(0, 6).join(", ")}`);
|
|
190
|
+
if (a.filesRenamed.length)
|
|
191
|
+
parts.push(`renamed ${a.filesRenamed.map((r) => `${r.from}→${r.to}`).slice(0, 4).join(", ")}`);
|
|
192
|
+
if (a.filesAdded.length)
|
|
193
|
+
parts.push(`${a.filesAdded.length} new file(s)`);
|
|
194
|
+
if (a.filesDeleted.length)
|
|
195
|
+
parts.push(`${a.filesDeleted.length} deleted file(s)`);
|
|
196
|
+
return parts.join("; ");
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=diff.js.map
|