@christianmorup/review-intent 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/dist/config.js ADDED
@@ -0,0 +1,58 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { z } from "zod";
4
+ export const DEFAULT_CONFIG_PATH = ".review/config.json";
5
+ /** Built-in sensitive-path policy, tuned to the Immeo stack. Each pattern is a
6
+ * regex tested against the posix-style changed path (case-insensitive). */
7
+ export const DEFAULT_SENSITIVE_PATHS = [
8
+ { label: "auth", pattern: "(^|/)auth" },
9
+ { label: "bicep / infra", pattern: "\\.bicep$|(^|/)bicep/" },
10
+ { label: "ADO pipeline", pattern: "azure-pipelines|(^|/)\\.azure" },
11
+ { label: "app config", pattern: "appsettings.*\\.json$|\\.config$" },
12
+ { label: "secrets / key vault", pattern: "(secret|keyvault|key-vault)" },
13
+ { label: "container", pattern: "(^|/)dockerfile" },
14
+ { label: "dependencies", pattern: "\\.csproj$|directory\\.packages\\.props$|package-lock\\.json$|packages\\.lock\\.json$" },
15
+ ];
16
+ export const DEFAULT_CONFIG = {
17
+ sensitivePaths: DEFAULT_SENSITIVE_PATHS,
18
+ churnFiles: 20,
19
+ churnLines: 600,
20
+ complexityThreshold: 15,
21
+ };
22
+ const ConfigFileSchema = z.object({
23
+ sensitivePaths: z
24
+ .array(z.object({ label: z.string().min(1), pattern: z.string().min(1) }))
25
+ .optional(),
26
+ churnFiles: z.number().int().positive().optional(),
27
+ churnLines: z.number().int().positive().optional(),
28
+ complexityThreshold: z.number().int().positive().optional(),
29
+ });
30
+ export class ConfigError extends Error {
31
+ }
32
+ /** Load the optional repo config, falling back to built-in defaults. Validation
33
+ * failures throw ConfigError (a malformed policy file should be loud). */
34
+ export function loadConfig(cwd, configPath = DEFAULT_CONFIG_PATH) {
35
+ const full = resolve(cwd, configPath);
36
+ if (!existsSync(full))
37
+ return DEFAULT_CONFIG;
38
+ let json;
39
+ try {
40
+ json = JSON.parse(readFileSync(full, "utf8"));
41
+ }
42
+ catch (err) {
43
+ throw new ConfigError(`${full} is not valid JSON: ${err.message}`);
44
+ }
45
+ const parsed = ConfigFileSchema.safeParse(json);
46
+ if (!parsed.success) {
47
+ const issues = parsed.error.issues
48
+ .map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
49
+ .join("\n");
50
+ throw new ConfigError(`${full} is not a valid config:\n${issues}`);
51
+ }
52
+ return {
53
+ sensitivePaths: parsed.data.sensitivePaths ?? DEFAULT_SENSITIVE_PATHS,
54
+ churnFiles: parsed.data.churnFiles ?? DEFAULT_CONFIG.churnFiles,
55
+ churnLines: parsed.data.churnLines ?? DEFAULT_CONFIG.churnLines,
56
+ complexityThreshold: parsed.data.complexityThreshold ?? DEFAULT_CONFIG.complexityThreshold,
57
+ };
58
+ }
@@ -0,0 +1,51 @@
1
+ import parseDiff from "parse-diff";
2
+ /** Parse raw unified diff text into a structured, render-friendly model. */
3
+ export function parseDiffText(raw) {
4
+ // Normalize CRLF -> LF first: `git diff` on a Windows repo with CRLF files
5
+ // emits CRLF, which leaves a trailing \r on every content line and breaks
6
+ // parse-diff's header detection (e.g. "new file mode\r"), mis-classifying an
7
+ // added file as renamed. Parse against LF regardless of source line endings.
8
+ const files = parseDiff(raw.replace(/\r\n/g, "\n"));
9
+ return files.map((f) => {
10
+ const path = (f.deleted ? f.from : f.to) ?? f.from ?? f.to ?? "(unknown)";
11
+ const status = f.new
12
+ ? "added"
13
+ : f.deleted
14
+ ? "deleted"
15
+ : f.from && f.to && f.from !== f.to
16
+ ? "renamed"
17
+ : "modified";
18
+ const hunks = (f.chunks ?? []).map((c) => {
19
+ const newStart = c.newStart ?? 0;
20
+ const newEnd = newStart + (c.newLines ?? 0) - 1;
21
+ const lines = (c.changes ?? []).map((ch) => {
22
+ const content = stripPrefix(ch.content);
23
+ if (ch.type === "add") {
24
+ return { type: "add", content, newNumber: ch.ln };
25
+ }
26
+ if (ch.type === "del") {
27
+ return { type: "del", content, oldNumber: ch.ln };
28
+ }
29
+ const normal = ch;
30
+ return {
31
+ type: "normal",
32
+ content,
33
+ oldNumber: normal.ln1,
34
+ newNumber: normal.ln2,
35
+ };
36
+ });
37
+ return { header: c.content, newStart, newEnd, lines };
38
+ });
39
+ return { path, status, hunks };
40
+ });
41
+ }
42
+ /** parse-diff keeps the leading +/-/space marker on each line's content. */
43
+ function stripPrefix(content) {
44
+ if (content.length === 0)
45
+ return content;
46
+ const first = content[0];
47
+ if (first === "+" || first === "-" || first === " ") {
48
+ return content.slice(1);
49
+ }
50
+ return content;
51
+ }
package/dist/git.js ADDED
@@ -0,0 +1,59 @@
1
+ import { execFileSync } from "node:child_process";
2
+ export class GitError extends Error {
3
+ }
4
+ function git(args, cwd) {
5
+ try {
6
+ return execFileSync("git", args, {
7
+ cwd,
8
+ encoding: "utf8",
9
+ maxBuffer: 64 * 1024 * 1024,
10
+ });
11
+ }
12
+ catch (err) {
13
+ const e = err;
14
+ throw new GitError((e.stderr || e.message).trim());
15
+ }
16
+ }
17
+ function branchExists(ref, cwd) {
18
+ try {
19
+ execFileSync("git", ["rev-parse", "--verify", "--quiet", ref], {
20
+ cwd,
21
+ stdio: "ignore",
22
+ });
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /** Resolve the base branch to diff against: explicit override, else main, else master. */
30
+ export function resolveBase(cwd, override) {
31
+ if (override) {
32
+ if (!branchExists(override, cwd)) {
33
+ throw new GitError(`Base ref "${override}" does not exist in this repo.`);
34
+ }
35
+ return override;
36
+ }
37
+ for (const candidate of ["main", "master"]) {
38
+ if (branchExists(candidate, cwd))
39
+ return candidate;
40
+ }
41
+ throw new GitError(`Could not find a base branch (tried "main" and "master"). Pass --base <ref>.`);
42
+ }
43
+ /**
44
+ * Produce the PR-style diff: changes on the current branch since it diverged
45
+ * from base (`git diff base...HEAD`). Returns the raw unified diff text.
46
+ */
47
+ export function getDiff(cwd, base) {
48
+ // Fails early with a clear message if cwd is not a git work tree.
49
+ try {
50
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
51
+ cwd,
52
+ stdio: "ignore",
53
+ });
54
+ }
55
+ catch {
56
+ throw new GitError(`Not a git repository: ${cwd}`);
57
+ }
58
+ return git(["diff", `${base}...HEAD`], cwd);
59
+ }
package/dist/match.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pure join: overlay the artifact's intent onto the parsed diff.
3
+ *
4
+ * - File-level intent is matched by path.
5
+ * - Per-hunk intent is matched by anchor: the note attaches to whichever hunk's
6
+ * new-line range [newStart, newEnd] contains the anchor line. Notes that match
7
+ * no hunk in the file surface in `unmatchedIntents` (never silently dropped).
8
+ * - Artifact entries for files absent from the diff surface in
9
+ * `filesWithoutChanges`.
10
+ */
11
+ export function buildReviewModel(artifact, diff, base, scorecard, reach, complexity) {
12
+ const intentByPath = new Map(artifact.files.map((f) => [f.path, f]));
13
+ const diffPaths = new Set(diff.map((f) => f.path));
14
+ const files = diff.map((file) => {
15
+ const fileIntent = intentByPath.get(file.path);
16
+ const hunkIntents = fileIntent?.hunks ?? [];
17
+ const matched = new Set();
18
+ const hunks = file.hunks.map((hunk) => {
19
+ const intents = [];
20
+ hunkIntents.forEach((hi, idx) => {
21
+ if (hi.anchor >= hunk.newStart && hi.anchor <= hunk.newEnd) {
22
+ intents.push(hi);
23
+ matched.add(idx);
24
+ }
25
+ });
26
+ return { ...hunk, intents };
27
+ });
28
+ const unmatchedIntents = hunkIntents.filter((_, idx) => !matched.has(idx));
29
+ return {
30
+ path: file.path,
31
+ status: file.status,
32
+ what: fileIntent?.what,
33
+ why: fileIntent?.why,
34
+ unmatchedIntents,
35
+ hunks,
36
+ };
37
+ });
38
+ const filesWithoutChanges = artifact.files
39
+ .filter((f) => !diffPaths.has(f.path))
40
+ .map((f) => ({ path: f.path, why: f.why }));
41
+ // Coverage mirrors the completeness contract: a file counts as covered when it
42
+ // has a what + why, a hunk when at least one intent anchored into it.
43
+ const intentCoverage = {
44
+ filesCovered: files.filter((f) => f.what && f.why).length,
45
+ filesTotal: files.length,
46
+ hunksCovered: files.reduce((n, f) => n + f.hunks.filter((h) => h.intents.length > 0).length, 0),
47
+ hunksTotal: files.reduce((n, f) => n + f.hunks.length, 0),
48
+ };
49
+ return {
50
+ title: artifact.title,
51
+ tldr: artifact.tldr,
52
+ overall: artifact.overall,
53
+ base,
54
+ diagrams: artifact.diagrams,
55
+ risks: artifact.risks,
56
+ tests: artifact.tests,
57
+ scorecard,
58
+ reach,
59
+ complexity,
60
+ intentCoverage,
61
+ files,
62
+ filesWithoutChanges,
63
+ };
64
+ }
package/dist/reach.js ADDED
@@ -0,0 +1,138 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ const SKIP_DIRS = new Set([
4
+ "node_modules",
5
+ "dist",
6
+ "build",
7
+ "out",
8
+ ".git",
9
+ "bin",
10
+ "obj",
11
+ ".vs",
12
+ ".idea",
13
+ "coverage",
14
+ ".next",
15
+ ]);
16
+ const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte)$/i;
17
+ const DEFAULT_MAX_FILES = 2000;
18
+ const DEFAULT_MAX_FILE_BYTES = 300 * 1024;
19
+ const DEFAULT_MAX_EDGES_PER_NODE = 8;
20
+ /** Walk the repo collecting code files (bounded). I/O — not pure. */
21
+ export function scanRepo(cwd, opts = {}) {
22
+ const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
23
+ const maxBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
24
+ const files = [];
25
+ let truncated = false;
26
+ const walk = (dir) => {
27
+ if (files.length >= maxFiles) {
28
+ truncated = true;
29
+ return;
30
+ }
31
+ let entries;
32
+ try {
33
+ entries = readdirSync(dir, { withFileTypes: true });
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ for (const entry of entries) {
39
+ if (files.length >= maxFiles) {
40
+ truncated = true;
41
+ return;
42
+ }
43
+ const full = join(dir, entry.name);
44
+ if (entry.isDirectory()) {
45
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
46
+ continue;
47
+ walk(full);
48
+ }
49
+ else if (entry.isFile() && CODE_EXT.test(entry.name)) {
50
+ try {
51
+ if (statSync(full).size > maxBytes)
52
+ continue;
53
+ const content = readFileSync(full, "utf8");
54
+ files.push({ path: toPosix(relative(cwd, full)), content });
55
+ }
56
+ catch {
57
+ // unreadable file — skip
58
+ }
59
+ }
60
+ }
61
+ };
62
+ walk(cwd);
63
+ return { files, truncated };
64
+ }
65
+ const SPEC_RE = /(?:\bfrom\s+|\bimport\s+|\brequire\s*\(\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]/g;
66
+ /**
67
+ * Pure: build the file-level reach graph. An edge `from → to` means the
68
+ * dependent file `from` imports/references the changed file `to`.
69
+ *
70
+ * Heuristic: matches import/require/from specifiers against each changed file's
71
+ * path-without-extension or basename. False positives (shared basenames) and
72
+ * misses (non-path imports like C# `using`) are possible by design — the
73
+ * renderer labels the graph as heuristic.
74
+ */
75
+ export function buildReachGraph(files, changedPaths, opts = {}) {
76
+ const maxEdges = opts.maxEdgesPerNode ?? DEFAULT_MAX_EDGES_PER_NODE;
77
+ const changed = changedPaths.map(toPosix);
78
+ const changedSet = new Set(changed);
79
+ // Precompute match keys per changed file.
80
+ const keys = changed.map((p) => ({
81
+ path: p,
82
+ noExt: stripExt(p),
83
+ base: stripExt(p.split("/").pop() ?? p),
84
+ }));
85
+ const edges = [];
86
+ const perNode = new Map();
87
+ let overflow = 0;
88
+ for (const file of files) {
89
+ if (changedSet.has(file.path))
90
+ continue; // don't link a changed file to itself
91
+ const specs = extractSpecifiers(file.content);
92
+ if (specs.length === 0)
93
+ continue;
94
+ const specKeys = specs.map((s) => ({ noExt: stripExt(normalize(s)), spec: s }));
95
+ for (const k of keys) {
96
+ const hit = specKeys.some((sk) => sk.noExt === k.noExt ||
97
+ sk.noExt.endsWith("/" + k.base) ||
98
+ sk.noExt === k.base);
99
+ if (!hit)
100
+ continue;
101
+ const count = perNode.get(k.path) ?? 0;
102
+ if (count >= maxEdges) {
103
+ overflow++;
104
+ continue;
105
+ }
106
+ perNode.set(k.path, count + 1);
107
+ edges.push({ from: file.path, to: k.path });
108
+ }
109
+ }
110
+ const notes = [];
111
+ if (opts.scanTruncated) {
112
+ notes.push("repo scan hit the file cap — some dependents may be missing");
113
+ }
114
+ if (overflow > 0) {
115
+ notes.push(`${overflow} additional edge(s) hidden (per-node cap)`);
116
+ }
117
+ return {
118
+ changed,
119
+ edges,
120
+ truncatedNote: notes.length ? notes.join("; ") : undefined,
121
+ };
122
+ }
123
+ function extractSpecifiers(content) {
124
+ const out = [];
125
+ for (const m of content.matchAll(SPEC_RE))
126
+ out.push(m[1]);
127
+ return out;
128
+ }
129
+ function normalize(spec) {
130
+ // strip leading ./ and ../ segments
131
+ return spec.replace(/^(?:\.\.?\/)+/, "");
132
+ }
133
+ function stripExt(p) {
134
+ return p.replace(/\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte)$/i, "");
135
+ }
136
+ function toPosix(p) {
137
+ return sep === "\\" ? p.split(sep).join("/") : p;
138
+ }