From b576d1ebde1cef77b88ee114d998fd257ea2ba07 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Tue, 29 Sep 2026 21:45:33 -0500 Subject: [PATCH 1/2] Reject retired versions in living docs After CLI 0.8.0 shipped, the README and command reference still explained current behavior through CLI 0.2, 0.3, 0.4, and 0.6, and RELEASING.md still said "Before publishing CLI 0.8.0". In the 2026-09-29 documentation audit, model review caught none of the retired-phrase or census probes, so this drift needs a mechanical check. The documentation test reads the package version, the API range and Plan formats in release/compatibility.json, and the Rails profile that artifact validation accepts. Living pages may name only those identities. A version without an API or Plan label is checked as a CLI version. Otherwise the just-retired CLI line would pass whenever the API range still accepts it, as API 0.7.x does beside CLI 0.8.0. Living pages also may not call the package version a candidate, upcoming, pending, or unpublished release, or give a version a prerelease suffix. Dated release history is exempt. Each failure names the file, line, and identity, and says how to fix it. The sweep states current behavior without the old versions. Every scanned page ships in the package or routes agent work, so each hit is fixed rather than kept in a baseline. --- README.md | 4 +- RELEASING.md | 7 +- docs/README.md | 3 +- docs/commands.md | 10 +- test/documentation.test.js | 296 +++++++++++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9cb725d..179bb98 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ install the CLI and Skill, then compile into your current folder with `firstdraf clone or GitHub push is required. The [Drawing Board guide](https://github.com/firstdraft/drawing-board#build-an-app-with-first-draft) is the Codespaces fallback. -CLI 0.4 and later make `--output .` the default. Keep the explicit flag with CLI 0.3, whose zero-flag command selects GitHub -publication. +`--output .` is also the default, so `firstdraft plan compile` alone writes to the current folder. GitHub publication +requires `--github`. ## What this repository owns diff --git a/RELEASING.md b/RELEASING.md index b4836a1..50e21d2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -21,14 +21,13 @@ CLI `0.8.x` requires API `0.7.x`, Plan `firstdraft.foundation-plan.sketch/0.23`, ordinary bookmark assets and metadata without a Plan switch. Earlier Plan formats and target profiles are not accepted for artifact materialization. Existing applications and old Plans are not migrated. -Before publishing CLI `0.8.0`, align the Service and Skills companions for the bookmark-assets contract. Source +Align the Service and Skills companions before publishing a CLI version that changes this contract. Source checks and packed-package smokes do not establish a published or deployed tuple. Staging continues to require `FIRSTDRAFT_STAGING_API_TOKEN`, including existing pinned Projects. Production remains the default and uses `FIRSTDRAFT_API_TOKEN`, as do custom origins. -Local output remains the default introduced in CLI `0.4.x`: `firstdraft plan compile` is equivalent to -`firstdraft plan compile --output .`, with GitHub publication selected by explicit `--github`. The root archive -remains `.firstdraft/design`. +Local output is the default: `firstdraft plan compile` is equivalent to `firstdraft plan compile --output .`, +with GitHub publication selected by explicit `--github`. The root archive is `.firstdraft/design`. `release/compatibility.json` declares the package version, accepted API-contract range, and accepted Plan formats. It is source-only metadata, validated by the normal test suite and absent from the npm tarball. Coordinate the diff --git a/docs/README.md b/docs/README.md index f7a2dcd..21e8a84 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,8 @@ authority boundary. Create another page only for a distinct audience, task, or a The documentation tests keep `AGENTS.md` at or below 2 KiB, the root README at or below 6 KiB, and this map at or below 4 KiB. They also require every public topic to remain reachable from this map or the root README and verify -repository-local links and fragments. The package check separately verifies that every relative link in the +repository-local links and fragments. Outside release history, they reject retired version identities and +release-status labels on the current version. The package check separately verifies that every relative link in the packaged Markdown resolves inside that exact package. ## Work on the repository diff --git a/docs/commands.md b/docs/commands.md index fc83e1d..a2a95c1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -59,8 +59,7 @@ commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflic Every remote command selects credentials from its effective origin: the exact `https://staging.firstdraft.com` origin requires `FIRSTDRAFT_STAGING_API_TOKEN`; production and custom origins use `FIRSTDRAFT_API_TOKEN`. Neither token is a fallback for the other. This includes existing staging Projects and retained status or artifact reads, -even when no flag is supplied. Upgrading from CLI `0.6.x` therefore requires moving the staging credential to -`FIRSTDRAFT_STAGING_API_TOKEN`; production tokens stay in `FIRSTDRAFT_API_TOKEN`. +even when no flag is supplied. The CLI sends the selected token as a Bearer credential on every API request. It does not save it in `.firstdraft`, print it, or require it for local commands. Revoke a token in the environment that issued it if it is exposed. A @@ -179,8 +178,7 @@ firstdraft plan compile This is equivalent to `firstdraft plan compile --output .`. Use `--output ./application` for another absent directory, or `--github` to publish to a private GitHub repository. `--github` and `--output` are mutually exclusive. No GitHub connection, repository clone, or push is required for local compilation. Compilation runs on the First -Draft service; output and the application runtime are local. CLI `0.3.x` used GitHub Publication as its default; -scripts that require that behavior must add `--github` when upgrading to `0.4.x` or later. +Draft service; output and the application runtime are local. Both `plan compile` modes first push the exact current bytes in `.firstdraft/foundation-plan.json`, even when those bytes are unchanged, and save the accepted ETag using the same @@ -202,10 +200,6 @@ destination is checked again after analysis; root adoption instead holds its own pre-move identity recheck described below. Other existing destinations remain invalid, so `--output ./application` retains its absent-directory contract. -The nested archive layout below is shared by CLI `0.3.x` and later. -[Published CLI `0.2.2`](release-history.md#022-publication-and-registry-observation) archives at top-level `design/`; -existing applications are not migrated automatically. - The default `--output .` is the noninteractive root-adoption mode. `./`, an absolute spelling of the current directory, and another spelling that resolves to that same physical directory select the same mode. It works at any real current directory that meets the preconditions below and does not recognize Drawing Board or another repository layout diff --git a/test/documentation.test.js b/test/documentation.test.js index 336ef04..86e1512 100644 --- a/test/documentation.test.js +++ b/test/documentation.test.js @@ -9,6 +9,8 @@ import { markdownLinkTargets, withoutFencedCode, } from "../scripts/markdown-documentation.js"; +import { RAILS_TARGET_PROFILE } from "../src/compilation-artifact.js"; +import { VERSION } from "../src/version.js"; const repository = fileURLToPath(new URL("..", import.meta.url)); const markdownFiles = [ @@ -133,6 +135,300 @@ test("local documentation links and fragments resolve", () => { } }); +test("living documentation names only current version identities", () => { + const identities = versionIdentities( + VERSION, + JSON.parse( + readFileSync(path.join(repository, "release/compatibility.json"), "utf8"), + ).requires, + ); + const findings = []; + + for (const [file, source] of sources) { + const name = path.relative(repository, file); + if (name === path.join("docs", "release-history.md")) continue; + + findings.push(...staleVersionFindings(name, source, identities)); + + for (const match of source.matchAll(/rails-sketch\/[\w-]*\w/g)) { + if (match[0] === RAILS_TARGET_PROFILE) continue; + + findings.push( + `${name}:${lineAt(source, match.index)} names ${match[0]}, but src/compilation-artifact.js accepts ` + + `${RAILS_TARGET_PROFILE}. Name that profile or describe the behavior without one.`, + ); + } + } + + assert.ok( + findings.length === 0, + `Update stale version identities:\n${findings.join("\n")}`, + ); +}); + +test("the version lint catches unlabeled, prerelease, and unreleased CLI versions", () => { + // Fixed identities from the 0.8.0 release, when the API minor trailed the CLI minor by one. + const identities = versionIdentities("0.8.0", { + api_contract: [">= 0.7.0", "< 0.8.0"], + foundation_plan_formats: ["firstdraft.foundation-plan.sketch/0.23"], + }); + const stale = [ + "The CLI/Skill `0.7.0` pair sent Plans.", + "| CLI | 0.7.0 |", + "npm `latest` selects `0.7.0`.", + "Scripts written for the 0.7 line keep `--github`.", + "CLI 0.8.0 has not been published.", + "CLI 0.8.0 has not been\npublished.", + "Before\npublishing CLI 0.8.0, align the companions.", + "CLI 0.8.0 isn't published yet.", + "The upcoming CLI 0.8.0 adds this flag.", + "Install `0.8.0-rc.1` to try it.", + ]; + const current = [ + "The current `0.8.x` source line contains the commands.", + "CLI `0.8.x` requires the service's `0.7.x` API contract.", + "Plan `0.23` has no `application.pwa` option.", + "Development uses Node.js 24.18.0.", + ]; + + for (const line of stale) { + assert.equal( + staleVersionFindings("probe.md", line, identities).length, + 1, + line, + ); + } + for (const line of current) { + assert.deepEqual( + staleVersionFindings("probe.md", line, identities), + [], + line, + ); + } +}); + +const identityLabels = { + cli: "CLI ", + api: "API ", + plan: "Plan ", + unlabeled: "", +}; + +const versionToken = /(?", [1]], + [">=", [0, 1]], +]); + +/** @typedef {"cli" | "api" | "plan" | "unlabeled"} IdentityKind */ +/** @typedef {{major: number, minor: number, patch: number | undefined}} Version */ + +/** + * @param {string} packageVersion + * @param {{api_contract: string[], foundation_plan_formats: string[]}} requires + */ +function versionIdentities(packageVersion, requires) { + const cliVersion = parseVersion(packageVersion); + const apiComparators = requires.api_contract.map(parseComparator); + const planVersions = requires.foundation_plan_formats.map((format) => + parseVersion(format.replace(/^.*\//, "")), + ); + const apiRange = requires.api_contract.join(" "); + const planFormats = requires.foundation_plan_formats.join(", "); + const withoutOldVersion = `state the current behavior without the old version, and ${releasedOnly}`; + /** @param {Version} version */ + const acceptsCli = (version) => + sameLine(version, cliVersion) && + (version.patch === undefined || version.patch === cliVersion.patch); + + /** @type {Record boolean>} */ + const accepts = { + cli: acceptsCli, + api: (version) => + apiComparators.every((comparator) => comparator.accepts(version)), + plan: (version) => + version.patch === undefined && + planVersions.some((plan) => sameLine(version, plan)), + // An unlabeled token is read as a CLI version. Otherwise a retired CLI line that the API range + // still accepts, such as CLI 0.7.x beside API 0.7.x, would pass. + unlabeled: acceptsCli, + }; + /** @type {Record} */ + const staleAdvice = { + cli: `but package.json is ${packageVersion}. Name that version or ${withoutOldVersion}`, + api: `but release/compatibility.json accepts API ${apiRange}. Name an accepted version or state the behavior without one.`, + plan: `but release/compatibility.json accepts ${planFormats}. Name an accepted format or state the behavior without one.`, + unlabeled: + `which has no API or Plan label, so it is checked as a CLI version against package.json's ` + + `${packageVersion}. Add its API or Plan label if it names one; otherwise ${withoutOldVersion}`, + }; + + return { + // Other majors, such as Node.js 24.18.0 or npm 11.16.0, are not First Draft identities. + productMajors: new Set( + [ + cliVersion, + ...apiComparators.map(({ bound }) => bound), + ...planVersions, + ].map(({ major }) => major), + ), + accepts, + staleAdvice, + }; +} + +/** + * @param {string} name + * @param {string} source + * @param {ReturnType} identities + * @returns {string[]} + */ +function staleVersionFindings( + name, + source, + { productMajors, accepts, staleAdvice }, +) { + const findings = []; + + for (const match of source.matchAll(versionToken)) { + const [token, tagPrefix, versionText] = match; + assert(versionText !== undefined); + const version = parseVersion(versionText); + if (!productMajors.has(version.major)) continue; + + const end = match.index + token.length; + const kind = identityKind( + tagPrefix, + source.slice(Math.max(0, match.index - 40), match.index), + source.slice(end, end + 20), + ); + const location = `${name}:${lineAt(source, match.index)}`; + const prerelease = + version.patch === undefined + ? undefined + : /^-[0-9A-Za-z][0-9A-Za-z.-]*\b/.exec(source.slice(end))?.[0]; + + if (!accepts[kind](version)) { + findings.push( + `${location} names ${identityLabels[kind]}${token}, ${staleAdvice[kind]}`, + ); + } else if (prerelease) { + findings.push( + `${location} names prerelease ${token}${prerelease}. Living pages name only released versions: ` + + `remove the prerelease suffix and ${releasedOnly}`, + ); + } else if (kind === "cli" || kind === "unlabeled") { + const status = releaseStatus.exec( + sentenceAround(source, match.index).replace(/\s+/g, " "), + ); + if (status) { + findings.push( + `${location} calls the current package version ${token} "${status[0]}". Living pages treat ` + + `package.json's version as released: remove the release-status wording and ${releasedOnly}`, + ); + } + } + } + + return findings; +} + +/** + * @param {string | undefined} tagPrefix + * @param {string} before + * @param {string} after + * @returns {IdentityKind} + */ +function identityKind(tagPrefix, before, after) { + if (tagPrefix === "v" || cliLabelBefore.test(before)) return "cli"; + if (/\bAPI\s+(?:contract\s+|version\s+)?`?$/.test(before)) return "api"; + if (/(?:sketch\/|\bPlan\s+(?:format\s+|version\s+)?`?)$/.test(before)) { + return "plan"; + } + + const labelAfter = /^(?:`|\*\*)?\s+(API|Plan|CLI)\b/.exec(after)?.[1]; + if (labelAfter === "API") return "api"; + if (labelAfter === "Plan") return "plan"; + return labelAfter === "CLI" ? "cli" : "unlabeled"; +} + +/** @param {string} value @returns {Version} */ +function parseVersion(value) { + const match = /^(\d+)\.(\d+)(?:\.(\d+|x))?$/.exec(value); + assert(match, `expected a version, found ${value}`); + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: + match[3] === undefined || match[3] === "x" ? undefined : Number(match[3]), + }; +} + +/** @param {string} requirement */ +function parseComparator(requirement) { + const match = /^(=|<|<=|>|>=)\s+(\S+)$/.exec(requirement); + const signs = comparatorSigns.get(match?.[1] ?? ""); + assert(match?.[2] && signs, `invalid comparator: ${requirement}`); + const bound = parseVersion(match[2]); + + return { + bound, + /** A line such as 0.7.x is compared as its first release. @param {Version} version */ + accepts(version) { + const order = + version.major - bound.major || + version.minor - bound.minor || + (version.patch ?? 0) - (bound.patch ?? 0); + + return signs.includes(Math.sign(order)); + }, + }; +} + +/** @param {Version} left @param {Version} right */ +function sameLine(left, right) { + return left.major === right.major && left.minor === right.minor; +} + +/** @param {string} source @param {number} index */ +function lineAt(source, index) { + return source.slice(0, index).split("\n").length; +} + +/** @param {string} source @param {number} index */ +function sentenceAround(source, index) { + let start = 0; + let end = source.length; + + for (const boundary of source.matchAll( + /[.!?](?=\s)|\n[ \t]*(?:\n|[-*+|#]|\d+\.\s)/g, + )) { + if (boundary.index < index) { + start = boundary.index + boundary[0].length; + } else { + end = boundary.index; + break; + } + } + + return source.slice(start, end); +} + /** @param {string} directory @returns {string[]} */ function findMarkdownFiles(directory) { return readdirSync(directory, { withFileTypes: true }) From fdefac739a05e3cdcbdb2bf989de7a96c1ccfa6a Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Wed, 30 Sep 2026 00:37:23 -0500 Subject: [PATCH 2/2] Update brace-expansion to the patched 5.0.12 npm audit fails on main and on every pull request: brace-expansion 5.0.9, which eslint reaches through minimatch, is affected by GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p, published 2026-09-29. npm audit fix moves the lockfile to 5.0.12 within minimatch's existing range. It is a development dependency, so the published package does not change. --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 236c6de..3109f09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -639,9 +639,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": {