@ikunin/sprintpilot 2.2.27 → 2.2.28

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.
@@ -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).
@@ -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
- [scriptAbs, '--json', '--project-root', ctx.projectRoot],
208
+ args,
194
209
  { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 },
195
210
  );
196
211
  if (r.status === 0) return { failed: false };
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.2.27
3
+ version: 2.2.28
4
4
  description: Sprintpilot — autopilot and multi-agent addon for BMad Method (git workflow, parallel agents, autonomous story execution)
5
5
  bmad_compatibility: ">=6.2.0"
6
6
  modules:
@@ -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('Usage: lint-changed.js [--limit 100] [--output-file path] [--linter <tool>]');
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
- const out = await lintLanguage(lang, langFiles);
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
- gates.push(
100
- runGate(
101
- 'lint-changed',
102
- 'node',
103
- [lintChangedPath, '--project-root', projectRoot],
104
- projectRoot,
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.27",
3
+ "version": "2.2.28",
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": {