bmad-plus 0.19.0 → 0.20.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.
@@ -3,10 +3,9 @@
3
3
  * derives the verdict from coverage — never from how many findings were written.
4
4
  *
5
5
  * The host agent reviews; this module only establishes what must be reviewed, where each
6
- * finding really is, and whether every selected file was accounted for. Design taken from
7
- * the open-code-review study (docs/research/open-code-review-2026-09-25): deterministic
8
- * selection with named exclusion reasons, verbatim-snippet anchoring, coverage-derived
9
- * disposition. No model call, no network, no write outside the review folder.
6
+ * finding really is, which checklists apply, and whether every selected file was accounted
7
+ * for. Written for BMAD+ after studying open-code-review (docs/research/open-code-review-2026-09-25)
8
+ * for ideas only. No model call, no network, no write outside the review folder.
10
9
  */
11
10
  'use strict';
12
11
 
@@ -14,6 +13,9 @@ const fs = require('node:fs');
14
13
  const path = require('node:path');
15
14
  const crypto = require('node:crypto');
16
15
  const { spawnSync } = require('node:child_process');
16
+ const { matchesAny } = require('./glob');
17
+ const { redactFields } = require('./redact');
18
+ const rules = require('./review-rules');
17
19
 
18
20
  const SCOPE_SCHEMA = 'bmad-plus/review-scope/1';
19
21
  const FINDINGS_SCHEMA = 'bmad-plus/review-findings/1';
@@ -72,24 +74,30 @@ const GENERATED_PATTERNS = [
72
74
  ];
73
75
  const UNIT_LIMITS = { files: 8, lines: 400 };
74
76
 
75
- const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
77
+ /**
78
+ * How much reviewing a change deserves. A pass is a complete read of the scope; later
79
+ * passes look for what the earlier ones missed and stop early when a pass adds nothing.
80
+ */
81
+ const EFFORTS = {
82
+ low: { passes: 1, refute: false },
83
+ medium: { passes: 2, refute: true },
84
+ high: { passes: 3, refute: true },
85
+ };
86
+ const CHURN = { plan: 150, split: 400 };
76
87
 
77
- /** A small glob: `**` spans directories, `*` stays within one, `?` is one character. Case-sensitive. */
78
- function globToRegExp(glob) {
79
- let source = '';
80
- for (let i = 0; i < glob.length; i++) {
81
- const c = glob[i];
82
- if (c === '*' && glob[i + 1] === '*') {
83
- const slash = glob[i + 2] === '/';
84
- source += slash ? '(?:.*/)?' : '.*';
85
- i += slash ? 2 : 1;
86
- } else if (c === '*') source += '[^/]*';
87
- else if (c === '?') source += '[^/]';
88
- else source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
89
- }
90
- return new RegExp(`^${source}$`);
88
+ /** The reviewing plan follows the effort chosen and the measured size of the change. */
89
+ function reviewPlan(effort, lines, units) {
90
+ const { passes, refute } = EFFORTS[effort];
91
+ return {
92
+ effort,
93
+ passes,
94
+ refute,
95
+ planFirst: lines >= CHURN.plan,
96
+ parallelUnits: units > 1 && lines >= CHURN.split,
97
+ };
91
98
  }
92
- const matchesAny = (file, patterns) => patterns.some((glob) => globToRegExp(glob).test(file));
99
+
100
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
93
101
 
94
102
  function git(projectDir, args) {
95
103
  const result = spawnSync('git', args, {
@@ -251,6 +259,10 @@ function buildScope(projectDir, options = {}) {
251
259
  ...(entry.oldPath ? { oldPath: entry.oldPath } : {}),
252
260
  });
253
261
  }
262
+ const effort = options.effort || 'medium';
263
+ if (!EFFORTS[effort]) throw new Error(`effort must be one of ${Object.keys(EFFORTS).join('|')}`);
264
+ const ruleset = options.ruleset || rules.loadRuleset(projectDir);
265
+ for (const item of selected) item.rules = rules.rulesFor(ruleset, item.path);
254
266
  const identity = {
255
267
  base,
256
268
  head: head || 'WORKTREE',
@@ -258,10 +270,16 @@ function buildScope(projectDir, options = {}) {
258
270
  workspace: Boolean(options.workspace),
259
271
  include,
260
272
  exclude,
273
+ effort,
274
+ rulesSha256: ruleset.sha256,
261
275
  };
262
- const units = planUnits(selected, options.unitLimits);
276
+ const units = planUnits(selected, options.unitLimits).map((unit) => ({
277
+ ...unit,
278
+ rules: [...new Set(unit.paths.flatMap((p) => selected.find((s) => s.path === p).rules))],
279
+ }));
263
280
  const lines = selected.reduce((n, item) => n + item.added + item.removed, 0);
264
- const body = { identity, selected, excluded, units };
281
+ const plan = reviewPlan(effort, lines, units.length);
282
+ const body = { identity, selected, excluded, units, plan };
265
283
  return {
266
284
  schema: SCOPE_SCHEMA,
267
285
  id: options.id,
@@ -292,6 +310,7 @@ const FINDING_KEYS = [
292
310
  'evidence',
293
311
  'refutation',
294
312
  'fix',
313
+ 'rule',
295
314
  ];
296
315
 
297
316
  /** Unknown values are errors, never coerced: a wrong enum is a wrong finding. */
@@ -332,8 +351,12 @@ function validateFindings(doc, scope) {
332
351
  errors.push(`${at}: a refuted finding keeps its refutation — the quoted ground`);
333
352
  if (finding.disposition === 'confirmed' && !String(finding.evidence || '').trim())
334
353
  errors.push(`${at}: a confirmed finding needs evidence`);
335
- if (scope && finding.path && !scope.selected.some((item) => item.path === finding.path))
354
+ const entry = scope && scope.selected.find((item) => item.path === finding.path);
355
+ if (scope && finding.path && !entry)
336
356
  errors.push(`${at}: ${finding.path} is not in the review scope`);
357
+ // A finding may name the checklist rule that led to it; the rule must apply to that file.
358
+ if (finding.rule !== undefined && entry && !(entry.rules || []).includes(finding.rule))
359
+ errors.push(`${at}: rule "${finding.rule}" does not apply to ${finding.path}`);
337
360
  }
338
361
  return errors;
339
362
  }
@@ -396,11 +419,27 @@ function anchorFindings(projectDir, scope, doc) {
396
419
  location: { status: 'unlocated', reason: 'the quoted code is not in the file' },
397
420
  };
398
421
  });
399
- const counts = { located: 0, ambiguous: 0, unlocated: 0 };
400
- for (const finding of anchored) counts[finding.location.status] += 1;
422
+ const counts = { located: 0, ambiguous: 0, unlocated: 0, redactions: 0 };
423
+ for (const finding of anchored) {
424
+ counts[finding.location.status] += 1;
425
+ // Located first, redacted second: the anchor needs the quote, the written record must not carry a secret.
426
+ const redacted = redactFields(finding, REDACTED_FIELDS);
427
+ if (redacted) finding.redactions = redacted;
428
+ counts.redactions += redacted;
429
+ }
401
430
  return { findings: anchored, counts };
402
431
  }
403
432
 
433
+ const REDACTED_FIELDS = [
434
+ 'existing_code',
435
+ 'content',
436
+ 'trigger',
437
+ 'consequence',
438
+ 'evidence',
439
+ 'refutation',
440
+ 'fix',
441
+ ];
442
+
404
443
  // ── Coverage and gate ─────────────────────────────────────────────────────────
405
444
 
406
445
  function validateCoverage(doc, scope) {
@@ -486,6 +525,73 @@ function reviewGate({ scope, findings, coverage, anchored }) {
486
525
  };
487
526
  }
488
527
 
528
+ // ── Comparing two reviews ─────────────────────────────────────────────────────
529
+
530
+ const COMPARE_SCHEMA = 'bmad-plus/review-compare/1';
531
+
532
+ /** A finding's identity survives moved lines and reindentation: path, category, normalised quote. */
533
+ function findingKey(file, finding) {
534
+ const code = String(finding.existing_code || '')
535
+ .split('\n')
536
+ .map(normalizeLine)
537
+ .filter(Boolean)
538
+ .join('\n');
539
+ return JSON.stringify([file, finding.category, code]);
540
+ }
541
+
542
+ /**
543
+ * What became of the open findings of an earlier review. `resolved` needs the later review
544
+ * to have completed that file; otherwise the finding is `not_reviewed`, never assumed fixed.
545
+ * Renames recorded in the later scope carry findings to the new path.
546
+ */
547
+ function compareReviews(before, after) {
548
+ const renamed = new Map(
549
+ after.scope.selected.filter((item) => item.oldPath).map((item) => [item.oldPath, item.path])
550
+ );
551
+ const completed = new Set(
552
+ ((after.coverage && after.coverage.items) || [])
553
+ .filter((item) => item.outcome === 'completed')
554
+ .map((item) => item.path)
555
+ );
556
+ const isOpen = (finding) => finding.disposition !== 'refuted';
557
+ const afterByKey = new Map(after.findings.findings.map((f) => [findingKey(f.path, f), f]));
558
+ const beforeKeys = new Set();
559
+ const buckets = { new: [], persisting: [], resolved: [], refuted: [], not_reviewed: [] };
560
+
561
+ for (const finding of before.findings.findings.filter(isOpen)) {
562
+ const file = renamed.get(finding.path) || finding.path;
563
+ const key = findingKey(file, finding);
564
+ beforeKeys.add(key);
565
+ const match = afterByKey.get(key);
566
+ const entry = {
567
+ before: finding.id,
568
+ path: file,
569
+ category: finding.category,
570
+ severity: finding.severity,
571
+ };
572
+ if (match && isOpen(match)) buckets.persisting.push({ ...entry, after: match.id });
573
+ else if (match) buckets.refuted.push({ ...entry, after: match.id });
574
+ else if (completed.has(file)) buckets.resolved.push(entry);
575
+ else buckets.not_reviewed.push(entry);
576
+ }
577
+ for (const finding of after.findings.findings.filter(isOpen)) {
578
+ if (!beforeKeys.has(findingKey(finding.path, finding)))
579
+ buckets.new.push({
580
+ after: finding.id,
581
+ path: finding.path,
582
+ category: finding.category,
583
+ severity: finding.severity,
584
+ });
585
+ }
586
+ return {
587
+ schema: COMPARE_SCHEMA,
588
+ before: { id: before.scope.id, scopeSha256: before.scope.sha256 },
589
+ after: { id: after.scope.id, scopeSha256: after.scope.sha256 },
590
+ counts: Object.fromEntries(Object.entries(buckets).map(([name, list]) => [name, list.length])),
591
+ buckets,
592
+ };
593
+ }
594
+
489
595
  function layout(projectDir, dir = DEFAULT_DIR, id) {
490
596
  const root = path.resolve(projectDir, dir, id);
491
597
  return {
@@ -494,6 +600,8 @@ function layout(projectDir, dir = DEFAULT_DIR, id) {
494
600
  findings: path.join(root, 'findings.json'),
495
601
  anchored: path.join(root, 'findings.anchored.json'),
496
602
  coverage: path.join(root, 'coverage.json'),
603
+ checklist: path.join(root, 'checklist.md'),
604
+ compare: path.join(root, 'compare.json'),
497
605
  };
498
606
  }
499
607
 
@@ -501,7 +609,9 @@ module.exports = {
501
609
  SCOPE_SCHEMA,
502
610
  FINDINGS_SCHEMA,
503
611
  COVERAGE_SCHEMA,
612
+ COMPARE_SCHEMA,
504
613
  DEFAULT_DIR,
614
+ EFFORTS,
505
615
  SEVERITIES,
506
616
  CATEGORIES,
507
617
  DISPOSITIONS,
@@ -509,7 +619,6 @@ module.exports = {
509
619
  OUTCOMES,
510
620
  SECRET_PATTERNS,
511
621
  GENERATED_PATTERNS,
512
- globToRegExp,
513
622
  parseNumstat,
514
623
  planUnits,
515
624
  buildScope,
@@ -518,5 +627,8 @@ module.exports = {
518
627
  anchorFindings,
519
628
  validateCoverage,
520
629
  reviewGate,
630
+ reviewPlan,
631
+ findingKey,
632
+ compareReviews,
521
633
  layout,
522
634
  };
@@ -4,6 +4,7 @@
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const crypto = require('node:crypto');
7
+ const { redactFields } = require('./redact');
7
8
 
8
9
  const SPEC_SCHEMA = 'bmad-plus/uat-spec/2';
9
10
  const RESULTS_SCHEMA = 'bmad-plus/uat-results/2';
@@ -714,6 +715,20 @@ function normalizeRun(doc, spec) {
714
715
  return run;
715
716
  }
716
717
 
718
+ /**
719
+ * Tester notes are free text, and testers paste terminal output into them: credentials go
720
+ * before the run is written. Returns the number of replacements, recorded on the run.
721
+ */
722
+ function redactRun(run) {
723
+ let count = redactFields(run, ['overallNote']);
724
+ for (const step of Object.values(run.steps || {})) {
725
+ count += redactFields(step, ['note']);
726
+ for (const expect of Object.values(step.expect || {})) count += redactFields(expect, ['note']);
727
+ }
728
+ if (count) run.redactions = (run.redactions || 0) + count;
729
+ return count;
730
+ }
731
+
717
732
  function readRuns(dir, spec) {
718
733
  const runs = [];
719
734
  const seen = new Set();
@@ -1013,6 +1028,7 @@ module.exports = {
1013
1028
  pageGuarantees,
1014
1029
  languageCode,
1015
1030
  normalizeRun,
1031
+ redactRun,
1016
1032
  readRuns,
1017
1033
  stepState,
1018
1034
  failures,
@@ -0,0 +1,7 @@
1
+ - **Untrusted triggers.** `pull_request_target`, `workflow_run` or `issue_comment` jobs that check out or execute code from a pull request with secrets or a write token available.
2
+ - **Script injection.** Expressions such as `${{ github.event.* }}`, branch names, titles or commit messages interpolated directly into `run:`; pass them through `env:` and quote them.
3
+ - **Permissions.** No top-level `permissions:` (defaults may be broad), or `write` scopes a job does not need; `id-token: write` on jobs that do not publish.
4
+ - **Pinning.** Third-party actions referenced by a moving tag or branch instead of a full commit SHA; container images without a digest.
5
+ - **Secrets.** Secrets echoed, written to artifacts or caches, or exposed to steps that run third-party code; `ACTIONS_STEP_DEBUG` left on.
6
+ - **Runners.** Self-hosted runners on public repositories; a job that assumes a tool preinstalled on GitHub-hosted images (`node`, `python`, `jq`) before setting it up.
7
+ - **Gates.** A step that should block marked `continue-on-error`; a required check that can be skipped by a path filter; a release job that does not depend on the test jobs.
@@ -0,0 +1,5 @@
1
+ - **Secrets.** A real credential, token, private URL or personal data committed in a configuration file or an example file; an example file whose placeholder looks real.
2
+ - **Dependencies.** A new dependency: is it maintained, necessary, and from the expected publisher (typosquatting)? A version range widened to accept a major; a lockfile out of step with its manifest; install scripts from a new package.
3
+ - **Defaults.** A security-relevant setting weakened (TLS verification, CORS origins, cookie flags, debug mode, log level exposing data) or a default that now differs between environments.
4
+ - **Consistency.** A key renamed in one environment file and not the others; a value duplicated where one source of truth exists; a feature flag left on.
5
+ - **Parsing.** YAML values that change type silently (`no`, `on`, `0755`, unquoted dates and versions); JSON with duplicate keys.
@@ -0,0 +1,6 @@
1
+ - **Privilege.** Containers running as root without need; `privileged: true`, added capabilities, host network or PID namespace; the Docker socket mounted into a container (root on the host).
2
+ - **Images.** Base images on `latest` or an unpinned tag; packages installed without cleanup or version pins where reproducibility matters; secrets passed as build arguments or copied into a layer.
3
+ - **Exposure.** Ports published on `0.0.0.0` for services that should stay internal (databases, caches, admin APIs); missing network separation between stacks.
4
+ - **Health and restart.** No health check for a service others depend on; `depends_on` without a health condition; restart policies that loop a crashing container.
5
+ - **Data.** Volumes for stateful services missing or anonymous; host paths mounted read-write where read-only suffices.
6
+ - **Resources.** No memory or CPU limits on a shared host; Kubernetes pods without requests/limits, probes or a `securityContext` (`runAsNonRoot`, `readOnlyRootFilesystem`).
@@ -0,0 +1,10 @@
1
+ Look for what the diff changes in behaviour, not how it reads.
2
+
3
+ - **Callers.** Search every caller of a changed function, export, route, command or schema. A signature, return shape or error type that changed is a defect at the caller that was not updated.
4
+ - **Removed behaviour.** Read what the diff deletes: a check, a branch, a cleanup, a log that an operator relies on. Deleted protection is the most often missed defect.
5
+ - **Boundaries.** Empty input, one element, the last element, the maximum size, a missing optional field, a concurrent second call, a retry after a partial failure.
6
+ - **Errors.** An error swallowed, logged and ignored, or turned into a success value. A failure path that leaves state half-written.
7
+ - **Trust.** Data that crosses a boundary (request, file, environment, another service, a model's output) and reaches a query, a command, a path, HTML or a permission decision without validation.
8
+ - **Tests.** A changed behaviour whose test still passes because it mocks the changed path, asserts nothing about the new case, or is skipped.
9
+
10
+ Do not report formatting, naming or style a linter enforces, and do not propose rewrites the change did not need.
@@ -0,0 +1,36 @@
1
+ # Built-in review checklists, applied by path. Every matching rule applies to a file.
2
+ # A project adds, replaces (same id) or disables rules in _bmad/review-rules.yaml.
3
+ schema: bmad-plus/review-rules/1
4
+ rules:
5
+ - id: general
6
+ title: Every change
7
+ globs: ['**']
8
+ doc: general.md
9
+ - id: javascript-typescript
10
+ title: JavaScript and TypeScript
11
+ globs: ['**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}', '**/*.{vue,svelte}']
12
+ doc: javascript-typescript.md
13
+ - id: python
14
+ title: Python
15
+ globs: ['**/*.{py,pyi}']
16
+ doc: python.md
17
+ - id: sql-and-migrations
18
+ title: SQL, queries and migrations
19
+ globs: ['**/*.sql', '**/{migrations,migrate,db/migrate}/**', '**/*.prisma', '**/schema.rb']
20
+ doc: sql-and-migrations.md
21
+ - id: shell
22
+ title: Shell and PowerShell scripts
23
+ globs: ['**/*.{sh,bash,zsh,ps1,psm1}', '**/{Makefile,makefile}']
24
+ doc: shell.md
25
+ - id: ci-workflows
26
+ title: CI workflows
27
+ globs: ['.github/workflows/**/*.{yml,yaml}', '**/.gitlab-ci.yml', '**/{bitbucket-pipelines.yml,azure-pipelines.yml}']
28
+ doc: ci-workflows.md
29
+ - id: containers
30
+ title: Containers and deployment manifests
31
+ globs: ['**/{Dockerfile,Containerfile}', '**/*.dockerfile', '**/{docker-,}compose*.{yml,yaml}', '**/{k8s,kubernetes,helm,charts}/**/*.{yml,yaml}']
32
+ doc: containers.md
33
+ - id: configuration
34
+ title: Configuration and dependency manifests
35
+ globs: ['**/*.{json,yml,yaml,toml,ini,properties}', '**/.env.example', '**/{package.json,pyproject.toml,requirements*.txt,go.mod,Cargo.toml,composer.json}']
36
+ doc: configuration.md
@@ -0,0 +1,7 @@
1
+ - **Async.** A promise that is created but not awaited or returned; `forEach` with an async callback; an `await` inside a loop that should run in parallel, or `Promise.all` where one failure must not cancel the others (`allSettled`). Unhandled rejections in event handlers and timers.
2
+ - **Nullish values.** Property access on a value that can be `undefined` after an `await`, a `find`, a map lookup or an optional API field; `||` used where `0`, `''` or `false` are valid values and `??` was meant.
3
+ - **Equality and numbers.** Loose equality across types; floating-point money; `parseInt` without a radix or on user input without a `NaN` check; array index arithmetic off by one.
4
+ - **Mutation.** A function that mutates its argument or a shared default object; sorting or splicing an array the caller still uses; state mutated in a React render or a reducer.
5
+ - **Injection.** `innerHTML`, `dangerouslySetInnerHTML`, `v-html`, template literals building SQL, shell commands through `exec` or `shell: true`, `eval`/`new Function`, a user-controlled path reaching `fs` without confinement, a user-controlled URL fetched by the server (SSRF), `Object.assign`/spread of untrusted keys (`__proto__`).
6
+ - **Types that lie.** `as` casts, non-null assertions (`!`) and `any` that hide a real `undefined` or a wrong shape at runtime.
7
+ - **Resources.** Listeners, intervals, streams, file handles or database connections opened without a matching close on every path, including errors.
@@ -0,0 +1,6 @@
1
+ - **Defaults and scope.** Mutable default arguments; a loop variable captured late by a closure; a module-level object mutated per request.
2
+ - **Exceptions.** Bare `except:` or `except Exception` that swallows and continues; `raise` without `from` that loses the cause; cleanup that is not in `finally` or a context manager.
3
+ - **Resources.** Files, sockets, database cursors and locks not managed by `with`; a subprocess whose output is never drained.
4
+ - **Injection.** SQL built with f-strings or `%`; `subprocess` with `shell=True` or a string command built from input; `pickle`, `yaml.load` without a safe loader, `eval`/`exec` on untrusted data; `os.path.join` with an absolute user path; `tarfile`/`zipfile` extraction without member path checks.
5
+ - **Types and values.** `None` returned on one path and used unconditionally; integer division where a float was meant; naive and aware datetimes compared; timezones assumed.
6
+ - **Concurrency.** Shared state touched from threads or async tasks without a lock; a blocking call inside an `async def`.
@@ -0,0 +1,6 @@
1
+ - **Failure handling.** Bash without `set -euo pipefail` (or an explicit reason); a failing command in a pipeline hidden by the last stage; PowerShell without `$ErrorActionPreference = 'Stop'` where errors must stop the script; native exit codes (`$LASTEXITCODE`) not checked.
2
+ - **Quoting.** Unquoted variables and command substitutions that split on spaces or expand globs; `eval` or `Invoke-Expression` on data.
3
+ - **Destructive commands.** `rm -rf`, `Remove-Item -Recurse`, `git reset --hard`, `DROP` or deploy commands on a path or target built from a variable that can be empty or wrong.
4
+ - **Secrets.** Tokens passed on the command line (visible in process lists and logs), echoed, or written to world-readable files; `set -x` in a script that handles credentials.
5
+ - **Portability.** Bash-only syntax under `#!/bin/sh`; GNU-only flags on macOS; PowerShell 7 syntax (`&&`, `??`, ternary) in scripts run by Windows PowerShell 5.1; CRLF line endings in a script run by Linux.
6
+ - **Temporary files.** Predictable names in shared directories instead of `mktemp`/`New-TemporaryFile`, and no cleanup trap.
@@ -0,0 +1,6 @@
1
+ - **Parameters.** Any value reaching a query must be bound, never concatenated; identifiers (table, column, sort order) chosen from an allow-list.
2
+ - **Scope of writes.** An `UPDATE` or `DELETE` whose `WHERE` can match more rows than intended, or none at all silently; a missing tenant or owner filter.
3
+ - **Migrations.** A migration that locks a large table, drops or renames a column still read by the running version, adds a `NOT NULL` column without a default or backfill, or cannot be rolled back. Data migrations must be idempotent and safe to rerun.
4
+ - **Transactions.** Several writes that must succeed together outside a transaction; a transaction held open across a network call.
5
+ - **Performance.** A new filter or join on a column without an index; a query inside a loop (N+1); an unbounded result set returned to a caller.
6
+ - **Nulls.** Comparisons with `NULL` using `=`; aggregates or `NOT IN` over nullable columns.