Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/qa/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,19 @@
/** Public entry point of the Release QA package. Real modules arrive with Stage 1. */
/** Public entry point of the Release QA package. */
export const toolName = 'release-qa';

export { parseCandidate, type Artifact, type Candidate } from './model/candidate.ts';
export { parseException, type Exception } from './model/exception.ts';
export { parseProject, type EnvironmentProfile, type Project, type Suite } from './model/project.ts';
export { parseRequirement, type ExecutionMode, type Requirement, type RequirementKey } from './model/requirement.ts';
export {
parseReport,
parseUploadProvenance,
type Attempt,
type MeasuredEnvironment,
type Outcome,
type Readiness,
type ReferenceContext,
type Report,
type UploadProvenance,
} from './model/result.ts';
export { ValidationError, type IssueCode, type ParseResult, type ValidationIssue } from './model/validate.ts';
71 changes: 71 additions & 0 deletions packages/qa/src/model/candidate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { at, Collector, item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts';

export interface Artifact {
profile: string;
name: string;
sha256: string;
assetId: number;
actionsArtifactId: number;
}

export interface Candidate {
schemaVersion: 1;
id: string;
repositoryId: number;
pullRequest: number;
sourceSha: string;
baseSha: string;
sourceTreeSha: string;
testRevision: string;
policyDigest: string;
build: { workflowPath: string; runId: number; attempt: number };
artifacts: Artifact[];
}

const SPEC: FieldSpec = {
required: ['id', 'repositoryId', 'pullRequest', 'sourceSha', 'baseSha', 'sourceTreeSha', 'testRevision', 'policyDigest', 'build', 'artifacts'],
};
const BUILD_SPEC: FieldSpec = { required: ['workflowPath', 'runId', 'attempt'] };
const ARTIFACT_SPEC: FieldSpec = { required: ['profile', 'name', 'sha256', 'assetId', 'actionsArtifactId'] };

export function parseCandidate(input: unknown): ParseResult<Candidate> {
return parseVersioned(input, SPEC, (c, rec) => {
const build = c.record(rec.build, 'build', BUILD_SPEC);
const artifactValues = c.array(rec.artifacts, 'artifacts', { min: 1 }) ?? [];
const artifacts = artifactValues.map((value, i) => readArtifact(c, value, item('artifacts', i)));

// An asset is one downloadable file; a profile cannot ship two files of the same name.
c.unique(artifacts.map((a, i) => ({ value: a?.assetId === undefined ? undefined : String(a.assetId), path: at(item('artifacts', i), 'assetId') })));
c.unique(artifacts.map((a, i) => ({ value: a?.profile === undefined || a.name === undefined ? undefined : `${a.profile}/${a.name}`, path: at(item('artifacts', i), 'name') })));

return {
schemaVersion: 1,
id: c.id(rec.id, 'id'),
repositoryId: c.int(rec.repositoryId, 'repositoryId'),
pullRequest: c.int(rec.pullRequest, 'pullRequest'),
sourceSha: c.gitSha(rec.sourceSha, 'sourceSha'),
baseSha: c.gitSha(rec.baseSha, 'baseSha'),
sourceTreeSha: c.gitSha(rec.sourceTreeSha, 'sourceTreeSha'),
testRevision: c.gitSha(rec.testRevision, 'testRevision'),
policyDigest: c.sha256(rec.policyDigest, 'policyDigest'),
build: build && {
workflowPath: c.relativePath(build.workflowPath, 'build.workflowPath'),
runId: c.int(build.runId, 'build.runId'),
attempt: c.int(build.attempt, 'build.attempt'),
},
artifacts,
} as Candidate;
});
}

function readArtifact(c: Collector, value: unknown, path: string): Artifact | undefined {
const rec = c.record(value, path, ARTIFACT_SPEC);
if (rec === undefined) return undefined;
return {
profile: c.profileId(rec.profile, at(path, 'profile')),
name: c.fileName(rec.name, at(path, 'name')),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On Windows, parseCandidate accepts artifact names containing <, >, ", |, ?, or *. Reject Windows-reserved filename characters in fileName before accepting the candidate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/model/candidate.ts, line 66:

<comment>On Windows, `parseCandidate` accepts artifact names containing `<`, `>`, `"`, `|`, `?`, or `*`. Reject Windows-reserved filename characters in `fileName` before accepting the candidate.</comment>

<file context>
@@ -0,0 +1,71 @@
+  if (rec === undefined) return undefined;
+  return {
+    profile: c.profileId(rec.profile, at(path, 'profile')),
+    name: c.fileName(rec.name, at(path, 'name')),
+    sha256: c.sha256(rec.sha256, at(path, 'sha256')),
+    assetId: c.int(rec.assetId, at(path, 'assetId')),
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed together with the P1: fileName rejects < > : " | ? * with tests per character; mutation-checked.

sha256: c.sha256(rec.sha256, at(path, 'sha256')),
assetId: c.int(rec.assetId, at(path, 'assetId')),
actionsArtifactId: c.int(rec.actionsArtifactId, at(path, 'actionsArtifactId')),
} as Artifact;
}
24 changes: 24 additions & 0 deletions packages/qa/src/model/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Candidate } from './candidate.ts';
import type { RequirementKey } from './requirement.ts';
import type { Collector } from './validate.ts';

/** What a record is allowed to refer to. Parsing a record alone cannot know this, so callers supply it. */
export interface ReferenceContext {
/** When given, the record must belong to this candidate. */
candidate?: Candidate;
/** When given, every requirement the record names must be one of these. */
requirements?: readonly RequirementKey[];
}

/** A record that names a candidate must name the one it is being checked against. */
export function checkCandidateId(c: Collector, context: ReferenceContext, candidateId: string | undefined, path: string): void {
if (context.candidate !== undefined && candidateId !== undefined && candidateId !== context.candidate.id) {
c.add('unknown-reference', path, `belongs to candidate "${candidateId}", not "${context.candidate.id}"`);
}
}

export function checkRequirementKnown(c: Collector, context: ReferenceContext, key: RequirementKey | undefined, path: string): void {
if (context.requirements !== undefined && key !== undefined && !context.requirements.includes(key)) {
c.add('unknown-reference', path, `"${key}" is not a required check`);
}
}
39 changes: 39 additions & 0 deletions packages/qa/src/model/exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { checkCandidateId, checkRequirementKnown, type ReferenceContext } from './context.ts';
import type { RequirementKey } from './requirement.ts';
import { item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts';

/** A request to accept named requirements as unmet for one candidate. It carries no authority by itself. */
export interface Exception {
schemaVersion: 1;
id: string;
candidateId: string;
requirements: RequirementKey[];
reason: string;
/** Claimed by the uploader; the maintainer's authority is verified separately. */
actor: string;
/** ISO-8601 UTC, e.g. `2026-09-20T12:00:00Z`. */
createdAt: string;
}

const SPEC: FieldSpec = { required: ['id', 'candidateId', 'requirements', 'reason', 'actor', 'createdAt'] };

export function parseException(input: unknown, context: ReferenceContext = {}): ParseResult<Exception> {
return parseVersioned(input, SPEC, (c, rec) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When schemaVersion is a bigint (or another value that JSON.stringify cannot serialize), parseException throws instead of honoring its non-throwing unknown input contract. Make the shared version-error formatting total, or catch this path and return a validation issue.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/model/exception.ts, line 21:

<comment>When `schemaVersion` is a `bigint` (or another value that `JSON.stringify` cannot serialize), `parseException` throws instead of honoring its non-throwing `unknown` input contract. Make the shared version-error formatting total, or catch this path and return a validation issue.</comment>

<file context>
@@ -0,0 +1,39 @@
+const SPEC: FieldSpec = { required: ['id', 'candidateId', 'requirements', 'reason', 'actor', 'createdAt'] };
+
+export function parseException(input: unknown, context: ReferenceContext = {}): ParseResult<Exception> {
+  return parseVersioned(input, SPEC, (c, rec) => {
+    const candidateId = c.id(rec.candidateId, 'candidateId');
+    checkCandidateId(c, context, candidateId, 'candidateId');
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The schema-version error text is built by a total describe() (try/catch around JSON.stringify), and parsing is wrapped so anything else that throws, such as an object with throwing getters, becomes an invalid-type issue. Tests cover a bigint, a circular object and a throwing Proxy for all four records.

const candidateId = c.id(rec.candidateId, 'candidateId');
checkCandidateId(c, context, candidateId, 'candidateId');

const requirements = (c.array(rec.requirements, 'requirements', { min: 1 }) ?? []).map((v, i) => c.requirementKey(v, item('requirements', i)));
c.unique(requirements.map((key, i) => ({ value: key, path: item('requirements', i) })));
requirements.forEach((key, i) => checkRequirementKnown(c, context, key, item('requirements', i)));

return {
schemaVersion: 1,
id: c.id(rec.id, 'id'),
candidateId,
requirements,
reason: c.text(rec.reason, 'reason', { max: 2000, multiline: true }),
actor: c.text(rec.actor, 'actor'),
createdAt: c.timestamp(rec.createdAt, 'createdAt'),
} as Exception;
});
}
105 changes: 105 additions & 0 deletions packages/qa/src/model/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { profileOf, readRequirement, type Requirement, type RequirementKey } from './requirement.ts';
import { at, Collector, item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts';

export interface EnvironmentProfile {
id: string;
os: 'windows' | 'linux';
arch: 'x86_64';
}

export interface Suite {
id: string;
requirements: RequirementKey[];
}

/** The consumer's `qa/project.json`. Read as data only; discovery never executes project code. */
export interface Project {
schemaVersion: 1;
projectId: string;
releaseBranch: string;
profiles: EnvironmentProfile[];
requirements: Requirement[];
suites: Suite[];
scenarioFiles: string[];
lifecycleModule: string;
workflows: { prepare: string; gate: string; publish: string };
markers: { releaseNotes: string; qa: string };
}

const SPEC: FieldSpec = {
required: ['projectId', 'releaseBranch', 'profiles', 'requirements', 'suites', 'scenarioFiles', 'lifecycleModule', 'workflows', 'markers'],
};
const PROFILE_SPEC: FieldSpec = { required: ['id', 'os', 'arch'] };
const SUITE_SPEC: FieldSpec = { required: ['id', 'requirements'] };
const WORKFLOWS_SPEC: FieldSpec = { required: ['prepare', 'gate', 'publish'] };
const MARKERS_SPEC: FieldSpec = { required: ['releaseNotes', 'qa'] };

export function parseProject(input: unknown): ParseResult<Project> {
return parseVersioned(input, SPEC, (c, rec) => {
const profiles = (c.array(rec.profiles, 'profiles', { min: 1 }) ?? []).map((v, i) => readProfile(c, v, item('profiles', i)));
c.unique(profiles.map((p, i) => ({ value: p?.id, path: at(item('profiles', i), 'id') })));
const profileIds = new Set(profiles.flatMap((p) => (p?.id === undefined ? [] : [p.id])));

const requirements = (c.array(rec.requirements, 'requirements', { min: 1 }) ?? []).map((v, i) => readRequirement(c, v, item('requirements', i)));
c.unique(requirements.map((r, i) => ({ value: r?.key, path: at(item('requirements', i), 'key') })));
requirements.forEach((r, i) => {
if (r?.key !== undefined && !profileIds.has(profileOf(r.key))) {
c.add('unknown-reference', at(item('requirements', i), 'key'), `profile "${profileOf(r.key)}" is not defined by this project`);
}
});
const definedKeys = new Set(requirements.flatMap((r) => (r?.key === undefined ? [] : [r.key])));

const suites = (c.array(rec.suites, 'suites') ?? []).map((v, i) => readSuite(c, v, item('suites', i), definedKeys));
c.unique(suites.map((s, i) => ({ value: s?.id, path: at(item('suites', i), 'id') })));

const scenarioFiles = (c.array(rec.scenarioFiles, 'scenarioFiles') ?? []).map((v, i) => c.relativePath(v, item('scenarioFiles', i)));

const workflows = c.record(rec.workflows, 'workflows', WORKFLOWS_SPEC);
const markers = c.record(rec.markers, 'markers', MARKERS_SPEC);
const releaseNotesMarker = markers && c.name(markers.releaseNotes, 'markers.releaseNotes');
const qaMarker = markers && c.name(markers.qa, 'markers.qa');
c.unique([
{ value: releaseNotesMarker, path: 'markers.releaseNotes' },
{ value: qaMarker, path: 'markers.qa' },
]);

return {
schemaVersion: 1,
projectId: c.id(rec.projectId, 'projectId'),
releaseBranch: c.branchName(rec.releaseBranch, 'releaseBranch'),
profiles,
requirements,
suites,
scenarioFiles,
lifecycleModule: c.relativePath(rec.lifecycleModule, 'lifecycleModule'),
workflows: workflows && {
prepare: c.fileName(workflows.prepare, 'workflows.prepare'),
gate: c.fileName(workflows.gate, 'workflows.gate'),
publish: c.fileName(workflows.publish, 'workflows.publish'),
},
markers: markers && { releaseNotes: releaseNotesMarker, qa: qaMarker },
} as Project;
});
}

function readProfile(c: Collector, value: unknown, path: string): EnvironmentProfile | undefined {
const rec = c.record(value, path, PROFILE_SPEC);
if (rec === undefined) return undefined;
return {
id: c.profileId(rec.id, at(path, 'id')),
os: c.oneOf(rec.os, at(path, 'os'), ['windows', 'linux']),
arch: c.oneOf(rec.arch, at(path, 'arch'), ['x86_64']),
} as EnvironmentProfile;
}

function readSuite(c: Collector, value: unknown, path: string, definedKeys: ReadonlySet<string>): Suite | undefined {
const rec = c.record(value, path, SUITE_SPEC);
if (rec === undefined) return undefined;
const listPath = at(path, 'requirements');
const keys = (c.array(rec.requirements, listPath, { min: 1 }) ?? []).map((v, i) => c.requirementKey(v, item(listPath, i)));
c.unique(keys.map((k, i) => ({ value: k, path: item(listPath, i) })));
keys.forEach((k, i) => {
if (k !== undefined && !definedKeys.has(k)) c.add('unknown-reference', item(listPath, i), `"${k}" is not a requirement of this project`);
});
return { id: c.id(rec.id, at(path, 'id')), requirements: keys } as Suite;
}
38 changes: 38 additions & 0 deletions packages/qa/src/model/requirement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { at, Collector, item, parseUnversioned, type FieldSpec, type ParseResult } from './validate.ts';

/** `<environment profile>/<scenario id>`, e.g. `windows/persistence`. */
export type RequirementKey = `${string}/${string}`;
export type ExecutionMode = 'automated' | 'manual';

export interface Requirement {
key: RequirementKey;
mode: ExecutionMode;
title: string;
/** Capabilities the environment must provide, e.g. `display`, `audio`, `hardware`. */
capabilities: string[];
}

const SPEC: FieldSpec = { required: ['key', 'mode', 'title', 'capabilities'] };

/** The profile half of a requirement key. */
export const profileOf = (key: RequirementKey): string => key.slice(0, key.indexOf('/'));

/** Reads a requirement nested in another record, reporting problems under `path`. */
export function readRequirement(c: Collector, value: unknown, path: string): Requirement | undefined {
const rec = c.record(value, path, SPEC);
return rec === undefined ? undefined : readRequirementFields(c, rec, path);
}

function readRequirementFields(c: Collector, rec: Record<string, unknown>, path: string): Requirement {
const capabilities = (c.array(rec.capabilities, at(path, 'capabilities')) ?? []).map((v, i) => c.name(v, item(at(path, 'capabilities'), i)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When capabilities is a sparse JavaScript array, .map() skips the missing slots and this parser accepts the malformed requirement. Use Array.from or an indexed loop so every slot is validated as a required capability name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/model/requirement.ts, line 27:

<comment>When `capabilities` is a sparse JavaScript array, `.map()` skips the missing slots and this parser accepts the malformed requirement. Use `Array.from` or an indexed loop so every slot is validated as a required capability name.</comment>

<file context>
@@ -0,0 +1,38 @@
+}
+
+function readRequirementFields(c: Collector, rec: Record<string, unknown>, path: string): Requirement {
+  const capabilities = (c.array(rec.capabilities, at(path, 'capabilities')) ?? []).map((v, i) => c.name(v, item(at(path, 'capabilities'), i)));
+  return {
+    key: c.requirementKey(rec.key, at(path, 'key')),
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. array() now returns Array.from(value), so holes are visited and validated. Tests cover a sparse capabilities array and a sparse attempts array; reverting to the raw array makes both fail.

return {
key: c.requirementKey(rec.key, at(path, 'key')),
mode: c.oneOf(rec.mode, at(path, 'mode'), ['automated', 'manual']),
title: c.text(rec.title, at(path, 'title')),
capabilities,
} as Requirement;
}

export function parseRequirement(input: unknown): ParseResult<Requirement> {
return parseUnversioned(input, SPEC, (c, rec) => readRequirementFields(c, rec, ''));
}
Loading
Loading