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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 3 additions & 4 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 2 additions & 8 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

296 changes: 296 additions & 0 deletions test/documentation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 = /(?<![\w.])(v?)(\d+\.\d+(?:\.(?:\d+|x))?)\b(?!\.\d)/g;

const releaseStatus =
/(?:\bnot|n't) (?:yet |been |yet been )?(?:published|released)\b|\b(?:candidates?|unreleased|unpublished|upcoming|pre-?releases?|pending|(?:published|released) yet|before publishing)\b/i;

const releasedOnly =
"keep dated release observations in docs/release-history.md.";

// Labels such as "CLI/Skill `", "**CLI** ", "CLI versions ", "| CLI | ", "firstdraft ", "cli@", or "latest=".
const cliLabelBefore =
/(?:\bCLI(?:\/Skill|'s)?(?:`|\*\*)?(?:\s+(?:versions?|releases?|line))?|\bfirstdraft|@firstdraft\.com\/cli`?|\b(?:latest|next)`?)\s*[:(=@|]?\s*(?:`|\*\*)?$/;

const comparatorSigns = new Map([
["=", [0]],
["<", [-1]],
["<=", [-1, 0]],
[">", [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<IdentityKind, (version: Version) => 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<IdentityKind, string>} */
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<typeof versionIdentities>} 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 })
Expand Down
Loading