From d24dfcd2d4e36f298f66364e24ad8aaa3aafa4e3 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Sat, 22 Aug 2026 13:09:41 -0500 Subject: [PATCH] Adopt reviewed GapSet API contract Move the CLI onto its 0.2 compatibility line so agents can review the complete canonical GapSet before Compile. Preserve the ordinary response bound while giving Analysis and artifacts generous endpoint-specific limits. --- README.md | 7 +- RELEASING.md | 18 +- docs/commands.md | 33 +-- package-lock.json | 4 +- package.json | 2 +- release/compatibility.json | 4 +- scripts/smoke-package.js | 19 +- src/api-response.js | 6 +- src/commands/plan-compile.js | 6 +- src/commands/plan-status.js | 320 ++++++++++++++++++++++++++++- src/compilation-artifact.js | 2 +- test/compilation-artifact.test.js | 2 +- test/compilation.test.js | 79 +++++++ test/plan-compile.test.js | 68 +++++- test/plan-publish.test.js | 29 ++- test/plan-status.test.js | 215 +++++++++++++++++-- test/release-compatibility.test.js | 16 +- 17 files changed, 756 insertions(+), 74 deletions(-) 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, ]);