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.
Files changed (35) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/config/eslint/.peaks-rules.cjs +123 -0
  3. package/dist/cli/commands/container-commands.js +2 -1
  4. package/dist/cli/commands/core/skill-command.js +32 -5
  5. package/dist/cli/commands/openspec-commands.js +2 -1
  6. package/dist/reporters/bdd-reporter.d.ts +36 -0
  7. package/dist/reporters/bdd-reporter.js +159 -0
  8. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
  9. package/dist/services/audit/enforcers/active-skill-resolver.js +53 -39
  10. package/dist/services/container/container-lease.js +2 -1
  11. package/dist/services/impact/impact-scan-service.js +4 -3
  12. package/dist/services/migrate-skill-name/migrate.js +2 -1
  13. package/dist/services/openspec/artifact-boundary.js +3 -2
  14. package/dist/services/openspec/coverage-evidence-reader.js +9 -8
  15. package/dist/services/prd/handoff-auto-regen.js +2 -1
  16. package/dist/services/qa/bdd-test-style-verifier.d.ts +88 -0
  17. package/dist/services/qa/bdd-test-style-verifier.js +268 -0
  18. package/dist/services/scan/type-sanity-service.js +2 -1
  19. package/dist/services/session/session-binding-bridge.js +17 -19
  20. package/dist/services/session/session-manager.js +36 -12
  21. package/dist/services/skills/presence-lease-service.js +1 -0
  22. package/dist/services/skills/skill-statusline-renderer.js +29 -32
  23. package/dist/services/skills/skill-statusline-service.d.ts +6 -0
  24. package/dist/services/skills/skill-statusline-service.js +107 -7
  25. package/dist/services/vm/vm-lease.js +2 -1
  26. package/dist/services/workflow/workflow-autonomous-resume-helpers.js +3 -2
  27. package/dist/services/workspace/workspace-service.js +2 -1
  28. package/dist/services/worktree/worktree-lease.js +2 -1
  29. package/dist/shared/path-safety.js +3 -5
  30. package/dist/shared/path-utils.d.ts +48 -0
  31. package/dist/shared/path-utils.js +65 -1
  32. package/docs/test-style-contract.md +135 -0
  33. package/package.json +5 -3
  34. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +17 -1
  35. 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, readdirSync, readFileSync } from 'node:fs';
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 exposes `readPresenceLease({ projectRoot, sessionId,
60
- // callerId, workflowId, graphRef })`; we need *some* callerId to
61
- // point at a single lease. Hook enforcers don't have a callerId
62
- // (they're per-IDE, per-window), so we walk the canonical
63
- // presence-index to find any active lease. When the index is empty
64
- // (e.g. ad-hoc / pre-migration projects) we fall through to the
65
- // legacy walk below.
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 entries = [];
71
+ let canonicalLeases = [];
71
72
  try {
72
- entries = readdirSync(sessionDir);
73
+ canonicalLeases = listPresenceLeases(projectRoot, sessionId);
73
74
  }
74
75
  catch {
75
- entries = [];
76
+ canonicalLeases = [];
76
77
  }
77
- const indexEntries = entries.filter((e) => !e.startsWith(ACTIVE_SKILL_PREFIX) && e.endsWith('.json') && !e.includes('.tmp-'));
78
- for (const entry of indexEntries) {
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
- const callerId = entry.slice('presence-'.length, -'.json'.length);
86
- // Derive workflowId from the same file name (canonical
87
- // leaseFilePath encodes `presence-<caller>-<workflow>.json`).
88
- const lastDash = callerId.lastIndexOf('-');
89
- if (lastDash < 0)
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: pureCaller,
98
- workflowId,
99
- graphRef: `graphs/${workflowId}.json`,
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 { skill: projection.lease.skill, callerId: pureCaller, sessionId, source: 'canonical' };
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 legacy walk */ }
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
- for (const entry of entries) {
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
- return { skill: parsed.skill, callerId, sessionId, source: 'file' };
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.replace(/\\/g, '/'));
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.replace(/\\/g, '/');
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.replace(/\\/g, '/');
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.replace(/\\/g, '/').split('/');
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).replace(/\\/g, '/');
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.replace(/\\/g, '/');
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.replace(/\\/g, '/');
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.replace(/\\/g, '/').replace(/\/$/, '');
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.replace(/\\/g, '/'));
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.replace(/\\/g, '/');
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.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''),
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).replace(/\\/g, '/');
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).replace(/\\/g, '/');
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.replace(/\\/g, '/').replace(/\/$/, '')}/changes/${input.changeId}`;
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).replace(/\\/g, '/'));
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, '').replace(/\\/g, '/').replace(/^\//, '')}`,
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;