@ikunin/sprintpilot 2.2.27 → 2.2.29
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/lib/orchestrator/profile-rules.js +10 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +77 -6
- package/_Sprintpilot/lib/orchestrator/verify.js +16 -1
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/scripts/lint-changed.js +117 -5
- package/_Sprintpilot/scripts/post-green-gates.js +15 -8
- package/package.json +1 -1
|
@@ -190,6 +190,16 @@ function flatToProfile(resolved, profileName) {
|
|
|
190
190
|
lint_enabled: coerceBool(get(resolved, 'git.lint.enabled'), false),
|
|
191
191
|
lint_blocking: coerceBool(get(resolved, 'git.lint.blocking'), false),
|
|
192
192
|
lint_output_limit: coerceInt(get(resolved, 'git.lint.output_limit'), 100),
|
|
193
|
+
// git.lint.linters — per-language preference map. v2.2.28+ forwards
|
|
194
|
+
// this to lint-changed.js as --linters-json so users can reorder
|
|
195
|
+
// priorities or disable individual linters. The default-shipped
|
|
196
|
+
// priority order in lint-changed.js matches the documented config
|
|
197
|
+
// defaults, so most users see no behavior change. Setting an empty
|
|
198
|
+
// array for a language disables linting for that language entirely.
|
|
199
|
+
lint_linters: (() => {
|
|
200
|
+
const v = get(resolved, 'git.lint.linters');
|
|
201
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
202
|
+
})(),
|
|
193
203
|
// git.platform.provider + base_url — forwarded to create-pr.js when
|
|
194
204
|
// the orchestrator opens or polls PRs. 'auto' delegates platform
|
|
195
205
|
// detection to create-pr.js (currently defaults to github).
|
|
@@ -96,30 +96,101 @@ function readStateFile(fs, filePath) {
|
|
|
96
96
|
// Narrow YAML parser sufficient for our write shape (the same shape we
|
|
97
97
|
// produce via dumpYaml above). We deliberately avoid js-yaml so we don't
|
|
98
98
|
// pull a runtime dep into the install-time script bundle.
|
|
99
|
+
//
|
|
100
|
+
// Supports:
|
|
101
|
+
// key: scalar (inline scalar)
|
|
102
|
+
// key: [a, b] (inline JSON array via parseScalar)
|
|
103
|
+
// key: (nested object — children at deeper indent)
|
|
104
|
+
// subkey: value
|
|
105
|
+
// key: (nested array — `- item` lines at deeper indent)
|
|
106
|
+
// - item-scalar
|
|
107
|
+
// - item-key: item-value
|
|
108
|
+
//
|
|
109
|
+
// The block-form array path was added in v2.2.29 — pre-2.2.29 the
|
|
110
|
+
// parser unconditionally `continue`d on any line without `:`, silently
|
|
111
|
+
// dropping every `- item` entry. Hand-edited state files (or any
|
|
112
|
+
// roundtrip through a tool that emits block-form YAML) lost their
|
|
113
|
+
// `story_queue`, leaving the autopilot's queue mysteriously empty.
|
|
99
114
|
function parseYamlNarrow(text) {
|
|
100
115
|
if (!text) return {};
|
|
101
116
|
const lines = text.split(/\r?\n/);
|
|
102
117
|
const root = {};
|
|
103
|
-
|
|
118
|
+
// Stack frame:
|
|
119
|
+
// indent — indent of the KEY that opened this container (its
|
|
120
|
+
// children live at indent > frame.indent)
|
|
121
|
+
// container — the object or array we're populating
|
|
122
|
+
// isArray — true once we've promoted container from {} to []
|
|
123
|
+
// parentObj — owner of container (used to swap {} → [] when the
|
|
124
|
+
// first child is a `- ` line)
|
|
125
|
+
// parentKey — slot on parentObj that holds container
|
|
126
|
+
const stack = [{ indent: -1, container: root, isArray: false, parentObj: null, parentKey: null }];
|
|
104
127
|
for (const raw of lines) {
|
|
105
128
|
const hashIdx = raw.indexOf('#');
|
|
106
129
|
const line = hashIdx === -1 ? raw : raw.slice(0, hashIdx);
|
|
107
130
|
if (!line.trim()) continue;
|
|
108
131
|
const indent = line.match(/^( *)/)[1].length;
|
|
109
132
|
const content = line.slice(indent).trimEnd();
|
|
133
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
134
|
+
const top = stack[stack.length - 1];
|
|
135
|
+
|
|
136
|
+
// List item shape: `- ` or bare `-`.
|
|
137
|
+
if (content === '-' || content.startsWith('- ')) {
|
|
138
|
+
// Promote container to array if this is the first list item seen
|
|
139
|
+
// for the current key. Root-level lists aren't supported (state
|
|
140
|
+
// files always have an object root) — skip cleanly.
|
|
141
|
+
if (!top.isArray) {
|
|
142
|
+
if (!top.parentObj || top.parentKey == null) continue;
|
|
143
|
+
const arr = [];
|
|
144
|
+
top.parentObj[top.parentKey] = arr;
|
|
145
|
+
top.container = arr;
|
|
146
|
+
top.isArray = true;
|
|
147
|
+
}
|
|
148
|
+
const rest = content === '-' ? '' : content.slice(2).trim();
|
|
149
|
+
if (rest === '') {
|
|
150
|
+
// Bare `-` with children below — append a fresh object and let
|
|
151
|
+
// subsequent deeper-indent lines populate it.
|
|
152
|
+
const child = {};
|
|
153
|
+
top.container.push(child);
|
|
154
|
+
stack.push({ indent, container: child, isArray: false, parentObj: null, parentKey: null });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const colon = rest.indexOf(':');
|
|
158
|
+
if (colon === -1) {
|
|
159
|
+
// Plain scalar list item.
|
|
160
|
+
top.container.push(parseScalar(rest));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// `- key: value` or `- key:` (object item).
|
|
164
|
+
const k = rest.slice(0, colon).trim();
|
|
165
|
+
const v = rest.slice(colon + 1).trim();
|
|
166
|
+
if (v === '') {
|
|
167
|
+
const child = {};
|
|
168
|
+
const wrapper = { [k]: child };
|
|
169
|
+
top.container.push(wrapper);
|
|
170
|
+
stack.push({ indent, container: child, isArray: false, parentObj: wrapper, parentKey: k });
|
|
171
|
+
} else {
|
|
172
|
+
top.container.push({ [k]: parseScalar(v) });
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Object key: value
|
|
110
178
|
const colon = content.indexOf(':');
|
|
111
179
|
if (colon === -1) continue;
|
|
112
180
|
const key = content.slice(0, colon).trim();
|
|
113
181
|
const rest = content.slice(colon + 1).trim();
|
|
114
|
-
|
|
115
|
-
|
|
182
|
+
if (top.isArray) {
|
|
183
|
+
// Defensive: a stray `key:` inside an array context is malformed.
|
|
184
|
+
// Skip rather than corrupt the array.
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
116
187
|
if (rest === '') {
|
|
117
188
|
const child = {};
|
|
118
|
-
|
|
119
|
-
stack.push({ indent,
|
|
189
|
+
top.container[key] = child;
|
|
190
|
+
stack.push({ indent, container: child, isArray: false, parentObj: top.container, parentKey: key });
|
|
120
191
|
continue;
|
|
121
192
|
}
|
|
122
|
-
|
|
193
|
+
top.container[key] = parseScalar(rest);
|
|
123
194
|
}
|
|
124
195
|
return root;
|
|
125
196
|
}
|
|
@@ -187,10 +187,25 @@ function runPostGreenGates(ctx) {
|
|
|
187
187
|
return null;
|
|
188
188
|
}
|
|
189
189
|
const cp = require('node:child_process');
|
|
190
|
+
const args = [scriptAbs, '--json', '--project-root', ctx.projectRoot];
|
|
191
|
+
// Forward output_limit (v2.2.28). Pre-2.2.28 the typed-profile field
|
|
192
|
+
// existed but lint-changed.js used its hardcoded default of 100.
|
|
193
|
+
if (typeof ctx.profile.lint_output_limit === 'number' && ctx.profile.lint_output_limit > 0) {
|
|
194
|
+
args.push('--output-limit', String(ctx.profile.lint_output_limit));
|
|
195
|
+
}
|
|
196
|
+
// Forward per-language linter map (v2.2.28). Lets users reorder or
|
|
197
|
+
// disable linters via git.lint.linters.{language}: [list].
|
|
198
|
+
if (ctx.profile.lint_linters && typeof ctx.profile.lint_linters === 'object') {
|
|
199
|
+
try {
|
|
200
|
+
args.push('--linters-json', JSON.stringify(ctx.profile.lint_linters));
|
|
201
|
+
} catch {
|
|
202
|
+
/* malformed user config — ignore, fall back to defaults */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
190
205
|
try {
|
|
191
206
|
const r = cp.spawnSync(
|
|
192
207
|
'node',
|
|
193
|
-
|
|
208
|
+
args,
|
|
194
209
|
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 },
|
|
195
210
|
);
|
|
196
211
|
if (r.status === 0) return { failed: false };
|
|
@@ -10,7 +10,95 @@ const { splitLines, countLines, headLines } = require('../lib/runtime/text');
|
|
|
10
10
|
const log = require('../lib/runtime/log');
|
|
11
11
|
|
|
12
12
|
function help() {
|
|
13
|
-
log.out(
|
|
13
|
+
log.out(
|
|
14
|
+
'Usage: lint-changed.js [--limit 100] [--output-file path] [--linter <tool>] [--linters-json <json>]',
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Normalize the user's linter-map config. The YAML has separate
|
|
19
|
+
// `javascript` and `typescript` keys; we classify both as `js-ts` for
|
|
20
|
+
// linting purposes (eslint / biome handle both). Aliases:
|
|
21
|
+
// javascript|typescript → js-ts
|
|
22
|
+
// Each language's value must be an array of strings (linter names).
|
|
23
|
+
function normalizeLintersConfig(raw) {
|
|
24
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const key of Object.keys(raw)) {
|
|
27
|
+
const val = raw[key];
|
|
28
|
+
if (!Array.isArray(val)) continue;
|
|
29
|
+
const list = val.filter((x) => typeof x === 'string' && x.length > 0);
|
|
30
|
+
const lang = key === 'javascript' || key === 'typescript' ? 'js-ts' : key;
|
|
31
|
+
if (out[lang]) out[lang] = out[lang].concat(list);
|
|
32
|
+
else out[lang] = list.slice();
|
|
33
|
+
}
|
|
34
|
+
// De-dupe each list (case where user set both javascript and typescript
|
|
35
|
+
// and they share linters).
|
|
36
|
+
for (const k of Object.keys(out)) {
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
out[k] = out[k].filter((x) => (seen.has(x) ? false : (seen.add(x), true)));
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Map a linter name (from config) to its (binPath, args) invocation.
|
|
44
|
+
// Returns null when the named linter isn't installed. Args are the
|
|
45
|
+
// preset flags for `<linter> [args] <files>` form.
|
|
46
|
+
async function resolveLinterByName(name, lang) {
|
|
47
|
+
// For node-bin-style tools (eslint, biome), prefer node_modules/.bin
|
|
48
|
+
// first so the project's pinned version wins over a global install.
|
|
49
|
+
if (name === 'eslint' || name === 'biome') {
|
|
50
|
+
const local = localBin(name);
|
|
51
|
+
if (local) return { bin: local, args: name === 'biome' ? ['check'] : [] };
|
|
52
|
+
if (await hasCli(name)) return { bin: name, args: name === 'biome' ? ['check'] : [] };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (name === 'ruff' && (await hasCli(name))) return { bin: name, args: ['check'] };
|
|
56
|
+
if (name === 'flake8' && (await hasCli(name))) return { bin: name, args: [] };
|
|
57
|
+
if (name === 'pylint' && (await hasCli(name))) return { bin: name, args: ['--output-format=text'] };
|
|
58
|
+
if (name === 'cargo' || name === 'cargo-clippy' || name === 'clippy') {
|
|
59
|
+
if (await hasCli('cargo')) return { bin: 'cargo', args: ['clippy', '--message-format=short'], noFiles: true };
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (name === 'golangci-lint' && (await hasCli(name))) return { bin: name, args: ['run'], noFiles: true };
|
|
63
|
+
if (name === 'rubocop' && (await hasCli(name))) return { bin: name, args: ['--format', 'simple'] };
|
|
64
|
+
if (name === 'checkstyle' && (await hasCli(name))) {
|
|
65
|
+
return { bin: name, args: fs.existsSync('checkstyle.xml') ? ['-c', 'checkstyle.xml'] : [] };
|
|
66
|
+
}
|
|
67
|
+
if (name === 'pmd' && (await hasCli(name))) return { bin: name, args: ['check', '-d'] };
|
|
68
|
+
if (name === 'cppcheck' && (await hasCli(name))) {
|
|
69
|
+
return {
|
|
70
|
+
bin: name,
|
|
71
|
+
args: lang === 'cpp' ? ['--enable=warning,style', '--language=c++'] : ['--enable=warning,style'],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (name === 'clang-tidy' && (await hasCli(name))) return { bin: name, args: [] };
|
|
75
|
+
if ((name === 'dotnet' || name === 'dotnet format' || name === 'dotnet-format') && (await hasCli('dotnet'))) {
|
|
76
|
+
return { bin: 'dotnet', args: ['format', '--verify-no-changes', '--diagnostics'], noFiles: true };
|
|
77
|
+
}
|
|
78
|
+
if (name === 'swiftlint' && (await hasCli(name))) return { bin: name, args: ['lint', '--quiet'] };
|
|
79
|
+
if (name === 'sqlfluff' && (await hasCli(name))) return { bin: name, args: ['lint', '--dialect', 'oracle'] };
|
|
80
|
+
if (name === 'ktlint' && (await hasCli(name))) return { bin: name, args: [] };
|
|
81
|
+
if (name === 'detekt' && (await hasCli(name))) return { bin: name, args: ['--input'] };
|
|
82
|
+
if (name === 'phpstan' && (await hasCli(name))) return { bin: name, args: ['analyse', '--no-progress'] };
|
|
83
|
+
if (name === 'phpcs' && (await hasCli(name))) return { bin: name, args: [] };
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Try the user's configured linters for `lang` in order. Returns the
|
|
88
|
+
// first linter's output, or null when none of the named linters are
|
|
89
|
+
// installed (caller falls back to default auto-detection).
|
|
90
|
+
async function lintWithConfig(lang, files, linterNames) {
|
|
91
|
+
if (!Array.isArray(linterNames) || linterNames.length === 0) {
|
|
92
|
+
// Empty list = lang disabled. Return empty-string sentinel so the
|
|
93
|
+
// caller doesn't fall through to auto-detection.
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
for (const name of linterNames) {
|
|
97
|
+
const resolved = await resolveLinterByName(name, lang);
|
|
98
|
+
if (!resolved) continue;
|
|
99
|
+
return runLinter(name, resolved.bin, resolved.args, resolved.noFiles ? [] : files);
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
14
102
|
}
|
|
15
103
|
|
|
16
104
|
const EXT_LANG = [
|
|
@@ -158,12 +246,28 @@ async function lintLanguage(lang, files) {
|
|
|
158
246
|
return null;
|
|
159
247
|
}
|
|
160
248
|
|
|
161
|
-
async function detectAndLint(files) {
|
|
249
|
+
async function detectAndLint(files, lintersConfig) {
|
|
162
250
|
const byLang = classify(files);
|
|
163
251
|
if (byLang.size === 0) return null;
|
|
164
252
|
const chunks = [];
|
|
165
253
|
for (const [lang, langFiles] of byLang) {
|
|
166
|
-
|
|
254
|
+
let out;
|
|
255
|
+
if (lintersConfig && lintersConfig[lang]) {
|
|
256
|
+
// User configured linters for this language — use their list.
|
|
257
|
+
const configured = await lintWithConfig(lang, langFiles, lintersConfig[lang]);
|
|
258
|
+
// null = none of the configured linters installed → fall back to
|
|
259
|
+
// hardcoded auto-detect. '' = explicitly disabled (empty list) →
|
|
260
|
+
// skip this language. string = a configured linter ran.
|
|
261
|
+
if (configured === null) {
|
|
262
|
+
out = await lintLanguage(lang, langFiles);
|
|
263
|
+
} else if (configured === '') {
|
|
264
|
+
out = null; // disabled
|
|
265
|
+
} else {
|
|
266
|
+
out = configured;
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
out = await lintLanguage(lang, langFiles);
|
|
270
|
+
}
|
|
167
271
|
if (out !== null) chunks.push(out);
|
|
168
272
|
}
|
|
169
273
|
if (chunks.length === 0) return null;
|
|
@@ -196,6 +300,14 @@ async function main() {
|
|
|
196
300
|
const limit = parseInt(opts.limit || '100', 10);
|
|
197
301
|
const outputFile = opts['output-file'] || '';
|
|
198
302
|
const override = opts.linter || '';
|
|
303
|
+
let lintersConfig = null;
|
|
304
|
+
if (opts['linters-json']) {
|
|
305
|
+
try {
|
|
306
|
+
lintersConfig = normalizeLintersConfig(JSON.parse(opts['linters-json']));
|
|
307
|
+
} catch (e) {
|
|
308
|
+
log.err(`WARN: invalid --linters-json: ${e.message}. Falling back to auto-detection.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
199
311
|
|
|
200
312
|
const modified = await tryGitStdout(['diff', '--name-only', 'HEAD']);
|
|
201
313
|
const untracked = await tryGitStdout(['ls-files', '--others', '--exclude-standard']);
|
|
@@ -213,10 +325,10 @@ async function main() {
|
|
|
213
325
|
fullOutput = await runOverride(override, all);
|
|
214
326
|
if (fullOutput === null) {
|
|
215
327
|
log.err(`WARN: Configured linter '${override}' not found, falling back to auto-detection`);
|
|
216
|
-
fullOutput = await detectAndLint(all);
|
|
328
|
+
fullOutput = await detectAndLint(all, lintersConfig);
|
|
217
329
|
}
|
|
218
330
|
} else {
|
|
219
|
-
fullOutput = await detectAndLint(all);
|
|
331
|
+
fullOutput = await detectAndLint(all, lintersConfig);
|
|
220
332
|
}
|
|
221
333
|
|
|
222
334
|
if (fullOutput === null) {
|
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Usage:
|
|
16
16
|
// post-green-gates.js [--json] [--changed-files <path>] [--project-root <path>]
|
|
17
|
+
// [--output-limit <N>]
|
|
17
18
|
// --changed-files: path to a newline-delimited list of changed files
|
|
18
19
|
// (default: derive from `git diff --name-only HEAD`)
|
|
20
|
+
// --output-limit: max lines of lint output per gate (forwarded to
|
|
21
|
+
// lint-changed.js as --limit). Honors
|
|
22
|
+
// `git.lint.output_limit` from config when called
|
|
23
|
+
// from the orchestrator (v2.2.28+).
|
|
19
24
|
//
|
|
20
25
|
// Each gate runs in a child process via execFileSync. Argv-only — no shell.
|
|
21
26
|
|
|
@@ -90,20 +95,22 @@ function main(argv) {
|
|
|
90
95
|
const changed = listChangedFiles(projectRoot, opts['changed-files']);
|
|
91
96
|
const jsTs = changed.filter(isJsTsFile);
|
|
92
97
|
const testFiles = changed.filter(isTestFile);
|
|
98
|
+
const outputLimit = parseInt(opts['output-limit'] || '0', 10);
|
|
99
|
+
const lintersJson = opts['linters-json'] || '';
|
|
93
100
|
|
|
94
101
|
const gates = [];
|
|
95
102
|
|
|
96
103
|
// Gate 1: lint-changed.
|
|
97
104
|
const lintChangedPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lint-changed.js');
|
|
98
105
|
if (fs.existsSync(lintChangedPath) && jsTs.length > 0) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
);
|
|
106
|
+
const lcArgs = [lintChangedPath, '--project-root', projectRoot];
|
|
107
|
+
if (outputLimit > 0) {
|
|
108
|
+
lcArgs.push('--limit', String(outputLimit));
|
|
109
|
+
}
|
|
110
|
+
if (lintersJson) {
|
|
111
|
+
lcArgs.push('--linters-json', lintersJson);
|
|
112
|
+
}
|
|
113
|
+
gates.push(runGate('lint-changed', 'node', lcArgs, projectRoot));
|
|
107
114
|
} else {
|
|
108
115
|
gates.push({
|
|
109
116
|
gate: 'lint-changed',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikunin/sprintpilot",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.29",
|
|
4
4
|
"description": "Sprintpilot — autopilot and multi-agent addon for BMad Method v6: git workflow, parallel agents, autonomous story execution",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|