@ikunin/sprintpilot 1.0.2 → 1.0.4
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/_Sprintpilot/Sprintpilot.md +17 -1
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/autopilot/config.yaml +18 -0
- package/_Sprintpilot/scripts/scan.js +457 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +206 -38
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +22 -15
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +47 -24
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +21 -21
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +34 -22
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +41 -43
- package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +1 -1
- package/lib/commands/install.js +247 -3
- package/lib/prompts.js +22 -0
- package/lib/substitute.js +1 -1
- package/package.json +4 -1
|
@@ -25,6 +25,22 @@ When Sprintpilot or the git addon is active:
|
|
|
25
25
|
- Each story gets its own isolated worktree and branch (`story/<key>`)
|
|
26
26
|
- Commits use conventional format: `feat(<epic>): <title> (<key>)`
|
|
27
27
|
|
|
28
|
+
### Autopilot configuration
|
|
29
|
+
|
|
30
|
+
Edit `_Sprintpilot/modules/autopilot/config.yaml`:
|
|
31
|
+
|
|
32
|
+
| Setting | Default | Values | Purpose |
|
|
33
|
+
|---------|---------|--------|---------|
|
|
34
|
+
| `autopilot.session_story_limit` | `3` | integer ≥ 0 | Stories fully implemented per autopilot run before checkpoint. `0` = unlimited. |
|
|
35
|
+
| `autopilot.retrospective_mode` | `auto` | `auto` / `stop` / `skip` | How epic-end retrospectives are handled (see below). |
|
|
36
|
+
|
|
37
|
+
`retrospective_mode` options:
|
|
38
|
+
- **`auto`** *(default)* — autopilot writes a deterministic retrospective artifact from `sprint-status.yaml` + `decision-log.yaml`, then continues. Single pass, no external skill call, safe under every CLI.
|
|
39
|
+
- **`stop`** — autopilot pauses at epic completion. Run `/bmad-retrospective` interactively, then re-run `/sprint-autopilot-on` to resume. Use this when you want the full multi-persona discussion as part of your process.
|
|
40
|
+
- **`skip`** — no retrospective artifact is written. **Not recommended** — you lose the epic-level learning record.
|
|
41
|
+
|
|
42
|
+
Both settings are prompted during `sprintpilot install` (interactive mode) with existing values as defaults, so reinstalls preserve your choices.
|
|
43
|
+
|
|
28
44
|
---
|
|
29
45
|
|
|
30
46
|
## Full skill reference by lifecycle phase
|
|
@@ -59,7 +75,7 @@ See **Mandatory sequence per story** section below.
|
|
|
59
75
|
|
|
60
76
|
| Skill | When to use |
|
|
61
77
|
|-------|-------------|
|
|
62
|
-
| `bmad-retrospective` | Run after all stories in an epic are `done`; saves lessons, marks epic `done` |
|
|
78
|
+
| `bmad-retrospective` | Run after all stories in an epic are `done`; saves lessons, marks epic `done`. Under autopilot this is driven by `autopilot.retrospective_mode` (`auto` inline, `stop` to pause for interactive use, or `skip`). |
|
|
63
79
|
|
|
64
80
|
---
|
|
65
81
|
|
|
@@ -18,3 +18,21 @@ autopilot:
|
|
|
18
18
|
#
|
|
19
19
|
# Set 0 to disable the limit and run until the sprint is complete.
|
|
20
20
|
session_story_limit: 3
|
|
21
|
+
|
|
22
|
+
# Retrospective handling at epic completion.
|
|
23
|
+
#
|
|
24
|
+
# - "auto" (default): autopilot generates a deterministic retrospective
|
|
25
|
+
# from sprint-status.yaml + decision-log.yaml, then continues with the
|
|
26
|
+
# next epic. Safe under every CLI including Gemini CLI — no external
|
|
27
|
+
# skill call, no persona simulation, single pass.
|
|
28
|
+
# - "stop": autopilot pauses at epic completion so you can run
|
|
29
|
+
# /bmad-retrospective interactively. Re-run /sprint-autopilot-on to
|
|
30
|
+
# resume with the next epic.
|
|
31
|
+
# - "skip": autopilot skips the retrospective entirely and continues.
|
|
32
|
+
# NOT RECOMMENDED — you lose the epic-level learning record.
|
|
33
|
+
#
|
|
34
|
+
# Background: the external bmad-retrospective skill can enter a
|
|
35
|
+
# multi-persona discussion loop when driven by autopilot on some CLIs,
|
|
36
|
+
# which exhausts the token/memory budget. That's why "auto" generates
|
|
37
|
+
# the artifact inline instead of invoking the external skill.
|
|
38
|
+
retrospective_mode: auto
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform codebase scanner.
|
|
5
|
+
*
|
|
6
|
+
* Replaces bash pipelines like `find ... -exec wc -l {} + | sort -rn | head -N`
|
|
7
|
+
* so sprintpilot skills work on Windows PowerShell / cmd / Gemini CLI, not just bash.
|
|
8
|
+
*
|
|
9
|
+
* Subcommands:
|
|
10
|
+
* files List files matching include globs, excluding ignore globs.
|
|
11
|
+
* Flags: --include, --exclude, --root, --limit, --count
|
|
12
|
+
* largest Top N files by line count.
|
|
13
|
+
* Flags: --include, --exclude, --root, --limit (default 10)
|
|
14
|
+
* loc Total line count across matched files.
|
|
15
|
+
* Flags: --include, --exclude, --root
|
|
16
|
+
* extensions Extension frequency histogram, descending.
|
|
17
|
+
* Flags: --exclude, --root, --limit (default 20)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
23
|
+
const log = require('../lib/runtime/log');
|
|
24
|
+
|
|
25
|
+
const DEFAULT_EXCLUDES = [
|
|
26
|
+
'node_modules',
|
|
27
|
+
'.git',
|
|
28
|
+
'vendor',
|
|
29
|
+
'target',
|
|
30
|
+
'dist',
|
|
31
|
+
'build',
|
|
32
|
+
'.next',
|
|
33
|
+
'.nuxt',
|
|
34
|
+
'.svelte-kit',
|
|
35
|
+
'coverage',
|
|
36
|
+
'.turbo',
|
|
37
|
+
'.cache',
|
|
38
|
+
'__pycache__',
|
|
39
|
+
'.venv',
|
|
40
|
+
'venv',
|
|
41
|
+
'.worktrees',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function help() {
|
|
45
|
+
log.out(
|
|
46
|
+
'Usage: scan.js <files|largest|loc|extensions> [--include <globs>] [--exclude <globs>] [--root <path>] [--limit <N>] [--count]',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Split a comma-delimited list, but keep commas inside {a,b} brace groups intact.
|
|
51
|
+
function splitList(value) {
|
|
52
|
+
if (value === undefined || value === null || value === true) return [];
|
|
53
|
+
const s = String(value);
|
|
54
|
+
const out = [];
|
|
55
|
+
let buf = '';
|
|
56
|
+
let depth = 0;
|
|
57
|
+
for (let i = 0; i < s.length; i++) {
|
|
58
|
+
const c = s[i];
|
|
59
|
+
if (c === '{') depth++;
|
|
60
|
+
else if (c === '}') depth = Math.max(0, depth - 1);
|
|
61
|
+
if (c === ',' && depth === 0) {
|
|
62
|
+
if (buf.trim()) out.push(buf.trim());
|
|
63
|
+
buf = '';
|
|
64
|
+
} else {
|
|
65
|
+
buf += c;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (buf.trim()) out.push(buf.trim());
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Find matching closing brace, respecting nesting. Returns -1 if unterminated.
|
|
73
|
+
function findBraceClose(glob, start) {
|
|
74
|
+
let depth = 1;
|
|
75
|
+
for (let i = start + 1; i < glob.length; i++) {
|
|
76
|
+
const c = glob[i];
|
|
77
|
+
if (c === '\\' && i + 1 < glob.length) {
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (c === '{') depth++;
|
|
82
|
+
else if (c === '}') {
|
|
83
|
+
depth--;
|
|
84
|
+
if (depth === 0) return i;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Split a brace group's body on commas at depth 0 (so nested braces stay intact).
|
|
91
|
+
function splitBraceAlts(body) {
|
|
92
|
+
const parts = [];
|
|
93
|
+
let buf = '';
|
|
94
|
+
let depth = 0;
|
|
95
|
+
for (let i = 0; i < body.length; i++) {
|
|
96
|
+
const c = body[i];
|
|
97
|
+
if (c === '\\' && i + 1 < body.length) {
|
|
98
|
+
buf += c + body[i + 1];
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (c === '{') depth++;
|
|
103
|
+
else if (c === '}') depth = Math.max(0, depth - 1);
|
|
104
|
+
if (c === ',' && depth === 0) {
|
|
105
|
+
parts.push(buf);
|
|
106
|
+
buf = '';
|
|
107
|
+
} else {
|
|
108
|
+
buf += c;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
parts.push(buf);
|
|
112
|
+
return parts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Convert a glob pattern into a RegExp.
|
|
116
|
+
// Supports: * (any chars except /), ** (any chars incl. /), ? (single non-/),
|
|
117
|
+
// {a,b} alternation (nestable), and literal path segments.
|
|
118
|
+
// Matching is against forward-slash paths.
|
|
119
|
+
function globToRegex(glob) {
|
|
120
|
+
let re = '';
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < glob.length) {
|
|
123
|
+
const c = glob[i];
|
|
124
|
+
if (c === '\\') {
|
|
125
|
+
if (i + 1 >= glob.length) {
|
|
126
|
+
// Trailing lone backslash — emit as a literal backslash.
|
|
127
|
+
re += '\\\\';
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Literal escape: pass the next char through verbatim.
|
|
132
|
+
const next = glob[i + 1];
|
|
133
|
+
re += '.+^$()|[]{}?*\\'.includes(next) ? '\\' + next : next;
|
|
134
|
+
i += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (c === '*') {
|
|
138
|
+
if (glob[i + 1] === '*') {
|
|
139
|
+
re += '.*';
|
|
140
|
+
i += 2;
|
|
141
|
+
if (glob[i] === '/') i++; // consume trailing slash of ** segment
|
|
142
|
+
} else {
|
|
143
|
+
re += '[^/]*';
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (c === '?') {
|
|
149
|
+
re += '[^/]';
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (c === '{') {
|
|
154
|
+
const end = findBraceClose(glob, i);
|
|
155
|
+
if (end === -1) {
|
|
156
|
+
// Unterminated brace — treat as literal.
|
|
157
|
+
re += '\\{';
|
|
158
|
+
i++;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const alts = splitBraceAlts(glob.slice(i + 1, end));
|
|
162
|
+
const altRegexes = alts.map((p) => globToRegex(p).source.slice(1, -1));
|
|
163
|
+
re += `(?:${altRegexes.join('|')})`;
|
|
164
|
+
i = end + 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if ('.+^$()|[]'.includes(c)) {
|
|
168
|
+
re += '\\' + c;
|
|
169
|
+
i++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
re += c;
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
return new RegExp('^' + re + '$');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function toPosix(p) {
|
|
179
|
+
return p.split(path.sep).join('/');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Compile a pattern into { raw, re, pathAnchored }.
|
|
183
|
+
// pathAnchored = true if the pattern contains a path separator; such patterns
|
|
184
|
+
// only match the full relative path. Basename-only patterns (no '/') match
|
|
185
|
+
// both the full path and the basename, so "*.ts" works at any depth.
|
|
186
|
+
function compilePatterns(patterns) {
|
|
187
|
+
return patterns.map((p) => ({
|
|
188
|
+
raw: p,
|
|
189
|
+
re: globToRegex(p),
|
|
190
|
+
pathAnchored: p.includes('/'),
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function matchesAny(relPath, compiled) {
|
|
195
|
+
if (compiled.length === 0) return false;
|
|
196
|
+
const basename = relPath.slice(relPath.lastIndexOf('/') + 1);
|
|
197
|
+
for (const { re, pathAnchored } of compiled) {
|
|
198
|
+
if (re.test(relPath)) return true;
|
|
199
|
+
if (!pathAnchored && re.test(basename)) return true;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isExcludedDir(name, excludeBasenames) {
|
|
205
|
+
return excludeBasenames.has(name);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function matchesExcludePath(relPath, compiled) {
|
|
209
|
+
return matchesAny(relPath, compiled);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Resolve a dirent to { kind: 'file' | 'dir' | 'other' }, following symlinks
|
|
213
|
+
// through stat(). Returns 'other' on broken links or errors.
|
|
214
|
+
function classifyEntry(fullPath, entry) {
|
|
215
|
+
if (entry.isFile()) return 'file';
|
|
216
|
+
if (entry.isDirectory()) return 'dir';
|
|
217
|
+
if (entry.isSymbolicLink()) {
|
|
218
|
+
try {
|
|
219
|
+
const st = fs.statSync(fullPath);
|
|
220
|
+
if (st.isFile()) return 'file';
|
|
221
|
+
if (st.isDirectory()) return 'dir';
|
|
222
|
+
} catch {
|
|
223
|
+
return 'other';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return 'other';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isWithinRoot(real, rootReal) {
|
|
230
|
+
if (real === rootReal) return true;
|
|
231
|
+
const prefix = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
232
|
+
return real.startsWith(prefix);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Walk directory tree, yielding files that match includes and not excludes.
|
|
236
|
+
// Follows symlinks (like GNU find's default) but:
|
|
237
|
+
// - breaks cycles by tracking the realpath of every directory visited
|
|
238
|
+
// - refuses to traverse symlinks that escape the --root boundary
|
|
239
|
+
function* walk(root, includes, excludes, excludeBasenames) {
|
|
240
|
+
const visited = new Set();
|
|
241
|
+
let rootReal;
|
|
242
|
+
try {
|
|
243
|
+
rootReal = fs.realpathSync(root);
|
|
244
|
+
} catch {
|
|
245
|
+
rootReal = path.resolve(root);
|
|
246
|
+
}
|
|
247
|
+
visited.add(rootReal);
|
|
248
|
+
|
|
249
|
+
const stack = [root];
|
|
250
|
+
while (stack.length) {
|
|
251
|
+
const dir = stack.pop();
|
|
252
|
+
let entries;
|
|
253
|
+
try {
|
|
254
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
255
|
+
} catch {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
const full = path.join(dir, entry.name);
|
|
260
|
+
const rel = toPosix(path.relative(root, full));
|
|
261
|
+
const kind = classifyEntry(full, entry);
|
|
262
|
+
if (kind === 'dir') {
|
|
263
|
+
if (isExcludedDir(entry.name, excludeBasenames)) continue;
|
|
264
|
+
if (matchesExcludePath(rel, excludes)) continue;
|
|
265
|
+
let real;
|
|
266
|
+
try {
|
|
267
|
+
real = fs.realpathSync(full);
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (visited.has(real)) continue;
|
|
272
|
+
if (!isWithinRoot(real, rootReal)) continue; // refuse symlinks that escape root
|
|
273
|
+
visited.add(real);
|
|
274
|
+
stack.push(full);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (kind !== 'file') continue;
|
|
278
|
+
if (matchesExcludePath(rel, excludes)) continue;
|
|
279
|
+
if (includes.length > 0 && !matchesAny(rel, includes)) continue;
|
|
280
|
+
// For symlinked files, verify the target is within root.
|
|
281
|
+
if (entry.isSymbolicLink()) {
|
|
282
|
+
try {
|
|
283
|
+
const fileReal = fs.realpathSync(full);
|
|
284
|
+
if (!isWithinRoot(fileReal, rootReal)) continue;
|
|
285
|
+
} catch {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
yield rel;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readLineCount(fullPath) {
|
|
295
|
+
let fd;
|
|
296
|
+
try {
|
|
297
|
+
fd = fs.openSync(fullPath, 'r');
|
|
298
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
299
|
+
let count = 0;
|
|
300
|
+
let bytesRead;
|
|
301
|
+
let lastByte = null;
|
|
302
|
+
let total = 0;
|
|
303
|
+
while ((bytesRead = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
|
|
304
|
+
total += bytesRead;
|
|
305
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
306
|
+
if (buf[i] === 0x0a) count++;
|
|
307
|
+
}
|
|
308
|
+
lastByte = buf[bytesRead - 1];
|
|
309
|
+
}
|
|
310
|
+
// Count the final line if the file is non-empty and doesn't end with \n
|
|
311
|
+
if (total > 0 && lastByte !== 0x0a) count++;
|
|
312
|
+
return count;
|
|
313
|
+
} catch {
|
|
314
|
+
return 0;
|
|
315
|
+
} finally {
|
|
316
|
+
if (fd !== undefined) {
|
|
317
|
+
try {
|
|
318
|
+
fs.closeSync(fd);
|
|
319
|
+
} catch {
|
|
320
|
+
/* ignore */
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function resolveRoot(opts) {
|
|
327
|
+
const root = opts.root ? path.resolve(opts.root) : process.cwd();
|
|
328
|
+
try {
|
|
329
|
+
if (!fs.statSync(root).isDirectory()) {
|
|
330
|
+
log.fail(`root is not a directory: ${root}`);
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
log.fail(`root does not exist: ${root}`);
|
|
334
|
+
}
|
|
335
|
+
return root;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildExcludes(extra) {
|
|
339
|
+
const list = [...DEFAULT_EXCLUDES, ...extra];
|
|
340
|
+
// Patterns: match the basename of a directory OR any path containing it.
|
|
341
|
+
const patterns = [];
|
|
342
|
+
const basenames = new Set();
|
|
343
|
+
for (const item of list) {
|
|
344
|
+
if (item.includes('/') || item.includes('*')) {
|
|
345
|
+
patterns.push(item);
|
|
346
|
+
} else {
|
|
347
|
+
basenames.add(item);
|
|
348
|
+
patterns.push(`**/${item}/**`);
|
|
349
|
+
patterns.push(item);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { compiled: compilePatterns(patterns), basenames };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function cmdFiles(opts) {
|
|
356
|
+
const root = resolveRoot(opts);
|
|
357
|
+
const includes = compilePatterns(splitList(opts.include));
|
|
358
|
+
const { compiled: excludes, basenames } = buildExcludes(splitList(opts.exclude));
|
|
359
|
+
const limit = opts.limit ? Number(opts.limit) : 0;
|
|
360
|
+
const count = opts.count === true || opts.count === 'true';
|
|
361
|
+
|
|
362
|
+
let n = 0;
|
|
363
|
+
const out = [];
|
|
364
|
+
for (const rel of walk(root, includes, excludes, basenames)) {
|
|
365
|
+
n++;
|
|
366
|
+
if (!count) {
|
|
367
|
+
out.push(rel);
|
|
368
|
+
if (limit > 0 && out.length >= limit) break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (count) {
|
|
372
|
+
log.out(String(n));
|
|
373
|
+
} else {
|
|
374
|
+
for (const p of out) log.out(p);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function cmdLargest(opts) {
|
|
379
|
+
const root = resolveRoot(opts);
|
|
380
|
+
const includes = compilePatterns(splitList(opts.include));
|
|
381
|
+
const { compiled: excludes, basenames } = buildExcludes(splitList(opts.exclude));
|
|
382
|
+
const limit = opts.limit ? Number(opts.limit) : 10;
|
|
383
|
+
|
|
384
|
+
const heap = []; // simple array; N is small so O(files * log N) is fine
|
|
385
|
+
for (const rel of walk(root, includes, excludes, basenames)) {
|
|
386
|
+
const full = path.join(root, rel);
|
|
387
|
+
const lines = readLineCount(full);
|
|
388
|
+
heap.push({ lines, path: rel });
|
|
389
|
+
}
|
|
390
|
+
heap.sort((a, b) => b.lines - a.lines);
|
|
391
|
+
for (const item of heap.slice(0, limit)) {
|
|
392
|
+
log.out(`${item.lines}\t${item.path}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function cmdLoc(opts) {
|
|
397
|
+
const root = resolveRoot(opts);
|
|
398
|
+
const includes = compilePatterns(splitList(opts.include));
|
|
399
|
+
const { compiled: excludes, basenames } = buildExcludes(splitList(opts.exclude));
|
|
400
|
+
|
|
401
|
+
let total = 0;
|
|
402
|
+
let fileCount = 0;
|
|
403
|
+
for (const rel of walk(root, includes, excludes, basenames)) {
|
|
404
|
+
total += readLineCount(path.join(root, rel));
|
|
405
|
+
fileCount++;
|
|
406
|
+
}
|
|
407
|
+
log.out(`${total}\t${fileCount}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function cmdExtensions(opts) {
|
|
411
|
+
const root = resolveRoot(opts);
|
|
412
|
+
const { compiled: excludes, basenames } = buildExcludes(splitList(opts.exclude));
|
|
413
|
+
const limit = opts.limit ? Number(opts.limit) : 20;
|
|
414
|
+
|
|
415
|
+
const counts = new Map();
|
|
416
|
+
for (const rel of walk(root, [], excludes, basenames)) {
|
|
417
|
+
const base = rel.split('/').pop();
|
|
418
|
+
const dot = base.lastIndexOf('.');
|
|
419
|
+
const ext = dot > 0 ? base.slice(dot + 1) : '(no-ext)';
|
|
420
|
+
counts.set(ext, (counts.get(ext) || 0) + 1);
|
|
421
|
+
}
|
|
422
|
+
const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
|
423
|
+
for (const [ext, n] of rows) {
|
|
424
|
+
log.out(`${n}\t${ext}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function main() {
|
|
429
|
+
const { opts, positional } = parseArgs(process.argv.slice(2), {
|
|
430
|
+
booleanFlags: ['count'],
|
|
431
|
+
});
|
|
432
|
+
if (opts.help || positional.length === 0) {
|
|
433
|
+
help();
|
|
434
|
+
process.exit(opts.help ? 0 : 1);
|
|
435
|
+
}
|
|
436
|
+
const cmd = positional[0];
|
|
437
|
+
switch (cmd) {
|
|
438
|
+
case 'files':
|
|
439
|
+
cmdFiles(opts);
|
|
440
|
+
break;
|
|
441
|
+
case 'largest':
|
|
442
|
+
cmdLargest(opts);
|
|
443
|
+
break;
|
|
444
|
+
case 'loc':
|
|
445
|
+
cmdLoc(opts);
|
|
446
|
+
break;
|
|
447
|
+
case 'extensions':
|
|
448
|
+
cmdExtensions(opts);
|
|
449
|
+
break;
|
|
450
|
+
default:
|
|
451
|
+
log.error(`Unknown subcommand: ${cmd}`);
|
|
452
|
+
help();
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
main();
|