jti-ai-review 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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/dist-cli/cli.js +143 -0
  4. package/dist-cli/config/loadEnv.js +59 -0
  5. package/dist-cli/config/providerConfig.js +171 -0
  6. package/dist-cli/context/buildContext.js +70 -0
  7. package/dist-cli/context/docCoverage.js +93 -0
  8. package/dist-cli/context/docMapper.js +18 -0
  9. package/dist-cli/init/fetchCursorModels.js +30 -0
  10. package/dist-cli/init/scaffold.js +158 -0
  11. package/dist-cli/init/setupEnv.js +247 -0
  12. package/dist-cli/init/skillContent.js +70 -0
  13. package/dist-cli/init/templates.js +412 -0
  14. package/dist-cli/prompt/systemPrompt.md +44 -0
  15. package/dist-cli/report/findLatestReview.js +34 -0
  16. package/dist-cli/report/formatReport.js +70 -0
  17. package/dist-cli/report/saveReport.js +37 -0
  18. package/dist-cli/review/buildPrompt.js +39 -0
  19. package/dist-cli/review/parseResult.js +116 -0
  20. package/dist-cli/review/providers/anthropic.js +3 -0
  21. package/dist-cli/review/providers/codex.js +3 -0
  22. package/dist-cli/review/providers/cursor.js +3 -0
  23. package/dist-cli/review/providers/gemini.js +3 -0
  24. package/dist-cli/review/providers/mock.js +108 -0
  25. package/dist-cli/review/providers/openai.js +3 -0
  26. package/dist-cli/review/providers/shared/anthropicChat.js +33 -0
  27. package/dist-cli/review/providers/shared/cursorChat.js +207 -0
  28. package/dist-cli/review/providers/shared/geminiChat.js +38 -0
  29. package/dist-cli/review/providers/shared/http.js +14 -0
  30. package/dist-cli/review/providers/shared/llmReview.js +12 -0
  31. package/dist-cli/review/providers/shared/openaiChat.js +32 -0
  32. package/dist-cli/review/reviewer.js +35 -0
  33. package/dist-cli/review/spawnReview.js +35 -0
  34. package/dist-cli/review/types.js +1 -0
  35. package/dist-cli/review-worker.js +34 -0
  36. package/dist-cli/utils/glob.js +13 -0
  37. package/package.json +52 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andriy Zahura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # jti-ai-review
2
+
3
+ npm package — local AI pre-commit review for **consumer repos**.
4
+
5
+ This repository is the **CLI source**. It does not run the review pipeline on its own commits.
6
+
7
+ Consumers install the package and run `npx ai-review init` to scaffold hooks, docs, and skills in their project.
8
+
9
+ ---
10
+
11
+ ## Package development
12
+
13
+ ```bash
14
+ npm install
15
+ npm run build:cli
16
+ npm run pack:local # test tarball without publishing
17
+ ```
18
+
19
+ Pre-commit hook here only runs `build:cli` (compile check).
20
+
21
+ To dogfood the full review flow locally:
22
+
23
+ ```bash
24
+ node dist-cli/cli.js init --skip-prompt
25
+ node dist-cli/cli.js run # after git add
26
+ ```
27
+
28
+ ---
29
+
30
+ ## Consumer install (npm)
31
+
32
+ ```bash
33
+ npm install -D jti-ai-review
34
+ npx ai-review init
35
+ npm install
36
+ ```
37
+
38
+ Init scaffolds in the **consumer project**:
39
+
40
+ - `.husky/pre-commit` → runs `ai-review` on commit
41
+ - `.env` / `.env.example`
42
+ - `review-mapping.json`, `docs/project-rules/`, `docs/ai-review/`
43
+ - `.cursor/skills/jti-review/`
44
+
45
+ ### Workflow (consumer repo)
46
+
47
+ ```text
48
+ 1. /jti-review → sync mapping + feature docs
49
+ 2. git add .
50
+ 3. git commit → review → [P] commit / [R] report / [C] cancel
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Providers
56
+
57
+ | Provider | Env key |
58
+ |----------|---------|
59
+ | cursor | `CURSOR_API_KEY` |
60
+ | openai | `OPENAI_API_KEY` |
61
+ | codex | `OPENAI_API_KEY` |
62
+ | claude | `ANTHROPIC_API_KEY` |
63
+ | gemini | `GOOGLE_API_KEY` |
64
+
65
+ Set `AI_REVIEW_PROVIDER` in `.env`. Falls back to `mock` without a key.
66
+
67
+ For reliable reviews, prefer **anthropic** or **openai** direct API keys over cursor Cloud Agents.
68
+
69
+ ---
70
+
71
+ ## What ships on npm
72
+
73
+ Only `dist-cli/`, `README.md`, and `LICENSE` — no consumer scaffold, no review reports.
74
+
75
+ ---
76
+
77
+ ## Commands
78
+
79
+ ```bash
80
+ npm run build:cli
81
+ npm run pack:local
82
+ npm publish
83
+ ```
84
+
85
+ CLI:
86
+
87
+ ```bash
88
+ ai-review init [--force] [--skip-prompt]
89
+ ai-review run [--provider <name>]
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Design
95
+
96
+ See [spec.md](spec.md) for full pipeline design.
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { openSync } from "node:fs";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import { ReadStream } from "node:tty";
7
+ import { Command } from "commander";
8
+ import { buildContext } from "./context/buildContext.js";
9
+ import { checkDocCoverage, formatDocCoverageAlerts, } from "./context/docCoverage.js";
10
+ import { loadMapping } from "./context/docMapper.js";
11
+ import { loadEnv, resolveDefaultProvider } from "./config/loadEnv.js";
12
+ import { normalizeProviderName } from "./config/providerConfig.js";
13
+ import { runInit, printInitResult } from "./init/scaffold.js";
14
+ import { runIsolatedReview } from "./review/spawnReview.js";
15
+ import { saveReport } from "./report/saveReport.js";
16
+ import { listProviders } from "./review/reviewer.js";
17
+ function getRootDir() {
18
+ return process.cwd();
19
+ }
20
+ function printSummary(result, reportPath) {
21
+ console.log("\n--- AI Pre-Commit Review ---\n");
22
+ console.log(`Overall score: ${result.overallScore}/10`);
23
+ console.log(`Issues: ${result.issues.length}`);
24
+ console.log(`Report: ${reportPath}\n`);
25
+ for (const issue of result.issues) {
26
+ const location = issue.file ? ` (${issue.file})` : "";
27
+ console.log(` [${issue.severity}]${location} ${issue.message}`);
28
+ }
29
+ console.log("");
30
+ }
31
+ function openFile(filePath) {
32
+ const platform = process.platform;
33
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
34
+ const args = platform === "win32" ? ["", filePath] : [filePath];
35
+ spawn(command, args, { detached: true, stdio: "ignore" }).unref();
36
+ }
37
+ function createPromptInterface() {
38
+ if (input.isTTY) {
39
+ return createInterface({ input, output, terminal: true });
40
+ }
41
+ if (process.platform === "win32") {
42
+ throw new Error("Interactive prompt requires a TTY. Run: npm run ai-review");
43
+ }
44
+ const ttyFd = openSync("/dev/tty", "r");
45
+ const ttyInput = new ReadStream(ttyFd);
46
+ return createInterface({
47
+ input: ttyInput,
48
+ output,
49
+ terminal: true,
50
+ });
51
+ }
52
+ async function promptAction(reportPath) {
53
+ const rl = createPromptInterface();
54
+ try {
55
+ while (true) {
56
+ const answer = (await rl.question("[P] Commit anyway [R] Open review [C] Cancel commit\n> "))
57
+ .trim()
58
+ .toLowerCase();
59
+ if (!answer) {
60
+ console.log("Choose P, R, or C.");
61
+ continue;
62
+ }
63
+ if (answer === "p" || answer === "commit") {
64
+ return "commit";
65
+ }
66
+ if (answer === "c" || answer === "cancel") {
67
+ return "cancel";
68
+ }
69
+ if (answer === "r" || answer === "open") {
70
+ openFile(reportPath);
71
+ console.log(`Opened ${reportPath}`);
72
+ continue;
73
+ }
74
+ console.log("Choose P, R, or C.");
75
+ }
76
+ }
77
+ finally {
78
+ rl.close();
79
+ }
80
+ }
81
+ async function runReview(provider) {
82
+ const rootDir = getRootDir();
83
+ console.log("Building review context (staged changes)...");
84
+ const context = await buildContext(rootDir);
85
+ if (context.changedFiles.length === 0) {
86
+ console.log("No staged changes to review.");
87
+ process.exit(0);
88
+ }
89
+ const mapping = await loadMapping(rootDir);
90
+ const docIssues = await checkDocCoverage(rootDir, context.changedFiles, mapping);
91
+ const docAlerts = formatDocCoverageAlerts(docIssues);
92
+ if (docAlerts) {
93
+ console.log(docAlerts);
94
+ }
95
+ console.log(`Staged: ${context.changedFiles.length} file(s). Starting isolated ${provider} review...`);
96
+ const result = await runIsolatedReview(provider, context);
97
+ const reportPath = await saveReport(rootDir, result, context);
98
+ printSummary(result, reportPath);
99
+ const action = await promptAction(reportPath);
100
+ process.exit(action === "commit" ? 0 : 1);
101
+ }
102
+ const program = new Command();
103
+ program
104
+ .name("ai-review")
105
+ .description("Local AI pre-commit review pipeline")
106
+ .version("0.1.0");
107
+ program
108
+ .command("init")
109
+ .description("Scaffold review config, agent docs, and pre-commit hook")
110
+ .option("--force", "overwrite existing scaffold files")
111
+ .option("--skip-prompt", "skip interactive API key prompt")
112
+ .action(async (options) => {
113
+ try {
114
+ await loadEnv(getRootDir());
115
+ const result = await runInit(getRootDir(), Boolean(options.force), {
116
+ skipPrompt: Boolean(options.skipPrompt),
117
+ });
118
+ printInitResult(result);
119
+ }
120
+ catch (error) {
121
+ console.error(error instanceof Error ? error.message : error);
122
+ process.exit(1);
123
+ }
124
+ });
125
+ program
126
+ .command("run")
127
+ .description("Review staged changes before commit")
128
+ .option("--provider <name>", "review provider (default: .env or mock)")
129
+ .action(async (options) => {
130
+ try {
131
+ await loadEnv(getRootDir());
132
+ const provider = normalizeProviderName(options.provider ?? resolveDefaultProvider());
133
+ if (!listProviders().includes(provider)) {
134
+ throw new Error(`Unknown provider "${provider}". Available: ${listProviders().join(", ")}`);
135
+ }
136
+ await runReview(provider);
137
+ }
138
+ catch (error) {
139
+ console.error(error instanceof Error ? error.message : error);
140
+ process.exit(1);
141
+ }
142
+ });
143
+ program.parse();
@@ -0,0 +1,59 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { detectConfiguredProvider, getProviderDefinition, normalizeProviderName, providerHasCredentials, } from "./providerConfig.js";
4
+ const ENV_FILES = [".env", ".env.local"];
5
+ function parseEnvLine(line) {
6
+ const trimmed = line.trim();
7
+ if (!trimmed || trimmed.startsWith("#")) {
8
+ return null;
9
+ }
10
+ const eq = trimmed.indexOf("=");
11
+ if (eq <= 0) {
12
+ return null;
13
+ }
14
+ const key = trimmed.slice(0, eq).trim();
15
+ let value = trimmed.slice(eq + 1).trim();
16
+ if ((value.startsWith('"') && value.endsWith('"')) ||
17
+ (value.startsWith("'") && value.endsWith("'"))) {
18
+ value = value.slice(1, -1);
19
+ }
20
+ return { key, value };
21
+ }
22
+ export async function loadEnv(rootDir) {
23
+ for (const file of ENV_FILES) {
24
+ const path = join(rootDir, file);
25
+ try {
26
+ const raw = await readFile(path, "utf8");
27
+ for (const line of raw.split("\n")) {
28
+ const parsed = parseEnvLine(line);
29
+ if (parsed && process.env[parsed.key] === undefined) {
30
+ process.env[parsed.key] = parsed.value;
31
+ }
32
+ }
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ }
38
+ }
39
+ export function resolveDefaultProvider() {
40
+ const configured = process.env.AI_REVIEW_PROVIDER?.trim();
41
+ if (configured) {
42
+ const normalized = normalizeProviderName(configured);
43
+ if (normalized === "mock") {
44
+ return "mock";
45
+ }
46
+ if (providerHasCredentials(normalized)) {
47
+ return normalized;
48
+ }
49
+ return "mock";
50
+ }
51
+ return detectConfiguredProvider() ?? "mock";
52
+ }
53
+ export function missingKeyMessage(provider) {
54
+ const def = getProviderDefinition(provider);
55
+ if (!def) {
56
+ return `Provider "${provider}" is not configured.`;
57
+ }
58
+ return `${def.keyPrompt} is missing for provider "${provider}". Add it to .env or run: npm run ai-review:init`;
59
+ }
@@ -0,0 +1,171 @@
1
+ export const MODEL_ID_RULE = `Use the API model ID (lowercase, hyphens — not the UI display name).
2
+ Examples: claude-fable-5, composer-2.5, gpt-4o-mini`;
3
+ export const PROVIDER_DEFINITIONS = [
4
+ {
5
+ name: "cursor",
6
+ aliases: [],
7
+ label: "Cursor",
8
+ envKeys: ["CURSOR_API_KEY"],
9
+ modelEnvVars: ["CURSOR_MODEL", "AI_REVIEW_MODEL"],
10
+ defaultModel: "auto",
11
+ models: [
12
+ { id: "auto", label: "Auto (account default)" },
13
+ { id: "composer-2.5", label: "Composer 2.5" },
14
+ { id: "claude-fable-5", label: "Claude Fable 5" },
15
+ { id: "claude-opus-4-8", label: "Claude Opus 4.8" },
16
+ { id: "claude-sonnet-5", label: "Claude Sonnet 5" },
17
+ ],
18
+ keyPrompt: "CURSOR_API_KEY",
19
+ modelIdHint: "Copy from Cursor Settings → Models, or run: curl -u $CURSOR_API_KEY: https://api.cursor.com/v1/models",
20
+ },
21
+ {
22
+ name: "openai",
23
+ aliases: [],
24
+ label: "OpenAI",
25
+ envKeys: ["OPENAI_API_KEY"],
26
+ modelEnvVars: ["OPENAI_MODEL", "AI_REVIEW_MODEL"],
27
+ defaultModel: "gpt-4o-mini",
28
+ models: [
29
+ { id: "gpt-4o-mini", label: "GPT-4o mini" },
30
+ { id: "gpt-4o", label: "GPT-4o" },
31
+ { id: "gpt-4.1", label: "GPT-4.1" },
32
+ { id: "o4-mini", label: "o4-mini" },
33
+ ],
34
+ keyPrompt: "OPENAI_API_KEY",
35
+ modelIdHint: "See platform.openai.com/docs/models",
36
+ },
37
+ {
38
+ name: "codex",
39
+ aliases: [],
40
+ label: "OpenAI Codex",
41
+ envKeys: ["OPENAI_API_KEY", "CODEX_API_KEY"],
42
+ modelEnvVars: ["CODEX_MODEL", "OPENAI_MODEL", "AI_REVIEW_MODEL"],
43
+ defaultModel: "gpt-4.1",
44
+ models: [
45
+ { id: "gpt-4.1", label: "GPT-4.1 (Codex)" },
46
+ { id: "o4-mini", label: "o4-mini" },
47
+ { id: "gpt-4o", label: "GPT-4o" },
48
+ ],
49
+ keyPrompt: "OPENAI_API_KEY",
50
+ modelIdHint: "Same IDs as OpenAI API",
51
+ },
52
+ {
53
+ name: "anthropic",
54
+ aliases: ["claude"],
55
+ label: "Anthropic Claude",
56
+ envKeys: ["ANTHROPIC_API_KEY"],
57
+ modelEnvVars: ["ANTHROPIC_MODEL", "AI_REVIEW_MODEL"],
58
+ defaultModel: "claude-sonnet-4-20250514",
59
+ models: [
60
+ { id: "claude-fable-5", label: "Claude Fable 5" },
61
+ { id: "claude-opus-4-8", label: "Claude Opus 4.8" },
62
+ { id: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
63
+ { id: "claude-3-5-haiku-20241022", label: "Claude 3.5 Haiku" },
64
+ ],
65
+ keyPrompt: "ANTHROPIC_API_KEY",
66
+ modelIdHint: "See docs.anthropic.com — e.g. claude-fable-5 (not 'Fable 5')",
67
+ },
68
+ {
69
+ name: "gemini",
70
+ aliases: ["google"],
71
+ label: "Google Gemini",
72
+ envKeys: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
73
+ modelEnvVars: ["GEMINI_MODEL", "GOOGLE_MODEL", "AI_REVIEW_MODEL"],
74
+ defaultModel: "gemini-2.0-flash",
75
+ models: [
76
+ { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
77
+ { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
78
+ { id: "gemini-1.5-pro", label: "Gemini 1.5 Pro" },
79
+ ],
80
+ keyPrompt: "GOOGLE_API_KEY",
81
+ modelIdHint: "See ai.google.dev/gemini-api/docs/models",
82
+ },
83
+ ];
84
+ const ALIAS_MAP = new Map();
85
+ for (const def of PROVIDER_DEFINITIONS) {
86
+ ALIAS_MAP.set(def.name, def.name);
87
+ for (const alias of def.aliases) {
88
+ ALIAS_MAP.set(alias, def.name);
89
+ }
90
+ }
91
+ export function normalizeProviderName(name) {
92
+ return ALIAS_MAP.get(name.trim().toLowerCase()) ?? name.trim().toLowerCase();
93
+ }
94
+ export function getProviderDefinition(name) {
95
+ const normalized = normalizeProviderName(name);
96
+ return PROVIDER_DEFINITIONS.find((def) => def.name === normalized);
97
+ }
98
+ export function isKnownProvider(name) {
99
+ return getProviderDefinition(name) !== undefined;
100
+ }
101
+ export function normalizeModelId(input, provider) {
102
+ const trimmed = input.trim();
103
+ if (!trimmed) {
104
+ return getProviderDefinition(provider)?.defaultModel ?? trimmed;
105
+ }
106
+ const lower = trimmed.toLowerCase();
107
+ const displayNameAliases = {
108
+ "fable 5": "claude-fable-5",
109
+ "claude fable 5": "claude-fable-5",
110
+ fable5: "claude-fable-5",
111
+ "fable-5": "claude-fable-5",
112
+ "composer 2.5": "composer-2.5",
113
+ "composer2.5": "composer-2.5",
114
+ };
115
+ if (displayNameAliases[lower]) {
116
+ return displayNameAliases[lower];
117
+ }
118
+ if (/\s/.test(trimmed)) {
119
+ return lower.replace(/\s+/g, "-");
120
+ }
121
+ return lower;
122
+ }
123
+ export function providerHasCredentials(name) {
124
+ const def = getProviderDefinition(name);
125
+ if (!def) {
126
+ return false;
127
+ }
128
+ return def.envKeys.some((key) => Boolean(process.env[key]?.trim()));
129
+ }
130
+ export function resolveModel(name) {
131
+ const def = getProviderDefinition(name);
132
+ if (!def) {
133
+ return process.env.AI_REVIEW_MODEL?.trim() || "gpt-4o-mini";
134
+ }
135
+ for (const envVar of def.modelEnvVars) {
136
+ const value = process.env[envVar]?.trim();
137
+ if (value) {
138
+ return value;
139
+ }
140
+ }
141
+ return def.defaultModel;
142
+ }
143
+ export function resolveApiKey(name) {
144
+ const def = getProviderDefinition(name);
145
+ if (!def) {
146
+ return undefined;
147
+ }
148
+ for (const key of def.envKeys) {
149
+ const value = process.env[key]?.trim();
150
+ if (value) {
151
+ return value;
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+ export function detectConfiguredProvider() {
157
+ const priority = ["cursor", "openai", "codex", "anthropic", "gemini"];
158
+ for (const name of priority) {
159
+ if (providerHasCredentials(name)) {
160
+ return name;
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+ export function listConfiguredProviders() {
166
+ return PROVIDER_DEFINITIONS.filter((def) => providerHasCredentials(def.name)).map((def) => def.name);
167
+ }
168
+ export function primaryModelEnvVar(name) {
169
+ const def = getProviderDefinition(name);
170
+ return def?.modelEnvVars[0] ?? "AI_REVIEW_MODEL";
171
+ }
@@ -0,0 +1,70 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { simpleGit } from "simple-git";
4
+ import { loadMapping, resolveDocDirs } from "./docMapper.js";
5
+ import { findLatestReview } from "../report/findLatestReview.js";
6
+ async function readDirMarkdown(dirPath) {
7
+ const result = {};
8
+ let entries;
9
+ try {
10
+ entries = await readdir(dirPath);
11
+ }
12
+ catch {
13
+ return result;
14
+ }
15
+ for (const entry of entries) {
16
+ const fullPath = join(dirPath, entry);
17
+ const info = await stat(fullPath);
18
+ if (info.isDirectory()) {
19
+ const nested = await readDirMarkdown(fullPath);
20
+ for (const [nestedPath, content] of Object.entries(nested)) {
21
+ result[nestedPath] = content;
22
+ }
23
+ continue;
24
+ }
25
+ if (info.isFile() && (entry.endsWith(".md") || entry.endsWith(".json"))) {
26
+ result[fullPath] = await readFile(fullPath, "utf8");
27
+ }
28
+ }
29
+ return result;
30
+ }
31
+ async function getStagedChanges(rootDir) {
32
+ const git = simpleGit(rootDir);
33
+ const diff = await git.diff(["--cached"]);
34
+ const summary = await git.diffSummary(["--cached"]);
35
+ const changedFiles = summary.files.map((file) => file.file);
36
+ return { changedFiles, diff };
37
+ }
38
+ export async function buildContext(rootDir) {
39
+ const mapping = await loadMapping(rootDir);
40
+ const { changedFiles, diff } = await getStagedChanges(rootDir);
41
+ const docDirs = resolveDocDirs(changedFiles, mapping);
42
+ const projectRules = {};
43
+ const featureDocs = {};
44
+ for (const dir of docDirs) {
45
+ const absDir = join(rootDir, dir);
46
+ const files = await readDirMarkdown(absDir);
47
+ const bucket = dir.includes("project-rules") ? projectRules : featureDocs;
48
+ for (const [filePath, content] of Object.entries(files)) {
49
+ bucket[filePath] = content;
50
+ }
51
+ }
52
+ let metadata = {};
53
+ try {
54
+ const pkgRaw = await readFile(join(rootDir, "package.json"), "utf8");
55
+ metadata = JSON.parse(pkgRaw);
56
+ }
57
+ catch {
58
+ metadata = {};
59
+ }
60
+ const previousReview = await findLatestReview(join(rootDir, "docs/reviews"));
61
+ return {
62
+ generatedAt: new Date().toISOString(),
63
+ changedFiles,
64
+ diff: diff || "(no staged diff)",
65
+ projectRules,
66
+ featureDocs,
67
+ metadata,
68
+ previousReview,
69
+ };
70
+ }
@@ -0,0 +1,93 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { constants } from "node:fs";
4
+ import { globMatch } from "../utils/glob.js";
5
+ const SKIP_UNMAPPED_CHECK = [
6
+ /^docs\/reviews\//,
7
+ /^docs\/ai-review\//,
8
+ /^docs\/project-rules\//,
9
+ /^\.husky\//,
10
+ /^cli\//,
11
+ /^dist-cli\//,
12
+ /^node_modules\//,
13
+ /^dist\//,
14
+ /^package-lock\.json$/,
15
+ /^review-mapping\.json$/,
16
+ ];
17
+ function isInfrastructurePath(file) {
18
+ return SKIP_UNMAPPED_CHECK.some((pattern) => pattern.test(file));
19
+ }
20
+ function findDocsDir(file, mapping) {
21
+ for (const entry of mapping.mappings) {
22
+ if (globMatch(file, entry.pattern)) {
23
+ return entry.docs;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ async function fileExists(path) {
29
+ try {
30
+ await access(path, constants.F_OK);
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ function isUnderDocs(dir, stagedFile) {
38
+ const prefix = dir.endsWith("/") ? dir : `${dir}/`;
39
+ return stagedFile === dir || stagedFile.startsWith(prefix);
40
+ }
41
+ export async function checkDocCoverage(rootDir, stagedFiles, mapping) {
42
+ const issues = [];
43
+ const stagedSet = new Set(stagedFiles);
44
+ for (const file of stagedFiles) {
45
+ if (isInfrastructurePath(file)) {
46
+ continue;
47
+ }
48
+ const docsDir = findDocsDir(file, mapping);
49
+ if (!docsDir) {
50
+ if (!file.startsWith("docs/")) {
51
+ issues.push({
52
+ type: "unmapped",
53
+ file,
54
+ message: `No mapping in review-mapping.json for ${file}`,
55
+ });
56
+ }
57
+ continue;
58
+ }
59
+ const readmePath = join(rootDir, docsDir, "README.md");
60
+ const hasReadme = await fileExists(readmePath);
61
+ if (!hasReadme) {
62
+ issues.push({
63
+ type: "missing_readme",
64
+ file,
65
+ docsDir,
66
+ message: `${docsDir}/README.md missing — create feature doc`,
67
+ });
68
+ continue;
69
+ }
70
+ const readmeStaged = [...stagedSet].some((staged) => isUnderDocs(docsDir, staged));
71
+ const isCodeFile = !file.startsWith("docs/");
72
+ if (isCodeFile && !readmeStaged) {
73
+ issues.push({
74
+ type: "docs_not_staged",
75
+ file,
76
+ docsDir,
77
+ message: `${file} staged but ${docsDir}/ not updated in this commit`,
78
+ });
79
+ }
80
+ }
81
+ return issues;
82
+ }
83
+ export function formatDocCoverageAlerts(issues) {
84
+ if (issues.length === 0) {
85
+ return "";
86
+ }
87
+ const lines = ["--- Doc coverage alerts ---\n"];
88
+ for (const issue of issues) {
89
+ lines.push(` [${issue.type}] ${issue.message}`);
90
+ }
91
+ lines.push("\nFix docs/mapping or run /jti-review before committing.\n");
92
+ return lines.join("\n");
93
+ }
@@ -0,0 +1,18 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { globMatch } from "../utils/glob.js";
4
+ export async function loadMapping(rootDir) {
5
+ const raw = await readFile(join(rootDir, "review-mapping.json"), "utf8");
6
+ return JSON.parse(raw);
7
+ }
8
+ export function resolveDocDirs(changedFiles, mapping) {
9
+ const dirs = new Set(mapping.alwaysInclude);
10
+ for (const file of changedFiles) {
11
+ for (const entry of mapping.mappings) {
12
+ if (globMatch(file, entry.pattern)) {
13
+ dirs.add(entry.docs);
14
+ }
15
+ }
16
+ }
17
+ return [...dirs];
18
+ }