mandrel 2.30.0 → 2.32.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/.agents/README.md +13 -17
- package/.agents/agents/acceptance-critic.md +1 -2
- package/.agents/docs/SDLC.md +4 -4
- package/.agents/docs/agentrc-reference.json +61 -57
- package/.agents/docs/configuration.md +274 -227
- package/.agents/docs/execution-reference.md +13 -14
- package/.agents/docs/quality-gates.md +195 -23
- package/.agents/instructions.md +2 -5
- package/.agents/rules/ci-remediation.md +44 -1
- package/.agents/rules/git-conventions-reference.md +27 -27
- package/.agents/rules/git-conventions.md +4 -2
- package/.agents/rules/known-tooling-behavior.md +66 -30
- package/.agents/rules/testing-standards.md +35 -71
- package/.agents/runtime-deps.json +0 -1
- package/.agents/schemas/agentrc.schema.json +1939 -1400
- package/.agents/schemas/lifecycle/README.md +21 -14
- package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
- package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
- package/.agents/scripts/README.md +7 -29
- package/.agents/scripts/apply-quality-bootstrap.js +27 -34
- package/.agents/scripts/bootstrap.js +28 -26
- package/.agents/scripts/check-baseline-drift.js +73 -13
- package/.agents/scripts/check-baseline-scope.js +362 -0
- package/.agents/scripts/check-dead-exports.js +9 -1
- package/.agents/scripts/check-gherkin-corpus.js +508 -0
- package/.agents/scripts/check-knip-entries.js +136 -0
- package/.agents/scripts/check-lifecycle-lint.js +36 -112
- package/.agents/scripts/check-schema-references.js +1 -1
- package/.agents/scripts/diagnose-friction.js +7 -4
- package/.agents/scripts/generate-config-docs.js +263 -171
- package/.agents/scripts/install-matrix-assert.js +0 -1
- package/.agents/scripts/lib/ITicketingProvider.js +0 -58
- package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
- package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
- package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +0 -1
- package/.agents/scripts/lib/baselines/envelope.js +41 -60
- package/.agents/scripts/lib/baselines/git-base.js +30 -37
- package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
- package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
- package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
- package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
- package/.agents/scripts/lib/bdd-step-index.js +326 -0
- package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
- package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
- package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
- package/.agents/scripts/lib/checks/loop-health.js +9 -37
- package/.agents/scripts/lib/child-exec.js +193 -0
- package/.agents/scripts/lib/cli/standard-args.js +1 -1
- package/.agents/scripts/lib/cli-args.js +64 -0
- package/.agents/scripts/lib/close-validation/gates.js +2 -2
- package/.agents/scripts/lib/close-validation/runner.js +3 -3
- package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
- package/.agents/scripts/lib/config/commands.js +3 -5
- package/.agents/scripts/lib/config/explain.js +5 -7
- package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
- package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
- package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
- package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
- package/.agents/scripts/lib/config/gates/index.js +5 -2
- package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
- package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
- package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
- package/.agents/scripts/lib/config/gates/shared.js +58 -9
- package/.agents/scripts/lib/config/github.js +0 -1
- package/.agents/scripts/lib/config/limits.js +3 -48
- package/.agents/scripts/lib/config/qa.js +105 -0
- package/.agents/scripts/lib/config/temp-paths.js +6 -5
- package/.agents/scripts/lib/config-settings-schema-delivery.js +237 -56
- package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
- package/.agents/scripts/lib/config-settings-schema.js +386 -39
- package/.agents/scripts/lib/crap-baseline-join.js +126 -9
- package/.agents/scripts/lib/crap-utils.js +84 -520
- package/.agents/scripts/lib/dead-exports-knip.js +79 -10
- package/.agents/scripts/lib/degraded-mode.js +2 -2
- package/.agents/scripts/lib/doc-tiers.js +3 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
- package/.agents/scripts/lib/fs-walk.js +52 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
- package/.agents/scripts/lib/git-utils.js +16 -36
- package/.agents/scripts/lib/knip-entry-sync.js +469 -0
- package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
- package/.agents/scripts/lib/observability/signal-validator.js +5 -85
- package/.agents/scripts/lib/observability/signals-writer.js +19 -62
- package/.agents/scripts/lib/observability/source-classifier.js +5 -7
- package/.agents/scripts/lib/observability/terse-result.js +3 -3
- package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
- package/.agents/scripts/lib/orchestration/code-review.js +2 -2
- package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
- package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
- package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +6 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +0 -1
- package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
- package/.agents/scripts/lib/orchestration/resolve-stories.js +5 -15
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
- package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +17 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +65 -25
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
- package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
- package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
- package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
- package/.agents/scripts/lib/signals/detectors/common.js +1 -1
- package/.agents/scripts/lib/signals/index.js +8 -6
- package/.agents/scripts/lib/signals/schema.js +20 -25
- package/.agents/scripts/lib/signals/write.js +8 -8
- package/.agents/scripts/lib/story-body/story-body.js +12 -59
- package/.agents/scripts/lib/temp-retention.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
- package/.agents/scripts/lib/ticket-body-sections.js +4 -5
- package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
- package/.agents/scripts/lib/worktree-manager.js +2 -3
- package/.agents/scripts/lint-label-vocabulary.js +2 -24
- package/.agents/scripts/pr-watch-with-update.js +7 -5
- package/.agents/scripts/providers/github/cache.js +2 -2
- package/.agents/scripts/providers/github/comments.js +6 -28
- package/.agents/scripts/providers/github/compose.js +0 -15
- package/.agents/scripts/providers/github/errors.js +10 -27
- package/.agents/scripts/providers/github/request-helpers.js +1 -2
- package/.agents/scripts/providers/github/sub-issues.js +10 -218
- package/.agents/scripts/providers/github.js +4 -7
- package/.agents/scripts/prune-baseline-orphans.js +181 -0
- package/.agents/scripts/resolve-stories.js +0 -2
- package/.agents/scripts/run-lint.js +61 -61
- package/.agents/scripts/run-test-profile.js +6 -6
- package/.agents/scripts/run-verify.js +48 -30
- package/.agents/scripts/single-story-close.js +20 -0
- package/.agents/scripts/single-story-init.js +12 -35
- package/.agents/scripts/update-dead-exports-baseline.js +321 -0
- package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
- package/.agents/skills/skills.index.json +2 -12
- package/.agents/skills/stack/qa/playwright/SKILL.md +48 -0
- package/.agents/workflows/audit-documentation.md +5 -6
- package/.agents/workflows/audit-to-stories.md +2 -2
- package/.agents/workflows/helpers/audit-lens-core.md +11 -12
- package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
- package/.agents/workflows/helpers/code-review.md +3 -8
- package/.agents/workflows/helpers/deliver-reference.md +2 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +27 -16
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
- package/.agents/workflows/mandrel-update.md +10 -10
- package/.agents/workflows/qa-assist.md +15 -20
- package/.agents/workflows/qa-explore.md +9 -8
- package/README.md +1 -1
- package/docs/CHANGELOG.md +49 -0
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
- package/package.json +12 -3
- package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
- package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
- package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
- package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
- package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
- package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
- package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
- package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
- package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
- package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
- package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
- package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
- package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
- package/.agents/scripts/check-gherkin-placeholders.js +0 -663
- package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
- package/.agents/scripts/lib/audit-suite/cli.js +0 -64
- package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
- package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
- package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
- package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
- package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
- package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
- package/.agents/scripts/lib/crap-baseline-index.js +0 -46
- package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
- package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
- package/.agents/scripts/lib/mutation/config-detector.js +0 -119
- package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
- package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
- package/.agents/scripts/lib/observability/active-story-env.js +0 -170
- package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
- package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
- package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
- package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
- package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
- package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
- package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
- package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
- package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
- package/.agents/scripts/lib/planning-corpus.js +0 -37
- package/.agents/scripts/lib/qa/coverage-report.js +0 -181
- package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
- package/.agents/scripts/lib/signals/detectors/index.js +0 -14
- package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
- package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
- package/.agents/scripts/lib/signals/read.js +0 -268
- package/.agents/scripts/lib/signals/span-tree.js +0 -291
- package/.agents/scripts/lib/story-lifecycle.js +0 -194
- package/.agents/scripts/lib/story-plan.js +0 -379
- package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
- package/.agents/scripts/lib/util/phase-timer.js +0 -163
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
- package/.agents/scripts/lint-baseline.js +0 -507
- package/.agents/scripts/providers/github/prs.js +0 -103
- package/.agents/scripts/signals-view.js +0 -309
- package/.agents/scripts/story-plan.js +0 -370
- package/.agents/scripts/sync-branch-from-base.js +0 -149
- package/.agents/scripts/validate-docs-freshness.js +0 -314
- package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
- package/.agents/workflows/helpers/signals.md +0 -112
|
@@ -1,411 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/* node:coverage ignore file */
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* check-lifecycle-doc-drift.js — enforce that the listener-model table
|
|
6
|
-
* in `docs/LIFECYCLE.md` stays in lockstep with the per-listener
|
|
7
|
-
* `this.events = Object.freeze([...])` subscription arrays under
|
|
8
|
-
* `.agents/scripts/lib/orchestration/lifecycle/listeners/`.
|
|
9
|
-
*
|
|
10
|
-
* Why this exists (Tech Spec F5, Epic #2880): the LIFECYCLE.md
|
|
11
|
-
* listener-model table is hand-authored prose that documents the
|
|
12
|
-
* lifecycle-bus contract. When a listener rebinds (e.g.
|
|
13
|
-
* `epic.merge.armed` → `epic.merge.confirmed`) the table drifts
|
|
14
|
-
* silently because nothing reads the code against the doc. This check
|
|
15
|
-
* closes that gap by failing CI on any per-listener subscription that
|
|
16
|
-
* exists in code but not in the doc — or vice versa.
|
|
17
|
-
*
|
|
18
|
-
* Scope:
|
|
19
|
-
* - Listener files scanned: every `*.js` under
|
|
20
|
-
* `.agents/scripts/lib/orchestration/lifecycle/listeners/` except
|
|
21
|
-
* `index.js` (the factory).
|
|
22
|
-
* - Subscription source: each listener exposes its subscription list
|
|
23
|
-
* via a `this.events = Object.freeze([...])` assignment containing
|
|
24
|
-
* a literal array of single-quoted event names. Listeners that do
|
|
25
|
-
* not match this pattern — wildcard observers (`bus.on('*', ...)`)
|
|
26
|
-
* and dynamic-event listeners that resolve subscriptions through
|
|
27
|
-
* identifiers (`Object.keys(...)`, imported constants) — are
|
|
28
|
-
* treated as **wildcard** for the purpose of this check. The doc
|
|
29
|
-
* row for a wildcard listener must carry `` `*` `` in its
|
|
30
|
-
* subscribes-to column.
|
|
31
|
-
* - Doc parsed: `docs/LIFECYCLE.md` § "4. Listener model" — every
|
|
32
|
-
* row in the listener table is keyed by the PascalCase listener
|
|
33
|
-
* name in its first column and the set of backticked event tokens
|
|
34
|
-
* in its second column.
|
|
35
|
-
*
|
|
36
|
-
* Drift detection:
|
|
37
|
-
* - `code-only`: a literal event in `this.events` that does not
|
|
38
|
-
* appear in the matching doc row's events column.
|
|
39
|
-
* - `doc-only`: a backticked event token in the doc row that does
|
|
40
|
-
* not appear in the listener's `this.events`.
|
|
41
|
-
* - `missing-row`: a listener file with no matching PascalCase row
|
|
42
|
-
* in the doc table.
|
|
43
|
-
* - `unknown-row`: a doc row whose listener name does not map to any
|
|
44
|
-
* file under listeners/. Listeners that ship outside the listeners/
|
|
45
|
-
* directory (LedgerWriter, TraceLogger) are exempted by allow-list.
|
|
46
|
-
*
|
|
47
|
-
* Exit codes:
|
|
48
|
-
* 0 — clean.
|
|
49
|
-
* 1 — at least one drift; offending listener + diff printed to stderr.
|
|
50
|
-
*
|
|
51
|
-
* Wiring:
|
|
52
|
-
* This script ships under the `baselines` CI check (see
|
|
53
|
-
* `.agentrc.json` → `github.branchProtection.requiredChecks`).
|
|
54
|
-
*
|
|
55
|
-
* Public test seam: `checkLifecycleDocDrift({ listenersDir, docPath })`
|
|
56
|
-
* runs the scanner against an arbitrary listener tree + doc path. The
|
|
57
|
-
* pure helpers `extractCodeEvents(src)`, `parseListenerTable(md)`, and
|
|
58
|
-
* `diffListenerEvents({ code, doc })` are exported for unit-level
|
|
59
|
-
* reuse (the contract test under tests/contract/lifecycle drives them
|
|
60
|
-
* directly).
|
|
61
|
-
*/
|
|
62
|
-
|
|
63
|
-
import { readdirSync, readFileSync } from 'node:fs';
|
|
64
|
-
import path from 'node:path';
|
|
65
|
-
import { fileURLToPath } from 'node:url';
|
|
66
|
-
|
|
67
|
-
import { runAsCli } from './lib/cli-utils.js';
|
|
68
|
-
|
|
69
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
70
|
-
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
71
|
-
|
|
72
|
-
const DEFAULT_LISTENERS_DIR = path.join(
|
|
73
|
-
REPO_ROOT,
|
|
74
|
-
'.agents',
|
|
75
|
-
'scripts',
|
|
76
|
-
'lib',
|
|
77
|
-
'orchestration',
|
|
78
|
-
'lifecycle',
|
|
79
|
-
'listeners',
|
|
80
|
-
);
|
|
81
|
-
const DEFAULT_DOC_PATH = path.join(REPO_ROOT, 'docs', 'LIFECYCLE.md');
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Listener classes documented in the LIFECYCLE.md listener table that
|
|
85
|
-
* are intentionally NOT shipped as files under listeners/ — they live
|
|
86
|
-
* in sibling modules (`ledger-writer.js`, `trace-logger.js`) but
|
|
87
|
-
* register against the bus on the same wildcard contract. They are
|
|
88
|
-
* exempt from the `unknown-row` check.
|
|
89
|
-
*/
|
|
90
|
-
const EXEMPT_DOC_ROWS = Object.freeze(new Set(['LedgerWriter', 'TraceLogger']));
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Convert a kebab-case basename to PascalCase, e.g.
|
|
94
|
-
* `acceptance-reconciler` → `AcceptanceReconciler`.
|
|
95
|
-
*
|
|
96
|
-
* @param {string} basename
|
|
97
|
-
* @returns {string}
|
|
98
|
-
*/
|
|
99
|
-
export function kebabToPascal(basename) {
|
|
100
|
-
return basename
|
|
101
|
-
.split('-')
|
|
102
|
-
.map((seg) => (seg.length ? seg[0].toUpperCase() + seg.slice(1) : seg))
|
|
103
|
-
.join('');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Extract the array of literal event names from a listener file's
|
|
108
|
-
* source. Looks for the canonical `this.events = Object.freeze([...])`
|
|
109
|
-
* pattern; the array body may span multiple lines. Returns:
|
|
110
|
-
* - `{ kind: 'literals', events: [...] }` when every array entry is
|
|
111
|
-
* a single- or double-quoted string literal.
|
|
112
|
-
* - `{ kind: 'wildcard' }` when no pattern matches OR the array body
|
|
113
|
-
* contains identifiers / function calls (i.e. the subscription set
|
|
114
|
-
* is dynamic at runtime; the doc row must use `*`).
|
|
115
|
-
*
|
|
116
|
-
* @param {string} src
|
|
117
|
-
* @returns {{ kind: 'literals', events: string[] } | { kind: 'wildcard' }}
|
|
118
|
-
*/
|
|
119
|
-
export function extractCodeEvents(src) {
|
|
120
|
-
// Greedy across newlines, non-greedy on the array body. The
|
|
121
|
-
// surrounding `Object.freeze([` … `])` anchors the match tightly so
|
|
122
|
-
// we never swallow unrelated array literals.
|
|
123
|
-
const re = /this\.events\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/;
|
|
124
|
-
const m = re.exec(src);
|
|
125
|
-
if (!m) return { kind: 'wildcard' };
|
|
126
|
-
const body = m[1];
|
|
127
|
-
// Build a constant table for top-level `const|export const NAME =
|
|
128
|
-
// '<literal>';` assignments so subscriptions that reference an event
|
|
129
|
-
// name constant (e.g. `Object.freeze([INTERVENTION_RECORDED_EVENT])`)
|
|
130
|
-
// resolve to a literal here instead of falling through to wildcard.
|
|
131
|
-
const constants = collectStringConstants(src);
|
|
132
|
-
// Strip comments inside the array body so they don't confuse the
|
|
133
|
-
// literal-vs-identifier split.
|
|
134
|
-
const bodyClean = body
|
|
135
|
-
.replace(/\/\/.*$/gm, '')
|
|
136
|
-
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
137
|
-
const literals = [];
|
|
138
|
-
let isDynamic = false;
|
|
139
|
-
for (const rawEntry of bodyClean.split(',')) {
|
|
140
|
-
const entry = rawEntry.trim();
|
|
141
|
-
if (entry.length === 0) continue;
|
|
142
|
-
const strLit = /^'([^']*)'$|^"([^"]*)"$/.exec(entry);
|
|
143
|
-
if (strLit) {
|
|
144
|
-
literals.push(strLit[1] ?? strLit[2]);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry) && constants.has(entry)) {
|
|
148
|
-
literals.push(constants.get(entry));
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
// Anything else (function call, spread, unknown identifier) means
|
|
152
|
-
// the array is dynamic at runtime; treat as wildcard.
|
|
153
|
-
isDynamic = true;
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
if (isDynamic) return { kind: 'wildcard' };
|
|
157
|
-
if (literals.length === 0) return { kind: 'wildcard' };
|
|
158
|
-
return { kind: 'literals', events: literals };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Collect top-level string constants of the shape
|
|
163
|
-
* `const NAME = '<literal>';` or `export const NAME = '<literal>';`
|
|
164
|
-
* so the array-body parser can resolve identifier-form event names.
|
|
165
|
-
*
|
|
166
|
-
* @param {string} src
|
|
167
|
-
* @returns {Map<string, string>}
|
|
168
|
-
*/
|
|
169
|
-
export function collectStringConstants(src) {
|
|
170
|
-
const out = new Map();
|
|
171
|
-
const re =
|
|
172
|
-
/^\s*(?:export\s+)?const\s+([A-Z_][A-Z0-9_]*)\s*=\s*(?:'([^']*)'|"([^"]*)")\s*;/gm;
|
|
173
|
-
let m = re.exec(src);
|
|
174
|
-
while (m !== null) {
|
|
175
|
-
out.set(m[1], m[2] ?? m[3]);
|
|
176
|
-
m = re.exec(src);
|
|
177
|
-
}
|
|
178
|
-
return out;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Parse the LIFECYCLE.md listener-model table. Returns a Map from
|
|
183
|
-
* PascalCase listener name → { events: Set<string>, hasWildcard:
|
|
184
|
-
* boolean }. The table is located by looking for a markdown table
|
|
185
|
-
* whose header includes "Listener" and "Subscribes to".
|
|
186
|
-
*
|
|
187
|
-
* @param {string} md
|
|
188
|
-
* @returns {Map<string, { events: Set<string>, hasWildcard: boolean }>}
|
|
189
|
-
*/
|
|
190
|
-
export function parseListenerTable(md) {
|
|
191
|
-
const out = new Map();
|
|
192
|
-
const lines = md.split(/\r?\n/);
|
|
193
|
-
// Find the header row that names "Listener" and "Subscribes to".
|
|
194
|
-
let inTable = false;
|
|
195
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
196
|
-
const line = lines[i];
|
|
197
|
-
if (!inTable) {
|
|
198
|
-
if (line.includes('| Listener') && line.includes('Subscribes to')) {
|
|
199
|
-
// Header row found; skip the separator on the next line and
|
|
200
|
-
// start parsing data rows.
|
|
201
|
-
inTable = true;
|
|
202
|
-
i += 1; // skip separator
|
|
203
|
-
}
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
// Data row or end-of-table.
|
|
207
|
-
if (!line.startsWith('|')) break;
|
|
208
|
-
const cells = line.split('|').map((c) => c.trim());
|
|
209
|
-
// cells[0] is empty before leading '|'; cells[1] is the listener,
|
|
210
|
-
// cells[2] is the subscribes-to column.
|
|
211
|
-
if (cells.length < 3) continue;
|
|
212
|
-
const nameCell = cells[1];
|
|
213
|
-
const subsCell = cells[2];
|
|
214
|
-
const nameMatch = /`([A-Za-z][A-Za-z0-9_]*)`/.exec(nameCell);
|
|
215
|
-
if (!nameMatch) continue;
|
|
216
|
-
const name = nameMatch[1];
|
|
217
|
-
const events = new Set();
|
|
218
|
-
let hasWildcard = false;
|
|
219
|
-
const tokenRe = /`([^`]+)`/g;
|
|
220
|
-
let tm = tokenRe.exec(subsCell);
|
|
221
|
-
while (tm !== null) {
|
|
222
|
-
const tok = tm[1];
|
|
223
|
-
if (tok === '*') hasWildcard = true;
|
|
224
|
-
// Event tokens look like `<word>.<word>(.<word>)*`. Anything
|
|
225
|
-
// else (e.g. `epic-*`, prose tokens) is ignored — wildcard rows
|
|
226
|
-
// already cover those cases.
|
|
227
|
-
else if (/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/i.test(tok)) {
|
|
228
|
-
events.add(tok);
|
|
229
|
-
}
|
|
230
|
-
tm = tokenRe.exec(subsCell);
|
|
231
|
-
}
|
|
232
|
-
out.set(name, { events, hasWildcard });
|
|
233
|
-
}
|
|
234
|
-
return out;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Diff a code-side event extraction against a doc-side row.
|
|
239
|
-
*
|
|
240
|
-
* @param {object} input
|
|
241
|
-
* @param {{ kind: 'literals', events: string[] } | { kind: 'wildcard' }} input.code
|
|
242
|
-
* @param {{ events: Set<string>, hasWildcard: boolean } | undefined} input.doc
|
|
243
|
-
* @returns {{ codeOnly: string[], docOnly: string[], wildcardMismatch: boolean }}
|
|
244
|
-
*/
|
|
245
|
-
export function diffListenerEvents({ code, doc }) {
|
|
246
|
-
const codeOnly = [];
|
|
247
|
-
const docOnly = [];
|
|
248
|
-
let wildcardMismatch = false;
|
|
249
|
-
if (!doc) {
|
|
250
|
-
// Caller surfaces this as `missing-row`; nothing to diff here.
|
|
251
|
-
if (code.kind === 'literals')
|
|
252
|
-
return { codeOnly: code.events.slice(), docOnly, wildcardMismatch };
|
|
253
|
-
return { codeOnly, docOnly, wildcardMismatch };
|
|
254
|
-
}
|
|
255
|
-
if (code.kind === 'wildcard') {
|
|
256
|
-
if (!doc.hasWildcard) wildcardMismatch = true;
|
|
257
|
-
return { codeOnly, docOnly, wildcardMismatch };
|
|
258
|
-
}
|
|
259
|
-
// Literal code-side; compare both directions.
|
|
260
|
-
const codeSet = new Set(code.events);
|
|
261
|
-
for (const ev of code.events) {
|
|
262
|
-
if (!doc.events.has(ev)) codeOnly.push(ev);
|
|
263
|
-
}
|
|
264
|
-
for (const ev of doc.events) {
|
|
265
|
-
if (!codeSet.has(ev)) docOnly.push(ev);
|
|
266
|
-
}
|
|
267
|
-
return { codeOnly, docOnly, wildcardMismatch };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Walk the listeners directory and return [{ pascalName, filePath,
|
|
272
|
-
* code }] for every `.js` file (excluding `index.js`).
|
|
273
|
-
*
|
|
274
|
-
* @param {string} listenersDir
|
|
275
|
-
* @param {{ read?: typeof readFileSync, readDir?: typeof readdirSync }} [opts]
|
|
276
|
-
* @returns {Array<{ pascalName: string, filePath: string, code: ReturnType<typeof extractCodeEvents> }>}
|
|
277
|
-
*/
|
|
278
|
-
export function loadCodeListeners(
|
|
279
|
-
listenersDir,
|
|
280
|
-
{ read = readFileSync, readDir = readdirSync } = {},
|
|
281
|
-
) {
|
|
282
|
-
const out = [];
|
|
283
|
-
const entries = readDir(listenersDir, { withFileTypes: true });
|
|
284
|
-
for (const ent of entries) {
|
|
285
|
-
if (!ent.isFile()) continue;
|
|
286
|
-
if (!ent.name.endsWith('.js')) continue;
|
|
287
|
-
if (ent.name === 'index.js') continue;
|
|
288
|
-
const basename = ent.name.replace(/\.js$/, '');
|
|
289
|
-
const pascalName = kebabToPascal(basename);
|
|
290
|
-
const filePath = path.join(listenersDir, ent.name);
|
|
291
|
-
const src = read(filePath, 'utf8');
|
|
292
|
-
const code = extractCodeEvents(src);
|
|
293
|
-
out.push({ pascalName, filePath, code });
|
|
294
|
-
}
|
|
295
|
-
return out;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Run the drift check end-to-end. Returns an array of findings; each
|
|
300
|
-
* finding carries a `kind` discriminator and the offending listener
|
|
301
|
-
* name + details.
|
|
302
|
-
*
|
|
303
|
-
* @param {object} [opts]
|
|
304
|
-
* @param {string} [opts.listenersDir] override the default listeners/
|
|
305
|
-
* path (used by tests + fixture injection).
|
|
306
|
-
* @param {string} [opts.docPath] override the default LIFECYCLE.md
|
|
307
|
-
* path.
|
|
308
|
-
* @param {typeof readFileSync} [opts.read]
|
|
309
|
-
* @param {typeof readdirSync} [opts.readDir]
|
|
310
|
-
* @returns {Array<{ kind: string, listener: string, detail?: string, codeOnly?: string[], docOnly?: string[] }>}
|
|
311
|
-
*/
|
|
312
|
-
export function checkLifecycleDocDrift({
|
|
313
|
-
listenersDir = DEFAULT_LISTENERS_DIR,
|
|
314
|
-
docPath = DEFAULT_DOC_PATH,
|
|
315
|
-
read = readFileSync,
|
|
316
|
-
readDir = readdirSync,
|
|
317
|
-
} = {}) {
|
|
318
|
-
const findings = [];
|
|
319
|
-
const md = read(docPath, 'utf8');
|
|
320
|
-
const docRows = parseListenerTable(md);
|
|
321
|
-
const codeListeners = loadCodeListeners(listenersDir, { read, readDir });
|
|
322
|
-
const codePascalNames = new Set(codeListeners.map((c) => c.pascalName));
|
|
323
|
-
|
|
324
|
-
for (const { pascalName, code } of codeListeners) {
|
|
325
|
-
const doc = docRows.get(pascalName);
|
|
326
|
-
if (!doc) {
|
|
327
|
-
findings.push({
|
|
328
|
-
kind: 'missing-row',
|
|
329
|
-
listener: pascalName,
|
|
330
|
-
detail: `listener file present in code but no matching row in LIFECYCLE.md listener table`,
|
|
331
|
-
});
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
const diff = diffListenerEvents({ code, doc });
|
|
335
|
-
if (diff.wildcardMismatch) {
|
|
336
|
-
findings.push({
|
|
337
|
-
kind: 'wildcard-mismatch',
|
|
338
|
-
listener: pascalName,
|
|
339
|
-
detail: `listener registers a wildcard or dynamic subscription in code but the doc row carries no \`*\` token`,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
if (diff.codeOnly.length > 0 || diff.docOnly.length > 0) {
|
|
343
|
-
findings.push({
|
|
344
|
-
kind: 'event-drift',
|
|
345
|
-
listener: pascalName,
|
|
346
|
-
codeOnly: diff.codeOnly,
|
|
347
|
-
docOnly: diff.docOnly,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
for (const [docName] of docRows) {
|
|
353
|
-
if (codePascalNames.has(docName)) continue;
|
|
354
|
-
if (EXEMPT_DOC_ROWS.has(docName)) continue;
|
|
355
|
-
findings.push({
|
|
356
|
-
kind: 'unknown-row',
|
|
357
|
-
listener: docName,
|
|
358
|
-
detail: `LIFECYCLE.md lists this listener but no matching file exists under listeners/`,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return findings;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Format a single finding as a human-readable diff line.
|
|
367
|
-
*
|
|
368
|
-
* @param {{ kind: string, listener: string, detail?: string, codeOnly?: string[], docOnly?: string[] }} f
|
|
369
|
-
* @returns {string}
|
|
370
|
-
*/
|
|
371
|
-
export function formatFinding(f) {
|
|
372
|
-
if (f.kind === 'event-drift') {
|
|
373
|
-
const parts = [];
|
|
374
|
-
if (f.codeOnly && f.codeOnly.length > 0) {
|
|
375
|
-
parts.push(`code-only: [${f.codeOnly.join(', ')}]`);
|
|
376
|
-
}
|
|
377
|
-
if (f.docOnly && f.docOnly.length > 0) {
|
|
378
|
-
parts.push(`doc-only: [${f.docOnly.join(', ')}]`);
|
|
379
|
-
}
|
|
380
|
-
return `[lifecycle-doc-drift][event-drift] ${f.listener}\n ${parts.join(' | ')}`;
|
|
381
|
-
}
|
|
382
|
-
return `[lifecycle-doc-drift][${f.kind}] ${f.listener}\n ${f.detail ?? ''}`;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
async function main() {
|
|
386
|
-
const findings = checkLifecycleDocDrift();
|
|
387
|
-
if (findings.length === 0) {
|
|
388
|
-
process.stdout.write(
|
|
389
|
-
'[lifecycle-doc-drift] clean: listener subscriptions match docs/LIFECYCLE.md listener-model table.\n',
|
|
390
|
-
);
|
|
391
|
-
return 0;
|
|
392
|
-
}
|
|
393
|
-
for (const f of findings) {
|
|
394
|
-
process.stderr.write(`${formatFinding(f)}\n`);
|
|
395
|
-
}
|
|
396
|
-
return 1;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
await runAsCli(import.meta.url, main, {
|
|
400
|
-
source: 'check-lifecycle-doc-drift',
|
|
401
|
-
propagateExitCode: true,
|
|
402
|
-
usage: {
|
|
403
|
-
invocation: 'node .agents/scripts/check-lifecycle-doc-drift.js',
|
|
404
|
-
summary:
|
|
405
|
-
"Fail when the listener-model table in docs/LIFECYCLE.md drifts from the lifecycle listeners' declared subscriptions.",
|
|
406
|
-
flags: [],
|
|
407
|
-
notes: [
|
|
408
|
-
'Exit codes:\n 0 doc and code agree\n 1 drift in either direction',
|
|
409
|
-
],
|
|
410
|
-
},
|
|
411
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/audit-suite/cli.js — Argv parsing + help text for run-audit-suite.
|
|
3
|
-
*
|
|
4
|
-
* Extracted from `.agents/scripts/run-audit-suite.js` (Story #963, Epic #946).
|
|
5
|
-
* No IO, no provider call. The entry-point composes these helpers with the
|
|
6
|
-
* runner from `./runner.js`.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { parseArgs } from 'node:util';
|
|
10
|
-
|
|
11
|
-
export const HELP = `Usage: node .agents/scripts/run-audit-suite.js \\
|
|
12
|
-
--audits <comma-list> [--ticket <id>] [--base-branch main] \\
|
|
13
|
-
[--substitution key=value]... [--run-id <id>]
|
|
14
|
-
|
|
15
|
-
Flags:
|
|
16
|
-
--audits Comma-separated audit workflow names (required).
|
|
17
|
-
--ticket Ticket id used for the {{ticketId}} substitution (optional).
|
|
18
|
-
--base-branch Value used for the {{baseBranch}} substitution (default: main).
|
|
19
|
-
--substitution Repeatable key=value substitution (e.g. --substitution alphaKey=val).
|
|
20
|
-
Allowed keys are the built-ins (auditOutputDir, ticketId, baseBranch,
|
|
21
|
-
changedFiles) plus any substitutionKeys declared on the requested audits.
|
|
22
|
-
--run-id Optional artifact prefix. When set, full prompt bodies are written
|
|
23
|
-
to <auditOutputDir>/audit-<run-id>-<audit>.md (defaults to
|
|
24
|
-
temp/audits/) so downstream agents can read them.
|
|
25
|
-
--help Show this message.
|
|
26
|
-
`;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Pure: split a comma-separated `--audits` value into a clean workflow list.
|
|
30
|
-
* Empty/whitespace tokens are dropped; nullish input yields an empty array.
|
|
31
|
-
*
|
|
32
|
-
* @param {string|null|undefined} commaList
|
|
33
|
-
* @returns {string[]}
|
|
34
|
-
*/
|
|
35
|
-
export function parseAuditList(commaList) {
|
|
36
|
-
return String(commaList ?? '')
|
|
37
|
-
.split(',')
|
|
38
|
-
.map((a) => a.trim())
|
|
39
|
-
.filter(Boolean);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Parse the run-audit-suite CLI argv into a flat `values` object.
|
|
44
|
-
* `strict: false` keeps the parser permissive so callers can pass extra
|
|
45
|
-
* positional/unknown flags without aborting the run.
|
|
46
|
-
*
|
|
47
|
-
* @param {string[]} argv
|
|
48
|
-
* @returns {Record<string, unknown>}
|
|
49
|
-
*/
|
|
50
|
-
export function parseArgv(argv) {
|
|
51
|
-
const { values } = parseArgs({
|
|
52
|
-
args: argv,
|
|
53
|
-
options: {
|
|
54
|
-
audits: { type: 'string' },
|
|
55
|
-
ticket: { type: 'string' },
|
|
56
|
-
'base-branch': { type: 'string' },
|
|
57
|
-
substitution: { type: 'string', multiple: true },
|
|
58
|
-
'run-id': { type: 'string' },
|
|
59
|
-
help: { type: 'boolean' },
|
|
60
|
-
},
|
|
61
|
-
strict: false,
|
|
62
|
-
});
|
|
63
|
-
return values;
|
|
64
|
-
}
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* bootstrap/baselines-layout-migration — Story #1401 (Epic #1386),
|
|
3
|
-
* re-targeted by Story #1467 (Epic #1179).
|
|
4
|
-
*
|
|
5
|
-
* Idempotent helper that brings a project's per-Epic ratchet snapshots into
|
|
6
|
-
* the `temp/epic/<id>/baselines/` namespace. Three legacy shapes are
|
|
7
|
-
* recognised and migrated:
|
|
8
|
-
*
|
|
9
|
-
* 1. Loose per-Epic snapshots at the baselines root
|
|
10
|
-
* (`baselines/epic-<id>-{maintainability,crap}.json`).
|
|
11
|
-
* 2. The flat prototype `baselines/snapshots/<id>/` tree.
|
|
12
|
-
* 3. The committed `baselines/epic/<id>/` subdirectory shape that the
|
|
13
|
-
* original Story #1396 introduced (now superseded — committed snapshots
|
|
14
|
-
* accumulated obsolete entries forever because nothing pruned them).
|
|
15
|
-
*
|
|
16
|
-
* All three shapes are relocated under `<repoRoot>/temp/epic/<id>/baselines/`,
|
|
17
|
-
* where they inherit the existing per-epic temp-tree cleanup contract:
|
|
18
|
-
* `/deliver` reaps `temp/epic/<id>/` on merge, so the ratchet snapshots
|
|
19
|
-
* are ephemeral scratch state — never committed, no manual prune.
|
|
20
|
-
*
|
|
21
|
-
* The main-tracked `baselines/{maintainability,crap}.json` files are NOT
|
|
22
|
-
* touched — they remain at the root as the `main`-baseline contract
|
|
23
|
-
* specifies.
|
|
24
|
-
*
|
|
25
|
-
* Pruning committed leftovers
|
|
26
|
-
* ---------------------------
|
|
27
|
-
* When the legacy `baselines/epic/<id>/` subdirectory shape is detected, the
|
|
28
|
-
* helper invokes `git rm -r --quiet baselines/epic/<id>` (with
|
|
29
|
-
* `--ignore-unmatch` for the untracked case) so the now-empty committed tree
|
|
30
|
-
* is removed in the same operation. Callers commit the resulting working-tree
|
|
31
|
-
* delta; on a clean repo (no committed `baselines/epic/`) the helper is a
|
|
32
|
-
* filesystem-only no-op.
|
|
33
|
-
*
|
|
34
|
-
* The helper reports the per-Epic outcome so the workflow can summarise
|
|
35
|
-
* exactly which snapshots moved and which were already in the target
|
|
36
|
-
* shape. Re-running on an already-migrated tree produces zero mutations.
|
|
37
|
-
*
|
|
38
|
-
* @module bootstrap/baselines-layout-migration
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
42
|
-
import fs from 'node:fs';
|
|
43
|
-
import path from 'node:path';
|
|
44
|
-
|
|
45
|
-
const BASELINE_FILE_RE = /^(maintainability|crap)\.json$/;
|
|
46
|
-
const LOOSE_FILE_RE = /^epic-(\d+)-(maintainability|crap)\.json$/;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Move a single snapshot file to its temp-namespace target. When the target
|
|
50
|
-
* already holds a canonical copy, the source is discarded instead of
|
|
51
|
-
* overwriting.
|
|
52
|
-
*/
|
|
53
|
-
function moveOneSnapshot({ from, to, label, moves }) {
|
|
54
|
-
if (fs.existsSync(to)) {
|
|
55
|
-
fs.rmSync(from);
|
|
56
|
-
moves.push({ from, to, action: 'discarded-superseded' });
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
60
|
-
fs.renameSync(from, to);
|
|
61
|
-
moves.push({ from, to, action: label });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Drop a directory if it exists on disk and is empty.
|
|
66
|
-
*/
|
|
67
|
-
function dropEmptyDir(absDir) {
|
|
68
|
-
if (fs.existsSync(absDir) && fs.readdirSync(absDir).length === 0) {
|
|
69
|
-
fs.rmdirSync(absDir);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Walk a per-Epic source directory (`<srcRoot>/<id>/*.json`) and migrate
|
|
75
|
-
* each baseline file into `<tempEpicRoot>/<id>/baselines/`. Returns the
|
|
76
|
-
* Epic dirs that were touched so the caller can run per-Epic cleanup
|
|
77
|
-
* (e.g. `git rm` for the committed shape).
|
|
78
|
-
*/
|
|
79
|
-
function migrateEpicDir({ srcRoot, tempEpicRoot, label, moves }) {
|
|
80
|
-
const touched = [];
|
|
81
|
-
if (!fs.existsSync(srcRoot) || !fs.statSync(srcRoot).isDirectory()) {
|
|
82
|
-
return touched;
|
|
83
|
-
}
|
|
84
|
-
for (const epicEnt of fs.readdirSync(srcRoot, { withFileTypes: true })) {
|
|
85
|
-
if (!epicEnt.isDirectory() || !/^\d+$/.test(epicEnt.name)) continue;
|
|
86
|
-
const epicId = epicEnt.name;
|
|
87
|
-
const fromDir = path.join(srcRoot, epicId);
|
|
88
|
-
const toDir = path.join(tempEpicRoot, epicId, 'baselines');
|
|
89
|
-
for (const fileEnt of fs.readdirSync(fromDir, { withFileTypes: true })) {
|
|
90
|
-
if (!fileEnt.isFile() || !BASELINE_FILE_RE.test(fileEnt.name)) continue;
|
|
91
|
-
moveOneSnapshot({
|
|
92
|
-
from: path.join(fromDir, fileEnt.name),
|
|
93
|
-
to: path.join(toDir, fileEnt.name),
|
|
94
|
-
label,
|
|
95
|
-
moves,
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
dropEmptyDir(fromDir);
|
|
99
|
-
touched.push({ epicId, fromDir });
|
|
100
|
-
}
|
|
101
|
-
return touched;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Migrate shape 1: loose per-Epic snapshots at the root of `baselines/`.
|
|
106
|
-
*/
|
|
107
|
-
function migrateLooseShape({ baselinesDir, tempEpicRoot, moves }) {
|
|
108
|
-
for (const ent of fs.readdirSync(baselinesDir, { withFileTypes: true })) {
|
|
109
|
-
if (!ent.isFile()) continue;
|
|
110
|
-
const m = ent.name.match(LOOSE_FILE_RE);
|
|
111
|
-
if (!m) continue;
|
|
112
|
-
const [, epicId, gate] = m;
|
|
113
|
-
moveOneSnapshot({
|
|
114
|
-
from: path.join(baselinesDir, ent.name),
|
|
115
|
-
to: path.join(tempEpicRoot, epicId, 'baselines', `${gate}.json`),
|
|
116
|
-
label: 'relocated-loose',
|
|
117
|
-
moves,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Stage the prune of a now-empty committed `baselines/epic/<id>/` via
|
|
124
|
-
* `git rm -r --quiet --ignore-unmatch`. `--ignore-unmatch` keeps the call
|
|
125
|
-
* safe when the path is not tracked (fresh-clone case).
|
|
126
|
-
*/
|
|
127
|
-
function pruneCommittedEpic({ fromDir, repoRoot, spawnSync, prunedDirs }) {
|
|
128
|
-
const epicRelPath = path
|
|
129
|
-
.relative(repoRoot, fromDir)
|
|
130
|
-
.split(path.sep)
|
|
131
|
-
.join('/');
|
|
132
|
-
const rm = spawnSync(
|
|
133
|
-
'git',
|
|
134
|
-
['rm', '-r', '--quiet', '--ignore-unmatch', '--', epicRelPath],
|
|
135
|
-
{ cwd: repoRoot, encoding: 'utf-8', stdio: 'pipe', shell: false },
|
|
136
|
-
);
|
|
137
|
-
prunedDirs.push({ path: epicRelPath, gitStatus: rm.status ?? null });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Detect and migrate any legacy per-Epic snapshot under `baselinesDir` into
|
|
142
|
-
* the `temp/epic/<id>/baselines/` shape under `repoRoot`. Returns one of
|
|
143
|
-
* three actions per discovered snapshot.
|
|
144
|
-
*
|
|
145
|
-
* @param {object} args
|
|
146
|
-
* @param {string} args.baselinesDir - Absolute path to the project's
|
|
147
|
-
* `baselines/` directory (the legacy source-of-snapshots locator).
|
|
148
|
-
* @param {string} [args.repoRoot] - Absolute path to the project root.
|
|
149
|
-
* Defaults to `path.dirname(baselinesDir)` so existing callers that only
|
|
150
|
-
* pass `baselinesDir` continue to work for repos where `baselines/` sits
|
|
151
|
-
* directly under the repo root (the canonical layout).
|
|
152
|
-
* @param {typeof defaultSpawnSync} [args.spawnSync] - Injected for tests.
|
|
153
|
-
*/
|
|
154
|
-
export function migrateBaselinesLayout(args) {
|
|
155
|
-
const baselinesDir = args.baselinesDir;
|
|
156
|
-
const repoRoot = args.repoRoot ?? path.dirname(baselinesDir);
|
|
157
|
-
const spawnSync = args.spawnSync ?? defaultSpawnSync;
|
|
158
|
-
const moves = [];
|
|
159
|
-
const prunedDirs = [];
|
|
160
|
-
|
|
161
|
-
if (!fs.existsSync(baselinesDir)) {
|
|
162
|
-
return { action: 'no-baselines-dir', moves, prunedDirs };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const tempEpicRoot = path.join(repoRoot, 'temp', 'epic');
|
|
166
|
-
|
|
167
|
-
// Shape 1: loose per-Epic snapshots at the root.
|
|
168
|
-
migrateLooseShape({ baselinesDir, tempEpicRoot, moves });
|
|
169
|
-
|
|
170
|
-
// Shape 2: prototype `baselines/snapshots/<id>/` tree.
|
|
171
|
-
const protoRoot = path.join(baselinesDir, 'snapshots');
|
|
172
|
-
migrateEpicDir({
|
|
173
|
-
srcRoot: protoRoot,
|
|
174
|
-
tempEpicRoot,
|
|
175
|
-
label: 'relocated-prototype',
|
|
176
|
-
moves,
|
|
177
|
-
});
|
|
178
|
-
dropEmptyDir(protoRoot);
|
|
179
|
-
|
|
180
|
-
// Shape 3: committed `baselines/epic/<id>/` subdirectory layout (the
|
|
181
|
-
// shape Story #1396 introduced; superseded by the temp-namespace
|
|
182
|
-
// contract in Story #1467). Move snapshots OUT to temp, prune each
|
|
183
|
-
// committed per-Epic dir via `git rm`, then drop the parent.
|
|
184
|
-
const committedEpicRoot = path.join(baselinesDir, 'epic');
|
|
185
|
-
const touched = migrateEpicDir({
|
|
186
|
-
srcRoot: committedEpicRoot,
|
|
187
|
-
tempEpicRoot,
|
|
188
|
-
label: 'relocated-committed',
|
|
189
|
-
moves,
|
|
190
|
-
});
|
|
191
|
-
for (const { fromDir } of touched) {
|
|
192
|
-
pruneCommittedEpic({ fromDir, repoRoot, spawnSync, prunedDirs });
|
|
193
|
-
}
|
|
194
|
-
dropEmptyDir(committedEpicRoot);
|
|
195
|
-
|
|
196
|
-
return {
|
|
197
|
-
action:
|
|
198
|
-
moves.length > 0 || prunedDirs.length > 0 ? 'migrated' : 'no-change',
|
|
199
|
-
moves,
|
|
200
|
-
prunedDirs,
|
|
201
|
-
};
|
|
202
|
-
}
|