diffdoc 0.3.0 → 0.4.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 CHANGED
@@ -34,7 +34,9 @@ Package scripts can call the installed binary:
34
34
  ```json
35
35
  {
36
36
  "scripts": {
37
+ "diffdoc:init": "diffdoc init",
37
38
  "diffdoc:summarize": "diffdoc summarize",
39
+ "diffdoc:status": "diffdoc status",
38
40
  "diffdoc:embed": "diffdoc embed",
39
41
  "diffdoc:search": "diffdoc search",
40
42
  "diffdoc:query": "diffdoc query",
@@ -114,6 +116,26 @@ DiffDoc includes `diffdoc embed` as a built-in convenience path for creating a l
114
116
 
115
117
  ## Commands
116
118
 
119
+ Initialize DiffDoc configuration for a repository:
120
+
121
+ ```bash
122
+ diffdoc init
123
+ ```
124
+
125
+ Use defaults without prompts:
126
+
127
+ ```bash
128
+ diffdoc init --yes
129
+ ```
130
+
131
+ Choose a provider and overwrite an existing config file:
132
+
133
+ ```bash
134
+ diffdoc init --provider cloud --force
135
+ ```
136
+
137
+ `init` creates or updates repo setup files, appends missing `.gitignore` entries, and prints next commands. It does not run `summarize` or `embed`.
138
+
117
139
  Summarize a repository into `./.diffdoc/manifest.json`:
118
140
 
119
141
  ```bash
@@ -138,6 +160,30 @@ Add include/exclude filters at runtime:
138
160
  diffdoc summarize --path . --mode all --include-glob "src/**/*.ts" --exclude-glob "**/*.test.ts"
139
161
  ```
140
162
 
163
+ Emit a CI-friendly JSON summarize report:
164
+
165
+ ```bash
166
+ diffdoc summarize --path . --mode delta --json
167
+ ```
168
+
169
+ Inspect manifest-relative artifact freshness:
170
+
171
+ ```bash
172
+ diffdoc status
173
+ ```
174
+
175
+ Use a custom manifest path under `--base-dir`:
176
+
177
+ ```bash
178
+ diffdoc status --manifest manifest.json
179
+ ```
180
+
181
+ Emit CI-friendly JSON output:
182
+
183
+ ```bash
184
+ diffdoc status --json
185
+ ```
186
+
141
187
  Embed the manifest into a local Vectra index at `./.diffdoc/vectra`:
142
188
 
143
189
  ```bash
@@ -174,12 +220,6 @@ Include retrieved code snapshots after the generated answer:
174
220
  diffdoc query "How does embedding work?" --top 3 --code
175
221
  ```
176
222
 
177
- Prompt the configured chat model directly:
178
-
179
- ```bash
180
- diffdoc prompt "Confirm the configured model is reachable."
181
- ```
182
-
183
223
  Use a custom config file:
184
224
 
185
225
  ```bash
@@ -259,6 +299,10 @@ Run `diffdoc summarize` and `diffdoc embed` before using the MCP server, otherwi
259
299
  - Manifest schema is currently `schemaVersion: 2`; older manifest shapes are not auto-migrated.
260
300
  - Commit `.diffdoc/manifest.json` when using delta workflows. Delta summarization reads the previous manifest state to decide which changed files need fresh summaries.
261
301
  - `summarize` requires a configured chat model.
302
+ - `summarize` prints run progress and final totals (`scanned`, `skipped`, `updated`, `failed`, `pruned`).
303
+ - `summarize --json` prints a single machine-readable run report to stdout for CI parsing.
304
+ - `status` does not require a configured chat or embedding model.
305
+ - `status --json` prints a machine-readable report with summary and index freshness details.
262
306
  - `embed` requires a configured embedding model.
263
307
  - `search` requires a configured embedding model and returns raw retrieval results without calling the chat model.
264
308
  - `query` requires both a configured chat model and embedding model.
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runInit = runInit;
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const promises_2 = require("node:readline/promises");
10
+ const node_process_1 = require("node:process");
11
+ const DEFAULT_CONFIG = {
12
+ baseDir: "./.diffdoc",
13
+ aiProvider: "local",
14
+ localLlmEndpoint: "http://localhost:11434/v1",
15
+ localEmbedEndpoint: "http://localhost:11434/v1/embeddings",
16
+ localChatModel: "qwen2.5-coder:7b",
17
+ localEmbedModel: "nomic-embed-code",
18
+ cloudLlmEndpoint: "https://api.openai.com/v1",
19
+ cloudChatModel: "gpt-4o-mini",
20
+ cloudEmbedModel: "text-embedding-3-small",
21
+ openaiApiKey: "",
22
+ includeGlobs: [],
23
+ excludeGlobs: [],
24
+ ignoreFile: ".diffdocignore"
25
+ };
26
+ function parseProvider(value, fallback) {
27
+ const provider = value || fallback;
28
+ if (provider !== "local" && provider !== "cloud") {
29
+ throw new Error('Invalid init provider. Expected "local" or "cloud".');
30
+ }
31
+ return provider;
32
+ }
33
+ function parseCsv(value) {
34
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
35
+ }
36
+ function toDisplayPath(filePath) {
37
+ return filePath.split(node_path_1.default.sep).join("/");
38
+ }
39
+ function resolveRepoPath(filePath) {
40
+ return node_path_1.default.resolve(process.cwd(), filePath);
41
+ }
42
+ function relativeToRepo(absolutePath) {
43
+ const relative = node_path_1.default.relative(process.cwd(), absolutePath) || ".";
44
+ return toDisplayPath(relative);
45
+ }
46
+ function normalizeRepoPattern(value) {
47
+ return toDisplayPath(value.trim())
48
+ .replace(/^\.\//, "")
49
+ .replace(/\/+/g, "/")
50
+ .replace(/\/$/, "");
51
+ }
52
+ async function fileExists(filePath) {
53
+ try {
54
+ await promises_1.default.access(filePath);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function readExistingConfig(configPath) {
62
+ try {
63
+ const parsed = JSON.parse(await promises_1.default.readFile(configPath, "utf8"));
64
+ return parsed && typeof parsed === "object" ? parsed : {};
65
+ }
66
+ catch (error) {
67
+ const nodeError = error;
68
+ if (nodeError.code === "ENOENT") {
69
+ return {};
70
+ }
71
+ throw error;
72
+ }
73
+ }
74
+ async function promptText(rl, question, fallback) {
75
+ const suffix = fallback ? ` (${fallback})` : "";
76
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
77
+ return answer || fallback;
78
+ }
79
+ async function promptBoolean(rl, question, fallback) {
80
+ const suffix = fallback ? "Y/n" : "y/N";
81
+ const answer = (await rl.question(`${question} (${suffix}): `)).trim().toLowerCase();
82
+ if (!answer)
83
+ return fallback;
84
+ return answer === "y" || answer === "yes";
85
+ }
86
+ async function buildInteractiveConfig(options, existing) {
87
+ const rl = (0, promises_2.createInterface)({ input: node_process_1.stdin, output: node_process_1.stdout });
88
+ try {
89
+ const fallbackProvider = parseProvider(existing.aiProvider, "local");
90
+ const provider = parseProvider(options.provider || await promptText(rl, "AI provider: local or cloud", fallbackProvider), fallbackProvider);
91
+ const baseDir = options.baseDir || await promptText(rl, "DiffDoc artifact directory", existing.baseDir || DEFAULT_CONFIG.baseDir);
92
+ const ignoreFile = await promptText(rl, "Ignore file path", existing.ignoreFile || DEFAULT_CONFIG.ignoreFile);
93
+ const includeGlobs = parseCsv(await promptText(rl, "Include globs, comma-separated", (existing.includeGlobs || []).join(",")));
94
+ const excludeGlobs = parseCsv(await promptText(rl, "Exclude globs, comma-separated", (existing.excludeGlobs || []).join(",")));
95
+ const config = {
96
+ ...DEFAULT_CONFIG,
97
+ ...existing,
98
+ baseDir,
99
+ aiProvider: provider,
100
+ ignoreFile,
101
+ includeGlobs,
102
+ excludeGlobs
103
+ };
104
+ if (provider === "local") {
105
+ config.localLlmEndpoint = await promptText(rl, "Local chat endpoint", config.localLlmEndpoint || DEFAULT_CONFIG.localLlmEndpoint);
106
+ config.localChatModel = await promptText(rl, "Local chat model", config.localChatModel || DEFAULT_CONFIG.localChatModel);
107
+ config.localEmbedEndpoint = await promptText(rl, "Local embedding endpoint", config.localEmbedEndpoint || DEFAULT_CONFIG.localEmbedEndpoint);
108
+ config.localEmbedModel = await promptText(rl, "Local embedding model", config.localEmbedModel || DEFAULT_CONFIG.localEmbedModel);
109
+ }
110
+ else {
111
+ config.cloudLlmEndpoint = await promptText(rl, "Cloud OpenAI-compatible endpoint", config.cloudLlmEndpoint || DEFAULT_CONFIG.cloudLlmEndpoint);
112
+ config.cloudChatModel = await promptText(rl, "Cloud chat model", config.cloudChatModel || DEFAULT_CONFIG.cloudChatModel);
113
+ config.cloudEmbedModel = await promptText(rl, "Cloud embedding model", config.cloudEmbedModel || DEFAULT_CONFIG.cloudEmbedModel);
114
+ if (await promptBoolean(rl, "Store OPENAI_API_KEY in config file", false)) {
115
+ config.openaiApiKey = await promptText(rl, "OpenAI-compatible API key", config.openaiApiKey || "");
116
+ }
117
+ else {
118
+ config.openaiApiKey = "";
119
+ }
120
+ }
121
+ return config;
122
+ }
123
+ finally {
124
+ rl.close();
125
+ }
126
+ }
127
+ function buildYesConfig(options, existing) {
128
+ return {
129
+ ...DEFAULT_CONFIG,
130
+ ...existing,
131
+ baseDir: options.baseDir || existing.baseDir || DEFAULT_CONFIG.baseDir,
132
+ aiProvider: parseProvider(options.provider || existing.aiProvider, "local"),
133
+ openaiApiKey: ""
134
+ };
135
+ }
136
+ async function writeJsonFile(filePath, value, summary, force) {
137
+ const exists = await fileExists(filePath);
138
+ if (exists && !force) {
139
+ summary.skipped.push(relativeToRepo(filePath));
140
+ summary.warnings.push(`${relativeToRepo(filePath)} already exists; pass --force to overwrite.`);
141
+ return;
142
+ }
143
+ await promises_1.default.mkdir(node_path_1.default.dirname(filePath), { recursive: true });
144
+ await promises_1.default.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
145
+ (exists ? summary.updated : summary.created).push(relativeToRepo(filePath));
146
+ }
147
+ function buildStarterIgnore(baseDir) {
148
+ const normalizedBaseDir = normalizeRepoPattern(baseDir);
149
+ return [
150
+ "# DiffDoc ignore patterns",
151
+ "node_modules/**",
152
+ ".git/**",
153
+ `${normalizedBaseDir}/**`,
154
+ "dist/**",
155
+ ""
156
+ ].join("\n");
157
+ }
158
+ async function createIgnoreFile(ignorePath, config, summary) {
159
+ if (await fileExists(ignorePath)) {
160
+ summary.skipped.push(relativeToRepo(ignorePath));
161
+ return;
162
+ }
163
+ await promises_1.default.writeFile(ignorePath, buildStarterIgnore(config.baseDir), "utf8");
164
+ summary.created.push(relativeToRepo(ignorePath));
165
+ }
166
+ function buildGitignoreEntries(configPath, config) {
167
+ const configRelative = normalizeRepoPattern(relativeToRepo(configPath));
168
+ const baseDir = normalizeRepoPattern(config.baseDir);
169
+ return [`${baseDir}/vectra/`, configRelative];
170
+ }
171
+ async function updateGitignore(configPath, config, summary) {
172
+ const gitignorePath = resolveRepoPath(".gitignore");
173
+ const entries = buildGitignoreEntries(configPath, config);
174
+ let existing = "";
175
+ let exists = false;
176
+ try {
177
+ existing = await promises_1.default.readFile(gitignorePath, "utf8");
178
+ exists = true;
179
+ }
180
+ catch (error) {
181
+ const nodeError = error;
182
+ if (nodeError.code !== "ENOENT")
183
+ throw error;
184
+ }
185
+ const existingLines = new Set(existing.split(/\r?\n/).map(normalizeRepoPattern).filter(Boolean));
186
+ const missing = entries.filter((entry) => !existingLines.has(normalizeRepoPattern(entry)));
187
+ if (missing.length === 0) {
188
+ summary.skipped.push(".gitignore");
189
+ return;
190
+ }
191
+ const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
192
+ await promises_1.default.writeFile(gitignorePath, `${existing}${prefix}${missing.join("\n")}\n`, "utf8");
193
+ (exists ? summary.updated : summary.created).push(".gitignore");
194
+ }
195
+ function printList(label, values) {
196
+ console.log(`${label}: ${values.length > 0 ? values.join(", ") : "none"}`);
197
+ }
198
+ async function runInit(options) {
199
+ const summary = { created: [], updated: [], skipped: [], warnings: [] };
200
+ const configPath = resolveRepoPath(options.config || ".diffdocrc");
201
+ const existingConfig = await readExistingConfig(configPath);
202
+ const config = options.yes
203
+ ? buildYesConfig(options, existingConfig)
204
+ : await buildInteractiveConfig(options, existingConfig);
205
+ const ignorePath = resolveRepoPath(config.ignoreFile || ".diffdocignore");
206
+ await writeJsonFile(configPath, config, summary, options.force);
207
+ await createIgnoreFile(ignorePath, config, summary);
208
+ await updateGitignore(configPath, config, summary);
209
+ console.log("DiffDoc init complete.");
210
+ console.log("");
211
+ console.log("Init changes:");
212
+ printList("Created", summary.created);
213
+ printList("Updated", summary.updated);
214
+ printList("Skipped", summary.skipped);
215
+ printList("Warnings", summary.warnings);
216
+ console.log("");
217
+ console.log("Next commands:");
218
+ console.log("1. diffdoc summarize --path . --mode all");
219
+ console.log("2. diffdoc embed");
220
+ console.log("3. diffdoc status");
221
+ }
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runStatus = runStatus;
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const vectra_1 = require("vectra");
10
+ const embed_1 = require("./embed");
11
+ const artifacts_1 = require("../types/artifacts");
12
+ const paths_1 = require("../utils/paths");
13
+ function getSummaryDir(manifestPath) {
14
+ return node_path_1.default.resolve(node_path_1.default.dirname(manifestPath), "summaries");
15
+ }
16
+ async function readManifest(manifestPath) {
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(await promises_1.default.readFile(manifestPath, "utf8"));
20
+ }
21
+ catch (error) {
22
+ const nodeError = error;
23
+ if (nodeError.code === "ENOENT") {
24
+ throw new Error(`Manifest not found: ${manifestPath}. Run \"diffdoc summarize\" first.`);
25
+ }
26
+ throw error;
27
+ }
28
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
29
+ throw new Error(`Invalid manifest JSON in ${manifestPath}. Expected an object.`);
30
+ }
31
+ const manifest = parsed;
32
+ if (manifest.schemaVersion !== artifacts_1.MANIFEST_SCHEMA_VERSION) {
33
+ throw new Error(`Unsupported manifest schema in ${manifestPath}. Expected schemaVersion ${artifacts_1.MANIFEST_SCHEMA_VERSION}.`);
34
+ }
35
+ return {
36
+ schemaVersion: artifacts_1.MANIFEST_SCHEMA_VERSION,
37
+ lastSyncedCommit: typeof manifest.lastSyncedCommit === "string" ? manifest.lastSyncedCommit : "",
38
+ files: manifest.files && typeof manifest.files === "object" ? manifest.files : {}
39
+ };
40
+ }
41
+ async function getSummaryStats(manifestPath, manifest) {
42
+ const summaryDir = getSummaryDir(manifestPath);
43
+ let entries = [];
44
+ try {
45
+ entries = await promises_1.default.readdir(summaryDir);
46
+ }
47
+ catch (error) {
48
+ const nodeError = error;
49
+ if (nodeError.code !== "ENOENT") {
50
+ throw error;
51
+ }
52
+ }
53
+ const summaryHashes = new Set(entries.filter((entry) => entry.endsWith(".json")).map((entry) => entry.slice(0, -5)));
54
+ const manifestHashes = new Set(Object.values(manifest.files));
55
+ let orphanCount = 0;
56
+ for (const hash of summaryHashes) {
57
+ if (!manifestHashes.has(hash)) {
58
+ orphanCount += 1;
59
+ }
60
+ }
61
+ let missingFromManifestCount = 0;
62
+ for (const hash of manifestHashes) {
63
+ if (!summaryHashes.has(hash)) {
64
+ missingFromManifestCount += 1;
65
+ }
66
+ }
67
+ return {
68
+ summaryFileCount: summaryHashes.size,
69
+ orphanCount,
70
+ missingFromManifestCount
71
+ };
72
+ }
73
+ async function getIndexFreshness(manifest, config) {
74
+ const indexPath = (0, embed_1.getVectraIndexPath)(config);
75
+ const index = new vectra_1.LocalIndex(indexPath);
76
+ const exists = await index.isIndexCreated();
77
+ if (!exists) {
78
+ return {
79
+ status: "missing",
80
+ missing: 0,
81
+ mismatched: 0,
82
+ extra: 0
83
+ };
84
+ }
85
+ const items = await index.listItems();
86
+ const indexHashesByPath = new Map();
87
+ for (const item of items) {
88
+ if (!item.id || typeof item.id !== "string") {
89
+ continue;
90
+ }
91
+ const hash = item.metadata && typeof item.metadata.hash === "string"
92
+ ? item.metadata.hash
93
+ : "";
94
+ indexHashesByPath.set(item.id, hash);
95
+ }
96
+ let missing = 0;
97
+ let mismatched = 0;
98
+ for (const [filePath, manifestHash] of Object.entries(manifest.files)) {
99
+ const indexedHash = indexHashesByPath.get(filePath);
100
+ if (indexedHash === undefined) {
101
+ missing += 1;
102
+ continue;
103
+ }
104
+ if (indexedHash !== manifestHash) {
105
+ mismatched += 1;
106
+ }
107
+ }
108
+ const manifestPathSet = new Set(Object.keys(manifest.files));
109
+ let extra = 0;
110
+ for (const filePath of indexHashesByPath.keys()) {
111
+ if (!manifestPathSet.has(filePath)) {
112
+ extra += 1;
113
+ }
114
+ }
115
+ return {
116
+ status: missing === 0 && mismatched === 0 && extra === 0 ? "fresh" : "stale",
117
+ missing,
118
+ mismatched,
119
+ extra
120
+ };
121
+ }
122
+ function formatSummaryFreshness(stats) {
123
+ if (stats.missingFromManifestCount === 0) {
124
+ return "fresh";
125
+ }
126
+ return `stale (missing: ${stats.missingFromManifestCount})`;
127
+ }
128
+ function buildStatusReport(manifest, summaryStats, indexFreshness) {
129
+ return {
130
+ manifestSchema: manifest.schemaVersion,
131
+ trackedFileCount: Object.keys(manifest.files).length,
132
+ summaryFileCount: summaryStats.summaryFileCount,
133
+ orphanCount: summaryStats.orphanCount,
134
+ summaryFreshness: {
135
+ status: summaryStats.missingFromManifestCount === 0 ? "fresh" : "stale",
136
+ missing: summaryStats.missingFromManifestCount
137
+ },
138
+ indexFreshness
139
+ };
140
+ }
141
+ function formatIndexFreshness(freshness) {
142
+ if (freshness.status === "missing") {
143
+ return "missing";
144
+ }
145
+ if (freshness.status === "fresh") {
146
+ return "fresh";
147
+ }
148
+ return `stale (missing: ${freshness.missing}, mismatched: ${freshness.mismatched}, extra: ${freshness.extra})`;
149
+ }
150
+ async function runStatus(options, config) {
151
+ const manifestPath = (0, paths_1.resolveDiffdocArtifactPath)(options.manifest, config.baseDir);
152
+ const manifest = await readManifest(manifestPath);
153
+ const summaryStats = await getSummaryStats(manifestPath, manifest);
154
+ const indexFreshness = await getIndexFreshness(manifest, config);
155
+ const report = buildStatusReport(manifest, summaryStats, indexFreshness);
156
+ if (options.json) {
157
+ console.log(JSON.stringify(report, null, 2));
158
+ return;
159
+ }
160
+ console.log(`manifest schema: ${report.manifestSchema}`);
161
+ console.log(`tracked files: ${report.trackedFileCount}`);
162
+ console.log(`summary files: ${report.summaryFileCount}`);
163
+ console.log(`orphans: ${report.orphanCount}`);
164
+ console.log(`summary freshness: ${formatSummaryFreshness(summaryStats)}`);
165
+ console.log(`index freshness: ${formatIndexFreshness(indexFreshness)}`);
166
+ }
@@ -182,7 +182,7 @@ async function deleteSummaryIfUnreferenced(summaryDir, hash, refs) {
182
182
  async function setManifestPathHash(filePath, newHash, manifest, manifestPath, summaryDir, refs) {
183
183
  const previousHash = manifest.files[filePath];
184
184
  if (previousHash === newHash) {
185
- return;
185
+ return false;
186
186
  }
187
187
  if (previousHash) {
188
188
  refs.set(previousHash, Math.max((refs.get(previousHash) || 1) - 1, 0));
@@ -193,16 +193,18 @@ async function setManifestPathHash(filePath, newHash, manifest, manifestPath, su
193
193
  if (previousHash) {
194
194
  await deleteSummaryIfUnreferenced(summaryDir, previousHash, refs);
195
195
  }
196
+ return true;
196
197
  }
197
198
  async function removeManifestPath(filePath, manifest, manifestPath, summaryDir, refs) {
198
199
  const previousHash = manifest.files[filePath];
199
200
  if (!previousHash) {
200
- return;
201
+ return false;
201
202
  }
202
203
  delete manifest.files[filePath];
203
204
  refs.set(previousHash, Math.max((refs.get(previousHash) || 1) - 1, 0));
204
205
  await writeManifest(manifestPath, manifest);
205
206
  await deleteSummaryIfUnreferenced(summaryDir, previousHash, refs);
207
+ return true;
206
208
  }
207
209
  async function ensureSummaryAsset(summaryDir, hash, summaryText, rawCodeSnapshot, includeCodeSnapshot) {
208
210
  const summaryPath = getSummaryPath(summaryDir, hash);
@@ -245,6 +247,7 @@ async function runSummarize(options, config) {
245
247
  if (options.mode !== "all" && options.mode !== "delta") {
246
248
  throw new Error('Invalid summarize mode. Expected "all" or "delta".');
247
249
  }
250
+ const startedAt = new Date();
248
251
  const commandCwd = process.cwd();
249
252
  const repoPath = node_path_1.default.resolve(commandCwd, options.path);
250
253
  const manifestPath = (0, paths_1.resolveDiffdocArtifactPath)(options.out, config.baseDir);
@@ -259,13 +262,29 @@ async function runSummarize(options, config) {
259
262
  : config.summarize.excludeGlobs.map(normalizeGlobPattern));
260
263
  const ignoreFile = options.ignoreFile || config.summarize.ignoreFile;
261
264
  const ignorePatterns = compileGlobs(await readIgnorePatterns(repoPath, ignoreFile));
265
+ const totals = { scanned: 0, skipped: 0, updated: 0, failed: 0, pruned: 0 };
262
266
  const failures = [];
267
+ const isJson = options.json;
268
+ if (!isJson) {
269
+ console.log(`Starting summarize run`);
270
+ console.log(`Mode: ${options.mode}`);
271
+ console.log(`Repo: ${repoPath}`);
272
+ console.log(`Manifest: ${manifestPath}`);
273
+ console.log(`Summaries: ${summaryDir}`);
274
+ console.log("---");
275
+ }
263
276
  if (options.mode === "all") {
264
277
  manifest.files = {};
265
278
  refs.clear();
266
279
  await writeManifest(manifestPath, manifest);
267
280
  const files = await walkCodeFiles(repoPath, includePatterns, excludePatterns, ignorePatterns);
268
- for (const filePath of files) {
281
+ const totalFiles = files.length;
282
+ if (!isJson) {
283
+ console.log(`Candidates: ${totalFiles}`);
284
+ }
285
+ for (let i = 0; i < files.length; i += 1) {
286
+ const filePath = files[i];
287
+ totals.scanned += 1;
269
288
  try {
270
289
  const absolutePath = node_path_1.default.join(repoPath, filePath);
271
290
  const rawCodeSnapshot = await promises_1.default.readFile(absolutePath, "utf8");
@@ -278,25 +297,51 @@ async function runSummarize(options, config) {
278
297
  manifest.files[filePath] = hash;
279
298
  refs.set(hash, (refs.get(hash) || 0) + 1);
280
299
  await writeManifest(manifestPath, manifest);
281
- console.log(`Summarized ${filePath}`);
300
+ totals.updated += 1;
301
+ if (!isJson) {
302
+ console.log(`[${i + 1}/${totalFiles}] summarized ${filePath}`);
303
+ }
282
304
  }
283
305
  catch (error) {
284
306
  const message = error instanceof Error ? error.message : String(error);
285
307
  failures.push({ filePath, message });
286
- console.error(`Failed ${filePath}: ${message}`);
308
+ totals.failed += 1;
309
+ if (!isJson) {
310
+ console.error(`[${i + 1}/${totalFiles}] failed ${filePath}: ${message}`);
311
+ }
287
312
  }
288
313
  }
289
314
  }
290
315
  else {
291
316
  const deltas = await (0, git_1.getGitDeltas)(repoPath, manifest.lastSyncedCommit);
317
+ const totalCandidates = deltas.modifiedOrAdded.length + deltas.deleted.length;
318
+ if (!isJson) {
319
+ console.log(`Candidates: ${totalCandidates} (${deltas.modifiedOrAdded.length} modified/added, ${deltas.deleted.length} deleted)`);
320
+ }
292
321
  for (const deletedPath of deltas.deleted) {
293
- await removeManifestPath(deletedPath, manifest, manifestPath, summaryDir, refs);
294
- console.log(`Pruned ${deletedPath}`);
322
+ const removed = await removeManifestPath(deletedPath, manifest, manifestPath, summaryDir, refs);
323
+ if (removed) {
324
+ totals.pruned += 1;
325
+ }
326
+ if (!isJson) {
327
+ console.log(`pruned ${deletedPath}`);
328
+ }
295
329
  }
296
- for (const filePath of deltas.modifiedOrAdded) {
330
+ for (let i = 0; i < deltas.modifiedOrAdded.length; i += 1) {
331
+ const filePath = deltas.modifiedOrAdded[i];
332
+ totals.scanned += 1;
297
333
  try {
298
334
  if (!shouldIncludeFile(filePath, includePatterns, excludePatterns, ignorePatterns)) {
299
- await removeManifestPath(filePath, manifest, manifestPath, summaryDir, refs);
335
+ const removed = await removeManifestPath(filePath, manifest, manifestPath, summaryDir, refs);
336
+ if (removed) {
337
+ totals.pruned += 1;
338
+ }
339
+ else {
340
+ totals.skipped += 1;
341
+ }
342
+ if (!isJson) {
343
+ console.log(`[${i + 1}/${deltas.modifiedOrAdded.length}] excluded ${filePath}`);
344
+ }
300
345
  continue;
301
346
  }
302
347
  const previousHash = manifest.files[filePath];
@@ -304,6 +349,10 @@ async function runSummarize(options, config) {
304
349
  const rawCodeSnapshot = await promises_1.default.readFile(absolutePath, "utf8");
305
350
  const hash = (0, hashing_1.hashFileContent)(rawCodeSnapshot);
306
351
  if (previousHash === hash) {
352
+ totals.skipped += 1;
353
+ if (!isJson) {
354
+ console.log(`[${i + 1}/${deltas.modifiedOrAdded.length}] unchanged ${filePath}`);
355
+ }
307
356
  continue;
308
357
  }
309
358
  const summaryPath = getSummaryPath(summaryDir, hash);
@@ -311,29 +360,77 @@ async function runSummarize(options, config) {
311
360
  const summaryText = await (0, llm_1.generateFunctionalSummary)(filePath, rawCodeSnapshot, config.chat);
312
361
  await ensureSummaryAsset(summaryDir, hash, summaryText, rawCodeSnapshot, options.includeCodeSnapshot);
313
362
  }
314
- await setManifestPathHash(filePath, hash, manifest, manifestPath, summaryDir, refs);
315
- console.log(`Updated ${filePath}`);
363
+ const changed = await setManifestPathHash(filePath, hash, manifest, manifestPath, summaryDir, refs);
364
+ if (changed) {
365
+ totals.updated += 1;
366
+ }
367
+ else {
368
+ totals.skipped += 1;
369
+ }
370
+ if (!isJson) {
371
+ console.log(`[${i + 1}/${deltas.modifiedOrAdded.length}] updated ${filePath}`);
372
+ }
316
373
  }
317
374
  catch (error) {
318
375
  const nodeError = error;
319
376
  if (nodeError.code === "ENOENT") {
320
- await removeManifestPath(filePath, manifest, manifestPath, summaryDir, refs);
377
+ const removed = await removeManifestPath(filePath, manifest, manifestPath, summaryDir, refs);
378
+ if (removed) {
379
+ totals.pruned += 1;
380
+ }
381
+ else {
382
+ totals.skipped += 1;
383
+ }
384
+ if (!isJson) {
385
+ console.log(`[${i + 1}/${deltas.modifiedOrAdded.length}] missing ${filePath}`);
386
+ }
321
387
  continue;
322
388
  }
323
389
  const message = error instanceof Error ? error.message : String(error);
324
390
  failures.push({ filePath, message });
325
- console.error(`Failed ${filePath}: ${message}`);
391
+ totals.failed += 1;
392
+ if (!isJson) {
393
+ console.error(`[${i + 1}/${deltas.modifiedOrAdded.length}] failed ${filePath}: ${message}`);
394
+ }
326
395
  }
327
396
  }
328
397
  }
329
398
  manifest.lastSyncedCommit = await (0, git_1.getCurrentCommit)(repoPath);
330
399
  await writeManifest(manifestPath, manifest);
331
400
  await pruneOrphanedSummaries(summaryDir, manifest);
332
- console.log(`Wrote manifest to ${manifestPath}`);
401
+ const finishedAt = new Date();
402
+ const durationMs = finishedAt.getTime() - startedAt.getTime();
403
+ const report = {
404
+ mode: options.mode,
405
+ repoPath,
406
+ manifestPath,
407
+ summaryDir,
408
+ startedAt: startedAt.toISOString(),
409
+ finishedAt: finishedAt.toISOString(),
410
+ durationMs,
411
+ totals,
412
+ failures
413
+ };
414
+ if (isJson) {
415
+ console.log(JSON.stringify(report, null, 2));
416
+ }
417
+ else {
418
+ console.log("---");
419
+ console.log(`Summarize complete`);
420
+ console.log(`Scanned: ${totals.scanned}`);
421
+ console.log(`Updated: ${totals.updated}`);
422
+ console.log(`Skipped: ${totals.skipped}`);
423
+ console.log(`Pruned: ${totals.pruned}`);
424
+ console.log(`Failed: ${totals.failed}`);
425
+ console.log(`Duration: ${(durationMs / 1000).toFixed(2)}s`);
426
+ console.log(`Manifest: ${manifestPath}`);
427
+ }
333
428
  if (failures.length > 0) {
334
- console.error(`\n${failures.length} file(s) failed during summarization:`);
335
- for (const failure of failures) {
336
- console.error(`- ${failure.filePath}: ${failure.message}`);
429
+ if (!isJson) {
430
+ console.error(`\n${failures.length} file(s) failed during summarization:`);
431
+ for (const failure of failures) {
432
+ console.error(`- ${failure.filePath}: ${failure.message}`);
433
+ }
337
434
  }
338
435
  throw new Error("Summarization completed with failures.");
339
436
  }
package/dist/index.js CHANGED
@@ -4,9 +4,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commander_1 = require("commander");
5
5
  const config_1 = require("./config");
6
6
  const embed_1 = require("./commands/embed");
7
+ const init_1 = require("./commands/init");
7
8
  const query_1 = require("./commands/query");
9
+ const status_1 = require("./commands/status");
8
10
  const summarize_1 = require("./commands/summarize");
9
- const llm_1 = require("./utils/llm");
10
11
  const program = new commander_1.Command();
11
12
  function collectOption(value, previous) {
12
13
  previous.push(value);
@@ -41,6 +42,23 @@ program
41
42
  .name("diffdoc")
42
43
  .description("Translate repository code shifts into plain-English business context")
43
44
  .version("0.1.0");
45
+ program
46
+ .command("init")
47
+ .description("Initialize DiffDoc configuration for this repository")
48
+ .option("--yes", "use defaults without prompting", false)
49
+ .option("--provider <provider>", "AI provider: local or cloud")
50
+ .option("--config <path>", "path to .diffdocrc JSON config file")
51
+ .option("--base-dir <path>", "DiffDoc artifact directory")
52
+ .option("--force", "overwrite existing config file", false)
53
+ .action(async (options) => {
54
+ try {
55
+ await (0, init_1.runInit)(options);
56
+ }
57
+ catch (error) {
58
+ console.error(error instanceof Error ? error.message : error);
59
+ process.exit(1);
60
+ }
61
+ });
44
62
  addChatOptions(addBaseOptions(program
45
63
  .command("summarize")))
46
64
  .description("Summarize repository code into a portable JSON manifest")
@@ -48,6 +66,7 @@ addChatOptions(addBaseOptions(program
48
66
  .option("--out <path>", "manifest output path under --base-dir", "manifest.json")
49
67
  .option("--mode <mode>", "summarization mode: all or delta", "all")
50
68
  .option("--include-code-snapshot", "store raw code in summary assets", false)
69
+ .option("--json", "print summarize report as JSON for CI", false)
51
70
  .option("--include-glob <pattern>", "include glob pattern (repeatable)", collectOption, [])
52
71
  .option("--exclude-glob <pattern>", "exclude glob pattern (repeatable)", collectOption, [])
53
72
  .option("--ignore-file <path>", "path to ignore pattern file relative to --path")
@@ -59,6 +78,7 @@ addChatOptions(addBaseOptions(program
59
78
  out: options.out,
60
79
  mode: options.mode,
61
80
  includeCodeSnapshot: options.includeCodeSnapshot,
81
+ json: options.json,
62
82
  includeGlobs: options.includeGlob,
63
83
  excludeGlobs: options.excludeGlob,
64
84
  ignoreFile: options.ignoreFile
@@ -69,21 +89,6 @@ addChatOptions(addBaseOptions(program
69
89
  process.exit(1);
70
90
  }
71
91
  });
72
- addChatOptions(addBaseOptions(program
73
- .command("prompt")))
74
- .description("Send a plain prompt to the configured LLM")
75
- .argument("<message...>", "prompt text to send")
76
- .action(async (messageParts, options) => {
77
- try {
78
- const config = (0, config_1.buildRuntimeConfig)(options, { chat: true });
79
- const response = await (0, llm_1.promptLlm)(messageParts.join(" "), config.chat);
80
- console.log(response);
81
- }
82
- catch (error) {
83
- console.error(error instanceof Error ? error.message : error);
84
- process.exit(1);
85
- }
86
- });
87
92
  addEmbeddingOptions(addChatOptions(addBaseOptions(program
88
93
  .command("query"))))
89
94
  .description("Answer a question using retrieved local Vectra context")
@@ -131,6 +136,21 @@ addCloudEndpointAndKeyOptions(addEmbeddingOptions(addBaseOptions(program
131
136
  process.exit(1);
132
137
  }
133
138
  });
139
+ addBaseOptions(program
140
+ .command("status"))
141
+ .description("Show manifest and index sync status")
142
+ .option("--manifest <path>", "manifest input path under --base-dir", "manifest.json")
143
+ .option("--json", "print status as JSON for CI", false)
144
+ .action(async (options) => {
145
+ try {
146
+ const config = (0, config_1.buildRuntimeConfig)(options, { embeddings: false, chat: false });
147
+ await (0, status_1.runStatus)({ manifest: options.manifest, json: options.json }, config);
148
+ }
149
+ catch (error) {
150
+ console.error(error instanceof Error ? error.message : error);
151
+ process.exit(1);
152
+ }
153
+ });
134
154
  program.parseAsync(process.argv).catch((error) => {
135
155
  console.error(error instanceof Error ? error.message : error);
136
156
  process.exit(1);
@@ -67,7 +67,7 @@ async function answerFromIndex(question, topK, config) {
67
67
  results: []
68
68
  };
69
69
  }
70
- const answer = await (0, llm_1.promptLlm)(buildAnswerPrompt(question, results), config.chat);
70
+ const answer = await (0, llm_1.generateAnswer)(buildAnswerPrompt(question, results), config.chat);
71
71
  return {
72
72
  answer,
73
73
  sources: results.map((result) => ({ filePath: result.filePath, score: result.score })),
package/dist/utils/llm.js CHANGED
@@ -4,7 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.generateFunctionalSummary = generateFunctionalSummary;
7
- exports.promptLlm = promptLlm;
7
+ exports.generateAnswer = generateAnswer;
8
8
  exports.generateEmbeddings = generateEmbeddings;
9
9
  const openai_1 = __importDefault(require("openai"));
10
10
  function createClient(config) {
@@ -31,7 +31,7 @@ async function generateFunctionalSummary(fileName, codeContent, config) {
31
31
  });
32
32
  return response.choices[0]?.message?.content?.trim() || "No business behavior summary was returned.";
33
33
  }
34
- async function promptLlm(prompt, config) {
34
+ async function generateAnswer(prompt, config) {
35
35
  const { client, model } = createClient(config);
36
36
  const response = await client.chat.completions.create({
37
37
  model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffdoc",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Translate repository code shifts into plain-English business context",
5
5
  "license": "MIT",
6
6
  "author": "Christopher Sullivan",
@@ -15,8 +15,8 @@
15
15
  "type": "commonjs",
16
16
  "main": "dist/index.js",
17
17
  "bin": {
18
- "diffdoc": "./dist/index.js",
19
- "diffdoc-mcp": "./dist/mcp.js"
18
+ "diffdoc": "dist/index.js",
19
+ "diffdoc-mcp": "dist/mcp.js"
20
20
  },
21
21
  "files": [
22
22
  "dist",