@ikunin/sprintpilot 2.0.9 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -10
- package/_Sprintpilot/Sprintpilot.md +1 -1
- package/_Sprintpilot/bin/autopilot.js +581 -0
- package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
- package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
- package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
- package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
- package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
- package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
- package/_Sprintpilot/lib/orchestrator/land.js +155 -0
- package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
- package/_Sprintpilot/lib/orchestrator/report.js +95 -0
- package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
- package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +26 -0
- package/_Sprintpilot/scripts/agent-adapter.js +4 -5
- package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
- package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
- package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
- package/_Sprintpilot/scripts/land-this-pr.js +110 -0
- package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
- package/_Sprintpilot/scripts/log-timing.js +12 -3
- package/_Sprintpilot/scripts/merge-shards.js +32 -12
- package/_Sprintpilot/scripts/post-green-gates.js +187 -0
- package/_Sprintpilot/scripts/preflight-merge.js +2 -1
- package/_Sprintpilot/scripts/resolve-dag.js +3 -1
- package/_Sprintpilot/scripts/scan.js +109 -13
- package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
- package/_Sprintpilot/scripts/state-shard.js +8 -1
- package/_Sprintpilot/scripts/summarize-timings.js +30 -12
- package/_Sprintpilot/scripts/with-retry.js +17 -5
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +10 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +2 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +7 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +7 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +7 -0
- package/lib/core/update-check.js +11 -1
- package/package.json +1 -1
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +0 -1388
|
@@ -103,7 +103,10 @@ function validatePhase(s) {
|
|
|
103
103
|
|
|
104
104
|
function validateAction(a) {
|
|
105
105
|
if (!VALID_ACTIONS.includes(a)) {
|
|
106
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: `invalid action '${a}': must be one of ${VALID_ACTIONS.join(', ')}`,
|
|
109
|
+
};
|
|
107
110
|
}
|
|
108
111
|
return { ok: true, value: a };
|
|
109
112
|
}
|
|
@@ -430,10 +433,16 @@ function main() {
|
|
|
430
433
|
const r = markPhase(projectRoot, story.value, phase.value, meta.value);
|
|
431
434
|
// Emit a brief JSON line so callers can log the duration if useful.
|
|
432
435
|
// Stdout is intentionally separate from the per-story shard.
|
|
433
|
-
process.stdout.write(
|
|
436
|
+
process.stdout.write(
|
|
437
|
+
`${JSON.stringify({ marked: phase.value, prev_phase: r.prev_phase, duration_ms: r.duration_ms })}\n`,
|
|
438
|
+
);
|
|
434
439
|
return;
|
|
435
440
|
}
|
|
436
|
-
appendLine(
|
|
441
|
+
appendLine(
|
|
442
|
+
projectRoot,
|
|
443
|
+
story.value,
|
|
444
|
+
buildEntry(action.value, story.value, phase.value, meta.value),
|
|
445
|
+
);
|
|
437
446
|
} catch (e) {
|
|
438
447
|
log.error(`timing write failed: ${e.message}`);
|
|
439
448
|
process.exit(1);
|
|
@@ -115,7 +115,9 @@ function acquireMergeLock(projectRoot) {
|
|
|
115
115
|
try {
|
|
116
116
|
const st = fs.statSync(file);
|
|
117
117
|
if (Date.now() - st.mtimeMs > STALE_LOCK_AGE_MS) {
|
|
118
|
-
log.warn(
|
|
118
|
+
log.warn(
|
|
119
|
+
`merge-shards: removing stale lock ${file} (older than ${STALE_LOCK_AGE_MS}ms)`,
|
|
120
|
+
);
|
|
119
121
|
fs.unlinkSync(file);
|
|
120
122
|
continue;
|
|
121
123
|
}
|
|
@@ -201,11 +203,7 @@ function shardUnchanged(file, snapshot) {
|
|
|
201
203
|
if (!snapshot) return false;
|
|
202
204
|
try {
|
|
203
205
|
const st = fs.statSync(file);
|
|
204
|
-
return
|
|
205
|
-
st.mtimeMs === snapshot.mtime &&
|
|
206
|
-
st.size === snapshot.size &&
|
|
207
|
-
st.ino === snapshot.ino
|
|
208
|
-
);
|
|
206
|
+
return st.mtimeMs === snapshot.mtime && st.size === snapshot.size && st.ino === snapshot.ino;
|
|
209
207
|
} catch {
|
|
210
208
|
return false;
|
|
211
209
|
}
|
|
@@ -421,7 +419,11 @@ function composeStateYaml(stateMerge) {
|
|
|
421
419
|
if (stateMerge.corrupt.length + stateMerge.invalid.length > 0) {
|
|
422
420
|
doc.shard_problems = [
|
|
423
421
|
...stateMerge.corrupt.map((c) => ({ story: c.story, kind: 'parse-error', detail: c.error })),
|
|
424
|
-
...stateMerge.invalid.map((c) => ({
|
|
422
|
+
...stateMerge.invalid.map((c) => ({
|
|
423
|
+
story: c.story,
|
|
424
|
+
kind: 'invalid-shape',
|
|
425
|
+
detail: c.reason,
|
|
426
|
+
})),
|
|
425
427
|
];
|
|
426
428
|
}
|
|
427
429
|
return `${yamlDump(doc)}\n`;
|
|
@@ -435,8 +437,16 @@ function composeDecisionYaml(decisionMerge) {
|
|
|
435
437
|
};
|
|
436
438
|
if (decisionMerge.corrupt.length + decisionMerge.invalid.length > 0) {
|
|
437
439
|
doc.shard_problems = [
|
|
438
|
-
...decisionMerge.corrupt.map((c) => ({
|
|
439
|
-
|
|
440
|
+
...decisionMerge.corrupt.map((c) => ({
|
|
441
|
+
story: c.story,
|
|
442
|
+
kind: 'parse-error',
|
|
443
|
+
detail: c.error,
|
|
444
|
+
})),
|
|
445
|
+
...decisionMerge.invalid.map((c) => ({
|
|
446
|
+
story: c.story,
|
|
447
|
+
kind: 'invalid-shape',
|
|
448
|
+
detail: c.reason,
|
|
449
|
+
})),
|
|
440
450
|
];
|
|
441
451
|
}
|
|
442
452
|
return `${yamlDump(doc)}\n`;
|
|
@@ -460,7 +470,13 @@ function merge(projectRoot, { layerId, archive, dryRun } = {}) {
|
|
|
460
470
|
archivedCorrupt.push({ kind: 'state', story: c.story, ...arch });
|
|
461
471
|
}
|
|
462
472
|
for (const c of decisions.corrupt.concat(decisions.invalid)) {
|
|
463
|
-
const arch = archiveCorrupt(
|
|
473
|
+
const arch = archiveCorrupt(
|
|
474
|
+
projectRoot,
|
|
475
|
+
'decision-log',
|
|
476
|
+
c.story,
|
|
477
|
+
c.file,
|
|
478
|
+
c.error || c.reason,
|
|
479
|
+
);
|
|
464
480
|
archivedCorrupt.push({ kind: 'decision-log', story: c.story, ...arch });
|
|
465
481
|
}
|
|
466
482
|
}
|
|
@@ -468,8 +484,12 @@ function merge(projectRoot, { layerId, archive, dryRun } = {}) {
|
|
|
468
484
|
const stateBody = composeStateYaml(state);
|
|
469
485
|
const decisionBody = composeDecisionYaml(decisions);
|
|
470
486
|
|
|
471
|
-
const stateWrite = writeAuthoritative(projectRoot, 'autopilot-state.yaml', stateBody, {
|
|
472
|
-
|
|
487
|
+
const stateWrite = writeAuthoritative(projectRoot, 'autopilot-state.yaml', stateBody, {
|
|
488
|
+
dryRun,
|
|
489
|
+
});
|
|
490
|
+
const decisionWrite = writeAuthoritative(projectRoot, 'decision-log.yaml', decisionBody, {
|
|
491
|
+
dryRun,
|
|
492
|
+
});
|
|
473
493
|
|
|
474
494
|
let archiveDir = null;
|
|
475
495
|
let archiveSkipped = [];
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// post-green-gates.js — composed post-GREEN quality pipeline.
|
|
4
|
+
//
|
|
5
|
+
// Called by the orchestrator after a `bmad-dev-story` GREEN phase
|
|
6
|
+
// completes verify. Runs three gates in order; first failing gate causes
|
|
7
|
+
// non-zero exit with a structured JSON report.
|
|
8
|
+
//
|
|
9
|
+
// Gates:
|
|
10
|
+
// 1. lint-changed.js — biome/eslint on changed files only
|
|
11
|
+
// 2. lint-test-pitfalls.js — LLM-test pitfalls (only on test files)
|
|
12
|
+
// 3. scan.js (ci-parity) — search for obvious CI-only failure modes
|
|
13
|
+
// (envs hard-coded to local-only assumptions)
|
|
14
|
+
//
|
|
15
|
+
// Usage:
|
|
16
|
+
// post-green-gates.js [--json] [--changed-files <path>] [--project-root <path>]
|
|
17
|
+
// --changed-files: path to a newline-delimited list of changed files
|
|
18
|
+
// (default: derive from `git diff --name-only HEAD`)
|
|
19
|
+
//
|
|
20
|
+
// Each gate runs in a child process via execFileSync. Argv-only — no shell.
|
|
21
|
+
|
|
22
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
27
|
+
const log = require('../lib/runtime/log');
|
|
28
|
+
|
|
29
|
+
function help() {
|
|
30
|
+
log.out(
|
|
31
|
+
[
|
|
32
|
+
'Usage: post-green-gates.js [--json] [--changed-files <path>] [--project-root <path>]',
|
|
33
|
+
'',
|
|
34
|
+
'Runs in order:',
|
|
35
|
+
' 1. lint-changed.js — formatter + linter on changed files',
|
|
36
|
+
' 2. lint-test-pitfalls.js — LLM-test pitfall scan (test files only)',
|
|
37
|
+
' 3. scan.js (ci-parity) — CI-only failure mode scan',
|
|
38
|
+
].join('\n'),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listChangedFiles(projectRoot, override) {
|
|
43
|
+
if (override) {
|
|
44
|
+
return fs
|
|
45
|
+
.readFileSync(override, 'utf8')
|
|
46
|
+
.split(/\r?\n/)
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter((s) => s.length > 0);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const out = execFileSync('git', ['diff', '--name-only', 'HEAD'], {
|
|
52
|
+
cwd: projectRoot,
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
});
|
|
55
|
+
return out
|
|
56
|
+
.split(/\r?\n/)
|
|
57
|
+
.map((s) => s.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
} catch (_e) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runGate(name, command, args, projectRoot) {
|
|
65
|
+
const r = spawnSync(command, args, { cwd: projectRoot, encoding: 'utf8' });
|
|
66
|
+
return {
|
|
67
|
+
gate: name,
|
|
68
|
+
ok: r.status === 0,
|
|
69
|
+
exit_code: r.status === null ? -1 : r.status,
|
|
70
|
+
stdout: r.stdout || '',
|
|
71
|
+
stderr: r.stderr || '',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isTestFile(p) {
|
|
76
|
+
return /\.test\.(ts|tsx|js|jsx|mts)$/.test(p) || /\.spec\.(ts|tsx|js|jsx|mts)$/.test(p);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isJsTsFile(p) {
|
|
80
|
+
return /\.(ts|tsx|js|jsx|mts|cjs)$/.test(p);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function main(argv) {
|
|
84
|
+
const { opts } = parseArgs(argv, { booleanFlags: ['json', 'help'] });
|
|
85
|
+
if (opts.help) {
|
|
86
|
+
help();
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
const projectRoot = path.resolve(opts['project-root'] || process.cwd());
|
|
90
|
+
const changed = listChangedFiles(projectRoot, opts['changed-files']);
|
|
91
|
+
const jsTs = changed.filter(isJsTsFile);
|
|
92
|
+
const testFiles = changed.filter(isTestFile);
|
|
93
|
+
|
|
94
|
+
const gates = [];
|
|
95
|
+
|
|
96
|
+
// Gate 1: lint-changed.
|
|
97
|
+
const lintChangedPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lint-changed.js');
|
|
98
|
+
if (fs.existsSync(lintChangedPath) && jsTs.length > 0) {
|
|
99
|
+
gates.push(
|
|
100
|
+
runGate(
|
|
101
|
+
'lint-changed',
|
|
102
|
+
'node',
|
|
103
|
+
[lintChangedPath, '--project-root', projectRoot],
|
|
104
|
+
projectRoot,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
gates.push({
|
|
109
|
+
gate: 'lint-changed',
|
|
110
|
+
ok: true,
|
|
111
|
+
exit_code: 0,
|
|
112
|
+
stdout: 'skipped (no JS/TS changes or script missing)',
|
|
113
|
+
stderr: '',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Gate 2: lint-test-pitfalls — only run on test files.
|
|
118
|
+
const pitfallsPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lint-test-pitfalls.js');
|
|
119
|
+
if (fs.existsSync(pitfallsPath) && testFiles.length > 0) {
|
|
120
|
+
gates.push(runGate('lint-test-pitfalls', 'node', [pitfallsPath, ...testFiles], projectRoot));
|
|
121
|
+
} else {
|
|
122
|
+
gates.push({
|
|
123
|
+
gate: 'lint-test-pitfalls',
|
|
124
|
+
ok: true,
|
|
125
|
+
exit_code: 0,
|
|
126
|
+
stdout: 'skipped (no test files in change set)',
|
|
127
|
+
stderr: '',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Gate 3: ci-parity via scan.js. Pattern set is intentionally conservative
|
|
132
|
+
// — flag obvious CI-only-fail patterns: `if (!process.env.CI)` skips,
|
|
133
|
+
// hardcoded localhost ports, `xit`/`xdescribe`. scan.js does the search;
|
|
134
|
+
// we treat its non-zero exit as a block.
|
|
135
|
+
const scanPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'scan.js');
|
|
136
|
+
if (fs.existsSync(scanPath) && jsTs.length > 0) {
|
|
137
|
+
gates.push(
|
|
138
|
+
runGate(
|
|
139
|
+
'ci-parity',
|
|
140
|
+
'node',
|
|
141
|
+
[
|
|
142
|
+
scanPath,
|
|
143
|
+
'--pattern',
|
|
144
|
+
'process.env.CI',
|
|
145
|
+
'--pattern',
|
|
146
|
+
'(localhost|127\\.0\\.0\\.1):\\d{4,5}',
|
|
147
|
+
'--paths',
|
|
148
|
+
...jsTs,
|
|
149
|
+
],
|
|
150
|
+
projectRoot,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
} else {
|
|
154
|
+
gates.push({
|
|
155
|
+
gate: 'ci-parity',
|
|
156
|
+
ok: true,
|
|
157
|
+
exit_code: 0,
|
|
158
|
+
stdout: 'skipped (no JS/TS changes or script missing)',
|
|
159
|
+
stderr: '',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const firstFail = gates.find((g) => !g.ok);
|
|
164
|
+
const overallOk = !firstFail;
|
|
165
|
+
|
|
166
|
+
if (opts.json) {
|
|
167
|
+
process.stdout.write(
|
|
168
|
+
`${JSON.stringify({ ok: overallOk, gates, first_fail: firstFail?.gate || null }, null, 2)}\n`,
|
|
169
|
+
);
|
|
170
|
+
} else {
|
|
171
|
+
for (const g of gates) {
|
|
172
|
+
log.out(`[${g.ok ? '✓' : '✗'}] ${g.gate}: exit=${g.exit_code}`);
|
|
173
|
+
if (!g.ok) {
|
|
174
|
+
if (g.stdout) log.out(g.stdout);
|
|
175
|
+
if (g.stderr) log.err(g.stderr);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
log.out(overallOk ? 'all gates passed' : `failed gate: ${firstFail?.gate}`);
|
|
179
|
+
}
|
|
180
|
+
return overallOk ? 0 : 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (require.main === module) {
|
|
184
|
+
process.exit(main(process.argv.slice(2)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { main, listChangedFiles, runGate, isTestFile, isJsTsFile };
|
|
@@ -196,7 +196,8 @@ function main() {
|
|
|
196
196
|
process.exit(1);
|
|
197
197
|
}
|
|
198
198
|
const projectRoot = opts['project-root'] || process.cwd();
|
|
199
|
-
const branchPrefix =
|
|
199
|
+
const branchPrefix =
|
|
200
|
+
opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
|
|
200
201
|
const timeout = opts['lock-timeout-sec']
|
|
201
202
|
? Number.parseInt(String(opts['lock-timeout-sec']), 10)
|
|
202
203
|
: DEFAULT_LOCK_TIMEOUT_SEC;
|
|
@@ -568,7 +568,9 @@ function main() {
|
|
|
568
568
|
}
|
|
569
569
|
|
|
570
570
|
if (command === 'graph') {
|
|
571
|
-
process.stdout.write(
|
|
571
|
+
process.stdout.write(
|
|
572
|
+
`${JSON.stringify({ nodes: dag.nodes, edges: dag.edges, epic: dag.epic })}\n`,
|
|
573
|
+
);
|
|
572
574
|
return;
|
|
573
575
|
}
|
|
574
576
|
if (command === 'layers') {
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* Flags: --include, --exclude, --root
|
|
16
16
|
* extensions Extension frequency histogram, descending.
|
|
17
17
|
* Flags: --exclude, --root, --limit (default 20)
|
|
18
|
+
*
|
|
19
|
+
* Ignore files: .gitignore and .aiexclude at the project root are parsed and
|
|
20
|
+
* applied as additional excludes by default. Pass --no-respect-ignore-files
|
|
21
|
+
* to disable. Negation patterns (`!pattern`) are logged to stderr and skipped.
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
const fs = require('fs');
|
|
@@ -41,9 +45,74 @@ const DEFAULT_EXCLUDES = [
|
|
|
41
45
|
'.worktrees',
|
|
42
46
|
];
|
|
43
47
|
|
|
48
|
+
const IGNORE_FILES = ['.gitignore', '.aiexclude'];
|
|
49
|
+
|
|
50
|
+
// Translate one .gitignore / .aiexclude line into zero or more scan.js exclude
|
|
51
|
+
// patterns. Blank lines and `#` comments → []. Negation (`!`) is unsupported
|
|
52
|
+
// and returns []; the caller reports a stderr note. Trailing `/` marks a
|
|
53
|
+
// directory; we expand to both `dir` and `dir/**` so descendant files are also
|
|
54
|
+
// excluded. Leading `/` anchors to the ignore file's directory; we strip it
|
|
55
|
+
// and rely on scan.js's path-anchored exclude semantics for patterns that
|
|
56
|
+
// contain a slash.
|
|
57
|
+
function parseIgnorePattern(line) {
|
|
58
|
+
let p = line.trim();
|
|
59
|
+
if (!p || p.startsWith('#')) return { patterns: [], negation: false };
|
|
60
|
+
if (p.startsWith('!')) return { patterns: [], negation: true };
|
|
61
|
+
// Unescape leading `\#` and `\!` (gitignore literal escapes).
|
|
62
|
+
if (p.startsWith('\\#') || p.startsWith('\\!')) p = p.slice(1);
|
|
63
|
+
|
|
64
|
+
const isDir = p.endsWith('/');
|
|
65
|
+
if (isDir) p = p.slice(0, -1);
|
|
66
|
+
|
|
67
|
+
const anchored = p.startsWith('/');
|
|
68
|
+
const body = anchored ? p.slice(1) : p;
|
|
69
|
+
if (!body) return { patterns: [], negation: false };
|
|
70
|
+
// compilePatterns handles a leading '/' by anchoring the pattern to the
|
|
71
|
+
// root, so we keep it intact for anchored patterns.
|
|
72
|
+
const prefix = anchored ? '/' : '';
|
|
73
|
+
|
|
74
|
+
const patterns = [];
|
|
75
|
+
if (isDir) {
|
|
76
|
+
patterns.push(`${prefix}${body}`);
|
|
77
|
+
patterns.push(`${prefix}${body}/**`);
|
|
78
|
+
} else {
|
|
79
|
+
patterns.push(`${prefix}${body}`);
|
|
80
|
+
// A non-anchored pattern that has no slash matches files at any depth as
|
|
81
|
+
// a basename, which scan.js's matcher already does. If the same name is
|
|
82
|
+
// also a directory anywhere in the tree, exclude its descendants too.
|
|
83
|
+
if (!anchored && !body.includes('/')) patterns.push(`**/${body}/**`);
|
|
84
|
+
}
|
|
85
|
+
return { patterns, negation: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function loadIgnoreFilePatterns(root) {
|
|
89
|
+
const out = [];
|
|
90
|
+
let negationCount = 0;
|
|
91
|
+
for (const name of IGNORE_FILES) {
|
|
92
|
+
const full = path.join(root, name);
|
|
93
|
+
let content;
|
|
94
|
+
try {
|
|
95
|
+
content = fs.readFileSync(full, 'utf8');
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
100
|
+
const { patterns, negation } = parseIgnorePattern(raw);
|
|
101
|
+
if (negation) negationCount++;
|
|
102
|
+
for (const p of patterns) out.push(p);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (negationCount > 0) {
|
|
106
|
+
log.error(
|
|
107
|
+
`scan.js: ignored ${negationCount} negation pattern(s) from .gitignore/.aiexclude (not supported)`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
44
113
|
function help() {
|
|
45
114
|
log.out(
|
|
46
|
-
'Usage: scan.js <files|largest|loc|extensions> [--include <globs>] [--exclude <globs>] [--root <path>] [--limit <N>] [--count]',
|
|
115
|
+
'Usage: scan.js <files|largest|loc|extensions> [--include <globs>] [--exclude <globs>] [--root <path>] [--limit <N>] [--count] [--no-respect-ignore-files]',
|
|
47
116
|
);
|
|
48
117
|
}
|
|
49
118
|
|
|
@@ -183,12 +252,22 @@ function toPosix(p) {
|
|
|
183
252
|
// pathAnchored = true if the pattern contains a path separator; such patterns
|
|
184
253
|
// only match the full relative path. Basename-only patterns (no '/') match
|
|
185
254
|
// both the full path and the basename, so "*.ts" works at any depth.
|
|
255
|
+
// A leading '/' anchors the pattern to the root (relative paths have no
|
|
256
|
+
// leading slash, so we strip it but keep pathAnchored=true).
|
|
186
257
|
function compilePatterns(patterns) {
|
|
187
|
-
return patterns.map((p) =>
|
|
188
|
-
raw
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
258
|
+
return patterns.map((p) => {
|
|
259
|
+
let raw = p;
|
|
260
|
+
let leadingSlash = false;
|
|
261
|
+
if (raw.startsWith('/')) {
|
|
262
|
+
raw = raw.slice(1);
|
|
263
|
+
leadingSlash = true;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
raw,
|
|
267
|
+
re: globToRegex(raw),
|
|
268
|
+
pathAnchored: leadingSlash || raw.includes('/'),
|
|
269
|
+
};
|
|
270
|
+
});
|
|
192
271
|
}
|
|
193
272
|
|
|
194
273
|
function matchesAny(relPath, compiled) {
|
|
@@ -335,8 +414,8 @@ function resolveRoot(opts) {
|
|
|
335
414
|
return root;
|
|
336
415
|
}
|
|
337
416
|
|
|
338
|
-
function buildExcludes(extra) {
|
|
339
|
-
const list = [...DEFAULT_EXCLUDES, ...extra];
|
|
417
|
+
function buildExcludes(extra, ignoreFromFiles) {
|
|
418
|
+
const list = [...DEFAULT_EXCLUDES, ...extra, ...ignoreFromFiles];
|
|
340
419
|
// Patterns: match the basename of a directory OR any path containing it.
|
|
341
420
|
const patterns = [];
|
|
342
421
|
const basenames = new Set();
|
|
@@ -352,10 +431,18 @@ function buildExcludes(extra) {
|
|
|
352
431
|
return { compiled: compilePatterns(patterns), basenames };
|
|
353
432
|
}
|
|
354
433
|
|
|
434
|
+
function ignoreFilePatternsFor(root, opts) {
|
|
435
|
+
if (opts['no-respect-ignore-files'] === true) return [];
|
|
436
|
+
return loadIgnoreFilePatterns(root);
|
|
437
|
+
}
|
|
438
|
+
|
|
355
439
|
function cmdFiles(opts) {
|
|
356
440
|
const root = resolveRoot(opts);
|
|
357
441
|
const includes = compilePatterns(splitList(opts.include));
|
|
358
|
-
const { compiled: excludes, basenames } = buildExcludes(
|
|
442
|
+
const { compiled: excludes, basenames } = buildExcludes(
|
|
443
|
+
splitList(opts.exclude),
|
|
444
|
+
ignoreFilePatternsFor(root, opts),
|
|
445
|
+
);
|
|
359
446
|
const limit = opts.limit ? Number(opts.limit) : 0;
|
|
360
447
|
const count = opts.count === true || opts.count === 'true';
|
|
361
448
|
|
|
@@ -378,7 +465,10 @@ function cmdFiles(opts) {
|
|
|
378
465
|
function cmdLargest(opts) {
|
|
379
466
|
const root = resolveRoot(opts);
|
|
380
467
|
const includes = compilePatterns(splitList(opts.include));
|
|
381
|
-
const { compiled: excludes, basenames } = buildExcludes(
|
|
468
|
+
const { compiled: excludes, basenames } = buildExcludes(
|
|
469
|
+
splitList(opts.exclude),
|
|
470
|
+
ignoreFilePatternsFor(root, opts),
|
|
471
|
+
);
|
|
382
472
|
const limit = opts.limit ? Number(opts.limit) : 10;
|
|
383
473
|
|
|
384
474
|
const heap = []; // simple array; N is small so O(files * log N) is fine
|
|
@@ -396,7 +486,10 @@ function cmdLargest(opts) {
|
|
|
396
486
|
function cmdLoc(opts) {
|
|
397
487
|
const root = resolveRoot(opts);
|
|
398
488
|
const includes = compilePatterns(splitList(opts.include));
|
|
399
|
-
const { compiled: excludes, basenames } = buildExcludes(
|
|
489
|
+
const { compiled: excludes, basenames } = buildExcludes(
|
|
490
|
+
splitList(opts.exclude),
|
|
491
|
+
ignoreFilePatternsFor(root, opts),
|
|
492
|
+
);
|
|
400
493
|
|
|
401
494
|
let total = 0;
|
|
402
495
|
let fileCount = 0;
|
|
@@ -409,7 +502,10 @@ function cmdLoc(opts) {
|
|
|
409
502
|
|
|
410
503
|
function cmdExtensions(opts) {
|
|
411
504
|
const root = resolveRoot(opts);
|
|
412
|
-
const { compiled: excludes, basenames } = buildExcludes(
|
|
505
|
+
const { compiled: excludes, basenames } = buildExcludes(
|
|
506
|
+
splitList(opts.exclude),
|
|
507
|
+
ignoreFilePatternsFor(root, opts),
|
|
508
|
+
);
|
|
413
509
|
const limit = opts.limit ? Number(opts.limit) : 20;
|
|
414
510
|
|
|
415
511
|
const counts = new Map();
|
|
@@ -427,7 +523,7 @@ function cmdExtensions(opts) {
|
|
|
427
523
|
|
|
428
524
|
function main() {
|
|
429
525
|
const { opts, positional } = parseArgs(process.argv.slice(2), {
|
|
430
|
-
booleanFlags: ['count'],
|
|
526
|
+
booleanFlags: ['count', 'no-respect-ignore-files'],
|
|
431
527
|
});
|
|
432
528
|
if (opts.help || positional.length === 0) {
|
|
433
529
|
help();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// stack-snapshot.js — capture a snapshot of the current per-story branch
|
|
4
|
+
// stack so `land-this-pr.js` can land the active PR without losing the
|
|
5
|
+
// rest of the in-flight stack.
|
|
6
|
+
//
|
|
7
|
+
// Output: JSON to stdout (or --output <path>). Shape:
|
|
8
|
+
// {
|
|
9
|
+
// base_branch: string,
|
|
10
|
+
// ts: ISO,
|
|
11
|
+
// branches: [{ name, head, story_key, status, parent? }],
|
|
12
|
+
// active_pr: { branch, number?, story_key } | null,
|
|
13
|
+
// }
|
|
14
|
+
//
|
|
15
|
+
// Argv-only. Uses `git -C <projectRoot>` for every git call.
|
|
16
|
+
|
|
17
|
+
const { execFileSync } = require('node:child_process');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
22
|
+
const log = require('../lib/runtime/log');
|
|
23
|
+
|
|
24
|
+
function help() {
|
|
25
|
+
log.out(
|
|
26
|
+
[
|
|
27
|
+
'Usage: stack-snapshot.js [--project-root <path>] [--base-branch <name>]',
|
|
28
|
+
' [--output <file>] [--active-branch <name>]',
|
|
29
|
+
' [--story-key <key>] [--pr-number <n>]',
|
|
30
|
+
].join('\n'),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function git(projectRoot, args) {
|
|
35
|
+
try {
|
|
36
|
+
return execFileSync('git', ['-C', projectRoot, ...args], { encoding: 'utf8' }).trim();
|
|
37
|
+
} catch (_e) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listLocalBranches(projectRoot, prefix) {
|
|
43
|
+
const raw = git(projectRoot, [
|
|
44
|
+
'for-each-ref',
|
|
45
|
+
'--format=%(refname:short)\t%(objectname)',
|
|
46
|
+
`refs/heads/${prefix}`,
|
|
47
|
+
]);
|
|
48
|
+
if (!raw) return [];
|
|
49
|
+
return raw
|
|
50
|
+
.split(/\n/)
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((line) => {
|
|
53
|
+
const [name, head] = line.split('\t');
|
|
54
|
+
return { name, head };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readSprintStatus(projectRoot) {
|
|
59
|
+
const p = path.join(
|
|
60
|
+
projectRoot,
|
|
61
|
+
'_bmad-output',
|
|
62
|
+
'implementation-artifacts',
|
|
63
|
+
'sprint-status.yaml',
|
|
64
|
+
);
|
|
65
|
+
if (!fs.existsSync(p)) return null;
|
|
66
|
+
try {
|
|
67
|
+
return fs.readFileSync(p, 'utf8');
|
|
68
|
+
} catch (_e) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function statusForStory(sprintStatusText, storyKey) {
|
|
74
|
+
if (!sprintStatusText) return 'unknown';
|
|
75
|
+
// Narrow regex — accept either "story_key: status" or block form.
|
|
76
|
+
const re = new RegExp(`^\\s*${storyKey}:\\s*(\\w+)`, 'm');
|
|
77
|
+
const m = sprintStatusText.match(re);
|
|
78
|
+
return m ? m[1] : 'unknown';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function snapshot(opts) {
|
|
82
|
+
const projectRoot = path.resolve(opts['project-root'] || process.cwd());
|
|
83
|
+
const baseBranch = opts['base-branch'] || 'main';
|
|
84
|
+
const branches = listLocalBranches(projectRoot, 'story/').map((b) => ({
|
|
85
|
+
...b,
|
|
86
|
+
story_key: b.name.slice('story/'.length),
|
|
87
|
+
}));
|
|
88
|
+
const sprintStatus = readSprintStatus(projectRoot);
|
|
89
|
+
for (const b of branches) {
|
|
90
|
+
b.status = statusForStory(sprintStatus, b.story_key);
|
|
91
|
+
}
|
|
92
|
+
const active =
|
|
93
|
+
opts['active-branch'] && opts['story-key']
|
|
94
|
+
? {
|
|
95
|
+
branch: opts['active-branch'],
|
|
96
|
+
story_key: opts['story-key'],
|
|
97
|
+
number: opts['pr-number'] ? Number(opts['pr-number']) : undefined,
|
|
98
|
+
}
|
|
99
|
+
: null;
|
|
100
|
+
return {
|
|
101
|
+
base_branch: baseBranch,
|
|
102
|
+
ts: new Date().toISOString(),
|
|
103
|
+
branches,
|
|
104
|
+
active_pr: active,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function main(argv) {
|
|
109
|
+
const { opts } = parseArgs(argv, { booleanFlags: ['help'] });
|
|
110
|
+
if (opts.help) {
|
|
111
|
+
help();
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
const snap = snapshot(opts);
|
|
115
|
+
const out = `${JSON.stringify(snap, null, 2)}\n`;
|
|
116
|
+
if (opts.output) {
|
|
117
|
+
fs.writeFileSync(path.resolve(opts.output), out, 'utf8');
|
|
118
|
+
} else {
|
|
119
|
+
process.stdout.write(out);
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (require.main === module) {
|
|
125
|
+
process.exit(main(process.argv.slice(2)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { main, snapshot, statusForStory };
|
|
@@ -575,7 +575,14 @@ function deepAssign(target, source) {
|
|
|
575
575
|
for (const k of Object.keys(source)) {
|
|
576
576
|
const sv = source[k];
|
|
577
577
|
const tv = out[k];
|
|
578
|
-
if (
|
|
578
|
+
if (
|
|
579
|
+
sv &&
|
|
580
|
+
typeof sv === 'object' &&
|
|
581
|
+
!Array.isArray(sv) &&
|
|
582
|
+
tv &&
|
|
583
|
+
typeof tv === 'object' &&
|
|
584
|
+
!Array.isArray(tv)
|
|
585
|
+
) {
|
|
579
586
|
out[k] = deepAssign(tv, sv);
|
|
580
587
|
} else {
|
|
581
588
|
out[k] = sv;
|