legacyver 1.0.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 +154 -0
- package/bin/legacyver.js +33 -0
- package/package.json +51 -0
- package/src/cache/hash.js +17 -0
- package/src/cache/index.js +106 -0
- package/src/cli/commands/analyze.js +240 -0
- package/src/cli/commands/cache.js +35 -0
- package/src/cli/commands/init.js +125 -0
- package/src/cli/commands/providers.js +83 -0
- package/src/cli/commands/version.js +12 -0
- package/src/cli/flags.js +87 -0
- package/src/cli/ui.js +104 -0
- package/src/crawler/filters.js +57 -0
- package/src/crawler/index.js +49 -0
- package/src/crawler/manifest.js +51 -0
- package/src/crawler/walk.js +57 -0
- package/src/llm/chunker.js +77 -0
- package/src/llm/cost-estimator.js +67 -0
- package/src/llm/index.js +49 -0
- package/src/llm/prompts.js +52 -0
- package/src/llm/providers/anthropic.js +57 -0
- package/src/llm/providers/google.js +65 -0
- package/src/llm/providers/groq.js +52 -0
- package/src/llm/providers/ollama.js +85 -0
- package/src/llm/providers/openai.js +52 -0
- package/src/llm/queue.js +99 -0
- package/src/parser/ast/generic.js +249 -0
- package/src/parser/ast/go.js +236 -0
- package/src/parser/ast/java.js +173 -0
- package/src/parser/ast/javascript.js +273 -0
- package/src/parser/ast/python.js +227 -0
- package/src/parser/ast/tree-sitter-init.js +80 -0
- package/src/parser/ast/typescript.js +250 -0
- package/src/parser/call-graph.js +86 -0
- package/src/parser/index.js +106 -0
- package/src/parser/pkg-builder.js +105 -0
- package/src/renderer/html.js +152 -0
- package/src/renderer/index.js +44 -0
- package/src/renderer/json.js +46 -0
- package/src/renderer/markdown.js +149 -0
- package/src/utils/config.js +65 -0
- package/src/utils/errors.js +70 -0
- package/src/utils/logger.js +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 legacyver contributors
|
|
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,154 @@
|
|
|
1
|
+
# legacyver
|
|
2
|
+
|
|
3
|
+
Generate technical documentation from undocumented or legacy codebases using AST parsing and LLMs.
|
|
4
|
+
|
|
5
|
+
Legacyver crawls your source code, extracts structural facts with AST parsers (Tree-sitter + regex fallback), sends them to an LLM with anti-hallucination constraints, and outputs clean Markdown, HTML, or JSON documentation.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g legacyver
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node.js >= 18.0.0.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 1. Run the setup wizard
|
|
19
|
+
legacyver init
|
|
20
|
+
|
|
21
|
+
# 2. Generate documentation
|
|
22
|
+
legacyver analyze ./src
|
|
23
|
+
|
|
24
|
+
# 3. View output
|
|
25
|
+
open legacyver-docs/index.md
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Using Ollama (Free, Local)
|
|
29
|
+
|
|
30
|
+
No API key required — just have [Ollama](https://ollama.ai) running:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
legacyver analyze ./src --provider ollama
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## CLI Reference
|
|
37
|
+
|
|
38
|
+
### `legacyver analyze <path>`
|
|
39
|
+
|
|
40
|
+
Main command. Analyzes a codebase and generates documentation.
|
|
41
|
+
|
|
42
|
+
| Flag | Description | Default |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `-p, --provider <name>` | LLM provider (`anthropic`, `openai`, `google`, `groq`, `ollama`) | `anthropic` |
|
|
45
|
+
| `-m, --model <name>` | Model name (overrides provider default) | Provider default |
|
|
46
|
+
| `-o, --output <dir>` | Output directory | `./legacyver-docs` |
|
|
47
|
+
| `-f, --format <type>` | Output format: `markdown`, `html`, `json` | `markdown` |
|
|
48
|
+
| `-c, --concurrency <n>` | Concurrent LLM requests | `5` |
|
|
49
|
+
| `--max-file-size <kb>` | Skip files larger than this (KB) | `500` |
|
|
50
|
+
| `--incremental` | Only re-analyze changed files | `false` |
|
|
51
|
+
| `--no-confirm` | Skip cost confirmation prompt | `false` |
|
|
52
|
+
| `--dry-run` | Show cost estimate, no LLM calls | `false` |
|
|
53
|
+
| `--ignore <patterns...>` | Additional glob patterns to ignore | `[]` |
|
|
54
|
+
| `--json-summary` | Output JSON summary to stdout | `false` |
|
|
55
|
+
| `--verbose` | Enable debug output | `false` |
|
|
56
|
+
|
|
57
|
+
### `legacyver init`
|
|
58
|
+
|
|
59
|
+
Interactive setup wizard. Configures your preferred LLM provider, saves API keys to OS user config, and creates a `.legacyverrc` file.
|
|
60
|
+
|
|
61
|
+
### `legacyver providers`
|
|
62
|
+
|
|
63
|
+
Lists all supported providers with API key status and pricing.
|
|
64
|
+
|
|
65
|
+
### `legacyver cache clear`
|
|
66
|
+
|
|
67
|
+
Deletes the `.legacyver-cache/` directory.
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Legacyver uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) for configuration. It searches for:
|
|
72
|
+
|
|
73
|
+
- `.legacyverrc` (JSON)
|
|
74
|
+
- `.legacyverrc.json`
|
|
75
|
+
- `.legacyverrc.yaml`
|
|
76
|
+
- `legacyver.config.js`
|
|
77
|
+
- `legacyver.config.mjs`
|
|
78
|
+
|
|
79
|
+
### `.legacyverrc` Schema
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"provider": "anthropic",
|
|
84
|
+
"model": null,
|
|
85
|
+
"format": "markdown",
|
|
86
|
+
"output": "./legacyver-docs",
|
|
87
|
+
"concurrency": 5,
|
|
88
|
+
"maxFileSizeKb": 500,
|
|
89
|
+
"incremental": false,
|
|
90
|
+
"ignore": ["test/**", "**/*.test.*"],
|
|
91
|
+
"languages": ["javascript", "typescript", "python", "java", "go"]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
CLI flags always take precedence over config file values.
|
|
96
|
+
|
|
97
|
+
## Supported Languages
|
|
98
|
+
|
|
99
|
+
| Language | Extensions | Parser |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| JavaScript | `.js`, `.jsx`, `.mjs` | Tree-sitter (WASM) or regex fallback |
|
|
102
|
+
| TypeScript | `.ts`, `.tsx` | Tree-sitter (WASM) or regex fallback |
|
|
103
|
+
| Python | `.py` | Tree-sitter (WASM) or regex fallback |
|
|
104
|
+
| Java | `.java` | Tree-sitter (WASM) or regex fallback |
|
|
105
|
+
| Go | `.go` | Tree-sitter (WASM) or regex fallback |
|
|
106
|
+
|
|
107
|
+
When Tree-sitter WASM grammars are not available, legacyver automatically falls back to a generic regex-based parser that extracts function signatures, class definitions, and imports.
|
|
108
|
+
|
|
109
|
+
## Supported LLM Providers
|
|
110
|
+
|
|
111
|
+
| Provider | Default Model | Input Cost/1k tokens | Output Cost/1k tokens | API Key Env Var |
|
|
112
|
+
|---|---|---|---|---|
|
|
113
|
+
| Anthropic | `claude-haiku-3-5` | $0.00025 | $0.00125 | `ANTHROPIC_API_KEY` |
|
|
114
|
+
| OpenAI | `gpt-4o-mini` | $0.00015 | $0.00060 | `OPENAI_API_KEY` |
|
|
115
|
+
| Google | `gemini-1.5-flash` | $0.000075 | $0.00030 | `GOOGLE_API_KEY` |
|
|
116
|
+
| Groq | `llama-3.1-70b-versatile` | $0.00059 | $0.00079 | `GROQ_API_KEY` |
|
|
117
|
+
| Ollama | `llama3.2` | Free | Free | None (local) |
|
|
118
|
+
|
|
119
|
+
## Ignore Rules
|
|
120
|
+
|
|
121
|
+
Legacyver respects a `.legacyverignore` file (same syntax as `.gitignore`) in your project root.
|
|
122
|
+
|
|
123
|
+
Default ignores include: `node_modules`, `.git`, `dist`, `build`, `__pycache__`, `venv`, `vendor`, minified files, lock files, and more.
|
|
124
|
+
|
|
125
|
+
## CI/CD Integration
|
|
126
|
+
|
|
127
|
+
Legacyver auto-detects non-interactive environments (no TTY) and disables spinners/prompts. For CI pipelines:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
legacyver analyze ./src --provider openai --no-confirm --json-summary
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The `--json-summary` flag outputs a machine-readable summary to stdout. The `--no-confirm` flag is required in non-interactive mode (otherwise legacyver exits with code 4).
|
|
134
|
+
|
|
135
|
+
### Incremental Builds
|
|
136
|
+
|
|
137
|
+
Use `--incremental` to skip unchanged files between CI runs:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
legacyver analyze ./src --incremental --no-confirm
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Cache is stored in `.legacyver-cache/` (auto-added to `.gitignore`).
|
|
144
|
+
|
|
145
|
+
## How It Works
|
|
146
|
+
|
|
147
|
+
1. **Crawler** discovers source files using fast-glob, respecting ignore rules
|
|
148
|
+
2. **AST Parser** extracts structural facts (functions, classes, imports, exports, call graph) using Tree-sitter or regex fallback
|
|
149
|
+
3. **LLM Engine** sends facts + source code to the LLM with anti-hallucination constraints — the LLM can only describe what's actually in the code
|
|
150
|
+
4. **Renderer** assembles the output as Markdown (with Mermaid diagrams), self-contained HTML (with search), or structured JSON
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
package/bin/legacyver.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { registerAnalyzeCommand } from '../src/cli/commands/analyze.js';
|
|
6
|
+
import { registerInitCommand } from '../src/cli/commands/init.js';
|
|
7
|
+
import { registerProvidersCommand } from '../src/cli/commands/providers.js';
|
|
8
|
+
import { registerCacheCommand } from '../src/cli/commands/cache.js';
|
|
9
|
+
import { setVerbose } from '../src/utils/logger.js';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const pkg = require('../package.json');
|
|
13
|
+
|
|
14
|
+
const program = new Command();
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.name('legacyver')
|
|
18
|
+
.description('Generate technical documentation from undocumented or legacy codebases')
|
|
19
|
+
.version(pkg.version)
|
|
20
|
+
.option('--verbose', 'Enable verbose debug output', false)
|
|
21
|
+
.hook('preAction', (thisCommand) => {
|
|
22
|
+
const opts = thisCommand.opts();
|
|
23
|
+
if (opts.verbose) {
|
|
24
|
+
setVerbose(true);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
registerAnalyzeCommand(program);
|
|
29
|
+
registerInitCommand(program);
|
|
30
|
+
registerProvidersCommand(program);
|
|
31
|
+
registerCacheCommand(program);
|
|
32
|
+
|
|
33
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "legacyver",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Generate technical documentation from undocumented or legacy codebases using AST parsing and LLMs",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"legacyver": "bin/legacyver.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.0.0"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"lint": "eslint src/ test/"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"documentation",
|
|
19
|
+
"legacy",
|
|
20
|
+
"codebase",
|
|
21
|
+
"ast",
|
|
22
|
+
"llm",
|
|
23
|
+
"tree-sitter",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@anthropic-ai/sdk": "^0.75.0",
|
|
30
|
+
"@google/generative-ai": "^0.24.1",
|
|
31
|
+
"chalk": "^5.6.2",
|
|
32
|
+
"cli-progress": "^3.12.0",
|
|
33
|
+
"commander": "^14.0.3",
|
|
34
|
+
"conf": "^15.1.0",
|
|
35
|
+
"cosmiconfig": "^9.0.0",
|
|
36
|
+
"fast-glob": "^3.3.3",
|
|
37
|
+
"groq-sdk": "^0.37.0",
|
|
38
|
+
"ignore": "^7.0.5",
|
|
39
|
+
"marked": "^17.0.3",
|
|
40
|
+
"openai": "^6.22.0",
|
|
41
|
+
"ora": "^9.3.0",
|
|
42
|
+
"p-limit": "^7.3.0",
|
|
43
|
+
"p-retry": "^7.1.1",
|
|
44
|
+
"picocolors": "^1.1.1",
|
|
45
|
+
"web-tree-sitter": "^0.26.5"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"eslint": "^10.0.0",
|
|
49
|
+
"vitest": "^4.0.18"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File hash computation for incremental cache.
|
|
3
|
+
* Uses Node.js crypto SHA-256 to produce deterministic file fingerprints.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Compute SHA-256 hash of a file's contents.
|
|
11
|
+
* @param {string} filePath - Absolute path to file
|
|
12
|
+
* @returns {string} Hex-encoded SHA-256 hash with "sha256:" prefix
|
|
13
|
+
*/
|
|
14
|
+
export function computeHash(filePath) {
|
|
15
|
+
const content = readFileSync(filePath);
|
|
16
|
+
return 'sha256:' + createHash('sha256').update(content).digest('hex');
|
|
17
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental cache module.
|
|
3
|
+
* Persists file hashes to .legacyver-cache/hashes.json so that
|
|
4
|
+
* unchanged files can skip LLM re-analysis on subsequent runs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const CACHE_FILE = 'hashes.json';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load the cache map from disk.
|
|
14
|
+
* @param {string} cacheDir - Path to .legacyver-cache directory
|
|
15
|
+
* @returns {Record<string, { hash: string, generatedAt: string }>} Cache map (empty object if no cache)
|
|
16
|
+
*/
|
|
17
|
+
export function loadCache(cacheDir) {
|
|
18
|
+
const cachePath = join(cacheDir, CACHE_FILE);
|
|
19
|
+
if (!existsSync(cachePath)) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const raw = readFileSync(cachePath, 'utf-8');
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch {
|
|
26
|
+
// Corrupted cache — start fresh
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Save the cache map to disk.
|
|
33
|
+
* Creates the cache directory if it doesn't exist.
|
|
34
|
+
* @param {string} cacheDir - Path to .legacyver-cache directory
|
|
35
|
+
* @param {Record<string, { hash: string, generatedAt: string }>} cacheMap - Cache map to persist
|
|
36
|
+
*/
|
|
37
|
+
export function saveCache(cacheDir, cacheMap) {
|
|
38
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
39
|
+
const cachePath = join(cacheDir, CACHE_FILE);
|
|
40
|
+
writeFileSync(cachePath, JSON.stringify(cacheMap, null, 2), 'utf-8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Separate manifest entries into cache hits and misses.
|
|
45
|
+
* A hit means the file hash matches the cached hash — no re-analysis needed.
|
|
46
|
+
* @param {import('../crawler/manifest.js').FileManifest[]} manifest - Current file manifest
|
|
47
|
+
* @param {Record<string, { hash: string }>} cacheMap - Loaded cache map
|
|
48
|
+
* @returns {{ hits: import('../crawler/manifest.js').FileManifest[], misses: import('../crawler/manifest.js').FileManifest[] }}
|
|
49
|
+
*/
|
|
50
|
+
export function getCacheHits(manifest, cacheMap) {
|
|
51
|
+
const hits = [];
|
|
52
|
+
const misses = [];
|
|
53
|
+
|
|
54
|
+
for (const file of manifest) {
|
|
55
|
+
const cached = cacheMap[file.relativePath];
|
|
56
|
+
if (cached && cached.hash === file.hash) {
|
|
57
|
+
hits.push(file);
|
|
58
|
+
} else {
|
|
59
|
+
misses.push(file);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { hits, misses };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Remove cache entries for files that no longer exist on disk.
|
|
68
|
+
* @param {Record<string, { hash: string }>} cacheMap - Current cache map
|
|
69
|
+
* @param {string[]} currentPaths - Relative paths of files currently on disk
|
|
70
|
+
* @returns {Record<string, { hash: string }>} Cleaned cache map
|
|
71
|
+
*/
|
|
72
|
+
export function purgeDeleted(cacheMap, currentPaths) {
|
|
73
|
+
const pathSet = new Set(currentPaths);
|
|
74
|
+
const cleaned = {};
|
|
75
|
+
for (const [key, value] of Object.entries(cacheMap)) {
|
|
76
|
+
if (pathSet.has(key)) {
|
|
77
|
+
cleaned[key] = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return cleaned;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Auto-add .legacyver-cache/ to .gitignore if .gitignore exists
|
|
85
|
+
* and doesn't already contain the entry.
|
|
86
|
+
* @param {string} projectRoot - Root directory of the project being analyzed
|
|
87
|
+
*/
|
|
88
|
+
export function addToGitignore(projectRoot) {
|
|
89
|
+
const gitignorePath = join(projectRoot, '.gitignore');
|
|
90
|
+
if (!existsSync(gitignorePath)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
95
|
+
const entry = '.legacyver-cache/';
|
|
96
|
+
|
|
97
|
+
// Check if already present (exact line match)
|
|
98
|
+
const lines = content.split('\n');
|
|
99
|
+
if (lines.some((line) => line.trim() === entry || line.trim() === '.legacyver-cache')) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Append entry with a newline separator
|
|
104
|
+
const separator = content.endsWith('\n') ? '' : '\n';
|
|
105
|
+
writeFileSync(gitignorePath, content + separator + entry + '\n', 'utf-8');
|
|
106
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analyze command — main pipeline orchestrator.
|
|
3
|
+
* Calls Crawler -> Parser -> LLM Engine -> Renderer in sequence.
|
|
4
|
+
* Manages progress bar, aggregates errors, prints final summary.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { loadConfig, mergeWithFlags } from '../../utils/config.js';
|
|
9
|
+
import { logger } from '../../utils/logger.js';
|
|
10
|
+
import { LegacyverError } from '../../utils/errors.js';
|
|
11
|
+
import { createSpinner, createProgressBar, confirmPrompt, printSummary } from '../ui.js';
|
|
12
|
+
import { applyFlags } from '../flags.js';
|
|
13
|
+
import { crawl } from '../../crawler/index.js';
|
|
14
|
+
import { parseAll } from '../../parser/index.js';
|
|
15
|
+
import { buildPKG } from '../../parser/pkg-builder.js';
|
|
16
|
+
import { resolveCallGraph } from '../../parser/call-graph.js';
|
|
17
|
+
import { createLLMEngine } from '../../llm/index.js';
|
|
18
|
+
import { buildChunks } from '../../llm/chunker.js';
|
|
19
|
+
import { estimateCost } from '../../llm/cost-estimator.js';
|
|
20
|
+
import { createQueue } from '../../llm/queue.js';
|
|
21
|
+
import { render } from '../../renderer/index.js';
|
|
22
|
+
import { loadCache, saveCache, getCacheHits, purgeDeleted, addToGitignore } from '../../cache/index.js';
|
|
23
|
+
|
|
24
|
+
async function runAnalyze(targetPath, options) {
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
const errors = [];
|
|
27
|
+
|
|
28
|
+
// If dry-run without a path, just show cost estimate info and exit early
|
|
29
|
+
const effectivePath = targetPath || process.cwd();
|
|
30
|
+
|
|
31
|
+
// Load and merge config
|
|
32
|
+
const fileConfig = await loadConfig(effectivePath);
|
|
33
|
+
const config = mergeWithFlags(fileConfig, {
|
|
34
|
+
provider: options.provider,
|
|
35
|
+
model: options.model,
|
|
36
|
+
output: options.output,
|
|
37
|
+
format: options.format,
|
|
38
|
+
concurrency: options.concurrency ? parseInt(options.concurrency, 10) : undefined,
|
|
39
|
+
maxFileSizeKb: options.maxFileSize ? parseInt(options.maxFileSize, 10) : undefined,
|
|
40
|
+
incremental: options.incremental,
|
|
41
|
+
noConfirm: !options.confirm, // commander inverts --no-confirm
|
|
42
|
+
dryRun: options.dryRun,
|
|
43
|
+
ignore: options.ignore,
|
|
44
|
+
jsonSummary: options.jsonSummary,
|
|
45
|
+
verbose: options.verbose,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const resolvedTarget = resolve(effectivePath);
|
|
49
|
+
const resolvedOutput = resolve(config.output);
|
|
50
|
+
|
|
51
|
+
// Stage 1: Crawl
|
|
52
|
+
const crawlSpinner = createSpinner('Discovering files...');
|
|
53
|
+
crawlSpinner.start();
|
|
54
|
+
|
|
55
|
+
let manifest;
|
|
56
|
+
try {
|
|
57
|
+
manifest = await crawl(resolvedTarget, {
|
|
58
|
+
maxFileSizeKb: config.maxFileSizeKb,
|
|
59
|
+
ignore: config.ignore,
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
crawlSpinner.fail('File discovery failed');
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
crawlSpinner.succeed(`Found ${manifest.length} files`);
|
|
67
|
+
|
|
68
|
+
if (manifest.length === 0) {
|
|
69
|
+
logger.warn('No files found to analyze. Check your target path and ignore rules.');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Handle incremental cache
|
|
74
|
+
let cacheMap = {};
|
|
75
|
+
let filesToAnalyze = manifest;
|
|
76
|
+
let cachedCount = 0;
|
|
77
|
+
|
|
78
|
+
if (config.incremental) {
|
|
79
|
+
const cacheDir = resolve(resolvedTarget, '.legacyver-cache');
|
|
80
|
+
cacheMap = loadCache(cacheDir);
|
|
81
|
+
cacheMap = purgeDeleted(cacheMap, manifest.map((f) => f.relativePath));
|
|
82
|
+
const { hits, misses } = getCacheHits(manifest, cacheMap);
|
|
83
|
+
cachedCount = hits.length;
|
|
84
|
+
filesToAnalyze = misses;
|
|
85
|
+
if (cachedCount > 0) {
|
|
86
|
+
logger.info(`Cache: ${cachedCount} files unchanged, ${misses.length} files to analyze`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Stage 2: Parse
|
|
91
|
+
const parseSpinner = createSpinner('Parsing AST...');
|
|
92
|
+
parseSpinner.start();
|
|
93
|
+
|
|
94
|
+
let allFileFacts;
|
|
95
|
+
try {
|
|
96
|
+
allFileFacts = await parseAll(filesToAnalyze, resolvedTarget);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
parseSpinner.fail('AST parsing failed');
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build call graph and PKG
|
|
103
|
+
const resolvedFacts = resolveCallGraph(allFileFacts);
|
|
104
|
+
const pkg = buildPKG(resolvedFacts, resolvedTarget);
|
|
105
|
+
|
|
106
|
+
parseSpinner.succeed(`Parsed ${allFileFacts.length} files (${pkg.meta.totalFiles} in PKG)`);
|
|
107
|
+
|
|
108
|
+
// Stage 3: LLM Engine
|
|
109
|
+
const chunks = buildChunks(resolvedFacts, config, resolvedTarget);
|
|
110
|
+
|
|
111
|
+
// Cost estimation
|
|
112
|
+
const costEstimate = estimateCost(chunks, config.provider);
|
|
113
|
+
logger.info(`Estimated cost: $${costEstimate.estimatedCostUSD.toFixed(4)} (${costEstimate.totalInputTokens} input tokens)`);
|
|
114
|
+
|
|
115
|
+
if (config.dryRun) {
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(JSON.stringify(costEstimate, null, 2));
|
|
118
|
+
console.log('');
|
|
119
|
+
logger.info('Dry run complete. No LLM calls made.');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Create engine only when we actually need it (not for dry runs)
|
|
124
|
+
const engine = createLLMEngine(config);
|
|
125
|
+
|
|
126
|
+
// Confirm cost if over $0.10 and not --no-confirm
|
|
127
|
+
if (costEstimate.estimatedCostUSD > 0.10 && !config.noConfirm) {
|
|
128
|
+
const confirmed = await confirmPrompt(
|
|
129
|
+
`Estimated cost is $${costEstimate.estimatedCostUSD.toFixed(4)}. Proceed?`
|
|
130
|
+
);
|
|
131
|
+
if (!confirmed) {
|
|
132
|
+
logger.info('Aborted by user.');
|
|
133
|
+
process.exit(4);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Execute LLM calls
|
|
138
|
+
const progressBar = createProgressBar(chunks.length);
|
|
139
|
+
progressBar.start();
|
|
140
|
+
|
|
141
|
+
const queue = createQueue(engine, {
|
|
142
|
+
concurrency: config.concurrency,
|
|
143
|
+
onProgress: () => progressBar.increment(),
|
|
144
|
+
onError: (err, chunk) => {
|
|
145
|
+
errors.push({ file: chunk.relativePath, error: err.message });
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const docFragments = await queue.processAll(chunks);
|
|
150
|
+
progressBar.stop();
|
|
151
|
+
|
|
152
|
+
logger.success(`Generated documentation for ${docFragments.length} files`);
|
|
153
|
+
|
|
154
|
+
// Update cache
|
|
155
|
+
if (config.incremental) {
|
|
156
|
+
const cacheDir = resolve(resolvedTarget, '.legacyver-cache');
|
|
157
|
+
for (const file of filesToAnalyze) {
|
|
158
|
+
cacheMap[file.relativePath] = {
|
|
159
|
+
hash: file.hash,
|
|
160
|
+
generatedAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
saveCache(cacheDir, cacheMap);
|
|
164
|
+
addToGitignore(resolvedTarget);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Stage 4: Render
|
|
168
|
+
const renderSpinner = createSpinner('Rendering output...');
|
|
169
|
+
renderSpinner.start();
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await render(docFragments, pkg, {
|
|
173
|
+
format: config.format,
|
|
174
|
+
outputDir: resolvedOutput,
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
renderSpinner.fail('Rendering failed');
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
renderSpinner.succeed(`Output written to ${resolvedOutput}`);
|
|
182
|
+
|
|
183
|
+
// Summary
|
|
184
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
185
|
+
const stats = {
|
|
186
|
+
filesAnalyzed: docFragments.length,
|
|
187
|
+
filesSkipped: manifest.length - filesToAnalyze.length - cachedCount,
|
|
188
|
+
filesCached: cachedCount,
|
|
189
|
+
errors: errors.length,
|
|
190
|
+
inputTokens: costEstimate.totalInputTokens,
|
|
191
|
+
outputTokens: costEstimate.totalOutputTokens,
|
|
192
|
+
estimatedCost: costEstimate.estimatedCostUSD,
|
|
193
|
+
outputDir: resolvedOutput,
|
|
194
|
+
duration: `${duration}s`,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (config.jsonSummary) {
|
|
198
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
199
|
+
} else {
|
|
200
|
+
printSummary(stats);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (errors.length > 0) {
|
|
204
|
+
logger.warn(`${errors.length} file(s) had errors during analysis:`);
|
|
205
|
+
for (const e of errors) {
|
|
206
|
+
logger.warn(` ${e.file}: ${e.error}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function registerAnalyzeCommand(program) {
|
|
212
|
+
const cmd = program
|
|
213
|
+
.command('analyze [path]')
|
|
214
|
+
.description('Analyze a codebase and generate documentation');
|
|
215
|
+
|
|
216
|
+
applyFlags(cmd, [
|
|
217
|
+
'provider', 'model', 'output', 'format', 'concurrency',
|
|
218
|
+
'maxFileSize', 'incremental', 'noConfirm', 'dryRun',
|
|
219
|
+
'ignore', 'jsonSummary',
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
cmd.action(async (targetPath, options) => {
|
|
223
|
+
try {
|
|
224
|
+
await runAnalyze(targetPath, options);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err instanceof LegacyverError) {
|
|
227
|
+
logger.error(err.message);
|
|
228
|
+
if (err.suggestion) {
|
|
229
|
+
logger.info(`Suggestion: ${err.suggestion}`);
|
|
230
|
+
}
|
|
231
|
+
process.exit(err.exitCode || 1);
|
|
232
|
+
}
|
|
233
|
+
logger.error('Unexpected error:', err.message);
|
|
234
|
+
if (process.env.DEBUG || options.verbose) {
|
|
235
|
+
console.error(err.stack);
|
|
236
|
+
}
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache subcommand — cache management utilities.
|
|
3
|
+
* Supports `cache clear` to delete .legacyver-cache/ directory.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { rmSync, existsSync } from 'node:fs';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { logger } from '../../utils/logger.js';
|
|
9
|
+
|
|
10
|
+
export function registerCacheCommand(program) {
|
|
11
|
+
const cache = program
|
|
12
|
+
.command('cache')
|
|
13
|
+
.description('Manage the incremental analysis cache');
|
|
14
|
+
|
|
15
|
+
cache
|
|
16
|
+
.command('clear')
|
|
17
|
+
.description('Delete the .legacyver-cache/ directory')
|
|
18
|
+
.option('--dir <path>', 'Project directory containing the cache', '.')
|
|
19
|
+
.action((options) => {
|
|
20
|
+
const cacheDir = resolve(options.dir, '.legacyver-cache');
|
|
21
|
+
|
|
22
|
+
if (!existsSync(cacheDir)) {
|
|
23
|
+
logger.info('No cache directory found. Nothing to clear.');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
29
|
+
logger.success(`Deleted ${cacheDir}`);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
logger.error(`Failed to delete cache: ${err.message}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|