diff --git a/README.md b/README.md index 8d3e05c..990d9a9 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ Foundation Plans with their users, then submit a valid Plan for the current bounded compilation and private GitHub publication journey. -The current `0.1.x` line includes local Plan initialization, application-key and UUID generation, conditional +The current `0.2.x` source line includes local Plan initialization, application-key and UUID generation, conditional whole-document push, whole-graph analysis polling, compile-and-publish orchestration, and read-only retained -Compilation download. Remote commands require a compatible First Draft service and are intended for coordinated -trials; publishing this CLI does not make the wider service generally available. +Compilation download. Analysis status exposes the complete reviewed GapSet before Compile. Remote commands require +a compatible First Draft service and are intended for coordinated trials; publishing this CLI does not make the +wider service generally available. ## Install diff --git a/RELEASING.md b/RELEASING.md index ce1711a..10cffbc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -36,14 +36,16 @@ binds its Foundation Plan format to the implemented CLI constant. The `script/release_compatibility_check` evaluator in `firstdraft/firstdraft` reads this declaration with the matching declarations from exact, clean checkouts of `firstdraft/firstdraft` and `firstdraft/skills`. It implements SemVer 2.0 -precedence. This CLI requires the service's `0.2.0` API contract because the always-present GitHub Publication -progress projection is incompatible with the strict response shape accepted by the published -`@firstdraft.com/cli@0.1.0-alpha.2`. Comparator arrays form one conjunction, while `foundation_plan_formats` lists -alternatives. A prerelease satisfies a comparator set only when a comparator explicitly names a prerelease with the -same major, minor, and patch numbers. Skills names the candidate CLI version explicitly, so a stale comparator makes -the three-repository candidate ineligible. `firstdraft.release-compatibility/1` is intentionally closed. The -evaluator in `firstdraft/firstdraft` rejects an unrecognized format and unknown keys, so adding a key requires a -coordinated compatibility-format bump rather than silently changing version 1. +precedence. CLI `0.2.x` requires the service's `0.3.x` API contract because Analysis now returns the complete reviewed +GapSet and digest. The released CLI `0.1.0` accepts only API `0.2.x`, uses a generic 2 MiB response bound for Analysis, +and cannot safely consume every schema-valid API `0.3.x` result. CLI `0.2.x` retains that generic bound but gives +Analysis and Compilation artifacts dedicated 128 MiB bounds. Comparator arrays form one conjunction, while +`foundation_plan_formats` lists alternatives. A prerelease satisfies a comparator set only when a comparator +explicitly names a prerelease with the same major, minor, and patch numbers. Skills names the candidate CLI version +explicitly, so a stale comparator makes the three-repository candidate ineligible. +`firstdraft.release-compatibility/1` is intentionally closed. The evaluator in `firstdraft/firstdraft` rejects an +unrecognized format and unknown keys, so adding a key requires a coordinated compatibility-format bump rather than +silently changing version 1. A compatible result establishes candidate eligibility, not authorization or runtime proof. Exact Git SHAs identify the three-repository candidate. A merge to `main` is integration only: report the merged SHA and ask the user whether diff --git a/docs/commands.md b/docs/commands.md index 76407c0..d90761f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3,10 +3,10 @@ This page owns the detailed public semantics of the current command surface. Run `firstdraft --help` or a command group's `--help` for concise executable syntax. See [Errors and recovery](errors.md) before retrying a failed mutation. -The current `0.1.x` line contains the auditable command shell, local Foundation Plan initialization, local +The current `0.2.x` source line contains the auditable command shell, local Foundation Plan initialization, local application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, -compile-and-publish orchestration, and read-only retained-Compilation download. The current `0.1.0` CLI requires the -service's `0.2.x` API contract. See the [release policy](../RELEASING.md) for versioning and channel semantics and +compile-and-publish orchestration, and read-only retained-Compilation download. CLI `0.2.x` requires the service's +`0.3.x` API contract. See the [release policy](../RELEASING.md) for versioning and channel semantics and [release history](release-history.md) for the transition from prereleases. ## Command map @@ -116,11 +116,19 @@ or `superseded`. Every validated analysis status is a successful read with exit `analysis.status` value and inspect `analysis.diagnostics` rather than treating a completed analysis with issues as a transport failure. +The projection includes the exact Head digest, Analyzer and Compiler releases, selected target, and +`analysis.gap_set` plus `analysis.gap_set_sha256`. A `valid` run always returns the complete parsed canonical +`firstdraft.foundation-gaps/2` object, including every ordered gap record and an empty `gaps` array when nothing is +missing. Both GapSet fields are `null` for every other status. The CLI validates the GapSet's Head, Project, +generation, releases, target, canonical digest, and complete record shapes, then prints the records without +truncating or rewriting them. + Status reads require the API origin pinned by a successful push. They never select an origin from the current environment, expose the private ETag, follow redirects, or modify local state. Each request has a bounded timeout, -every response is byte-bounded and fully validated, and polling will not silently switch to a replacement analysis. -The wait repeats only validated `processing` responses and stops on its first failed read. A network failure is safe -to retry a bounded number of times because the command sends only `GET` requests. See +ordinary response reads retain a 2 MiB bound, while this potentially gap-heavy response has a dedicated 128 MiB +bound. Every response is fully validated, and polling will not silently switch to a replacement analysis. The wait +repeats only validated `processing` responses and stops on its first failed read. A network failure is safe to retry +a bounded number of times because the command sends only `GET` requests. See [read-only failures](errors.md#read-only-status-failures) if the problem persists. ## Compile and publish the current Plan @@ -133,10 +141,10 @@ firstdraft plan compile `plan compile` is the single terminal action. It first pushes the exact current bytes in `.firstdraft/foundation-plan.json`, even when those bytes are unchanged, and saves the accepted ETag using the same -contract as `plan push`. It then waits up to two minutes for an analysis whose graph version exactly matches that -accepted push, polling past a terminal result retained for an older Head. Invalid JSON, schema diagnostics, semantic -diagnostics, a failed analysis, a superseded analysis, or a recurring diagnostic stop the command with structured -output; no Compilation or Publication is requested. +contract as `plan push`. It then waits up to two minutes for an analysis whose graph version and +`head_source_sha256` exactly match that accepted push, polling past a terminal result retained for an older Head. +Invalid JSON, schema diagnostics, semantic diagnostics, a failed analysis, a superseded analysis, or a recurring +diagnostic stop the command with structured output; no Compilation or Publication is requested. Only a `valid` analysis proceeds to the internal GitHub Publication lifecycle. Invoking `plan compile` is the authorization to request that lifecycle. Immediately before its conditional mutation, the CLI re-reads the local @@ -152,7 +160,7 @@ URLs, raw server projections, local paths, or environment values. Success writes GitHub repository URL plus a newline to stdout. If the command fails after progress has begun, its structured JSON error envelope is the final stderr document after the progress lines. -The closed API `0.2.x` progress-reason allowlist is `github.configuration_missing`, `github.oauth_unavailable`, +The closed API `0.3.x` progress-reason allowlist is `github.configuration_missing`, `github.oauth_unavailable`, `github.api_unavailable`, `github.reauthorization_required`, `github.account_mismatch`, `github.installation_unavailable`, `github.installation_not_ready`, `github.preflight_unavailable`, the legacy-only `github.preflight_unclassified`, and these stage-specific fallbacks: `github.preflight_unavailable.configuration`, @@ -208,4 +216,5 @@ ETag, exact-byte SHA-256, canonical UTF-8 JSON envelope, provenance, metadata-on strict Base64 contents, file digests, modes, owners, and source-subject UUIDs. It writes only into a uniquely created sibling directory, verifies the complete tree, and atomically renames it into the still-absent destination. On POSIX, directories use mode `0755` and files use artifact-declared `0644` or `0755`; Windows verifies structure, -contents, and digests without claiming POSIX mode bits. +contents, and digests without claiming POSIX mode bits. The declared and streamed artifact envelope is bounded at +128 MiB. diff --git a/package-lock.json b/package-lock.json index 0710e03..dc1d53a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firstdraft.com/cli", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firstdraft.com/cli", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "bin": { "firstdraft": "bin/firstdraft.js" diff --git a/package.json b/package.json index a2038c7..4b15e5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/cli", - "version": "0.1.0", + "version": "0.2.0", "description": "Command-line interface for First Draft", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index 6d96ce4..e5b258b 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,9 +1,9 @@ { "format": "firstdraft.release-compatibility/1", "component": "cli", - "version": "0.1.0", + "version": "0.2.0", "requires": { - "api_contract": [">= 0.2.0", "< 0.3.0"], + "api_contract": [">= 0.3.0", "< 0.4.0"], "foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.19"] } } diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index e53ad95..d766529 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -332,6 +332,7 @@ async function exercisePackedCompilation(projectDirectory) { ); const statusPath = `/v1/projects/${projectId}/compilations/${compilationId}`; const artifactPath = `${statusPath}/artifact`; + const analyzerRelease = "foundation-plan-rails/application-2026-08"; const compilerRelease = "foundation-plan-rails/compiler-scalar-2026-08"; const target = { id: "rails", profile: "rails-sketch/2026-08" }; const contents = Buffer.from("class Movie < ApplicationRecord\nend\n"); @@ -406,14 +407,30 @@ async function exercisePackedCompilation(projectDirectory) { completed_at: "2026-07-30T12:00:02.000000Z", }, }; + const gapSet = { + format: "firstdraft.foundation-gaps/2", + source: { sha256: headSha256 }, + project: { id: projectId, graph_version: 1 }, + analysis: { release: analyzerRelease }, + compiler_release: compilerRelease, + target, + gaps: [], + }; const analysis = { project: { id: projectId, graph_version: 1 }, analysis: { id: analysisId, graph_version: 1, - analyzer_release: "foundation-plan-analyzer/2026-08", + head_source_sha256: headSha256, + analyzer_release: analyzerRelease, + compiler_release: compilerRelease, + target, status: "valid", diagnostics: [], + gap_set: gapSet, + gap_set_sha256: sha256( + Buffer.from(`${JSON.stringify(gapSet, null, 2)}\n`), + ), started_at: "2026-07-30T12:00:00.000Z", completed_at: "2026-07-30T12:00:01.000Z", }, diff --git a/src/api-response.js b/src/api-response.js index 4ebc5a7..040c1b0 100644 --- a/src/api-response.js +++ b/src/api-response.js @@ -36,9 +36,9 @@ export async function sendRequest(fetchFunction, endpoint, request) { } } -/** @param {Response} response */ -export async function readResponseBody(response) { - const bytes = await readResponseBytes(response); +/** @param {Response} response @param {number} [maximumBytes] */ +export async function readResponseBody(response, maximumBytes) { + const bytes = await readResponseBytes(response, maximumBytes); let text; try { diff --git a/src/commands/plan-compile.js b/src/commands/plan-compile.js index 1828737..77de137 100644 --- a/src/commands/plan-compile.js +++ b/src/commands/plan-compile.js @@ -105,6 +105,9 @@ export async function compilePlan({ const acceptedGraphVersion = /** @type {{graph_version: number}} */ ( pushed.body.project ).graph_version; + const acceptedHeadSourceSha256 = /** @type {{source_sha256: string}} */ ( + pushed.body.foundation_plan + ).source_sha256; let status; onProgress({ phase: "analysis", status: "waiting" }); @@ -133,7 +136,8 @@ export async function compilePlan({ } if ( status.body.project.graph_version !== acceptedGraphVersion || - status.body.analysis.graph_version !== acceptedGraphVersion + status.body.analysis.graph_version !== acceptedGraphVersion || + status.body.analysis.head_source_sha256 !== acceptedHeadSourceSha256 ) { throw new PlanStatusChangedError(status.body); } diff --git a/src/commands/plan-status.js b/src/commands/plan-status.js index 6dc0985..7818b10 100644 --- a/src/commands/plan-status.js +++ b/src/commands/plan-status.js @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { lstatSync, readFileSync } from "node:fs"; import { @@ -13,6 +14,9 @@ import { isUuidV7, readPlanState } from "../plan-state.js"; const REQUEST_TIMEOUT_MS = 30_000; const WAIT_TIMEOUT_MS = 120_000; const POLL_INTERVAL_MS = 1_000; +export const MAX_PLAN_STATUS_RESPONSE_BYTES = 128 * 1024 * 1024; +const GAP_SET_FORMAT = "firstdraft.foundation-gaps/2"; +const GAP_TEXT_MAX_BYTES = 64 * 1024; const TERMINAL_STATUSES = new Set([ "valid", "issues_found", @@ -23,14 +27,50 @@ const ALL_STATUSES = new Set(["processing", ...TERMINAL_STATUSES]); const RESPONSE_KEYS = ["analysis", "project"]; const PROJECT_KEYS = ["graph_version", "id"]; const ANALYSIS_KEYS = [ + "id", + "graph_version", + "head_source_sha256", "analyzer_release", - "completed_at", + "compiler_release", + "target", + "status", "diagnostics", - "graph_version", - "id", + "gap_set", + "gap_set_sha256", "started_at", + "completed_at", +]; +const TARGET_KEYS = ["id", "profile"]; +const GAP_SET_KEYS = [ + "format", + "source", + "project", + "analysis", + "compiler_release", + "target", + "gaps", +]; +const GAP_SOURCE_KEYS = ["sha256"]; +const GAP_PROJECT_KEYS = ["id", "graph_version"]; +const GAP_ANALYSIS_KEYS = ["release"]; +const GAP_REQUIRED_ENTRY_KEYS = [ + "classification", + "code", + "kind", "status", + "reason", + "consequence", ]; +const GAP_OPTIONAL_ENTRY_KEYS = ["pointer", "readable_path", "cause"]; +const GAP_CLASSIFICATIONS = new Set([ + "service_support_gap", + "target_support_gap", +]); +const GAP_STATUSES = new Set([ + "skipped_at_import", + "not_generated", + "partially_generated", +]); const DIAGNOSTIC_KEYS = [ "code", "location", @@ -40,6 +80,8 @@ const DIAGNOSTIC_KEYS = [ "subject", "suggestions", ]; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/; /** * @typedef {object} PlanStatusFileSystem @@ -83,7 +125,7 @@ export class PlanStatusTimeoutError extends Error { /** * @typedef {object} AnalysisResponse * @property {{id: string, graph_version: number}} project - * @property {{id: string, graph_version: number, analyzer_release: string, status: string, diagnostics: Record[], started_at: string | null, completed_at: string | null}} analysis + * @property {{id: string, graph_version: number, head_source_sha256: string, analyzer_release: string, compiler_release: string, target: {id: string, profile: string}, status: string, diagnostics: Record[], gap_set: Record | null, gap_set_sha256: string | null, started_at: string | null, completed_at: string | null}} analysis */ /** @typedef {{source_pointer: string} | {line: number, column: number}} SourceLocation */ @@ -152,7 +194,10 @@ export async function readPlanStatus({ redirect: "error", signal: createRequestSignal(requestTimeout), }); - body = await readResponseBody(response); + body = await readResponseBody( + response, + response.status === 200 ? MAX_PLAN_STATUS_RESPONSE_BYTES : undefined, + ); } catch (error) { if ( error instanceof FirstDraftNetworkError && @@ -247,6 +292,7 @@ function parseAnalysisResponse(value, projectId) { const project = value.project; const analysis = value.analysis; + const target = isRecord(analysis) ? analysis.target : null; if ( !hasRequiredKeys(project, PROJECT_KEYS) || !hasRequiredKeys(analysis, ANALYSIS_KEYS) || @@ -255,13 +301,17 @@ function parseAnalysisResponse(value, projectId) { !isUuidV7(analysis.id) || !isGraphVersion(analysis.graph_version) || analysis.graph_version !== project.graph_version || - typeof analysis.analyzer_release !== "string" || - Buffer.byteLength(analysis.analyzer_release) === 0 || - Buffer.byteLength(analysis.analyzer_release) > 256 || + typeof analysis.head_source_sha256 !== "string" || + !SHA256_PATTERN.test(analysis.head_source_sha256) || + !isIdentifier(analysis.analyzer_release) || + !isIdentifier(analysis.compiler_release) || + !isTarget(target) || typeof analysis.status !== "string" || !ALL_STATUSES.has(analysis.status) || !Array.isArray(analysis.diagnostics) || !analysis.diagnostics.every(isStructuredDiagnostic) || + (analysis.gap_set_sha256 !== null && + typeof analysis.gap_set_sha256 !== "string") || !isNullableTimestamp(analysis.started_at) || !isNullableTimestamp(analysis.completed_at) ) { @@ -269,6 +319,16 @@ function parseAnalysisResponse(value, projectId) { } const terminal = TERMINAL_STATUSES.has(analysis.status); + const gapSet = parseGapSet(analysis.gap_set, { + projectId: project.id, + graphVersion: project.graph_version, + headSourceSha256: analysis.head_source_sha256, + analyzerRelease: analysis.analyzer_release, + compilerRelease: analysis.compiler_release, + target, + }); + const gapSetSha256 = analysis.gap_set_sha256; + const hasGapSet = gapSet !== null && typeof gapSetSha256 === "string"; if ( (terminal && analysis.completed_at === null) || (!terminal && analysis.completed_at !== null) || @@ -283,7 +343,13 @@ function parseAnalysisResponse(value, projectId) { (analysis.status === "issues_found" && !analysis.diagnostics.some( (diagnostic) => diagnostic.severity === "error", - )) + )) || + (analysis.status === "valid") !== hasGapSet || + (analysis.gap_set === null) !== (gapSetSha256 === null) || + (analysis.gap_set !== null && gapSet === null) || + (typeof gapSetSha256 === "string" && + (!SHA256_PATTERN.test(gapSetSha256) || + gapSetSha256 !== sha256(canonicalGapSetSource(gapSet)))) ) { return null; } @@ -296,15 +362,238 @@ function parseAnalysisResponse(value, projectId) { analysis: { id: analysis.id, graph_version: analysis.graph_version, + head_source_sha256: analysis.head_source_sha256, analyzer_release: analysis.analyzer_release, + compiler_release: analysis.compiler_release, + target: { id: target.id, profile: target.profile }, status: analysis.status, diagnostics: analysis.diagnostics.map(copyDiagnostic), + gap_set: gapSet, + gap_set_sha256: gapSetSha256, started_at: analysis.started_at, completed_at: analysis.completed_at, }, }; } +/** @param {unknown} value @returns {value is string} */ +function isIdentifier(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) > 0 && + Buffer.byteLength(value) <= 256 && + IDENTIFIER_PATTERN.test(value) + ); +} + +/** + * @param {unknown} value + * @returns {value is Record & {id: string, profile: string}} + */ +function isTarget(value) { + return ( + hasExactKeys(value, TARGET_KEYS) && + isIdentifier(value.id) && + isIdentifier(value.profile) + ); +} + +/** + * @param {unknown} value + * @param {{projectId: string, graphVersion: number, headSourceSha256: string, analyzerRelease: string, compilerRelease: string, target: {id: string, profile: string}}} identity + * @returns {Record | null} + */ +function parseGapSet(value, identity) { + if (value === null) return null; + if (!hasExactKeys(value, GAP_SET_KEYS)) return null; + + const source = value.source; + const project = value.project; + const analysis = value.analysis; + const target = value.target; + if ( + value.format !== GAP_SET_FORMAT || + !hasExactKeys(source, GAP_SOURCE_KEYS) || + source.sha256 !== identity.headSourceSha256 || + !hasExactKeys(project, GAP_PROJECT_KEYS) || + project.id !== identity.projectId || + project.graph_version !== identity.graphVersion || + !hasExactKeys(analysis, GAP_ANALYSIS_KEYS) || + analysis.release !== identity.analyzerRelease || + value.compiler_release !== identity.compilerRelease || + !hasExactKeys(target, TARGET_KEYS) || + target.id !== identity.target.id || + target.profile !== identity.target.profile || + !Array.isArray(value.gaps) || + !value.gaps.every(isGapEntry) || + !gapsAreCanonicallyOrdered(value.gaps) + ) { + return null; + } + + return { + format: GAP_SET_FORMAT, + source: { sha256: source.sha256 }, + project: { id: project.id, graph_version: project.graph_version }, + analysis: { release: analysis.release }, + compiler_release: value.compiler_release, + target: { id: target.id, profile: target.profile }, + gaps: value.gaps.map(copyGapEntry), + }; +} + +/** @param {Record[]} gaps */ +function gapsAreCanonicallyOrdered(gaps) { + return gaps.every((entry, index) => { + const previous = gaps[index - 1]; + return previous === undefined || compareGapEntries(previous, entry) <= 0; + }); +} + +/** @param {Record} left @param {Record} right */ +function compareGapEntries(left, right) { + return ( + compareGapLocations(left, right) || + compareUtf8(String(left.code), String(right.code)) || + compareUtf8(String(left.kind), String(right.kind)) + ); +} + +/** @param {Record} left @param {Record} right */ +function compareGapLocations(left, right) { + const leftPointer = typeof left.pointer === "string"; + const rightPointer = typeof right.pointer === "string"; + if (leftPointer !== rightPointer) return leftPointer ? -1 : 1; + if (typeof left.pointer !== "string" || typeof right.pointer !== "string") { + return compareUtf8( + typeof left.readable_path === "string" ? left.readable_path : "", + typeof right.readable_path === "string" ? right.readable_path : "", + ); + } + + const leftTokens = pointerSortTokens(left.pointer); + const rightTokens = pointerSortTokens(right.pointer); + for ( + let index = 0; + index < Math.min(leftTokens.length, rightTokens.length); + index += 1 + ) { + const leftToken = leftTokens[index]; + const rightToken = rightTokens[index]; + if (leftToken === undefined || rightToken === undefined) { + throw new TypeError("GapSet pointer token comparison is inconsistent."); + } + const comparison = comparePointerTokens(leftToken, rightToken); + if (comparison !== 0) return comparison; + } + return leftTokens.length - rightTokens.length; +} + +/** @param {string} pointer */ +function pointerSortTokens(pointer) { + return pointer + .split("/") + .slice(1) + .map((token) => { + const decoded = token.replaceAll("~1", "/").replaceAll("~0", "~"); + return /^(?:0|[1-9][0-9]*)$/.test(decoded) + ? { kind: 0, value: BigInt(decoded) } + : { kind: 1, value: decoded }; + }); +} + +/** + * @param {{kind: number, value: bigint | string}} left + * @param {{kind: number, value: bigint | string}} right + */ +function comparePointerTokens(left, right) { + if (left.kind !== right.kind) return left.kind - right.kind; + if (typeof left.value === "bigint" && typeof right.value === "bigint") { + return left.value < right.value ? -1 : left.value > right.value ? 1 : 0; + } + return compareUtf8(String(left.value), String(right.value)); +} + +/** @param {string} left @param {string} right */ +function compareUtf8(left, right) { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +/** @param {unknown} value */ +function isGapEntry(value) { + if (!isRecord(value)) return false; + const keys = Object.keys(value); + if ( + !GAP_REQUIRED_ENTRY_KEYS.every((key) => keys.includes(key)) || + !keys.every( + (key) => + GAP_REQUIRED_ENTRY_KEYS.includes(key) || + GAP_OPTIONAL_ENTRY_KEYS.includes(key), + ) || + !isGapText(value.classification) || + !GAP_CLASSIFICATIONS.has(value.classification) || + !isGapIdentifier(value.code) || + !isGapIdentifier(value.kind) || + !isGapText(value.status) || + !GAP_STATUSES.has(value.status) || + !isGapText(value.reason) || + !isGapText(value.consequence) || + (value.pointer !== undefined && + (!isGapText(value.pointer) || !isJsonPointer(value.pointer))) || + (value.readable_path !== undefined && !isGapText(value.readable_path)) || + (value.cause !== undefined && !isGapText(value.cause)) + ) { + return false; + } + + return value.classification === "service_support_gap" + ? value.status === "skipped_at_import" + : value.status === "not_generated" || + value.status === "partially_generated"; +} + +/** @param {unknown} value @returns {value is string} */ +function isGapIdentifier(value) { + return isGapText(value) && IDENTIFIER_PATTERN.test(value); +} + +/** @param {unknown} value @returns {value is string} */ +function isGapText(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) > 0 && + Buffer.byteLength(value) <= GAP_TEXT_MAX_BYTES && + !value.includes("\0") + ); +} + +/** @param {Record} entry */ +function copyGapEntry(entry) { + return { + classification: entry.classification, + code: entry.code, + kind: entry.kind, + status: entry.status, + ...(entry.pointer === undefined ? {} : { pointer: entry.pointer }), + ...(entry.readable_path === undefined + ? {} + : { readable_path: entry.readable_path }), + reason: entry.reason, + consequence: entry.consequence, + ...(entry.cause === undefined ? {} : { cause: entry.cause }), + }; +} + +/** @param {Record | null} gapSet */ +function canonicalGapSetSource(gapSet) { + return Buffer.from(`${JSON.stringify(gapSet, null, 2)}\n`, "utf8"); +} + +/** @param {Buffer} source */ +function sha256(source) { + return createHash("sha256").update(source).digest("hex"); +} + /** @param {unknown} value @returns {value is StructuredDiagnostic} */ function isStructuredDiagnostic(value) { if ( @@ -474,6 +763,19 @@ function hasRequiredKeys(value, keys) { return isRecord(value) && keys.every((key) => hasOwn(value, key)); } +/** + * @param {unknown} value + * @param {string[]} keys + * @returns {value is Record} + */ +function hasExactKeys(value, keys) { + return ( + isRecord(value) && + Object.keys(value).length === keys.length && + keys.every((key) => hasOwn(value, key)) + ); +} + /** @param {Record} value @param {string} key */ function hasOwn(value, key) { return Object.prototype.hasOwnProperty.call(value, key); diff --git a/src/compilation-artifact.js b/src/compilation-artifact.js index a550479..397f2a5 100644 --- a/src/compilation-artifact.js +++ b/src/compilation-artifact.js @@ -19,7 +19,7 @@ export const ARTIFACT_MEDIA_TYPE = "application/vnd.firstdraft.compilation-artifact+json"; export const ARTIFACT_FORMAT = "firstdraft.compilation-artifact/1"; export const FOUNDATION_PLAN_FORMAT = "firstdraft.foundation-plan.sketch/0.19"; -export const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024; +export const MAX_ARTIFACT_BYTES = 128 * 1024 * 1024; const ARTIFACT_KEYS = ["format", "provenance", "manifest_sha256", "files"]; const PROVENANCE_KEYS = [ diff --git a/test/compilation-artifact.test.js b/test/compilation-artifact.test.js index 8a66248..a04247c 100644 --- a/test/compilation-artifact.test.js +++ b/test/compilation-artifact.test.js @@ -42,7 +42,7 @@ const EXPECTED = { }; test("parses canonical binary-safe artifact bytes and materializes an exact tree", (context) => { - assert.equal(MAX_ARTIFACT_BYTES, 16 * 1024 * 1024); + assert.equal(MAX_ARTIFACT_BYTES, 128 * 1024 * 1024); const fixture = artifactFixture(); const artifact = parseCompilationArtifact(fixture.source, EXPECTED); assert.equal(artifact.provenance.head_source_sha256, HEAD_SHA256); diff --git a/test/compilation.test.js b/test/compilation.test.js index 2e08092..3381537 100644 --- a/test/compilation.test.js +++ b/test/compilation.test.js @@ -16,6 +16,7 @@ import { run } from "../src/cli.js"; import { ARTIFACT_MEDIA_TYPE, FOUNDATION_PLAN_FORMAT, + MAX_ARTIFACT_BYTES, } from "../src/compilation-artifact.js"; const PROJECT_ID = "01900000-0000-7000-8000-000000003001"; @@ -237,6 +238,84 @@ test("download requires succeeded status and validates historical Head provenanc assert.equal(existsSync(transportOutput), false); }); +test("artifact downloads enforce the 128 MiB declared and streamed bound", async (context) => { + assert.equal(MAX_ARTIFACT_BYTES, 128 * 1024 * 1024); + const cwd = remoteDirectory(context); + const fixture = artifactFixture(); + const metadata = { + path: ARTIFACT_PATH, + sha256: fixture.sha256, + media_type: ARTIFACT_MEDIA_TYPE, + byte_size: MAX_ARTIFACT_BYTES, + }; + const status = compilationBody("succeeded", { + artifact: fixture, + compilation: { artifact: metadata }, + }); + + let declaredCancelled = false; + const declaredBody = new ReadableStream({ + cancel() { + declaredCancelled = true; + }, + }); + const declared = await invoke( + [ + "compilation", + "download", + COMPILATION_ID, + "--output", + path.join(cwd, "declared-overflow"), + ], + { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(status), + new Response(declaredBody, { + status: 200, + headers: { + "Content-Type": ARTIFACT_MEDIA_TYPE, + "Content-Length": String(MAX_ARTIFACT_BYTES + 1), + }, + }), + ]), + }, + ); + assertHandledFailure(declared, "invalid_artifact"); + assert.equal(declaredCancelled, true); + + let streamedCancelled = false; + const streamedBody = new ReadableStream({ + start(controller) { + controller.enqueue({ byteLength: MAX_ARTIFACT_BYTES + 1 }); + }, + cancel() { + streamedCancelled = true; + }, + }); + const streamed = await invoke( + [ + "compilation", + "download", + COMPILATION_ID, + "--output", + path.join(cwd, "streamed-overflow"), + ], + { + cwd, + fetchFunction: sequenceFetch([ + jsonResponse(status), + new Response(streamedBody, { + status: 200, + headers: { "Content-Type": ARTIFACT_MEDIA_TYPE }, + }), + ]), + }, + ); + assertHandledFailure(streamed, "invalid_artifact"); + assert.equal(streamedCancelled, true); +}); + test("compilation syntax and output preflight fail before network access", async (context) => { const inaccessible = async () => { throw new Error("network must remain inaccessible"); diff --git a/test/plan-compile.test.js b/test/plan-compile.test.js index 1b0f17b..20204aa 100644 --- a/test/plan-compile.test.js +++ b/test/plan-compile.test.js @@ -23,6 +23,9 @@ const CREATED_AT = "2026-08-04T12:00:00.000Z"; const STARTED_AT = "2026-08-04T12:00:01.000Z"; const COMPLETED_AT = "2026-08-04T12:00:02.000Z"; const REPOSITORY_URL = "https://github.com/octocat/movie-catalog"; +const ANALYZER_RELEASE = "foundation-plan-rails/application-2026-08"; +const COMPILER_RELEASE = "foundation-plan-rails/compiler-2026-08"; +const TARGET = { id: "rails", profile: "rails-sketch/2026-08" }; const SUCCESS_PROGRESS = `First Draft: Analyzing Foundation Plan... First Draft: Foundation Plan analysis valid. First Draft: Compiling application... @@ -162,6 +165,33 @@ test("plan compile waits past a terminal analysis for the prior graph version", assert.equal(publications, 1); }); +test("plan compile rejects an older Head at the accepted graph version", async (context) => { + const cwd = localDirectory(context, PLAN_SOURCE, { + api_url: "https://api.example.test", + foundation_plan_etag: ETAG, + }); + const olderHead = "0".repeat(64); + let publications = 0; + const result = await invoke(["plan", "compile"], { + cwd, + planCompilePush: successfulPush, + fetchFunction: sequenceFetch([ + jsonResponse(analysisBody("valid", 1, ANALYSIS_ID, olderHead)), + ]), + planCompilePublish: async () => { + publications += 1; + return publicationBody(); + }, + }); + + assertHandledFailure(result, "analysis_changed"); + assert.equal( + errorEnvelope(result.stderr).current.analysis.head_source_sha256, + olderHead, + ); + assert.equal(publications, 0); +}); + test("invalid JSON and schema diagnostics stop before analysis or Publication", async (context) => { for (const code of ["invalid_json", "schema_invalid"]) { const source = Buffer.from( @@ -378,14 +408,24 @@ function acceptedPlanBody(graphVersion = 1) { }; } -/** @param {string} status @param {number} [graphVersion] @param {string} [analysisId] */ -function analysisBody(status, graphVersion = 1, analysisId = ANALYSIS_ID) { +/** @param {string} status @param {number} [graphVersion] @param {string} [analysisId] @param {string} [headSourceSha256] */ +function analysisBody( + status, + graphVersion = 1, + analysisId = ANALYSIS_ID, + headSourceSha256 = HEAD_SHA256, +) { + const gapSet = + status === "valid" ? emptyGapSet({ graphVersion, headSourceSha256 }) : null; return { project: { id: PROJECT_ID, graph_version: graphVersion }, analysis: { id: analysisId, graph_version: graphVersion, - analyzer_release: "foundation-plan-analyzer/2026-08", + head_source_sha256: headSourceSha256, + analyzer_release: ANALYZER_RELEASE, + compiler_release: COMPILER_RELEASE, + target: TARGET, status, diagnostics: status === "issues_found" @@ -393,12 +433,30 @@ function analysisBody(status, graphVersion = 1, analysisId = ANALYSIS_ID) { : status === "analysis_failed" ? [structuredDiagnostic("analysis_failed")] : [], + gap_set: gapSet, + gap_set_sha256: + gapSet === null + ? null + : sha256(Buffer.from(`${JSON.stringify(gapSet, null, 2)}\n`)), started_at: STARTED_AT, completed_at: COMPLETED_AT, }, }; } +/** @param {{graphVersion: number, headSourceSha256: string}} identity */ +function emptyGapSet({ graphVersion, headSourceSha256 }) { + return { + format: "firstdraft.foundation-gaps/2", + source: { sha256: headSourceSha256 }, + project: { id: PROJECT_ID, graph_version: graphVersion }, + analysis: { release: ANALYZER_RELEASE }, + compiler_release: COMPILER_RELEASE, + target: TARGET, + gaps: [], + }; +} + function publicationBody() { return { project: { @@ -412,8 +470,8 @@ function publicationBody() { graph_version: 1, head_source_sha256: HEAD_SHA256, status: "succeeded", - compiler_release: "foundation-plan-rails/compiler-2026-08", - target: { id: "rails", profile: "rails-sketch/2026-08" }, + compiler_release: COMPILER_RELEASE, + target: TARGET, artifact: { sha256: "1".repeat(64), manifest_sha256: "2".repeat(64), diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index fe9e556..294d3c0 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -24,6 +24,7 @@ const CREATED_AT = "2026-08-01T12:00:00.000Z"; const STARTED_AT = "2026-08-01T12:00:01.000Z"; const COMPLETED_AT = "2026-08-01T12:00:02.000Z"; const RETRY_AT = "2026-08-07T16:15:00.000000Z"; +const ANALYZER_RELEASE = "foundation-plan-rails/application-2026-08"; const COMPILER_RELEASE = "foundation-plan-rails/compiler-2026-08"; const TARGET = { id: "rails", profile: "rails-sketch/2026-08" }; const ARTIFACT = { @@ -633,7 +634,10 @@ test("local prerequisites reject before publication network access", async (cont status: 200, etag: '"opaque"', outcome: "updated", - body: { project: { graph_version: 11 } }, + body: { + project: { graph_version: 11 }, + foundation_plan: { source_sha256: HEAD_SHA256 }, + }, }), }), "invalid_configuration", @@ -1397,7 +1401,10 @@ async function invoke(argv, options = {}) { status: 200, etag: ETAG, outcome: "updated", - body: { project: { graph_version: 11 } }, + body: { + project: { graph_version: 11 }, + foundation_plan: { source_sha256: HEAD_SHA256 }, + }, }), planCompileReadStatus: async () => ({ status: 200, @@ -1409,14 +1416,30 @@ async function invoke(argv, options = {}) { } function validAnalysis() { + const gapSet = { + format: "firstdraft.foundation-gaps/2", + source: { sha256: HEAD_SHA256 }, + project: { id: PROJECT_ID, graph_version: 11 }, + analysis: { release: ANALYZER_RELEASE }, + compiler_release: COMPILER_RELEASE, + target: TARGET, + gaps: [], + }; return { project: { id: PROJECT_ID, graph_version: 11 }, analysis: { id: ANALYSIS_ID, graph_version: 11, - analyzer_release: "foundation-plan-analyzer/2026-08", + head_source_sha256: HEAD_SHA256, + analyzer_release: ANALYZER_RELEASE, + compiler_release: COMPILER_RELEASE, + target: TARGET, status: "valid", diagnostics: [], + gap_set: gapSet, + gap_set_sha256: sha256( + Buffer.from(`${JSON.stringify(gapSet, null, 2)}\n`), + ), started_at: STARTED_AT, completed_at: COMPLETED_AT, }, diff --git a/test/plan-status.test.js b/test/plan-status.test.js index c01f222..6e7eff8 100644 --- a/test/plan-status.test.js +++ b/test/plan-status.test.js @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { once } from "node:events"; import { mkdtempSync, @@ -15,6 +16,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { run } from "../src/cli.js"; +import { MAX_PLAN_STATUS_RESPONSE_BYTES } from "../src/commands/plan-status.js"; /** @typedef {{input: string | URL | Request, init: RequestInit | undefined}} FetchCall */ @@ -28,6 +30,10 @@ const API_TOKEN = `fd_${"a".repeat(43)}`; const ETAG = '"opaque:plan-validator"'; const STARTED_AT = "2026-07-30T12:00:00.123Z"; const COMPLETED_AT = "2026-07-30T12:00:01.456Z"; +const HEAD_SOURCE_SHA256 = "1".repeat(64); +const ANALYZER_RELEASE = "foundation-plan-rails/application-2026-08"; +const COMPILER_RELEASE = "foundation-plan-rails/compiler-application-2026-08"; +const TARGET = { id: "rails", profile: "rails-sketch/2026-08" }; const PLAN_STATUS_HELP = `First Draft CLI Usage: @@ -218,6 +224,39 @@ test("diagnostics preserve each supported optional subject shape", async (contex } }); +test("plan status exposes the complete canonical GapSet and digest", async (context) => { + const cwd = remoteDirectory(context); + const body = analysisBody("valid"); + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([jsonResponse(body)], []), + }); + + assert.equal(result.status, 0); + const output = JSON.parse(result.stdout); + assert.deepEqual(output.analysis.gap_set, body.analysis.gap_set); + assert.equal(output.analysis.gap_set_sha256, body.analysis.gap_set_sha256); + assert.deepEqual(output.analysis.gap_set.gaps, body.analysis.gap_set.gaps); +}); + +test("plan status accepts the pinned Service zero-gap canonical digest", async (context) => { + const cwd = remoteDirectory(context); + const body = analysisBody("valid"); + body.analysis.gap_set.gaps = []; + body.analysis.gap_set_sha256 = + "1fae5c215a5d9cf7a244ac5bd7c121d6da12497d5af2bf2e97073f41ac2dff7f"; + const result = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([jsonResponse(body)], []), + }); + + assert.deepEqual(result, { + status: 0, + stdout: jsonOutput(body), + stderr: "", + }); +}); + test("all validated analysis states are command results rather than transport failures", async (context) => { for (const status of [ "processing", @@ -733,6 +772,20 @@ test("network, redirect, and response-stream failures advise bounded retries", a test("success responses are rejected unless every contract field is valid", async (context) => { const valid = analysisBody("valid"); + const wrongGapIdentity = structuredClone(valid); + wrongGapIdentity.analysis.gap_set.source.sha256 = "2".repeat(64); + wrongGapIdentity.analysis.gap_set_sha256 = sha256( + Buffer.from( + `${JSON.stringify(wrongGapIdentity.analysis.gap_set, null, 2)}\n`, + ), + ); + const noncanonicalGapOrder = structuredClone(valid); + noncanonicalGapOrder.analysis.gap_set.gaps.reverse(); + noncanonicalGapOrder.analysis.gap_set_sha256 = sha256( + Buffer.from( + `${JSON.stringify(noncanonicalGapOrder.analysis.gap_set, null, 2)}\n`, + ), + ); const cases = [ { name: "wrong media type", response: textResponse(JSON.stringify(valid)) }, { name: "invalid UTF-8", response: byteResponse(Uint8Array.of(0xff)) }, @@ -762,6 +815,41 @@ test("success responses are rejected unless every contract field is valid", asyn name: "empty analyzer release", body: analysisBody("valid", { analysis: { analyzer_release: "" } }), }, + { + name: "invalid Compiler release", + body: analysisBody("valid", { + analysis: { compiler_release: "contains spaces" }, + }), + }, + { + name: "wrong GapSet source identity", + body: wrongGapIdentity, + }, + { + name: "noncanonical GapSet order", + body: noncanonicalGapOrder, + }, + { + name: "mismatched GapSet digest", + body: analysisBody("valid", { + analysis: { gap_set_sha256: "f".repeat(64) }, + }), + }, + { + name: "valid without a GapSet", + body: analysisBody("valid", { + analysis: { gap_set: null, gap_set_sha256: null }, + }), + }, + { + name: "nonvalid with a GapSet", + body: analysisBody("analysis_failed", { + analysis: { + gap_set: valid.analysis.gap_set, + gap_set_sha256: valid.analysis.gap_set_sha256, + }, + }), + }, { name: "unknown status", body: analysisBody("valid", { analysis: { status: "done" } }), @@ -901,7 +989,8 @@ test("valid timestamps include offsets, lowercase RFC 3339 markers, and leap sec }); }); -test("declared and streamed response sizes are bounded", async (context) => { +test("plan status has a dedicated 128 MiB declared and streamed response bound", async (context) => { + assert.equal(MAX_PLAN_STATUS_RESPONSE_BYTES, 128 * 1024 * 1024); const cwd = remoteDirectory(context); let declaredCancelled = false; const declaredStream = new ReadableStream({ @@ -917,7 +1006,7 @@ test("declared and streamed response sizes are bounded", async (context) => { status: 200, headers: { "Content-Type": "application/json", - "Content-Length": String(2 * 1024 * 1024 + 1), + "Content-Length": String(MAX_PLAN_STATUS_RESPONSE_BYTES + 1), }, }), ], @@ -930,7 +1019,7 @@ test("declared and streamed response sizes are bounded", async (context) => { let streamedCancelled = false; const streamedBody = new ReadableStream({ start(controller) { - controller.enqueue(new Uint8Array(2 * 1024 * 1024 + 1)); + controller.enqueue({ byteLength: MAX_PLAN_STATUS_RESPONSE_BYTES + 1 }); }, cancel() { streamedCancelled = true; @@ -1065,29 +1154,115 @@ function localDirectory(context, state) { /** * @param {string} status * @param {{project?: Record, analysis?: Record}} [overrides] + * @returns {{project: Record, analysis: Record}} */ function analysisBody(status, overrides = {}) { - const terminal = status !== "processing"; - const diagnostics = status === "issues_found" ? [diagnostic("error")] : []; + const project = { + id: PROJECT_ID, + graph_version: 1, + ...overrides.project, + }; + const preliminaryAnalysis = { + id: ANALYSIS_ID, + graph_version: 1, + head_source_sha256: HEAD_SOURCE_SHA256, + analyzer_release: ANALYZER_RELEASE, + compiler_release: COMPILER_RELEASE, + target: TARGET, + status, + ...overrides.analysis, + }; + const finalStatus = preliminaryAnalysis.status; + const terminal = finalStatus !== "processing"; + const diagnostics = Object.hasOwn(overrides.analysis ?? {}, "diagnostics") + ? overrides.analysis?.diagnostics + : finalStatus === "issues_found" + ? [diagnostic("error")] + : []; + const startedAt = Object.hasOwn(overrides.analysis ?? {}, "started_at") + ? overrides.analysis?.started_at + : terminal + ? STARTED_AT + : null; + const completedAt = Object.hasOwn(overrides.analysis ?? {}, "completed_at") + ? overrides.analysis?.completed_at + : terminal + ? COMPLETED_AT + : null; + const selectedGapSet = Object.hasOwn(overrides.analysis ?? {}, "gap_set") + ? overrides.analysis?.gap_set + : finalStatus === "valid" + ? gapSet(project, preliminaryAnalysis) + : null; + const selectedGapSetSha256 = Object.hasOwn( + overrides.analysis ?? {}, + "gap_set_sha256", + ) + ? overrides.analysis?.gap_set_sha256 + : selectedGapSet === null + ? null + : sha256(Buffer.from(`${JSON.stringify(selectedGapSet, null, 2)}\n`)); + const analysis = { + id: preliminaryAnalysis.id, + graph_version: preliminaryAnalysis.graph_version, + head_source_sha256: preliminaryAnalysis.head_source_sha256, + analyzer_release: preliminaryAnalysis.analyzer_release, + compiler_release: preliminaryAnalysis.compiler_release, + target: preliminaryAnalysis.target, + status: preliminaryAnalysis.status, + diagnostics, + gap_set: selectedGapSet, + gap_set_sha256: selectedGapSetSha256, + started_at: startedAt, + completed_at: completedAt, + }; + return { - project: { - id: PROJECT_ID, - graph_version: 1, - ...overrides.project, - }, - analysis: { - id: ANALYSIS_ID, - graph_version: 1, - analyzer_release: "scalar-rails/1", - status, - diagnostics, - started_at: terminal ? STARTED_AT : null, - completed_at: terminal ? COMPLETED_AT : null, - ...overrides.analysis, - }, + project, + analysis, + }; +} + +/** @param {Record} project @param {Record} analysis */ +function gapSet(project, analysis) { + return { + format: "firstdraft.foundation-gaps/2", + source: { sha256: analysis.head_source_sha256 }, + project: { id: project.id, graph_version: project.graph_version }, + analysis: { release: analysis.analyzer_release }, + compiler_release: analysis.compiler_release, + target: analysis.target, + gaps: [ + { + classification: "service_support_gap", + code: "foundation_plan.import.unsupported_field", + kind: "field", + status: "skipped_at_import", + pointer: "/application/entities/0/fields/0", + readable_path: "movie.poster", + reason: "Attachment Fields are not imported yet.", + consequence: "The generated application omits this Field.", + cause: "The importer has no attachment-field writer.", + }, + { + classification: "target_support_gap", + code: "foundation_plan.rails.not_generated", + kind: "scaffold", + status: "not_generated", + pointer: "/application/scaffolds/0", + readable_path: "movies", + reason: "This Scaffold is not generated yet.", + consequence: "The generated application has no Scaffold route.", + }, + ], }; } +/** @param {Buffer} source */ +function sha256(source) { + return createHash("sha256").update(source).digest("hex"); +} + /** * @param {"error" | "warning"} severity * @param {Record} [overrides] diff --git a/test/release-compatibility.test.js b/test/release-compatibility.test.js index 6220688..9afc42c 100644 --- a/test/release-compatibility.test.js +++ b/test/release-compatibility.test.js @@ -13,6 +13,10 @@ const compatibility = JSON.parse( "utf8", ), ); +const commandReference = await readFile( + new URL("../docs/commands.md", import.meta.url), + "utf8", +); const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; @@ -35,9 +39,17 @@ test("release compatibility declares the coordinated CLI contract", () => { "foundation_plan_formats", ]); assert.deepEqual(compatibility.requires.api_contract, [ - ">= 0.2.0", - "< 0.3.0", + ">= 0.3.0", + "< 0.4.0", ]); + assert.match( + commandReference, + /The closed API `0\.3\.x` progress-reason allowlist/, + ); + assert.match( + commandReference, + /analysis whose graph version and\s+`head_source_sha256` exactly match that accepted push/, + ); assert.deepEqual(compatibility.requires.foundation_plan_formats, [ FOUNDATION_PLAN_FORMAT, ]);