Merge pull request #94 from open-gsd/feat/phase-uat-passed-3184
feat(sdk): isPhaseUatPassed predicate + phase.uat-passed query (#3184)
This commit is contained in:
6
.changeset/3184-phase-uat-passed.md
Normal file
6
.changeset/3184-phase-uat-passed.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3713
|
||||
---
|
||||
**`phase.uat-passed` query and `isPhaseUatPassed` SDK export** — adds canonical read-only query `phase.uat-passed` (alias `phase uat-passed`) and programmatic export `isPhaseUatPassed(projectDir, phase, workstream?)` returning typed `{ passed, reasons, reasonsHuman, items }`. Predicate consumes `*-HUMAN-UAT.md` files; hardens against markdown injection (YAML frontmatter, fenced code blocks, HTML comments, blockquote-prefixed lines); surfaces operator-mistake signals as typed `REASON_CODE` frozen enum values (`CASE_MISMATCH`, `ORPHAN_ITEM_MISSING_RESULT`, `BRACKETED_PLACEHOLDER`, `NO_ITEMS_EXTRACTED`). Typed `PhaseUatPassedError extends GSDError` for invalid arguments. Refs #3184.
|
||||
<!-- docs-exempt: SDK-only programmatic export in v1 per issue scope. Docs for phase.uat-passed deferred to follow-up issue pending @henjolo design confirmation. -->
|
||||
@@ -389,6 +389,14 @@ const PHASE_COMMAND_ALIASES = [
|
||||
"subcommand": "list-artifacts",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "phase.uat-passed",
|
||||
"aliases": [
|
||||
"phase uat-passed"
|
||||
],
|
||||
"subcommand": "uat-passed",
|
||||
"mutation": false
|
||||
},
|
||||
{
|
||||
"canonical": "phase.next-decimal",
|
||||
"aliases": [
|
||||
|
||||
@@ -343,6 +343,20 @@ export type { WSTransportOptions } from './ws-transport.js';
|
||||
// Query registry argv normalization (matches `gsd-sdk query` and `GSDTools` hot path)
|
||||
export { createRegistry, normalizeQueryCommand } from './query/index.js';
|
||||
|
||||
// Phase UAT predicate — programmatic API surface (#3184)
|
||||
export {
|
||||
isPhaseUatPassed,
|
||||
phaseUatPassed,
|
||||
REASON_CODE,
|
||||
ERROR_CODE,
|
||||
PhaseUatPassedError,
|
||||
} from './query/phase-uat-passed.js';
|
||||
export type {
|
||||
UatReason,
|
||||
ReasonCode,
|
||||
ErrorCode,
|
||||
} from './query/phase-uat-passed.js';
|
||||
|
||||
// Workstream utilities
|
||||
export { validateWorkstreamName, relPlanningPath } from './workstream-utils.js';
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export const INIT_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [
|
||||
export const PHASE_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [
|
||||
{ canonical: 'phase.list-plans', aliases: ['phase list-plans'], subcommand: 'list-plans', mutation: false },
|
||||
{ canonical: 'phase.list-artifacts', aliases: ['phase list-artifacts'], subcommand: 'list-artifacts', mutation: false },
|
||||
{ canonical: 'phase.uat-passed', aliases: ['phase uat-passed'], subcommand: 'uat-passed', mutation: false },
|
||||
{ canonical: 'phase.next-decimal', aliases: ['phase next-decimal'], subcommand: 'next-decimal', mutation: false },
|
||||
{ canonical: 'phase.add', aliases: ['phase add'], subcommand: 'add', mutation: true },
|
||||
{ canonical: 'phase.add-batch', aliases: ['phase add-batch'], subcommand: 'add-batch', mutation: true },
|
||||
|
||||
@@ -24,6 +24,7 @@ import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, va
|
||||
import {
|
||||
phaseListPlans, phaseListArtifacts,
|
||||
} from './phase-list-queries.js';
|
||||
import { phaseUatPassed } from './phase-uat-passed.js';
|
||||
import {
|
||||
phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete,
|
||||
phaseScaffold, phaseNextDecimal, phasesList, phasesClear, phasesArchive,
|
||||
@@ -86,6 +87,7 @@ export const FAMILY_HANDLERS: Record<string, Readonly<Record<string, QueryHandle
|
||||
phase: {
|
||||
'phase.list-plans': phaseListPlans,
|
||||
'phase.list-artifacts': phaseListArtifacts,
|
||||
'phase.uat-passed': phaseUatPassed,
|
||||
'phase.add': phaseAdd,
|
||||
'phase.add-batch': phaseAddBatch,
|
||||
'phase.insert': phaseInsert,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CommandManifestEntry } from './command-manifest.types.js';
|
||||
export const PHASE_COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
||||
{ family: 'phase', canonical: 'phase.list-plans', aliases: ['phase list-plans'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'phase', canonical: 'phase.list-artifacts', aliases: ['phase list-artifacts'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'phase', canonical: 'phase.uat-passed', aliases: ['phase uat-passed'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'phase', canonical: 'phase.next-decimal', aliases: ['phase next-decimal'], mutation: false, outputMode: 'json' },
|
||||
{ family: 'phase', canonical: 'phase.add', aliases: ['phase add'], mutation: true, outputMode: 'json' },
|
||||
{ family: 'phase', canonical: 'phase.add-batch', aliases: ['phase add-batch'], mutation: true, outputMode: 'json' },
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
/** Resolve `.planning/phases/<dir>` for a phase token, or null. */
|
||||
async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise<string | null> {
|
||||
export async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise<string | null> {
|
||||
const phasesDir = planningPaths(projectDir, workstream).phases;
|
||||
const normalized = normalizePhaseName(phase);
|
||||
try {
|
||||
|
||||
593
sdk/src/query/phase-uat-passed.test.ts
Normal file
593
sdk/src/query/phase-uat-passed.test.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Tests for isPhaseUatPassed across UAT result classification paths.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isPhaseUatPassed, REASON_CODE, PhaseUatPassedError, ERROR_CODE } from './phase-uat-passed.js';
|
||||
import { createRegistry } from './index.js';
|
||||
|
||||
const UAT_PASS_CONTENT = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
### 1. First item
|
||||
expected: thing should happen
|
||||
result: pass
|
||||
`;
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-uat-passed-'));
|
||||
const phaseDir = join(tmpDir, '.planning', 'phases', '05-walking-skeleton');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), UAT_PASS_CONTENT);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('isPhaseUatPassed', () => {
|
||||
it('returns passed=true when a single UAT file contains one pass result', async () => {
|
||||
const result = await isPhaseUatPassed(tmpDir, '5');
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].result).toBe('pass');
|
||||
expect(result.reasons.length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns passed=false with NO_UAT_FILES reason when phase dir has no UAT files', async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c4-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-empty');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
// Write a non-UAT file to ensure the dir exists but has no *-HUMAN-UAT.md
|
||||
await writeFile(join(phaseDir, '05-PLAN.md'), '# Plan\nNothing here.\n');
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.items.length).toBe(0);
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.NO_UAT_FILES);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns passed=false with NO_PHASE_DIR reason when phase has no directory', async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c3-'));
|
||||
try {
|
||||
const otherPhaseDir = join(localTmp, '.planning', 'phases', '06-other');
|
||||
await mkdir(otherPhaseDir, { recursive: true });
|
||||
await writeFile(join(otherPhaseDir, '06-HUMAN-UAT.md'), UAT_PASS_CONTENT);
|
||||
|
||||
// Query phase 5 which has NO directory in this fixture
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.items.length).toBe(0);
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.NO_PHASE_DIR);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns passed=false with NON_PASS_RESULT reason when single UAT item has result: issue', async () => {
|
||||
const nonPassContent = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
### 1. Some item
|
||||
expected: thing happens
|
||||
result: issue
|
||||
`;
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c2-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-non-pass');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), nonPassContent);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.NON_PASS_RESULT);
|
||||
expect(result.reasons[0].capturedValue).toBe('issue');
|
||||
expect(result.reasons[0].itemName).toBe('Some item');
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores ### item content inside YAML frontmatter region', async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c5-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-frontmatter-injection');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
malicious_demo: |
|
||||
### 1. Frontmatter-injected item
|
||||
expected: nothing
|
||||
result: pass
|
||||
---
|
||||
|
||||
### 1. Real item
|
||||
expected: real thing
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].name).toBe('Real item');
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores ### item content inside fenced code blocks', async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c6-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-fenced-injection');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
Some prose.
|
||||
|
||||
\`\`\`markdown
|
||||
### 1. Fenced example
|
||||
expected: blah
|
||||
result: pass
|
||||
\`\`\`
|
||||
|
||||
### 1. Real item
|
||||
expected: real
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].name).toBe('Real item');
|
||||
expect(result.passed).toBe(true);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores ### item content inside HTML comments', async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c7-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-html-comment-injection');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
<!--
|
||||
### 1. Commented-out item
|
||||
expected: blah
|
||||
result: pass
|
||||
-->
|
||||
|
||||
### 1. Real item
|
||||
expected: real
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].name).toBe('Real item');
|
||||
expect(result.passed).toBe(true);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores ### item content on blockquote-prefixed lines', async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c8-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-blockquote-injection');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
> ### 1. Quoted item
|
||||
expected: blah
|
||||
result: pass
|
||||
|
||||
### 1. Real item
|
||||
expected: real
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].name).toBe('Real item');
|
||||
expect(result.passed).toBe(true);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('parses bold-prefixed **result:** key as equivalent to bare result:', async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c9-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-bold-key');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
### 1. Bold-key item
|
||||
expected: thing
|
||||
**result:** pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].name).toBe('Bold-key item');
|
||||
expect(result.items[0].result).toBe('pass');
|
||||
expect(result.passed).toBe(true);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("human_verification items in frontmatter contribute HUMAN_VERIFICATION_NEEDED reasons", async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c11-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-human-verification');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
human_verification:
|
||||
- name: manual smoke test
|
||||
expected: app loads
|
||||
---
|
||||
|
||||
### 1. Real pass
|
||||
expected: thing
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.items.length).toBe(2);
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.HUMAN_VERIFICATION_NEEDED);
|
||||
expect(result.reasons[0].itemName).toBe('manual smoke test');
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws PhaseUatPassedError with PROJECT_DIR_MISSING code when projectDir does not exist", async () => {
|
||||
const missingPath = `/nonexistent/path/that/should/never/be/real-${Date.now()}`;
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await isPhaseUatPassed(missingPath, '5');
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(PhaseUatPassedError);
|
||||
expect((thrown as PhaseUatPassedError).code).toBe(ERROR_CODE.PROJECT_DIR_MISSING);
|
||||
});
|
||||
|
||||
it("emits NO_ITEMS_EXTRACTED reason when UAT file has no parseable items, orphans, or placeholders", async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c14-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-no-items');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
This phase doesn't have any UAT items yet.
|
||||
Prose only.
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.items.length).toBe(0);
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.NO_ITEMS_EXTRACTED);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits BRACKETED_PLACEHOLDER reason when result value is wrapped in brackets", async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c13-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-bracketed-placeholder');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
### 1. Forgot to fill in result
|
||||
expected: thing
|
||||
result: [pending]
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.items.length).toBe(0);
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.BRACKETED_PLACEHOLDER);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits ORPHAN_ITEM_MISSING_RESULT reason for headings missing the result field", async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c12-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-orphan-heading');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
### 1. Forgot to fill this in
|
||||
expected: something
|
||||
|
||||
### 2. Real one
|
||||
expected: works
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.ORPHAN_ITEM_MISSING_RESULT);
|
||||
expect(result.reasons[0].itemName).toBe('Forgot to fill this in');
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits CASE_MISMATCH reason when result value is \"PASS\" (uppercase variant of pass)", async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c10-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-case-mismatch');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
source: roadmap
|
||||
started: 2026-05-18T00:00:00Z
|
||||
updated: 2026-05-18T00:00:00Z
|
||||
---
|
||||
|
||||
### 1. Uppercase pass item
|
||||
expected: thing happens
|
||||
result: PASS
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.CASE_MISMATCH);
|
||||
expect(result.reasons[0].capturedValue).toBe('PASS');
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves UAT body content when file contains an internal --- horizontal rule', async () => {
|
||||
// Regression: frontmatter regex with /m flag treated mid-body --- as a
|
||||
// second frontmatter block and stripped everything between them.
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-hrule-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-hrule');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
---
|
||||
|
||||
### 1. Item above rule
|
||||
expected: works
|
||||
result: pass
|
||||
|
||||
---
|
||||
|
||||
### 2. Item below rule
|
||||
expected: also works
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.items.length).toBe(2);
|
||||
expect(result.items[0].name).toBe('Item above rule');
|
||||
expect(result.items[1].name).toBe('Item below rule');
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('aggregates items and reasons across multiple UAT files in the same phase', async () => {
|
||||
// Finding #4: multiple *-HUMAN-UAT.md files — all-pass + one failure
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-multi-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-multi');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
|
||||
const passFile = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
---
|
||||
|
||||
### 1. Passing check
|
||||
expected: it works
|
||||
result: pass
|
||||
`;
|
||||
const failFile = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
---
|
||||
|
||||
### 1. Failing check
|
||||
expected: it works
|
||||
result: issue
|
||||
|
||||
### 2. Another pass
|
||||
expected: also works
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(phaseDir, '05a-HUMAN-UAT.md'), passFile);
|
||||
await writeFile(join(phaseDir, '05b-HUMAN-UAT.md'), failFile);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5');
|
||||
// Total items: 1 from first file + 2 from second file
|
||||
expect(result.items.length).toBe(3);
|
||||
// One NON_PASS_RESULT reason from the failing item
|
||||
expect(result.reasons.length).toBe(1);
|
||||
expect(result.reasons[0].code).toBe(REASON_CODE.NON_PASS_RESULT);
|
||||
expect(result.passed).toBe(false);
|
||||
// reasonsHuman must be non-empty
|
||||
expect(result.reasonsHuman.length).toBe(1);
|
||||
expect(typeof result.reasonsHuman[0]).toBe('string');
|
||||
expect(result.reasonsHuman[0].length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws PhaseUatPassedError with INVALID_PHASE_NUM when phase arg is empty', async () => {
|
||||
// Finding #5: INVALID_PHASE_NUM error path via phaseUatPassed handler
|
||||
const { phaseUatPassed, ERROR_CODE: EC } = await import('./phase-uat-passed.js');
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-invalidphase-'));
|
||||
try {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await phaseUatPassed([], localTmp);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(PhaseUatPassedError);
|
||||
expect((thrown as PhaseUatPassedError).code).toBe(EC.INVALID_PHASE_NUM);
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves isPhaseUatPassed via workstream routing', async () => {
|
||||
// Finding #6: workstream-keyed project under .planning/workstreams/ws1/phases/
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-ws-'));
|
||||
try {
|
||||
const wsPhaseDir = join(localTmp, '.planning', 'workstreams', 'ws1', 'phases', '05-ws-phase');
|
||||
await mkdir(wsPhaseDir, { recursive: true });
|
||||
const content = `---
|
||||
status: complete
|
||||
phase: 5
|
||||
---
|
||||
|
||||
### 1. WS item
|
||||
expected: works in workstream
|
||||
result: pass
|
||||
`;
|
||||
await writeFile(join(wsPhaseDir, '05-HUMAN-UAT.md'), content);
|
||||
|
||||
const result = await isPhaseUatPassed(localTmp, '5', 'ws1');
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].name).toBe('WS item');
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase.uat-passed registry wire-up (cycle 16)', () => {
|
||||
it('phase.uat-passed is registered and dispatchable through the query registry', async () => {
|
||||
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c16-'));
|
||||
try {
|
||||
const phaseDir = join(localTmp, '.planning', 'phases', '05-walking-skeleton');
|
||||
await mkdir(phaseDir, { recursive: true });
|
||||
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), UAT_PASS_CONTENT);
|
||||
|
||||
const registry = createRegistry();
|
||||
|
||||
// The handler must be found — if not registered, this returns undefined.
|
||||
expect(registry.has('phase.uat-passed'), 'phase.uat-passed handler not found in registry').toBe(true);
|
||||
|
||||
// Dispatch via the real registry path; args[0] is the phase token.
|
||||
const result = await registry.dispatch('phase.uat-passed', ['5'], localTmp);
|
||||
|
||||
const data = result.data as { passed: boolean; items: Array<Record<string, unknown>>; reasons: unknown[] };
|
||||
expect(data.passed).toBe(true);
|
||||
expect(data.items.length).toBe(1);
|
||||
expect(data.items[0].result).toBe('pass');
|
||||
} finally {
|
||||
await rm(localTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
297
sdk/src/query/phase-uat-passed.ts
Normal file
297
sdk/src/query/phase-uat-passed.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* isPhaseUatPassed — SDK predicate answering "is phase N's UAT contract satisfied?"
|
||||
* Implements the isPhaseUatPassed predicate per #3184.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
import { resolvePhaseDir } from './phase-list-queries.js';
|
||||
import { extractFrontmatter } from './frontmatter.js';
|
||||
import { parseVerificationFrontmatterItems } from './uat.js';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
export const REASON_CODE = Object.freeze({
|
||||
NON_PASS_RESULT: 'non_pass_result',
|
||||
CASE_MISMATCH: 'case_mismatch',
|
||||
HUMAN_VERIFICATION_NEEDED: 'human_verification_needed',
|
||||
ORPHAN_ITEM_MISSING_RESULT: 'orphan_item_missing_result',
|
||||
BRACKETED_PLACEHOLDER: 'bracketed_placeholder',
|
||||
NO_ITEMS_EXTRACTED: 'no_items_extracted',
|
||||
NO_PHASE_DIR: 'no_phase_dir',
|
||||
NO_UAT_FILES: 'no_uat_files',
|
||||
} as const);
|
||||
|
||||
export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE];
|
||||
|
||||
export const ERROR_CODE = Object.freeze({
|
||||
PROJECT_DIR_MISSING: 'project_dir_missing',
|
||||
INVALID_PHASE_NUM: 'invalid_phase_num',
|
||||
} as const);
|
||||
export type ErrorCode = typeof ERROR_CODE[keyof typeof ERROR_CODE];
|
||||
|
||||
export class PhaseUatPassedError extends GSDError {
|
||||
constructor(message: string, public readonly code: ErrorCode) {
|
||||
super(message, ErrorClassification.Validation);
|
||||
}
|
||||
}
|
||||
|
||||
export type UatReason = {
|
||||
code: ReasonCode;
|
||||
file?: string;
|
||||
itemName?: string;
|
||||
capturedValue?: string;
|
||||
};
|
||||
|
||||
/** Regex to parse all UAT items regardless of result value.
|
||||
* Accepts optional bold markers (**key:**) around expected/result keys. */
|
||||
const UAT_ITEM_PATTERN =
|
||||
/###\s*(\d+)\.\s*([^\n]+)\n(?:\*\*)?expected:(?:\*\*)?\s*([^\n]+)\n(?:\*\*)?result:(?:\*\*)?\s*(\w+)/g;
|
||||
|
||||
interface UatItem {
|
||||
[key: string]: unknown;
|
||||
test: number;
|
||||
name: string;
|
||||
expected: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip regions from file content that could contain markdown-shaped text
|
||||
* but should not be treated as UAT items (frontmatter, code fences, etc.).
|
||||
* Passes are applied in order; each returns a sanitised string.
|
||||
*/
|
||||
function stripMarkdownInjection(content: string): string {
|
||||
// Pass 1: strip YAML frontmatter region (---\n...\n---). No /m flag — ^ must
|
||||
// anchor to position 0 only, preventing mid-body --- separators from triggering.
|
||||
let s = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '');
|
||||
// Pass 2: strip fenced code blocks (``` ... ```)
|
||||
s = s.replace(/```[\s\S]*?```/g, '');
|
||||
// Pass 3: strip HTML comment regions (<!-- ... -->)
|
||||
s = s.replace(/<!--[\s\S]*?-->/g, '');
|
||||
// Pass 4: strip blockquote-prefixed lines (any line starting with optional whitespace + >)
|
||||
s = s.replace(/^\s*>.*$/gm, '');
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all UAT items from already-stripped content.
|
||||
* Callers must pass pre-stripped (sanitised) content — no internal strip is performed.
|
||||
*/
|
||||
function parseAllUatItems(sanitised: string): UatItem[] {
|
||||
const items: UatItem[] = [];
|
||||
UAT_ITEM_PATTERN.lastIndex = 0;
|
||||
let m: RegExpMatchArray | null;
|
||||
while ((m = UAT_ITEM_PATTERN.exec(sanitised)) !== null) {
|
||||
const [, num, name, expected, result] = m;
|
||||
items.push({
|
||||
test: parseInt(num, 10),
|
||||
name: name.trim(),
|
||||
expected: expected.trim(),
|
||||
result,
|
||||
});
|
||||
}
|
||||
UAT_ITEM_PATTERN.lastIndex = 0;
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Build a human-readable string for a single UatReason. */
|
||||
function humanizeReason(r: UatReason): string {
|
||||
switch (r.code) {
|
||||
case REASON_CODE.CASE_MISMATCH:
|
||||
return `"${r.itemName ?? 'item'}" has a case-mismatched result ("${r.capturedValue ?? ''}" — expected "pass")`;
|
||||
case REASON_CODE.NON_PASS_RESULT:
|
||||
return `"${r.itemName ?? 'item'}" has result "${r.capturedValue ?? 'unknown'}" (expected "pass")`;
|
||||
case REASON_CODE.NO_ITEMS_EXTRACTED:
|
||||
return `${r.file ?? 'file'} contained no UAT items`;
|
||||
case REASON_CODE.NO_PHASE_DIR:
|
||||
return 'Phase directory not found';
|
||||
case REASON_CODE.NO_UAT_FILES:
|
||||
return 'No *-HUMAN-UAT.md files found in the phase directory';
|
||||
case REASON_CODE.HUMAN_VERIFICATION_NEEDED:
|
||||
return `"${r.itemName ?? 'item'}" requires manual human verification`;
|
||||
case REASON_CODE.BRACKETED_PLACEHOLDER:
|
||||
return `"${r.itemName ?? 'item'}" has an unfilled placeholder result: ${r.capturedValue ?? ''}`;
|
||||
case REASON_CODE.ORPHAN_ITEM_MISSING_RESULT:
|
||||
return `"${r.itemName ?? 'item'}" is missing a result field`;
|
||||
default:
|
||||
return `${r.code}: ${JSON.stringify(r)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const HEADING_PATTERN = /###\s*(\d+)\.\s*([^\n]+)/g;
|
||||
|
||||
/**
|
||||
* Scan stripped body for `### N. Name` headings whose number is NOT represented
|
||||
* in the set of captured item numbers. Returns orphan entries.
|
||||
* Headings that have a bracketed result line are excluded here — they will be
|
||||
* handled by bracketed-placeholder detection (cycle 13).
|
||||
*/
|
||||
function findOrphanHeadings(
|
||||
strippedBody: string,
|
||||
capturedNumbers: Set<number>,
|
||||
brackPlaceholderNumbers: Set<number>,
|
||||
): Array<{ num: number; name: string }> {
|
||||
const orphans: Array<{ num: number; name: string }> = [];
|
||||
HEADING_PATTERN.lastIndex = 0;
|
||||
let m: RegExpMatchArray | null;
|
||||
while ((m = HEADING_PATTERN.exec(strippedBody)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
const name = m[2].trim();
|
||||
if (!capturedNumbers.has(num) && !brackPlaceholderNumbers.has(num)) {
|
||||
orphans.push({ num, name });
|
||||
}
|
||||
}
|
||||
HEADING_PATTERN.lastIndex = 0;
|
||||
return orphans;
|
||||
}
|
||||
|
||||
export async function isPhaseUatPassed(
|
||||
projectDir: string,
|
||||
phase: string,
|
||||
workstream?: string,
|
||||
): Promise<{
|
||||
passed: boolean;
|
||||
reasons: UatReason[];
|
||||
reasonsHuman: string[];
|
||||
items: Record<string, unknown>[];
|
||||
}> {
|
||||
try {
|
||||
await stat(projectDir);
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new PhaseUatPassedError(
|
||||
`projectDir does not exist: ${projectDir}`,
|
||||
ERROR_CODE.PROJECT_DIR_MISSING,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const dir = await resolvePhaseDir(phase, projectDir, workstream);
|
||||
if (!dir) {
|
||||
const reasons: UatReason[] = [{ code: REASON_CODE.NO_PHASE_DIR }];
|
||||
return {
|
||||
passed: false,
|
||||
reasons,
|
||||
reasonsHuman: reasons.map(humanizeReason),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const files = await readdir(dir);
|
||||
const uatFiles = files.filter((f) => f.endsWith('-HUMAN-UAT.md'));
|
||||
|
||||
if (uatFiles.length === 0) {
|
||||
const reasons: UatReason[] = [{ code: REASON_CODE.NO_UAT_FILES }];
|
||||
return {
|
||||
passed: false,
|
||||
reasons,
|
||||
reasonsHuman: reasons.map(humanizeReason),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const items: UatItem[] = [];
|
||||
const reasons: UatReason[] = [];
|
||||
|
||||
for (const file of uatFiles) {
|
||||
const filePath = join(dir, file);
|
||||
const relFile = relative(projectDir, filePath);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const strippedBody = stripMarkdownInjection(content);
|
||||
const itemsBeforeFile = items.length;
|
||||
const reasonsBeforeFile = reasons.length;
|
||||
const parsed = parseAllUatItems(strippedBody);
|
||||
for (const item of parsed) {
|
||||
items.push(item);
|
||||
if (item.result !== 'pass') {
|
||||
const code =
|
||||
item.result.toLowerCase() === 'pass'
|
||||
? REASON_CODE.CASE_MISMATCH
|
||||
: REASON_CODE.NON_PASS_RESULT;
|
||||
reasons.push({
|
||||
code,
|
||||
file: relFile,
|
||||
itemName: item.name,
|
||||
capturedValue: item.result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect bracketed placeholders (cycle 13): headings with result: [value]
|
||||
const brackPlaceholderNumbers = new Set<number>();
|
||||
const BRACK_RESULT_PATTERN = /result:\s*\[(\w+)\]/g;
|
||||
let bm: RegExpMatchArray | null;
|
||||
BRACK_RESULT_PATTERN.lastIndex = 0;
|
||||
while ((bm = BRACK_RESULT_PATTERN.exec(strippedBody)) !== null) {
|
||||
// find nearest preceding heading
|
||||
const before = strippedBody.slice(0, bm.index);
|
||||
const headingMatch = before.match(/###\s*(\d+)\.\s*([^\n]+)\s*$/m);
|
||||
if (headingMatch) {
|
||||
const num = parseInt(headingMatch[1], 10);
|
||||
const name = headingMatch[2].trim();
|
||||
brackPlaceholderNumbers.add(num);
|
||||
reasons.push({
|
||||
code: REASON_CODE.BRACKETED_PLACEHOLDER,
|
||||
file: relFile,
|
||||
itemName: name,
|
||||
capturedValue: `[${bm[1]}]`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect orphan headings: headings with no captured item and no bracketed result.
|
||||
const capturedNumbers = new Set(parsed.map((i) => i.test));
|
||||
const orphans = findOrphanHeadings(strippedBody, capturedNumbers, brackPlaceholderNumbers);
|
||||
for (const orphan of orphans) {
|
||||
reasons.push({
|
||||
code: REASON_CODE.ORPHAN_ITEM_MISSING_RESULT,
|
||||
file: relFile,
|
||||
itemName: orphan.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge frontmatter human_verification items into the roster.
|
||||
const fm = extractFrontmatter(content);
|
||||
const fmItems = parseVerificationFrontmatterItems(fm);
|
||||
for (const fmItem of fmItems) {
|
||||
const name = String(fmItem.name ?? '');
|
||||
// Add a synthetic UatItem so items.length is accurate.
|
||||
items.push({ test: -1, name, expected: String(fmItem.expected ?? ''), result: 'human_needed' });
|
||||
reasons.push({
|
||||
code: REASON_CODE.HUMAN_VERIFICATION_NEEDED,
|
||||
file: relFile,
|
||||
itemName: name,
|
||||
capturedValue: 'human_needed',
|
||||
});
|
||||
}
|
||||
|
||||
// If this file contributed no items and no diagnostic reasons, flag it.
|
||||
if (items.length === itemsBeforeFile && reasons.length === reasonsBeforeFile) {
|
||||
reasons.push({ code: REASON_CODE.NO_ITEMS_EXTRACTED, file: relFile });
|
||||
}
|
||||
}
|
||||
|
||||
const passed = items.length > 0 && reasons.length === 0;
|
||||
|
||||
return { passed, reasons, reasonsHuman: reasons.map(humanizeReason), items };
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryHandler adapter for `phase.uat-passed` registry entry.
|
||||
*
|
||||
* args[0] — phase token (required, e.g. '5' or '05-walking-skeleton').
|
||||
* Matches the args convention used by phase.list-plans / phase.list-artifacts.
|
||||
*/
|
||||
export const phaseUatPassed: QueryHandler = async (args, projectDir, workstream) => {
|
||||
const phase = args[0];
|
||||
if (typeof phase !== 'string' || phase.trim() === '') {
|
||||
throw new PhaseUatPassedError(
|
||||
'phase argument is required',
|
||||
ERROR_CODE.INVALID_PHASE_NUM,
|
||||
);
|
||||
}
|
||||
const result = await isPhaseUatPassed(projectDir, phase, workstream);
|
||||
return { data: result };
|
||||
};
|
||||
@@ -127,7 +127,11 @@ async function isLockProcessDead(lockPath: string): Promise<boolean | null> {
|
||||
try {
|
||||
const raw = await readFile(lockPath, 'utf-8');
|
||||
const pid = parseInt(raw.trim(), 10);
|
||||
if (!Number.isFinite(pid) || pid <= 0) return true;
|
||||
// An empty or unparseable lock file means the writer opened the file with
|
||||
// O_EXCL but hasn't finished writing the PID yet (async window between
|
||||
// `open` and `writeFile`). Treat this as "unknown / still alive" — do NOT
|
||||
// steal the lock, let the normal retry + timeout path handle it.
|
||||
if (!Number.isFinite(pid) || pid <= 0) return null;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return false;
|
||||
|
||||
@@ -195,7 +195,7 @@ function parseUatItems(content: string): Record<string, unknown>[] {
|
||||
* rather than the body, parseVerificationItems was returning [] because it
|
||||
* only searched the body for a "## Human Verification" heading.
|
||||
*/
|
||||
function parseVerificationFrontmatterItems(fm: Record<string, unknown>): Record<string, unknown>[] {
|
||||
export function parseVerificationFrontmatterItems(fm: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const items: Record<string, unknown>[] = [];
|
||||
const hvArray = fm.human_verification;
|
||||
if (!Array.isArray(hvArray)) return items;
|
||||
|
||||
Reference in New Issue
Block a user