mandrel 2.25.0 → 2.26.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/agents/acceptance-critic.md +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +64 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
* (Story #2197, Epic #2173).
|
|
4
4
|
*
|
|
5
5
|
* `refreshBaseline()` is the single funnel through which every baseline
|
|
6
|
-
* regeneration (maintainability, crap, coverage) must flow.
|
|
7
|
-
* previously assembled their own envelopes and called
|
|
8
|
-
* MUST migrate to this entry point — Stories 3/4/5 of
|
|
9
|
-
* migration; Story #2197 only lands the service surface
|
|
6
|
+
* regeneration (maintainability, crap, coverage, duplication) must flow.
|
|
7
|
+
* Callers that previously assembled their own envelopes and called
|
|
8
|
+
* `fs.writeFileSync` MUST migrate to this entry point — Stories 3/4/5 of
|
|
9
|
+
* Epic #2173 do that migration; Story #2197 only lands the service surface
|
|
10
|
+
* and tests. Story #4944 migrated the last holdout, `duplication`.
|
|
10
11
|
*
|
|
11
12
|
* The service is **scoring-agnostic**: it does not itself walk the
|
|
12
|
-
* filesystem to compute MI / CRAP / coverage scores. Scoring
|
|
13
|
-
* by the per-kind default scorers resolved lazily via
|
|
13
|
+
* filesystem to compute MI / CRAP / coverage / duplication scores. Scoring
|
|
14
|
+
* is provided by the per-kind default scorers resolved lazily via
|
|
15
|
+
* `resolveDefaultScorer`
|
|
14
16
|
* (built with the project config resolved against `cwd`) and, in tests or
|
|
15
17
|
* production wiring, injected via the `scorer` option for hermetic
|
|
16
18
|
* determinism. The service is the policy layer:
|
|
@@ -29,7 +31,8 @@
|
|
|
29
31
|
* Public API (Task #2203, AC-1 / AC-2 / AC-7):
|
|
30
32
|
*
|
|
31
33
|
* refreshBaseline({
|
|
32
|
-
* kind, // 'maintainability' | 'crap' | 'coverage'
|
|
34
|
+
* kind, // 'maintainability' | 'crap' | 'coverage'
|
|
35
|
+
* // | 'duplication' REQUIRED
|
|
33
36
|
* baseRef, // git ref to diff against; default 'origin/main'
|
|
34
37
|
* headRef, // git ref under inspection; default 'HEAD'
|
|
35
38
|
* scopeFiles, // Array<string> | null
|
|
@@ -62,8 +65,9 @@
|
|
|
62
65
|
*
|
|
63
66
|
* Acceptance contract:
|
|
64
67
|
*
|
|
65
|
-
* AC-1: All callers that produce a maintainability/crap/coverage
|
|
66
|
-
* go through refreshBaseline(). Enforced by
|
|
68
|
+
* AC-1: All callers that produce a maintainability/crap/coverage/
|
|
69
|
+
* duplication baseline go through refreshBaseline(). Enforced by
|
|
70
|
+
* the Task #2208 invariant.
|
|
67
71
|
* AC-2: scopeFiles=null && !fullScope -> diff-derived scope (Task #2207).
|
|
68
72
|
* AC-4: Out-of-scope rows + their updatedAt fields are preserved byte-
|
|
69
73
|
* for-byte (Task #2209).
|
|
@@ -93,6 +97,7 @@ import {
|
|
|
93
97
|
isIgnoredByGlobs as isIgnoredByGlobsMi,
|
|
94
98
|
scanDirectory as scanDirectoryMi,
|
|
95
99
|
} from '../maintainability-utils.js';
|
|
100
|
+
import { resolveDetectClones, scanDuplication } from './duplication-scanner.js';
|
|
96
101
|
import { filterExcludedRows } from './kinds/maintainability.js';
|
|
97
102
|
import { canonicalizeBaselinePath } from './path-canon.js';
|
|
98
103
|
import {
|
|
@@ -108,7 +113,12 @@ const execFileAsync = promisify(nodeExecFile);
|
|
|
108
113
|
* Kinds the refresh service knows how to dispatch. Stays in lockstep with
|
|
109
114
|
* the per-kind modules under `.agents/scripts/lib/baselines/kinds/`.
|
|
110
115
|
*/
|
|
111
|
-
const SUPPORTED_KINDS = Object.freeze([
|
|
116
|
+
const SUPPORTED_KINDS = Object.freeze([
|
|
117
|
+
'maintainability',
|
|
118
|
+
'crap',
|
|
119
|
+
'coverage',
|
|
120
|
+
'duplication',
|
|
121
|
+
]);
|
|
112
122
|
|
|
113
123
|
/**
|
|
114
124
|
* Per-kind file-extension predicate for diff-scope derivation (Task #2207).
|
|
@@ -125,6 +135,12 @@ const KIND_FILE_PREDICATES = Object.freeze({
|
|
|
125
135
|
maintainability: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
|
|
126
136
|
crap: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
|
|
127
137
|
coverage: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
|
|
138
|
+
// Duplication shares the source-extension filter deliberately. jscpd is
|
|
139
|
+
// configured for the `javascript` format only, so a changed `.ts` file
|
|
140
|
+
// admitted here simply yields no row on either side of the merge —
|
|
141
|
+
// over-inclusive is inert, under-inclusive would silently pin a changed
|
|
142
|
+
// file's prior row. Erring wide is the safe direction.
|
|
143
|
+
duplication: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
|
|
128
144
|
});
|
|
129
145
|
|
|
130
146
|
/**
|
|
@@ -338,6 +354,7 @@ const KIND_SCORER_BUILDERS = Object.freeze({
|
|
|
338
354
|
maintainability: buildDefaultMaintainabilityScorer,
|
|
339
355
|
crap: buildDefaultCrapScorer,
|
|
340
356
|
coverage: buildDefaultCoverageScorer,
|
|
357
|
+
duplication: buildDefaultDuplicationScorer,
|
|
341
358
|
});
|
|
342
359
|
|
|
343
360
|
/**
|
|
@@ -384,7 +401,7 @@ export function resolveDefaultScorer(kind, { cwd } = {}) {
|
|
|
384
401
|
* so callers (and tests) can assert what actually got scored.
|
|
385
402
|
*
|
|
386
403
|
* @param {{
|
|
387
|
-
* kind: 'maintainability' | 'crap' | 'coverage',
|
|
404
|
+
* kind: 'maintainability' | 'crap' | 'coverage' | 'duplication',
|
|
388
405
|
* baseRef?: string,
|
|
389
406
|
* headRef?: string,
|
|
390
407
|
* scopeFiles?: string[] | null,
|
|
@@ -745,3 +762,44 @@ function validateOptions({ kind, scopeFiles, fullScope, writePath }) {
|
|
|
745
762
|
);
|
|
746
763
|
}
|
|
747
764
|
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Build the default duplication scorer.
|
|
768
|
+
*
|
|
769
|
+
* **This scorer always scans the whole target tree, in every scope mode.**
|
|
770
|
+
* That is not an oversight — duplication is the only kind here whose metric
|
|
771
|
+
* is *pairwise*: a clone is a relationship between two files, and jscpd can
|
|
772
|
+
* only report it if both sides are in the corpus it was handed. Narrowing the
|
|
773
|
+
* scan to the diff would drop every clone between a changed file and an
|
|
774
|
+
* unchanged one, i.e. exactly the duplication a refactor is most likely to
|
|
775
|
+
* introduce. So the `files` argument is intentionally unused: scope narrowing
|
|
776
|
+
* for this kind is a **write-side** concern, applied by the service handing
|
|
777
|
+
* `scope` to `writer.write()`, where `mergeRowsByScope` keeps the freshly
|
|
778
|
+
* scanned rows for in-scope files and preserves the prior rows verbatim for
|
|
779
|
+
* everything else.
|
|
780
|
+
*
|
|
781
|
+
* Reads `targetDirs` / `ignoreGlobs` off `gates.duplication`, not off a
|
|
782
|
+
* flattened accessor: `resolveQuality` never lifted duplication into the
|
|
783
|
+
* legacy bag (the kind post-dates it), so `quality.duplication` is always
|
|
784
|
+
* `undefined` and reading it would silently scan nothing.
|
|
785
|
+
*
|
|
786
|
+
* @param {{ cwd: string, config?: object, quality?: object, detect?: (opts: object) => Promise<object[]> }} opts
|
|
787
|
+
* @returns {(files: string[], opts: object) => Promise<object[]>}
|
|
788
|
+
*/
|
|
789
|
+
function buildDefaultDuplicationScorer({ cwd, config, quality, detect } = {}) {
|
|
790
|
+
const dupCfg =
|
|
791
|
+
resolveQualityBlock({ quality, config })?.gates?.duplication ?? {};
|
|
792
|
+
const targetDirs = Array.isArray(dupCfg.targetDirs) ? dupCfg.targetDirs : [];
|
|
793
|
+
const ignoreGlobs = Array.isArray(dupCfg.ignoreGlobs)
|
|
794
|
+
? dupCfg.ignoreGlobs
|
|
795
|
+
: [];
|
|
796
|
+
return async (_files, opts) => {
|
|
797
|
+
const effectiveCwd = opts?.cwd ?? cwd ?? process.cwd();
|
|
798
|
+
return scanDuplication({
|
|
799
|
+
targetDirs,
|
|
800
|
+
cwd: effectiveCwd,
|
|
801
|
+
ignoreGlobs,
|
|
802
|
+
detect: detect ?? resolveDetectClones(),
|
|
803
|
+
});
|
|
804
|
+
};
|
|
805
|
+
}
|
|
@@ -1,33 +1,44 @@
|
|
|
1
1
|
// .agents/scripts/lib/baselines/scope.js
|
|
2
2
|
//
|
|
3
|
-
// Story #1962 / Task #1970 —
|
|
4
|
-
// `check-baselines.js` dispatcher
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Story #1962 / Task #1970 — the ScopeResolution helper behind the
|
|
4
|
+
// `check-baselines.js` dispatcher: `resolveDispatchScope` in
|
|
5
|
+
// `lib/orchestration/check-baselines/phases/compare.js` is its one
|
|
6
|
+
// caller. Routing the read side's scope decision through a single pure
|
|
7
|
+
// function keeps the precedence rules from being re-implemented per gate.
|
|
8
|
+
//
|
|
9
|
+
// It is NOT the writers' resolver. `lib/baselines/refresh-service.js`
|
|
10
|
+
// defines its own private `resolveScope` (Story #3658) over a different
|
|
11
|
+
// input set, and the header here used to claim otherwise — a claim that
|
|
12
|
+
// made the read/write pair look coupled when it is not. Corrected in
|
|
13
|
+
// Story #4922.
|
|
9
14
|
//
|
|
10
15
|
// The resolver is intentionally pure — it takes already-extracted
|
|
11
|
-
// inputs and returns a frozen ScopeResolution.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// being silently re-implemented at every call site.
|
|
16
|
+
// inputs and returns a frozen ScopeResolution. Env reading and config
|
|
17
|
+
// loading happen in the caller; that keeps this module trivially
|
|
18
|
+
// testable.
|
|
15
19
|
//
|
|
16
20
|
// Precedence (highest → lowest):
|
|
17
21
|
//
|
|
18
|
-
// 1.
|
|
19
|
-
// Operator-typed beats anything in env/config. A CLI override of
|
|
20
|
-
// `--full-scope` wins even if the config says `'diff'`.
|
|
21
|
-
// 2. Environment — `BASELINE_SCOPE` ('full' | 'diff') and
|
|
22
|
+
// 1. Environment — `BASELINE_SCOPE` ('full' | 'diff') and
|
|
22
23
|
// `BASELINE_REF` (any git ref). The dispatcher reads these from
|
|
23
|
-
// `process.env` and forwards via `
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// 3. Config — `configScope` ('full' | 'diff') and `configRef` (any
|
|
24
|
+
// `process.env` and forwards via `envScope` / `envRef` so the
|
|
25
|
+
// resolver itself never touches process state. CI sets these.
|
|
26
|
+
// 2. Config — `configScope` ('full' | 'diff') and `configRef` (any
|
|
27
27
|
// git ref) from `delivery.quality.gateScoping` in `.agentrc.json`.
|
|
28
|
-
//
|
|
28
|
+
// 3. Default — `mode='diff'` against `ref='main'`. This is the
|
|
29
29
|
// framework-wide fallback when nothing else is configured.
|
|
30
30
|
//
|
|
31
|
+
// Story #4922 removed a fourth, highest-precedence layer: a
|
|
32
|
+
// `cliFlags.fullScope` / `cliFlags.changedSinceRef` operator override,
|
|
33
|
+
// plus the `cliFlags.changedFiles` → `files` plumbing that fed it. No
|
|
34
|
+
// production caller ever populated any of the three — only this module's
|
|
35
|
+
// own unit tests did — so the layer's only effect was to advertise a
|
|
36
|
+
// `--full-scope` / `--changed-since` contract that `check-baselines.js`
|
|
37
|
+
// does not implement, and a `files` set that no consumer read. Operators
|
|
38
|
+
// who need full scope set `BASELINE_SCOPE=full`, which is what CI does.
|
|
39
|
+
// (`mergeRowsByScope` below still takes a `files`-bearing scope — that
|
|
40
|
+
// one comes from the refresh service's own resolver, not from here.)
|
|
41
|
+
//
|
|
31
42
|
// Missing-ref fallback: when the resolved mode is `'diff'` but no ref
|
|
32
43
|
// is supplied at any layer, the resolver falls back to `'main'` rather
|
|
33
44
|
// than producing a half-resolved scope with `ref=null`. The dispatcher
|
|
@@ -45,17 +56,8 @@
|
|
|
45
56
|
// kind: string, // echoed back for caller convenience
|
|
46
57
|
// mode: 'full' | 'diff',
|
|
47
58
|
// ref: string | null, // null in full mode; ref string in diff mode
|
|
48
|
-
// files: Set<string>, // empty Set in full mode (sentinel for "all")
|
|
49
59
|
// source: string, // which layer won (debug / friction signal)
|
|
50
60
|
// }
|
|
51
|
-
//
|
|
52
|
-
// `files` is intentionally a Set rather than an Array — callers
|
|
53
|
-
// repeatedly check membership during per-row filtering, and Set lookup
|
|
54
|
-
// is O(1). An empty Set in `'full'` mode means "no filter applies".
|
|
55
|
-
// A non-empty Set in `'diff'` mode means "only these paths are in
|
|
56
|
-
// scope" (the dispatcher pre-computes them via `git diff --name-only`
|
|
57
|
-
// and forwards via `cliFlags.changedFiles`); when omitted, the writer
|
|
58
|
-
// is expected to compute the diff itself against `ref`.
|
|
59
61
|
|
|
60
62
|
const VALID_MODES = new Set(['full', 'diff']);
|
|
61
63
|
const DEFAULT_DIFF_REF = 'main';
|
|
@@ -84,45 +86,19 @@ function asMode(v) {
|
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
/**
|
|
87
|
-
*
|
|
88
|
-
* empty Set when the input is missing or empty.
|
|
89
|
-
*
|
|
90
|
-
* @param {unknown} v
|
|
91
|
-
* @returns {Set<string>}
|
|
92
|
-
*/
|
|
93
|
-
function asFilesSet(v) {
|
|
94
|
-
if (v instanceof Set) {
|
|
95
|
-
return new Set(
|
|
96
|
-
Array.from(v).filter((f) => typeof f === 'string' && f.length > 0),
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
if (Array.isArray(v)) {
|
|
100
|
-
return new Set(v.filter((f) => typeof f === 'string' && f.length > 0));
|
|
101
|
-
}
|
|
102
|
-
return new Set();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Resolve a scope against the layered precedence (CLI > env > config >
|
|
89
|
+
* Resolve a scope against the layered precedence (env > config >
|
|
107
90
|
* default). Pure; no I/O.
|
|
108
91
|
*
|
|
109
92
|
* @param {object} input
|
|
110
|
-
* @param {string} input.kind
|
|
93
|
+
* @param {string} input.kind - Baseline kind (e.g. `'lint'`).
|
|
111
94
|
* @param {string} [input.configScope] - `'full'` | `'diff'` from agentrc.
|
|
112
95
|
* @param {string} [input.configRef] - Diff ref from agentrc.
|
|
113
|
-
* @param {
|
|
114
|
-
* @param {
|
|
115
|
-
* @param {string} [input.cliFlags.changedSinceRef] - `--changed-since <ref>`.
|
|
116
|
-
* @param {string} [input.cliFlags.envScope] - From `BASELINE_SCOPE`.
|
|
117
|
-
* @param {string} [input.cliFlags.envRef] - From `BASELINE_REF`.
|
|
118
|
-
* @param {Iterable<string>} [input.cliFlags.changedFiles]
|
|
119
|
-
* Pre-computed diff paths (when caller already ran `git diff
|
|
120
|
-
* --name-only`). Becomes `files`; only meaningful in `'diff'` mode.
|
|
96
|
+
* @param {string} [input.envScope] - From `BASELINE_SCOPE`.
|
|
97
|
+
* @param {string} [input.envRef] - From `BASELINE_REF`.
|
|
121
98
|
* @returns {{
|
|
122
99
|
* kind: string,
|
|
123
100
|
* mode: 'full' | 'diff',
|
|
124
101
|
* ref: string | null,
|
|
125
|
-
* files: Set<string>,
|
|
126
102
|
* source: string,
|
|
127
103
|
* }}
|
|
128
104
|
*/
|
|
@@ -131,59 +107,34 @@ export function resolveScope(input = {}) {
|
|
|
131
107
|
typeof input.kind === 'string' && input.kind.length > 0
|
|
132
108
|
? input.kind
|
|
133
109
|
: 'unknown';
|
|
134
|
-
const cli = input.cliFlags ?? {};
|
|
135
|
-
|
|
136
|
-
// ---- Layer 1: CLI flags (highest precedence) -------------------------
|
|
137
|
-
if (cli.fullScope === true) {
|
|
138
|
-
return Object.freeze({
|
|
139
|
-
kind,
|
|
140
|
-
mode: 'full',
|
|
141
|
-
ref: null,
|
|
142
|
-
files: new Set(),
|
|
143
|
-
source: 'cli:--full-scope',
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
const cliRef = asNonEmptyString(cli.changedSinceRef);
|
|
147
|
-
if (cliRef) {
|
|
148
|
-
return Object.freeze({
|
|
149
|
-
kind,
|
|
150
|
-
mode: 'diff',
|
|
151
|
-
ref: cliRef,
|
|
152
|
-
files: asFilesSet(cli.changedFiles),
|
|
153
|
-
source: 'cli:--changed-since',
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
110
|
|
|
157
|
-
// ---- Layer
|
|
158
|
-
const envMode = asMode(
|
|
111
|
+
// ---- Layer 1: Environment (extracted by the caller) ------------------
|
|
112
|
+
const envMode = asMode(input.envScope);
|
|
159
113
|
if (envMode === 'full') {
|
|
160
114
|
return Object.freeze({
|
|
161
115
|
kind,
|
|
162
116
|
mode: 'full',
|
|
163
117
|
ref: null,
|
|
164
|
-
files: new Set(),
|
|
165
118
|
source: 'env:BASELINE_SCOPE=full',
|
|
166
119
|
});
|
|
167
120
|
}
|
|
168
|
-
const envRef = asNonEmptyString(
|
|
121
|
+
const envRef = asNonEmptyString(input.envRef);
|
|
169
122
|
if (envMode === 'diff' || envRef) {
|
|
170
123
|
return Object.freeze({
|
|
171
124
|
kind,
|
|
172
125
|
mode: 'diff',
|
|
173
126
|
ref: envRef ?? DEFAULT_DIFF_REF,
|
|
174
|
-
files: asFilesSet(cli.changedFiles),
|
|
175
127
|
source: envRef ? 'env:BASELINE_REF' : 'env:BASELINE_SCOPE=diff',
|
|
176
128
|
});
|
|
177
129
|
}
|
|
178
130
|
|
|
179
|
-
// ---- Layer
|
|
131
|
+
// ---- Layer 2: Config (delivery.quality.gateScoping) ------------------
|
|
180
132
|
const cfgMode = asMode(input.configScope);
|
|
181
133
|
if (cfgMode === 'full') {
|
|
182
134
|
return Object.freeze({
|
|
183
135
|
kind,
|
|
184
136
|
mode: 'full',
|
|
185
137
|
ref: null,
|
|
186
|
-
files: new Set(),
|
|
187
138
|
source: 'config:gateScoping.scope=full',
|
|
188
139
|
});
|
|
189
140
|
}
|
|
@@ -193,19 +144,17 @@ export function resolveScope(input = {}) {
|
|
|
193
144
|
kind,
|
|
194
145
|
mode: 'diff',
|
|
195
146
|
ref: cfgRef ?? DEFAULT_DIFF_REF,
|
|
196
|
-
files: asFilesSet(cli.changedFiles),
|
|
197
147
|
source: cfgRef
|
|
198
148
|
? 'config:gateScoping.diffRef'
|
|
199
149
|
: 'config:gateScoping.scope=diff',
|
|
200
150
|
});
|
|
201
151
|
}
|
|
202
152
|
|
|
203
|
-
// ---- Layer
|
|
153
|
+
// ---- Layer 3: Default ------------------------------------------------
|
|
204
154
|
return Object.freeze({
|
|
205
155
|
kind,
|
|
206
156
|
mode: 'diff',
|
|
207
157
|
ref: DEFAULT_DIFF_REF,
|
|
208
|
-
files: asFilesSet(cli.changedFiles),
|
|
209
158
|
source: 'default',
|
|
210
159
|
});
|
|
211
160
|
}
|
|
@@ -261,21 +261,26 @@ export function writeFile(absPath, envelope, opts = {}) {
|
|
|
261
261
|
// across runs and platforms. Per-kind row keys retain their natural
|
|
262
262
|
// declaration order; the row sort is done by `sortRows()`.
|
|
263
263
|
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
264
|
+
// The projection is deliberately explicit (Story #4775): a per-kind envelope
|
|
265
|
+
// stamp reaches disk only if it is named here, so an unlisted one is present
|
|
266
|
+
// in memory, passes validation, and is absent from the file it exists to
|
|
267
|
+
// protect. That is not hypothetical — Story #4901 added `provenanceStamped`
|
|
268
|
+
// to `envelopeExtras()` and not to this list, and every baseline written
|
|
269
|
+
// between then and Story #4969 lost it at this boundary, leaving the
|
|
270
|
+
// `provenance-unstamped` axis reading an absence its own writer had
|
|
271
|
+
// manufactured.
|
|
272
|
+
//
|
|
273
|
+
// A stamp a kind does not set is simply `undefined`, and `JSON.stringify`
|
|
274
|
+
// omits an undefined-valued key — so naming all three unconditionally emits
|
|
275
|
+
// exactly what the per-key `undefined` guards used to, with nothing to
|
|
276
|
+
// half-apply when the next stamp is added.
|
|
269
277
|
const canonical = {
|
|
270
278
|
$schema: envelope.$schema,
|
|
271
279
|
kernelVersion: envelope.kernelVersion,
|
|
272
280
|
generatedAt: envelope.generatedAt,
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
...(envelope.tsTranspilerVersion === undefined
|
|
277
|
-
? {}
|
|
278
|
-
: { tsTranspilerVersion: envelope.tsTranspilerVersion }),
|
|
281
|
+
scoringSemantics: envelope.scoringSemantics,
|
|
282
|
+
tsTranspilerVersion: envelope.tsTranspilerVersion,
|
|
283
|
+
provenanceStamped: envelope.provenanceStamped,
|
|
279
284
|
rollup: envelope.rollup,
|
|
280
285
|
rows: envelope.rows,
|
|
281
286
|
};
|
|
@@ -8,10 +8,17 @@ import { createGitInterface } from './git-utils.js';
|
|
|
8
8
|
*
|
|
9
9
|
* Pure; no I/O.
|
|
10
10
|
*
|
|
11
|
+
* Module-private since Story #4944. It was exported for
|
|
12
|
+
* `diff-scope-cli.js#resolveDiffScopeFiles`, which the duplication-CLI
|
|
13
|
+
* migration deleted; the three remaining callers all live in this file, so
|
|
14
|
+
* exporting it now would ship a seam only the tests reach. Its behaviour is
|
|
15
|
+
* covered through `diffNameOnly`, which returns this function's output
|
|
16
|
+
* verbatim.
|
|
17
|
+
*
|
|
11
18
|
* @param {string | null | undefined} stdout
|
|
12
19
|
* @returns {string[]}
|
|
13
20
|
*/
|
|
14
|
-
|
|
21
|
+
function parseNameOnlyStdout(stdout) {
|
|
15
22
|
if (!stdout) return [];
|
|
16
23
|
return stdout
|
|
17
24
|
.split('\n')
|
|
@@ -62,13 +62,71 @@ function parsePositiveInt(value) {
|
|
|
62
62
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** The only two merge-watch postures `--merge-watch-mode` accepts. */
|
|
66
|
+
const MERGE_WATCH_MODES = ['sync', 'async'];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse `--merge-watch-mode` (Story #4949), the per-invocation override of
|
|
70
|
+
* `delivery.mergeWatch.mode`. Absence is preserved as `undefined` so the
|
|
71
|
+
* caller can distinguish "not supplied" (fall back to config) from an explicit
|
|
72
|
+
* posture — the same contract {@link parsePositiveInt} gives
|
|
73
|
+
* `--max-wait-seconds`.
|
|
74
|
+
*
|
|
75
|
+
* Unlike that sibling, an unrecognized value **throws** rather than degrading
|
|
76
|
+
* to absent. A `--max-wait-seconds` typo falls back to a sane bound; a
|
|
77
|
+
* `--merge-watch-mode` typo would fall back to `sync` and silently return a
|
|
78
|
+
* multi-Story run to a serialized foreground wait per close, with the wall
|
|
79
|
+
* clock as the only evidence. Parsing runs before the first close phase, so
|
|
80
|
+
* failing here costs no mutation.
|
|
81
|
+
*
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @returns {'sync'|'async'|undefined}
|
|
84
|
+
*/
|
|
85
|
+
export function parseMergeWatchMode(value) {
|
|
86
|
+
if (value == null) return undefined;
|
|
87
|
+
const mode = String(value).trim().toLowerCase();
|
|
88
|
+
if (MERGE_WATCH_MODES.includes(mode)) return mode;
|
|
89
|
+
throw new Error(
|
|
90
|
+
`--merge-watch-mode must be one of ${MERGE_WATCH_MODES.join('|')} (got "${value}")`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* {@link parseMergeWatchMode} degraded to the "absent" value instead of
|
|
96
|
+
* throwing — how the tolerant parse below treats a flag that failed
|
|
97
|
+
* validation. Reporting `undefined` is safe there and only there, because a
|
|
98
|
+
* tolerant parse never drives a pipeline: its caller surfaces the rejection
|
|
99
|
+
* as the run's failure and runs no phase at all.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @returns {'sync'|'async'|undefined}
|
|
103
|
+
*/
|
|
104
|
+
function tolerantMergeWatchMode(value) {
|
|
105
|
+
try {
|
|
106
|
+
return parseMergeWatchMode(value);
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
65
112
|
/**
|
|
66
113
|
* Standardized CLI argument parser for sprint scripts.
|
|
67
114
|
* Supports options like --epic, --story, --dry-run, --skip-dashboard.
|
|
115
|
+
*
|
|
116
|
+
* Throws when a *validating* parser rejects a flag value (currently only
|
|
117
|
+
* `--merge-watch-mode`). Callers that must not throw — an error handler
|
|
118
|
+
* needing `storyId` to report an envelope — use {@link parseSprintArgsTolerant}
|
|
119
|
+
* rather than calling this a second time inside their own catch.
|
|
120
|
+
*
|
|
68
121
|
* @param {string[]} args Array of arguments (defaults to process.argv)
|
|
122
|
+
* @param {{ tolerant?: boolean }} [options] `tolerant` degrades a rejected
|
|
123
|
+
* flag to its absent value instead of throwing. For reporting only.
|
|
69
124
|
* @returns {object} Parsed and typed argument values
|
|
70
125
|
*/
|
|
71
|
-
export function parseSprintArgs(
|
|
126
|
+
export function parseSprintArgs(
|
|
127
|
+
args = process.argv,
|
|
128
|
+
{ tolerant = false } = {},
|
|
129
|
+
) {
|
|
72
130
|
const { values, positionals } = parseArgs({
|
|
73
131
|
args: args.slice(2),
|
|
74
132
|
options: {
|
|
@@ -85,6 +143,10 @@ export function parseSprintArgs(args = process.argv) {
|
|
|
85
143
|
// Story #4543 — per-run override of `delivery.mergeWatch.maxWaitSeconds`
|
|
86
144
|
// (the merge wait's per-invocation bound). Absent means "use the config".
|
|
87
145
|
'max-wait-seconds': { type: 'string' },
|
|
146
|
+
// Story #4949 — per-invocation override of `delivery.mergeWatch.mode`.
|
|
147
|
+
// Absent means "use the config"; see `parseMergeWatchMode` for why an
|
|
148
|
+
// unrecognized value fails closed instead of degrading to absent.
|
|
149
|
+
'merge-watch-mode': { type: 'string' },
|
|
88
150
|
executor: { type: 'string' },
|
|
89
151
|
cwd: { type: 'string' },
|
|
90
152
|
'recut-of': { type: 'string' },
|
|
@@ -115,6 +177,12 @@ export function parseSprintArgs(args = process.argv) {
|
|
|
115
177
|
// headless caller with no host tool-invocation ceiling, so it lands in
|
|
116
178
|
// one block instead of returning `pending` at the default 300s.
|
|
117
179
|
maxWaitSeconds: parsePositiveInt(values['max-wait-seconds']),
|
|
180
|
+
// Story #4949 — per-invocation override of `delivery.mergeWatch.mode`.
|
|
181
|
+
// `undefined` when the flag is absent, which is what lets the merge wait
|
|
182
|
+
// fall back to the config; anything unrecognized throws here.
|
|
183
|
+
mergeWatchMode: tolerant
|
|
184
|
+
? tolerantMergeWatchMode(values['merge-watch-mode'])
|
|
185
|
+
: parseMergeWatchMode(values['merge-watch-mode']),
|
|
118
186
|
executor: values.executor ?? null,
|
|
119
187
|
// Resolve worktree cwd from flag or env. Empty string/whitespace → null.
|
|
120
188
|
cwd:
|
|
@@ -137,6 +205,52 @@ export function parseSprintArgs(args = process.argv) {
|
|
|
137
205
|
return parsed;
|
|
138
206
|
}
|
|
139
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Last-resort tolerant parse: the fields, or an empty bag if even the
|
|
210
|
+
* tolerant pass cannot produce one.
|
|
211
|
+
*
|
|
212
|
+
* @param {string[]} args
|
|
213
|
+
* @returns {object}
|
|
214
|
+
*/
|
|
215
|
+
function parseSprintArgsOrEmpty(args) {
|
|
216
|
+
try {
|
|
217
|
+
return parseSprintArgs(args, { tolerant: true });
|
|
218
|
+
} catch {
|
|
219
|
+
return {};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Parse argv **without ever throwing**, returning the fields alongside the
|
|
225
|
+
* rejection rather than in place of it.
|
|
226
|
+
*
|
|
227
|
+
* `parseSprintArgs` gained its first *validating* parser in
|
|
228
|
+
* {@link parseMergeWatchMode} (Story #4949), which made a latent shape in the
|
|
229
|
+
* CLI entries fatal: their catch blocks called
|
|
230
|
+
* `failedTerminalFor(err, parseSprintArgs())` — re-invoking the very parser
|
|
231
|
+
* that had just thrown. The second throw escaped the catch, so an
|
|
232
|
+
* unparseable argv produced a bare stack trace with **no terminal envelope
|
|
233
|
+
* and no friction signal**, on the two surfaces whose whole contract is that
|
|
234
|
+
* they always emit one. An error handler must not depend on an operation
|
|
235
|
+
* already known to fail.
|
|
236
|
+
*
|
|
237
|
+
* So the entries parse **once**, up front, through this wrapper: `args`
|
|
238
|
+
* carries the `storyId` and skip flags the envelope is built from, and
|
|
239
|
+
* `error` is the failure to report. The tolerant re-parse degrades **only**
|
|
240
|
+
* the flag that failed validation; every other field parses normally. Use
|
|
241
|
+
* the result to *report*, never to run a pipeline.
|
|
242
|
+
*
|
|
243
|
+
* @param {string[]} [args] Array of arguments (defaults to `process.argv`)
|
|
244
|
+
* @returns {{ args: object, error: Error|null }}
|
|
245
|
+
*/
|
|
246
|
+
export function parseSprintArgsTolerant(args = process.argv) {
|
|
247
|
+
try {
|
|
248
|
+
return { args: parseSprintArgs(args), error: null };
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return { args: parseSprintArgsOrEmpty(args), error };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
140
254
|
const SUPPORTED_FLAG_TYPES = new Set([
|
|
141
255
|
'boolean',
|
|
142
256
|
'ticket',
|