mandrel 2.56.0 → 2.58.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/plan-critic.md +13 -18
- package/.agents/agents/story-worker.md +25 -33
- package/.agents/docs/agentrc-reference.json +0 -30
- package/.agents/docs/configuration.md +8 -28
- package/.agents/docs/execution-reference.md +5 -5
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/instructions.md +9 -10
- package/.agents/schemas/agentrc.schema.json +9 -185
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +107 -17
- package/.agents/scripts/ceremony-derive.js +191 -0
- package/.agents/scripts/check-context-budget.js +28 -33
- package/.agents/scripts/check-cyclomatic.js +4 -3
- package/.agents/scripts/deliver-light.js +31 -94
- package/.agents/scripts/evidence-gate.js +17 -1
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
- package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
- package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
- package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
- package/.agents/scripts/lib/close-validation/gates.js +52 -1
- package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
- package/.agents/scripts/lib/config/delivery-routing.js +7 -33
- package/.agents/scripts/lib/config/explain.js +0 -19
- package/.agents/scripts/lib/config/limits.js +18 -78
- package/.agents/scripts/lib/config/quality.js +6 -3
- package/.agents/scripts/lib/config/runners.js +3 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
- package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
- package/.agents/scripts/lib/config-settings-schema.js +16 -143
- package/.agents/scripts/lib/crap-engine.js +35 -4
- package/.agents/scripts/lib/crap-utils.js +17 -1
- package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
- package/.agents/scripts/lib/orchestration/code-review.js +7 -3
- package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
- package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
- package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
- package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +189 -387
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
- package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +305 -0
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +138 -170
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +128 -297
- package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +36 -135
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
- package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
- package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
- package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
- package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
- package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
- package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
- package/.agents/scripts/lib/story-body/story-body.js +54 -240
- package/.agents/scripts/lib/templates/decomposer-prompts.js +133 -121
- package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
- package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
- package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
- package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
- package/.agents/scripts/lib/test-run-credit.js +277 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
- package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
- package/.agents/scripts/lib/workers/crap-worker.js +32 -41
- package/.agents/scripts/plan-context.js +7 -9
- package/.agents/scripts/plan-critics.js +28 -54
- package/.agents/scripts/plan-persist.js +25 -68
- package/.agents/scripts/quality-preview.js +51 -0
- package/.agents/scripts/run-tests.js +12 -0
- package/.agents/scripts/stories-wave-tick.js +23 -45
- package/.agents/scripts/test-isolate.js +13 -180
- package/.agents/scripts/update-coverage-baseline.js +25 -70
- package/.agents/scripts/update-crap-baseline.js +19 -123
- package/.agents/skills/core/scope-triage/SKILL.md +3 -3
- package/.agents/workflows/audit-clean-code.md +4 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
- package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
- package/.agents/workflows/helpers/code-review.md +2 -3
- package/.agents/workflows/helpers/deliver-digest.md +46 -55
- package/.agents/workflows/helpers/deliver-light.md +40 -105
- package/.agents/workflows/helpers/deliver-reference.md +1 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +54 -55
- package/.agents/workflows/helpers/deliver-story.md +10 -13
- package/.agents/workflows/helpers/plan-reference.md +163 -221
- package/.agents/workflows/mandrel-plan.md +31 -40
- package/.agents/workflows/memory-consolidate.md +9 -13
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/registry.js +98 -2
- package/lib/migrations/index.js +4 -0
- package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
- package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/framework-version.js +0 -39
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
- package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
- package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
- package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/test-isolate/render-report.js — the human-readable `test-isolate` report.
|
|
3
|
+
*
|
|
4
|
+
* Story #5316: extracted verbatim from `.agents/scripts/test-isolate.js`,
|
|
5
|
+
* where no test could reach it (CRAP 72 at cyclomatic 8, 0% coverage). Pure
|
|
6
|
+
* string building — no I/O, no clock — so the whole surface is assertable.
|
|
7
|
+
*
|
|
8
|
+
* The three sections it renders are split into helpers so each is
|
|
9
|
+
* independently readable and none of them alone approaches the cyclomatic
|
|
10
|
+
* ceiling; `renderReport` is left as the composition.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The flipper section: files that passed alone and failed in the suite, plus
|
|
15
|
+
* the bisection suspects for each.
|
|
16
|
+
*
|
|
17
|
+
* @param {string[]} lines Accumulator, appended in place.
|
|
18
|
+
* @param {import('./runner.js').IsolateReport} report
|
|
19
|
+
*/
|
|
20
|
+
function pushFlipperSection(lines, report) {
|
|
21
|
+
if (report.flippers.length === 0) {
|
|
22
|
+
lines.push('✓ No flippers detected — every file that passed alone');
|
|
23
|
+
lines.push(' also passed in the full suite run.');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
lines.push(`✗ ${report.flippers.length} flipper(s) detected:`);
|
|
27
|
+
for (const f of report.flippers) lines.push(` - ${f}`);
|
|
28
|
+
lines.push('');
|
|
29
|
+
if (report.bisections.length === 0) return;
|
|
30
|
+
lines.push('Likely polluters (bisection suspects):');
|
|
31
|
+
for (const b of report.bisections) {
|
|
32
|
+
const tag = b.inconclusive ? ' [inconclusive]' : '';
|
|
33
|
+
lines.push(` ${b.file}${tag}`);
|
|
34
|
+
for (const s of b.suspects) lines.push(` ← ${s}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One env-mutating file's added/removed/changed summary. Empty parts are
|
|
40
|
+
* omitted, so a file that only added a var reads as `added=[...]` alone.
|
|
41
|
+
*
|
|
42
|
+
* @param {{added: string[], removed: string[], changed: string[]}} envDiff
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function formatEnvDiff(envDiff) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
if (envDiff.added.length > 0)
|
|
48
|
+
parts.push(`added=[${envDiff.added.join(', ')}]`);
|
|
49
|
+
if (envDiff.removed.length > 0) {
|
|
50
|
+
parts.push(`removed=[${envDiff.removed.join(', ')}]`);
|
|
51
|
+
}
|
|
52
|
+
if (envDiff.changed.length > 0) {
|
|
53
|
+
parts.push(`changed=[${envDiff.changed.join(', ')}]`);
|
|
54
|
+
}
|
|
55
|
+
return parts.join(' ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The env-leak section: files whose process exited with `process.env` still
|
|
60
|
+
* mutated, called out even when no failure cascade has manifested yet.
|
|
61
|
+
*
|
|
62
|
+
* @param {string[]} lines Accumulator, appended in place.
|
|
63
|
+
* @param {import('./runner.js').IsolateReport} report
|
|
64
|
+
*/
|
|
65
|
+
function pushEnvSection(lines, report) {
|
|
66
|
+
if (report.envMutators.length === 0) {
|
|
67
|
+
lines.push('✓ No env-var leaks detected across isolated runs.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
lines.push(
|
|
71
|
+
`⚠ ${report.envMutators.length} file(s) left process.env mutated:`,
|
|
72
|
+
);
|
|
73
|
+
for (const m of report.envMutators) {
|
|
74
|
+
lines.push(` ${m.file}`);
|
|
75
|
+
lines.push(` ${formatEnvDiff(m.envDiff)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render the diagnostic report as text.
|
|
81
|
+
*
|
|
82
|
+
* @param {import('./runner.js').IsolateReport} report
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function renderReport(report) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push('=== test-isolate diagnostic report ===');
|
|
89
|
+
lines.push(`Files scanned: ${report.files.length}`);
|
|
90
|
+
lines.push(`Wall duration: ${(report.durationMs / 1000).toFixed(1)}s`);
|
|
91
|
+
lines.push('');
|
|
92
|
+
pushFlipperSection(lines, report);
|
|
93
|
+
lines.push('');
|
|
94
|
+
pushEnvSection(lines, report);
|
|
95
|
+
lines.push('');
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/test-isolate/run-isolate.js — the `test-isolate` orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Story #5316: moved out of `.agents/scripts/test-isolate.js` (CRAP 56 at
|
|
5
|
+
* cyclomatic 7, 0% coverage) so it can be driven from a test. The CLI shell
|
|
6
|
+
* that remains does nothing but call this and translate the exit code.
|
|
7
|
+
*
|
|
8
|
+
* `resolveFiles` and `diagnose` are named seams defaulting to the real
|
|
9
|
+
* implementations, per [`rules/test-seams.md`](../../../rules/test-seams.md):
|
|
10
|
+
* a unit test substitutes them rather than running real suites in child
|
|
11
|
+
* processes, and the production caller passes neither.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseIsolateArgv } from './cli-options.js';
|
|
15
|
+
import { resolveTestFiles as defaultResolveTestFiles } from './list-files.js';
|
|
16
|
+
import { createProgressLogger } from './progress-log.js';
|
|
17
|
+
import { renderReport } from './render-report.js';
|
|
18
|
+
import { diagnoseIsolation as defaultDiagnoseIsolation } from './runner.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The report shape returned when no file matched the pattern — a real report
|
|
22
|
+
* with nothing in it, so callers never branch on null.
|
|
23
|
+
*
|
|
24
|
+
* @returns {import('./runner.js').IsolateReport}
|
|
25
|
+
*/
|
|
26
|
+
function emptyReport() {
|
|
27
|
+
return {
|
|
28
|
+
pattern: null,
|
|
29
|
+
files: [],
|
|
30
|
+
isolated: [],
|
|
31
|
+
suite: [],
|
|
32
|
+
flippers: [],
|
|
33
|
+
bisections: [],
|
|
34
|
+
envMutators: [],
|
|
35
|
+
durationMs: 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run the isolation diagnosis end to end.
|
|
41
|
+
*
|
|
42
|
+
* Exit code is 1 when the run found anything worth an operator's attention —
|
|
43
|
+
* a flipper OR an env mutator — and 0 otherwise. An empty match set is not a
|
|
44
|
+
* failure: nothing was asked of the suite, so nothing can be wrong with it.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} [opts]
|
|
47
|
+
* @param {string[]} [opts.argv]
|
|
48
|
+
* @param {string} [opts.repoRoot]
|
|
49
|
+
* @param {(line: string) => void} [opts.onLog]
|
|
50
|
+
* @param {(args: {pattern: string|undefined, repoRoot: string}) => string[]} [opts.resolveFiles]
|
|
51
|
+
* @param {(args: object) => Promise<import('./runner.js').IsolateReport>} [opts.diagnose]
|
|
52
|
+
* @returns {Promise<{ exitCode: number, report: import('./runner.js').IsolateReport }>}
|
|
53
|
+
*/
|
|
54
|
+
export async function runTestIsolate({
|
|
55
|
+
argv = [],
|
|
56
|
+
repoRoot,
|
|
57
|
+
onLog = (s) => process.stdout.write(`${s}\n`),
|
|
58
|
+
resolveFiles = defaultResolveTestFiles,
|
|
59
|
+
diagnose = defaultDiagnoseIsolation,
|
|
60
|
+
} = {}) {
|
|
61
|
+
const options = parseIsolateArgv(argv);
|
|
62
|
+
const files = resolveFiles({ pattern: options.pattern, repoRoot });
|
|
63
|
+
if (files.length === 0) {
|
|
64
|
+
onLog(
|
|
65
|
+
`[test-isolate] no test files matched pattern: ${options.pattern ?? '<default>'}`,
|
|
66
|
+
);
|
|
67
|
+
return { exitCode: 0, report: emptyReport() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!options.quiet) {
|
|
71
|
+
onLog(`[test-isolate] scanning ${files.length} file(s)...`);
|
|
72
|
+
}
|
|
73
|
+
const report = await diagnose({
|
|
74
|
+
repoRoot,
|
|
75
|
+
files,
|
|
76
|
+
workers: options.workers,
|
|
77
|
+
suiteConcurrency: options.suiteConcurrency,
|
|
78
|
+
maxBisectDepth: options.maxBisectDepth,
|
|
79
|
+
maxBisectTargets: options.maxBisectTargets,
|
|
80
|
+
onProgress: options.quiet ? undefined : createProgressLogger(onLog),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
onLog(options.json ? JSON.stringify(report, null, 2) : renderReport(report));
|
|
84
|
+
|
|
85
|
+
const clean = report.flippers.length === 0 && report.envMutators.length === 0;
|
|
86
|
+
return { exitCode: clean ? 0 : 1, report };
|
|
87
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/test-run-credit.js — let a green `npm test` **that routes through
|
|
3
|
+
* mandrel's own runner** earn the credit close reads (Story #5313, scoped by
|
|
4
|
+
* Story #5324).
|
|
5
|
+
*
|
|
6
|
+
* This is a **bonus, not the contract.** The deposit every project can rely
|
|
7
|
+
* on is `evidence-gate.js --standalone --scope-id <id> --gate test --worktree
|
|
8
|
+
* <workCwd> -- npm test`: it spawns whatever `npm test` resolves to and
|
|
9
|
+
* stamps what it just ran, so it is honest on any runner. What this module
|
|
10
|
+
* adds is that a repo whose `test` script *is* `run-tests.js` need not type
|
|
11
|
+
* that wrapper — the runner already knows the tree it ran against, whether
|
|
12
|
+
* the run was green, and whether it ran the whole suite, so it deposits on
|
|
13
|
+
* the way out.
|
|
14
|
+
*
|
|
15
|
+
* The reach is therefore exactly one call site: `run-tests.js`. A consumer
|
|
16
|
+
* whose `npm test` is `vitest run` or `jest` never loads this module, so it
|
|
17
|
+
* deposits nothing **and prints nothing** — silence is not a signal, and no
|
|
18
|
+
* delivery surface may tell an agent to confirm credit by reading for the
|
|
19
|
+
* line below. `mandrel doctor`'s `test-credit-path` check reports which of
|
|
20
|
+
* the two shapes a project is and names the wrapper as the remedy.
|
|
21
|
+
*
|
|
22
|
+
* On a green **full-tier** run inside a `story-<id>` checkout the runner
|
|
23
|
+
* records the `test` gate's evidence in the same keyspace
|
|
24
|
+
* `close-validation/runner.js` consults — keyed on HEAD and the tree
|
|
25
|
+
* fingerprint, hashed on the exact `{ cmd: 'npm', args: ['test'], cwd }`
|
|
26
|
+
* close will spawn — so close's `test` gate short-circuits at unchanged HEAD.
|
|
27
|
+
* The freshness keying is untouched: a later commit voids the record exactly
|
|
28
|
+
* as it voids one `evidence-gate.js` wrote.
|
|
29
|
+
*
|
|
30
|
+
* What it deliberately does **not** do is write the coverage capture stamp:
|
|
31
|
+
* that stamp is a claim that `coverage/coverage-final.json` describes this
|
|
32
|
+
* tree, and a bare `npm test` produces no such artifact. The CRAP gate still
|
|
33
|
+
* runs `coverage-capture.js` when it needs one.
|
|
34
|
+
*
|
|
35
|
+
* Total: every failure — not a Story branch, no git, an unwritable evidence
|
|
36
|
+
* file — is reported by reason and never fails the test run that earned it.
|
|
37
|
+
*
|
|
38
|
+
* @module lib/test-run-credit
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
|
|
43
|
+
import { gitSpawn as defaultGitSpawn } from './git-utils.js';
|
|
44
|
+
import {
|
|
45
|
+
recordPass as defaultRecordPass,
|
|
46
|
+
shouldSkip as defaultShouldSkip,
|
|
47
|
+
hashCommandConfig,
|
|
48
|
+
treeFingerprint,
|
|
49
|
+
} from './validation-evidence.js';
|
|
50
|
+
|
|
51
|
+
/** The gate name close's runner looks the record up under. */
|
|
52
|
+
const GATE_NAME = 'test';
|
|
53
|
+
|
|
54
|
+
/** The exact command close spawns for that gate — the hash must match it. */
|
|
55
|
+
const GATE_COMMAND = Object.freeze({
|
|
56
|
+
cmd: 'npm',
|
|
57
|
+
args: Object.freeze(['test']),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read one trimmed git stdout line, or `null` on any failure.
|
|
62
|
+
*
|
|
63
|
+
* @param {Function} gitSpawnFn
|
|
64
|
+
* @param {string} cwd
|
|
65
|
+
* @param {string[]} args
|
|
66
|
+
* @returns {string|null}
|
|
67
|
+
*/
|
|
68
|
+
function gitLine(gitSpawnFn, cwd, args) {
|
|
69
|
+
try {
|
|
70
|
+
const res = gitSpawnFn(cwd, ...args);
|
|
71
|
+
if (res?.status !== 0) return null;
|
|
72
|
+
const line = String(res.stdout ?? '').trim();
|
|
73
|
+
return line.length > 0 ? line : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The Story id a checkout's branch names, or `null` off a Story branch.
|
|
81
|
+
*
|
|
82
|
+
* @param {string|null} branch
|
|
83
|
+
* @returns {number|null}
|
|
84
|
+
*/
|
|
85
|
+
export function storyIdFromBranch(branch) {
|
|
86
|
+
const match = /^story-(\d+)$/.exec(branch ?? '');
|
|
87
|
+
if (!match) return null;
|
|
88
|
+
const id = Number.parseInt(match[1], 10);
|
|
89
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The checkout whose temp tree holds the evidence keyspace: the **main**
|
|
94
|
+
* checkout, which is where close runs with `--cwd <main-repo>`. From a
|
|
95
|
+
* worktree `git rev-parse --git-common-dir` names the main `.git`; from the
|
|
96
|
+
* main checkout it names its own. Either way the parent is the checkout.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} cwd
|
|
99
|
+
* @param {Function} gitSpawnFn
|
|
100
|
+
* @returns {string|null}
|
|
101
|
+
*/
|
|
102
|
+
export function resolveEvidenceRoot(cwd, gitSpawnFn) {
|
|
103
|
+
const common = gitLine(gitSpawnFn, cwd, ['rev-parse', '--git-common-dir']);
|
|
104
|
+
if (!common) return null;
|
|
105
|
+
const absolute = path.resolve(cwd, common);
|
|
106
|
+
return path.basename(absolute) === '.git' ? path.dirname(absolute) : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Deposit the `test` gate's evidence for a green full-suite run.
|
|
111
|
+
*
|
|
112
|
+
* @param {{
|
|
113
|
+
* cwd: string,
|
|
114
|
+
* tier?: string,
|
|
115
|
+
* status?: number,
|
|
116
|
+
* durationMs?: number|null,
|
|
117
|
+
* gitSpawnFn?: typeof defaultGitSpawn,
|
|
118
|
+
* recordPassFn?: typeof defaultRecordPass,
|
|
119
|
+
* }} args
|
|
120
|
+
* @returns {{ deposited: boolean, reason: string, storyId?: number, sha?: string }}
|
|
121
|
+
*/
|
|
122
|
+
export function depositTestRunCredit({
|
|
123
|
+
cwd,
|
|
124
|
+
tier = 'full',
|
|
125
|
+
status = 0,
|
|
126
|
+
durationMs = null,
|
|
127
|
+
gitSpawnFn = defaultGitSpawn,
|
|
128
|
+
recordPassFn = defaultRecordPass,
|
|
129
|
+
} = {}) {
|
|
130
|
+
if (status !== 0) return { deposited: false, reason: 'run-not-green' };
|
|
131
|
+
if (tier !== 'full') return { deposited: false, reason: 'not-full-tier' };
|
|
132
|
+
const storyId = storyIdFromBranch(
|
|
133
|
+
gitLine(gitSpawnFn, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
134
|
+
);
|
|
135
|
+
if (storyId === null)
|
|
136
|
+
return { deposited: false, reason: 'not-a-story-branch' };
|
|
137
|
+
const sha = gitLine(gitSpawnFn, cwd, ['rev-parse', 'HEAD']);
|
|
138
|
+
const evidenceRoot = resolveEvidenceRoot(cwd, gitSpawnFn);
|
|
139
|
+
if (!sha || !evidenceRoot) {
|
|
140
|
+
return { deposited: false, reason: 'tree-unreadable', storyId };
|
|
141
|
+
}
|
|
142
|
+
return writeRecord({
|
|
143
|
+
storyId,
|
|
144
|
+
sha,
|
|
145
|
+
cwd,
|
|
146
|
+
evidenceRoot,
|
|
147
|
+
durationMs,
|
|
148
|
+
gitSpawnFn,
|
|
149
|
+
recordPassFn,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Deposit and say so on stderr — the runner's one-line hook, printed only
|
|
155
|
+
* when this runner is the one running. It names the outcome by reason, so a
|
|
156
|
+
* green run that deposited nothing (wrong branch, partial tier) says so
|
|
157
|
+
* rather than passing silently; a project on another runner prints no line
|
|
158
|
+
* at all, which is why absence of this line is never evidence either way.
|
|
159
|
+
*
|
|
160
|
+
* @param {Parameters<typeof depositTestRunCredit>[0] & { log?: (line: string) => void }} args
|
|
161
|
+
* @returns {ReturnType<typeof depositTestRunCredit>}
|
|
162
|
+
*/
|
|
163
|
+
export function reportTestRunCredit({
|
|
164
|
+
log = (line) => process.stderr.write(`${line}\n`),
|
|
165
|
+
...args
|
|
166
|
+
} = {}) {
|
|
167
|
+
const credit = depositTestRunCredit(args);
|
|
168
|
+
log(
|
|
169
|
+
credit.deposited
|
|
170
|
+
? `[run-tests] ✓ deposited the close test credit for story #${credit.storyId} at ${credit.sha.slice(0, 7)}`
|
|
171
|
+
: `[run-tests] no close test credit deposited (${credit.reason})`,
|
|
172
|
+
);
|
|
173
|
+
return credit;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Is the `test` gate already credited for this tree — did a green bare
|
|
178
|
+
* `npm test` in the Story worktree deposit its evidence? The read side of
|
|
179
|
+
* {@link depositTestRunCredit}, consulted by `close-validation/gates.js`.
|
|
180
|
+
*
|
|
181
|
+
* When it did, close registers the plain `test` gate beside the capture gate
|
|
182
|
+
* even though coverage-capture is the active test runner: the runner's
|
|
183
|
+
* evidence check then skips it as credited, so close REPORTS the suite the
|
|
184
|
+
* worker already ran instead of silently folding it into the capture. It is
|
|
185
|
+
* never a second spend — an uncredited tree resolves `false` and the
|
|
186
|
+
* pre-#5313 shape (capture alone runs the suite) is unchanged. Every
|
|
187
|
+
* uncertainty resolves `false`.
|
|
188
|
+
*
|
|
189
|
+
* @param {{
|
|
190
|
+
* storyId?: number|null,
|
|
191
|
+
* cwd?: string,
|
|
192
|
+
* evidenceCwd?: string|null,
|
|
193
|
+
* gitSpawnImpl?: typeof defaultGitSpawn,
|
|
194
|
+
* shouldSkipImpl?: typeof defaultShouldSkip,
|
|
195
|
+
* log?: (line: string) => void,
|
|
196
|
+
* }} opts
|
|
197
|
+
* @returns {boolean}
|
|
198
|
+
*/
|
|
199
|
+
export function predictsTestEvidenceCredit({
|
|
200
|
+
storyId,
|
|
201
|
+
cwd,
|
|
202
|
+
evidenceCwd,
|
|
203
|
+
gitSpawnImpl = defaultGitSpawn,
|
|
204
|
+
shouldSkipImpl = defaultShouldSkip,
|
|
205
|
+
log,
|
|
206
|
+
} = {}) {
|
|
207
|
+
if (!Number.isInteger(storyId) || storyId <= 0) return false;
|
|
208
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return false;
|
|
209
|
+
try {
|
|
210
|
+
const sha = gitLine(gitSpawnImpl, cwd, ['rev-parse', 'HEAD']);
|
|
211
|
+
if (!sha) return false;
|
|
212
|
+
const verdict = shouldSkipImpl(
|
|
213
|
+
{
|
|
214
|
+
storyId,
|
|
215
|
+
gateName: GATE_NAME,
|
|
216
|
+
currentSha: sha,
|
|
217
|
+
configHash: hashCommandConfig({
|
|
218
|
+
cmd: GATE_COMMAND.cmd,
|
|
219
|
+
args: [...GATE_COMMAND.args],
|
|
220
|
+
cwd: path.resolve(cwd),
|
|
221
|
+
}),
|
|
222
|
+
inputFingerprint: treeFingerprint(cwd, gitSpawnImpl),
|
|
223
|
+
},
|
|
224
|
+
{ cwd: evidenceCwd ?? cwd, standalone: true },
|
|
225
|
+
);
|
|
226
|
+
if (verdict.skip !== true) return false;
|
|
227
|
+
log?.(
|
|
228
|
+
'[close-validation] a green `npm test` already deposited the test credit for this tree — registering the plain `test` gate so close reports it as credited.',
|
|
229
|
+
);
|
|
230
|
+
return true;
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The write itself, split out so the guard chain above stays flat.
|
|
238
|
+
*
|
|
239
|
+
* @param {{ storyId: number, sha: string, cwd: string, evidenceRoot: string, durationMs: number|null, gitSpawnFn: Function, recordPassFn: Function }} args
|
|
240
|
+
* @returns {{ deposited: boolean, reason: string, storyId: number, sha: string }}
|
|
241
|
+
*/
|
|
242
|
+
function writeRecord({
|
|
243
|
+
storyId,
|
|
244
|
+
sha,
|
|
245
|
+
cwd,
|
|
246
|
+
evidenceRoot,
|
|
247
|
+
durationMs,
|
|
248
|
+
gitSpawnFn,
|
|
249
|
+
recordPassFn,
|
|
250
|
+
}) {
|
|
251
|
+
try {
|
|
252
|
+
recordPassFn(
|
|
253
|
+
{
|
|
254
|
+
storyId,
|
|
255
|
+
gateName: GATE_NAME,
|
|
256
|
+
sha,
|
|
257
|
+
configHash: hashCommandConfig({
|
|
258
|
+
cmd: GATE_COMMAND.cmd,
|
|
259
|
+
args: [...GATE_COMMAND.args],
|
|
260
|
+
cwd: path.resolve(cwd),
|
|
261
|
+
}),
|
|
262
|
+
exitCode: 0,
|
|
263
|
+
durationMs,
|
|
264
|
+
inputFingerprint: treeFingerprint(cwd, gitSpawnFn),
|
|
265
|
+
},
|
|
266
|
+
{ cwd: evidenceRoot, standalone: true },
|
|
267
|
+
);
|
|
268
|
+
return { deposited: true, reason: 'recorded', storyId, sha };
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return {
|
|
271
|
+
deposited: false,
|
|
272
|
+
reason: `record-failed: ${err?.message ?? err}`,
|
|
273
|
+
storyId,
|
|
274
|
+
sha,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|