peaks-loop 4.0.9 → 4.0.11
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/CHANGELOG.md +53 -0
- package/config/eslint/.peaks-rules.cjs +123 -0
- package/dist/cli/commands/container-commands.js +2 -1
- package/dist/cli/commands/core/skill-command.js +32 -5
- package/dist/cli/commands/openspec-commands.js +2 -1
- package/dist/reporters/bdd-reporter.d.ts +36 -0
- package/dist/reporters/bdd-reporter.js +159 -0
- package/dist/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
- package/dist/services/audit/enforcers/active-skill-resolver.js +53 -39
- package/dist/services/container/container-lease.js +2 -1
- package/dist/services/impact/impact-scan-service.js +4 -3
- package/dist/services/migrate-skill-name/migrate.js +2 -1
- package/dist/services/openspec/artifact-boundary.js +3 -2
- package/dist/services/openspec/coverage-evidence-reader.js +9 -8
- package/dist/services/prd/handoff-auto-regen.js +2 -1
- package/dist/services/qa/bdd-test-style-verifier.d.ts +88 -0
- package/dist/services/qa/bdd-test-style-verifier.js +268 -0
- package/dist/services/scan/type-sanity-service.js +2 -1
- package/dist/services/session/session-binding-bridge.js +17 -19
- package/dist/services/session/session-manager.js +36 -12
- package/dist/services/skills/presence-lease-service.js +1 -0
- package/dist/services/skills/skill-statusline-renderer.js +29 -32
- package/dist/services/skills/skill-statusline-service.d.ts +6 -0
- package/dist/services/skills/skill-statusline-service.js +107 -7
- package/dist/services/vm/vm-lease.js +2 -1
- package/dist/services/workflow/workflow-autonomous-resume-helpers.js +3 -2
- package/dist/services/workspace/workspace-service.js +2 -1
- package/dist/services/worktree/worktree-lease.js +2 -1
- package/dist/shared/path-safety.js +3 -5
- package/dist/shared/path-utils.d.ts +48 -0
- package/dist/shared/path-utils.js +65 -1
- package/docs/test-style-contract.md +135 -0
- package/package.json +5 -3
- package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +17 -1
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +21 -1
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
* `legacyPresence === true` is passed by the caller
|
|
25
25
|
* 4. null (caller did not set a skill; enforcers can decide to skip)
|
|
26
26
|
*/
|
|
27
|
-
import { existsSync,
|
|
27
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
29
|
import { getSessionIdCanonical } from '../../session/session-manager.js';
|
|
30
30
|
import { getSessionDir } from '../../session/getSessionDir.js';
|
|
31
|
-
import { readPresenceLease } from '../../skills/presence-lease-service.js';
|
|
31
|
+
import { readPresenceLease, listPresenceLeases } from '../../skills/presence-lease-service.js';
|
|
32
32
|
import { getCurrentSessionId } from '../../skills/skill-presence-service.js';
|
|
33
33
|
const ACTIVE_SKILL_PREFIX = 'active-skill-';
|
|
34
34
|
/**
|
|
@@ -43,71 +43,82 @@ const ACTIVE_SKILL_PREFIX = 'active-skill-';
|
|
|
43
43
|
export function resolveActiveSkillForCaller(projectRoot, opts) {
|
|
44
44
|
const envOverride = process.env.PEAKS_ACTIVE_SKILL;
|
|
45
45
|
if (typeof envOverride === 'string' && envOverride.length > 0) {
|
|
46
|
-
return { skill: envOverride, callerId: null, sessionId: null, source: 'env' };
|
|
46
|
+
return { skill: envOverride, callerId: null, sessionId: null, mode: null, source: 'env' };
|
|
47
47
|
}
|
|
48
48
|
let sessionId = null;
|
|
49
49
|
try {
|
|
50
50
|
sessionId = getSessionIdCanonical(projectRoot);
|
|
51
51
|
}
|
|
52
52
|
catch {
|
|
53
|
-
return { skill: null, callerId: null, sessionId: null, source: 'none' };
|
|
53
|
+
return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
|
|
54
54
|
}
|
|
55
55
|
if (sessionId === null) {
|
|
56
|
-
return { skill: null, callerId: null, sessionId: null, source: 'none' };
|
|
56
|
+
return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
|
|
57
57
|
}
|
|
58
58
|
// 4.0.8 preferred: read the canonical lease projection. The lease
|
|
59
|
-
// service
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
59
|
+
// service writes leases to `<sessionDir>/leases/presence-<caller>-<workflow>.json`
|
|
60
|
+
// (RD §4). We enumerate that directory via `listPresenceLeases`,
|
|
61
|
+
// which is the lease-service's own helper and knows the canonical
|
|
62
|
+
// layout. The first in-flight (status === 'preparing' | 'running')
|
|
63
|
+
// lease wins; this preserves the prior behaviour of returning a
|
|
64
|
+
// single skill per resolution. When the lease dir is empty (e.g.
|
|
65
|
+
// ad-hoc / pre-migration projects) we fall through to the legacy
|
|
66
|
+
// walk below.
|
|
66
67
|
const sessionDir = getSessionDir(projectRoot, sessionId);
|
|
67
68
|
if (!existsSync(sessionDir)) {
|
|
68
|
-
return { skill: null, callerId: null, sessionId, source: 'none' };
|
|
69
|
+
return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
|
|
69
70
|
}
|
|
70
|
-
let
|
|
71
|
+
let canonicalLeases = [];
|
|
71
72
|
try {
|
|
72
|
-
|
|
73
|
+
canonicalLeases = listPresenceLeases(projectRoot, sessionId);
|
|
73
74
|
}
|
|
74
75
|
catch {
|
|
75
|
-
|
|
76
|
+
canonicalLeases = [];
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// The canonical presence-index lives at `<sessionDir>/presence-index/<callerId>.json`.
|
|
80
|
-
// We synthesise the read from any presence-* lease file we find;
|
|
81
|
-
// the readPresenceLease call surfaces a typed `PresenceProjection`
|
|
82
|
-
// and we map `lease.skill` → resolution.
|
|
83
|
-
if (!entry.startsWith('presence-'))
|
|
78
|
+
for (const lease of canonicalLeases) {
|
|
79
|
+
if (lease.status !== 'preparing' && lease.status !== 'running')
|
|
84
80
|
continue;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
if (typeof lease.skill !== 'string' || lease.skill.length === 0)
|
|
82
|
+
continue;
|
|
83
|
+
// When a callerId is supplied, restrict the walk to that
|
|
84
|
+
// caller's lease. Slice 2026-08-04-rid-005 surfaces this for the
|
|
85
|
+
// statusline read so two concurrent sessions bound to the same
|
|
86
|
+
// project session do not see each other's skill.
|
|
87
|
+
if (typeof opts?.callerId === 'string' && opts.callerId.length > 0 && lease.callerId !== opts.callerId)
|
|
90
88
|
continue;
|
|
91
|
-
const workflowId = callerId.slice(lastDash + 1);
|
|
92
|
-
const pureCaller = callerId.slice(0, lastDash);
|
|
93
89
|
try {
|
|
94
90
|
const projection = readPresenceLease({
|
|
95
91
|
projectRoot,
|
|
96
92
|
sessionId,
|
|
97
|
-
callerId:
|
|
98
|
-
workflowId,
|
|
99
|
-
graphRef:
|
|
93
|
+
callerId: lease.callerId,
|
|
94
|
+
workflowId: lease.workflowId,
|
|
95
|
+
graphRef: lease.graphRef,
|
|
100
96
|
});
|
|
101
97
|
if (projection.lease !== null && typeof projection.lease.skill === 'string' && projection.lease.skill.length > 0) {
|
|
102
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
skill: projection.lease.skill,
|
|
100
|
+
callerId: lease.callerId,
|
|
101
|
+
sessionId,
|
|
102
|
+
mode: typeof projection.mode === 'string' && projection.mode.length > 0 ? projection.mode : null,
|
|
103
|
+
source: 'canonical',
|
|
104
|
+
};
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
|
-
catch { /* fall through to
|
|
107
|
+
catch { /* fall through to next lease */ }
|
|
106
108
|
}
|
|
107
109
|
// Legacy fall-back (one minor release). Gated on `legacyPresence:
|
|
108
|
-
// true` so production hooks prefer the canonical projection.
|
|
110
|
+
// true` so production hooks prefer the canonical projection. Reads
|
|
111
|
+
// the session dir root for the per-caller `active-skill-*.json`
|
|
112
|
+
// marker files written by the 4.0.7 `setSkillPresenceForCaller`.
|
|
109
113
|
if (opts?.legacyPresence === true) {
|
|
110
|
-
|
|
114
|
+
let legacyEntries = [];
|
|
115
|
+
try {
|
|
116
|
+
legacyEntries = readdirSync(sessionDir);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
legacyEntries = [];
|
|
120
|
+
}
|
|
121
|
+
for (const entry of legacyEntries) {
|
|
111
122
|
if (!entry.startsWith(ACTIVE_SKILL_PREFIX) || !entry.endsWith('.json'))
|
|
112
123
|
continue;
|
|
113
124
|
const callerId = entry.slice(ACTIVE_SKILL_PREFIX.length, -'.json'.length);
|
|
@@ -116,7 +127,10 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
|
|
|
116
127
|
const raw = readFileSync(filePath, 'utf8');
|
|
117
128
|
const parsed = JSON.parse(raw);
|
|
118
129
|
if (typeof parsed.skill === 'string' && parsed.skill.length > 0) {
|
|
119
|
-
|
|
130
|
+
const legacyMode = typeof parsed.mode === 'string' && parsed.mode.length > 0
|
|
131
|
+
? parsed.mode
|
|
132
|
+
: null;
|
|
133
|
+
return { skill: parsed.skill, callerId, sessionId, mode: legacyMode, source: 'file' };
|
|
120
134
|
}
|
|
121
135
|
}
|
|
122
136
|
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
@@ -129,5 +143,5 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
|
|
|
129
143
|
// walk can still resolve a session id if the canonical resolver
|
|
130
144
|
// returns null (e.g. ad-hoc projects without a `.peaks/_runtime/session.json`).
|
|
131
145
|
void getCurrentSessionId;
|
|
132
|
-
return { skill: null, callerId: null, sessionId, source: 'none' };
|
|
146
|
+
return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
|
|
133
147
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { randomBytes } from 'node:crypto';
|
|
31
31
|
import { posix as path } from 'node:path';
|
|
32
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
32
33
|
export const DEFAULT_TTL_BY_ROLE = Object.freeze({
|
|
33
34
|
rd: 30 * 60 * 1_000,
|
|
34
35
|
qa: 15 * 60 * 1_000,
|
|
@@ -126,7 +127,7 @@ export function deserializeContainerLease(raw) {
|
|
|
126
127
|
function joinPath(...segments) {
|
|
127
128
|
if (segments.length === 0)
|
|
128
129
|
return '';
|
|
129
|
-
const normalized = segments.map((s) => s
|
|
130
|
+
const normalized = segments.map((s) => normalizePath(s));
|
|
130
131
|
let acc = normalized[0];
|
|
131
132
|
for (let i = 1; i < normalized.length; i++) {
|
|
132
133
|
acc = path.join(acc, normalized[i]);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* command; `peaks impact must-check` returns the must-check list
|
|
14
14
|
* subset. Persistence is per-invocation (no state file).
|
|
15
15
|
*/
|
|
16
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
16
17
|
/**
|
|
17
18
|
* Default business flow map (covers the most common B-end
|
|
18
19
|
* archetypes; users can override via --flows in the CLI).
|
|
@@ -34,7 +35,7 @@ export const DEFAULT_BUSINESS_FLOWS = new Map([
|
|
|
34
35
|
* (the file itself, the parent dir, the project-wide pattern).
|
|
35
36
|
*/
|
|
36
37
|
function pathToGlobs(path) {
|
|
37
|
-
const normalized = path
|
|
38
|
+
const normalized = normalizePath(path);
|
|
38
39
|
const parts = normalized.split('/');
|
|
39
40
|
// Drop the filename → get the parent pattern: "src/foo/" → "src/**"
|
|
40
41
|
const parent = parts.length > 1 ? parts.slice(0, -1).join('/') + '/**' : '**';
|
|
@@ -42,7 +43,7 @@ function pathToGlobs(path) {
|
|
|
42
43
|
}
|
|
43
44
|
/** Simple glob matcher supporting `**` and `*`. */
|
|
44
45
|
export function matchGlob(pattern, path) {
|
|
45
|
-
const normalizedPath = path
|
|
46
|
+
const normalizedPath = normalizePath(path);
|
|
46
47
|
// Build regex by splitting on glob tokens, escaping the rest.
|
|
47
48
|
// Order: ** (doublestar) → .* ; * → [^/]* ; other → escape
|
|
48
49
|
let regexStr = '';
|
|
@@ -108,7 +109,7 @@ export function runImpactScan(opts) {
|
|
|
108
109
|
// Predict other files that may be affected (siblings of changed files).
|
|
109
110
|
const impactedFiles = [];
|
|
110
111
|
for (const f of changed) {
|
|
111
|
-
const parts = f
|
|
112
|
+
const parts = normalizePath(f).split('/');
|
|
112
113
|
if (parts.length <= 1)
|
|
113
114
|
continue;
|
|
114
115
|
const parent = parts.slice(0, -1).join('/');
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { join, relative } from 'node:path';
|
|
22
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
22
23
|
/**
|
|
23
24
|
* Skip-paths are matched against the path relative to `projectRoot`,
|
|
24
25
|
* forward-slashed (Windows-friendly). Each entry is a SUBSTRING —
|
|
@@ -41,7 +42,7 @@ function escapeRe(input) {
|
|
|
41
42
|
}
|
|
42
43
|
/** True iff `absPath` falls under any skip-path (substring match). */
|
|
43
44
|
export function shouldSkip(projectRoot, absPath) {
|
|
44
|
-
const rel = relative(projectRoot, absPath)
|
|
45
|
+
const rel = normalizePath(relative(projectRoot, absPath));
|
|
45
46
|
return SKIP_DIRS.some((skip) => rel.includes(skip));
|
|
46
47
|
}
|
|
47
48
|
/**
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { posix, relative, resolve, isAbsolute as nodeIsAbsolute } from 'node:path';
|
|
24
24
|
import { isPathInsideArtifactRoot as _isPathInsideArtifactRoot } from '../../shared/path-safety.js';
|
|
25
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
25
26
|
export function ok(value) {
|
|
26
27
|
return { ok: true, value };
|
|
27
28
|
}
|
|
@@ -111,7 +112,7 @@ export function planArtifactPath(input) {
|
|
|
111
112
|
// Always normalize separators to forward-slashes for the JSON-safe output,
|
|
112
113
|
// then run posix.normalize so empty segments (foo//bar) and `..` segments
|
|
113
114
|
// collapse to canonical form.
|
|
114
|
-
const forwardSlashed = candidateRaw
|
|
115
|
+
const forwardSlashed = normalizePath(candidateRaw);
|
|
115
116
|
const normalized = posix.normalize(forwardSlashed);
|
|
116
117
|
// Resolve against workspaceRoot. If the candidate is already absolute,
|
|
117
118
|
// posix.normalize preserves the leading `/`; `resolve` keeps it absolute
|
|
@@ -129,7 +130,7 @@ export function planArtifactPath(input) {
|
|
|
129
130
|
});
|
|
130
131
|
}
|
|
131
132
|
const relativePath = relative(workspaceRoot, absolutePath);
|
|
132
|
-
const jsonSafeRelativePath = relativePath
|
|
133
|
+
const jsonSafeRelativePath = normalizePath(relativePath);
|
|
133
134
|
return ok({ absolutePath, relativePath, jsonSafeRelativePath });
|
|
134
135
|
}
|
|
135
136
|
const WORKSPACE_UNAVAILABLE_NEXT_ACTIONS = [
|
|
@@ -24,6 +24,7 @@ import { posix, relative, resolve } from 'node:path';
|
|
|
24
24
|
import { readFile, stat } from 'node:fs/promises';
|
|
25
25
|
import { isDirectory } from 'peaks-loop-shared/fs';
|
|
26
26
|
import { ok, err } from './artifact-boundary.js';
|
|
27
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Discovery
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
@@ -63,7 +64,7 @@ export async function resolveCoverageSummaryPath(input) {
|
|
|
63
64
|
return err({ code: 'missing', triedPaths });
|
|
64
65
|
}
|
|
65
66
|
function toPosix(p) {
|
|
66
|
-
return p
|
|
67
|
+
return normalizePath(p).replace(/\/$/, '');
|
|
67
68
|
}
|
|
68
69
|
async function isReadable(p) {
|
|
69
70
|
try {
|
|
@@ -112,7 +113,7 @@ export async function readC8Summary(path) {
|
|
|
112
113
|
if (lines === null || statements === null || functions === null || branches === null) {
|
|
113
114
|
continue;
|
|
114
115
|
}
|
|
115
|
-
const normalizedKey = posix.normalize(key
|
|
116
|
+
const normalizedKey = posix.normalize(normalizePath(key));
|
|
116
117
|
files.set(normalizedKey, {
|
|
117
118
|
path: normalizePathSep(normalizedKey),
|
|
118
119
|
relativePath: normalizedKey,
|
|
@@ -141,7 +142,7 @@ function asMetric(input) {
|
|
|
141
142
|
return { pct, covered, total };
|
|
142
143
|
}
|
|
143
144
|
function normalizePathSep(p) {
|
|
144
|
-
return p
|
|
145
|
+
return normalizePath(p);
|
|
145
146
|
}
|
|
146
147
|
// ---------------------------------------------------------------------------
|
|
147
148
|
// Capability Mapping block parser (proposal.md)
|
|
@@ -215,7 +216,7 @@ function parseCapabilityRow(line) {
|
|
|
215
216
|
return null;
|
|
216
217
|
const row = {
|
|
217
218
|
capability,
|
|
218
|
-
source: source
|
|
219
|
+
source: normalizePath(source).replace(/^\.\//, '').replace(/\/$/, ''),
|
|
219
220
|
};
|
|
220
221
|
if (testAnchor !== undefined && testAnchor !== '') {
|
|
221
222
|
row.testAnchor = testAnchor;
|
|
@@ -236,7 +237,7 @@ function parseCapabilityRow(line) {
|
|
|
236
237
|
export async function resolveCapabilityFiles(input) {
|
|
237
238
|
const abs = resolve(input.projectRoot, input.row.source);
|
|
238
239
|
if (!(await isDirectory(abs))) {
|
|
239
|
-
const rel = relative(input.projectRoot, abs)
|
|
240
|
+
const rel = normalizePath(relative(input.projectRoot, abs));
|
|
240
241
|
return [rel];
|
|
241
242
|
}
|
|
242
243
|
return await walkTsFiles(abs, input.projectRoot);
|
|
@@ -261,7 +262,7 @@ async function walkTsFiles(absRoot, projectRoot) {
|
|
|
261
262
|
stack.push(full);
|
|
262
263
|
}
|
|
263
264
|
else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
264
|
-
const rel = relative(projectRoot, full)
|
|
265
|
+
const rel = normalizePath(relative(projectRoot, full));
|
|
265
266
|
out.push(rel);
|
|
266
267
|
}
|
|
267
268
|
}
|
|
@@ -339,7 +340,7 @@ export async function validateCapabilityCoverage(input) {
|
|
|
339
340
|
export async function findStaleChangeFiles(input) {
|
|
340
341
|
const { stat: statFn } = await import('node:fs/promises');
|
|
341
342
|
const summaryMtime = (await statFn(input.summary.path)).mtimeMs;
|
|
342
|
-
const changeRoot = `${input.openspecRoot
|
|
343
|
+
const changeRoot = `${normalizePath(input.openspecRoot).replace(/\/$/, '')}/changes/${input.changeId}`;
|
|
343
344
|
const candidates = [
|
|
344
345
|
`${changeRoot}/proposal.md`,
|
|
345
346
|
`${changeRoot}/tasks.md`,
|
|
@@ -375,7 +376,7 @@ export async function findStaleChangeFiles(input) {
|
|
|
375
376
|
try {
|
|
376
377
|
const m = (await statFn(candidate)).mtimeMs;
|
|
377
378
|
if (m > summaryMtime) {
|
|
378
|
-
stale.push(relative(input.projectRoot, candidate)
|
|
379
|
+
stale.push(normalizePath(relative(input.projectRoot, candidate)));
|
|
379
380
|
}
|
|
380
381
|
}
|
|
381
382
|
catch {
|
|
@@ -16,6 +16,7 @@ import { dirname, join } from 'node:path';
|
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
|
|
18
18
|
import { sha256OfBody } from './handoff-service.js';
|
|
19
|
+
import { normalizePath } from '../../shared/path-utils.js';
|
|
19
20
|
/**
|
|
20
21
|
* Auto-regen the prd/handoff.md under `.peaks/_runtime/<sid>/prd/`.
|
|
21
22
|
* Returns the outcome; never throws.
|
|
@@ -58,7 +59,7 @@ export async function autoRegenPrdHandoff(opts) {
|
|
|
58
59
|
'goals: []',
|
|
59
60
|
'acceptanceCriteria: []',
|
|
60
61
|
'preservedBehavior: []',
|
|
61
|
-
`handoffPath: ${handoffPath.replace(opts.projectRoot, '')
|
|
62
|
+
`handoffPath: ${normalizePath(handoffPath.replace(opts.projectRoot, '')).replace(/^\//, '')}`,
|
|
62
63
|
'---',
|
|
63
64
|
''
|
|
64
65
|
].join('\n');
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/services/qa/bdd-test-style-verifier.ts
|
|
3
|
+
*
|
|
4
|
+
* rid-2026-08-05-bdd-test-style Slice B — peaks-qa verification-time
|
|
5
|
+
* BDD test-style verifier. This is the read-only, post-edit companion
|
|
6
|
+
* to the `scripts/migrate-to-bdd.mjs` AST migrator shipped in Slice A.
|
|
7
|
+
*
|
|
8
|
+
* Purpose:
|
|
9
|
+
* When peaks-qa runs its verification gate, it picks up the git diff
|
|
10
|
+
* for the slice and asks this module whether the new / modified test
|
|
11
|
+
* files comply with the BDD given-when-then style. The verdict is
|
|
12
|
+
* surfaced as either `ok` (and the slice can advance) or one of two
|
|
13
|
+
* structured failure reasons (`missing-given-when-then` or
|
|
14
|
+
* `description-no-should-when`) that the caller turns into a
|
|
15
|
+
* `qa-handoff` rejection back to peaks-rd.
|
|
16
|
+
*
|
|
17
|
+
* Why a real AST and not a regex:
|
|
18
|
+
* The Slice A migrator established the convention: test files have
|
|
19
|
+
* multi-line `it(...)` calls, nested arrow bodies, and string
|
|
20
|
+
* literals that often contain words like "when" inside the assertion
|
|
21
|
+
* message (not in the description). A regex pass on the raw source
|
|
22
|
+
* would false-positive on string internals. The TypeScript Compiler
|
|
23
|
+
* API (already a dev dep via vitest) lets us:
|
|
24
|
+
* 1. Inspect the first `StringLiteral` argument of an `it` /
|
|
25
|
+
* `test` / `describe` call without scanning comments or
|
|
26
|
+
* string content inside the body.
|
|
27
|
+
* 2. Walk only the leading-comment ranges that sit before the
|
|
28
|
+
* first statement of the callback block, so a `// when:`
|
|
29
|
+
* inside an `expect(actual).toEqual('when X happens')` is
|
|
30
|
+
* correctly ignored.
|
|
31
|
+
*
|
|
32
|
+
* No new dependencies. The verifier is intentionally synchronous and
|
|
33
|
+
* pure (input source + path list -> verdict) so peaks-qa can call it
|
|
34
|
+
* from a deterministic verification step without subprocess overhead.
|
|
35
|
+
*
|
|
36
|
+
* Anti-fake-green (CLI silent-catch rule):
|
|
37
|
+
* This module throws on parse failure. It does NOT swallow parse
|
|
38
|
+
* errors and return `{ ok: true }` — that would silently green-light
|
|
39
|
+
* malformed test files. A parse error is a structural problem; the
|
|
40
|
+
* caller must surface it.
|
|
41
|
+
*/
|
|
42
|
+
/** Structured failure reasons the verifier can return. */
|
|
43
|
+
export type BddStyleFailureReason = 'missing-given-when-then' | 'description-no-should-when';
|
|
44
|
+
/** Successful verdict — includes the count of inspected `it`/`test` calls. */
|
|
45
|
+
export interface BddStyleOk {
|
|
46
|
+
readonly ok: true;
|
|
47
|
+
readonly scanned: number;
|
|
48
|
+
}
|
|
49
|
+
/** Structured failure verdict — the file/line makes the rejection actionable. */
|
|
50
|
+
export interface BddStyleFail {
|
|
51
|
+
readonly ok: false;
|
|
52
|
+
readonly reason: BddStyleFailureReason;
|
|
53
|
+
readonly file: string;
|
|
54
|
+
readonly line: number;
|
|
55
|
+
/** For `description-no-should-when`: the original description. */
|
|
56
|
+
readonly description?: string;
|
|
57
|
+
/** For `missing-given-when-then`: a stable string the caller can compare. */
|
|
58
|
+
readonly expected?: string;
|
|
59
|
+
}
|
|
60
|
+
export type BddStyleVerdict = BddStyleOk | BddStyleFail;
|
|
61
|
+
/** Public input surface — keep small so the contract is hard to misuse. */
|
|
62
|
+
export interface VerifyBddStyleInput {
|
|
63
|
+
readonly projectRoot: string;
|
|
64
|
+
readonly testFiles: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Verify that every `it(...)` / `test(...)` call in the given test
|
|
68
|
+
* files follows the BDD given-when-then contract.
|
|
69
|
+
*
|
|
70
|
+
* Contract:
|
|
71
|
+
* 1. The first `StringLiteral` argument of every `it` / `test` call
|
|
72
|
+
* MUST match `/(\bwhen\b|\bshould\b)/` (word-boundary anchored,
|
|
73
|
+
* case-insensitive). A regex on the raw description is correct
|
|
74
|
+
* here because the description itself is a literal — there is
|
|
75
|
+
* no nested template literal to misread.
|
|
76
|
+
* 2. The callback body (the second argument when it is an arrow /
|
|
77
|
+
* function expression with a block) MUST have a `// given:`,
|
|
78
|
+
* `// when:`, `// then:` triple at the top, in that order,
|
|
79
|
+
* within the first 3 leading-comment ranges before the first
|
|
80
|
+
* statement. The `// arrange:` / `// act:` / `// assert:` AAA
|
|
81
|
+
* legacy is NOT accepted — the contract is given-when-then
|
|
82
|
+
* only.
|
|
83
|
+
*
|
|
84
|
+
* Returns the FIRST failure encountered (file order, then
|
|
85
|
+
* top-to-bottom line order). A structured `BddStyleFail` is what the
|
|
86
|
+
* caller maps to `qa-handoff` rejection.
|
|
87
|
+
*/
|
|
88
|
+
export declare function verifyBddStyle(input: VerifyBddStyleInput): BddStyleVerdict;
|