-
Notifications
You must be signed in to change notification settings - Fork 0
feat(model): add versioned contracts and validation (Task 1.1) #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; |
| 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')), | ||
| 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; | ||
| } | ||
| 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`); | ||
| } | ||
| } |
| 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) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| }); | ||
| } | ||
| 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; | ||
| } |
| 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))); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, '')); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: On Windows,
parseCandidateaccepts artifact names containing<,>,",|,?, or*. Reject Windows-reserved filename characters infileNamebefore accepting the candidate.Prompt for AI agents
There was a problem hiding this comment.
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.