From 4fc4ec83eccda6aeb542cc6b552262701c8bdc16 Mon Sep 17 00:00:00 2001 From: Adrian Elton-Browning Date: Tue, 6 Oct 2026 17:23:13 +0100 Subject: [PATCH 1/5] feat: extract --check and the check-sync GitHub Action (#28) --- .bumpy/extract-check-and-check-sync.md | 9 + .github/actions/check-sync/action.yml | 77 ++++++ .github/actions/check-sync/check-sync.mjs | 224 ++++++++++++++++++ .github/workflows/ci_test.yml | 14 ++ .intent/review-state.json | 146 +++++++++--- CLAUDE.md | 3 +- TESTING.md | 3 + _artifacts/domain_map.yaml | 2 +- _artifacts/skill_spec.md | 6 + examples/CLI_EXAMPLES.md | 3 + packages/mdcode/README.md | 136 ++++++++++- .../skills/sync-markdown-code-blocks/SKILL.md | 5 +- packages/mdcode/src/cli.ts | 33 ++- packages/mdcode/src/commands/extract.test.ts | 107 ++++++++- packages/mdcode/src/commands/extract.ts | 173 +++++++++++--- packages/mdcode/src/region.test.ts | 10 + packages/mdcode/src/region.ts | 14 +- .../usage/tests/check-sync-action.test.ts | 201 ++++++++++++++++ 18 files changed, 1084 insertions(+), 82 deletions(-) create mode 100644 .bumpy/extract-check-and-check-sync.md create mode 100644 .github/actions/check-sync/action.yml create mode 100755 .github/actions/check-sync/check-sync.mjs create mode 100644 packages/usage/tests/check-sync-action.test.ts diff --git a/.bumpy/extract-check-and-check-sync.md b/.bumpy/extract-check-and-check-sync.md new file mode 100644 index 0000000..cdfb1d3 --- /dev/null +++ b/.bumpy/extract-check-and-check-sync.md @@ -0,0 +1,9 @@ +--- +mdcode-ts: minor +--- + +Added `mdcode extract --check`. It works out every target exactly as `extract` would and compares it with the file on disk instead of writing it. It exits 1 with an `out_of_sync` error for each block whose part of a file would change: a region that differs or is missing, a whole file that differs (pass `--force` so existing whole files are compared rather than skipped), or a file that doesn't exist yet. In `--check` results, `unchanged` is a new target action. + +Added the `check-sync` GitHub Action (`adrianbrowning/mdcode-ts/.github/actions/check-sync`). It fails a job when Markdown blocks and their files disagree in either direction, using `update --check` and `extract --check --force`, and writes nothing. Each problem becomes an annotation and a row in the job summary. + +Fixed `extract` indenting a spliced region a second time when its markers are indented. `update` copies such a region with its indentation, so extracting it used to push every line right. `extract --force` now keeps an overwritten file's final newline, and refuses to replace a symlinked target with a regular file. diff --git a/.github/actions/check-sync/action.yml b/.github/actions/check-sync/action.yml new file mode 100644 index 0000000..9f5810e --- /dev/null +++ b/.github/actions/check-sync/action.yml @@ -0,0 +1,77 @@ +name: mdcode check sync +description: Fail when Markdown code blocks and the files they link to disagree, in either direction, without changing the checkout. + +inputs: + documents: + description: > + Markdown files to check, one path or glob per line (paths may contain + spaces). Leave empty with project or config to check the configuration's + documents. + default: '' + directions: + description: > + Which directions to check, space- or comma-separated: `update` (do the + blocks show their files?) and `extract` (would extracting the blocks + change a file?). + default: update extract + base: + description: > + For update, the directory file= paths resolve against (--base). Default: + each document's own directory, or the configuration's sourceRoot. + default: '' + dir: + description: > + For extract, the directory file= targets resolve against (--dir). + Default: base when set, else each document's own directory, matching + update; with project or config, the configuration's outputRoot. + default: '' + ignore-anonymous: + description: For extract, skip blocks without file= (they have no linked file). + default: 'true' + project: + description: Load mdcode.config.json from the working directory (--project). + default: 'false' + config: + description: Load this configuration file instead (--config). + default: '' + working-directory: + description: Directory to run in, relative to the workspace. + default: '.' + node-version: + description: Node.js version to set up (22.17 or later). Empty to use the runner's Node.js. + default: '22' + mdcode-command: + description: > + Command that runs mdcode. Default: `npx --yes mdcode-ts@`, the + release this action's ref belongs to. + default: '' + +outputs: + problems: + description: Number of problems found; 0 when everything is in sync. + value: ${{ steps.check.outputs.problems }} + +runs: + using: composite + steps: + - name: Set up Node.js + if: inputs.node-version != '' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + + - name: Check Markdown and linked files are in sync + id: check + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + DOCUMENTS: ${{ inputs.documents }} + DIRECTIONS: ${{ inputs.directions }} + BASE: ${{ inputs.base }} + DIR: ${{ inputs.dir }} + IGNORE_ANONYMOUS: ${{ inputs.ignore-anonymous }} + PROJECT: ${{ inputs.project }} + CONFIG: ${{ inputs.config }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + MDCODE_COMMAND: ${{ inputs.mdcode-command }} + run: node "$GITHUB_ACTION_PATH/check-sync.mjs" diff --git a/.github/actions/check-sync/check-sync.mjs b/.github/actions/check-sync/check-sync.mjs new file mode 100755 index 0000000..6b5bba7 --- /dev/null +++ b/.github/actions/check-sync/check-sync.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +/** + * The check-sync action: fail when Markdown code blocks and the files they + * link to disagree, in either direction, without writing anything. + * + * files → Markdown: `mdcode update --check`, does each block show its file? + * Markdown → files: `mdcode extract --check --force`, would extract change a file? + * + * Inputs arrive as environment variables (see action.yml). Exit 0 when both + * directions are in sync, 1 when anything drifted or could not be read, 2 when + * the action itself was misconfigured or mdcode could not run. + */ +import { spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +import { glob } from "node:fs/promises"; +import { dirname, join, posix } from "node:path"; + +const DIRECTIONS = { + update: { label: "files → Markdown (update)", fix: "mdcode update --apply" }, + extract: { label: "Markdown → files (extract)", fix: "mdcode extract --force" }, +}; + +const env = name => (process.env[name] ?? "").trim(); +const flag = name => /^(true|1|yes)$/i.test(env(name)); + +class ConfigError extends Error {} + +/** The command that runs mdcode: the input, else the release this action belongs to, from npm. */ +function mdcodeCommand() { + if (env("MDCODE_COMMAND")) { + return env("MDCODE_COMMAND"); + } + + const manifest = join(import.meta.dirname, "..", "..", "..", "packages", "mdcode", "package.json"); + const { name, version } = JSON.parse(readFileSync(manifest, "utf-8")); + return `npx --yes ${name}@${version}`; +} + +/** The directions to check, from a space- or comma-separated list. */ +function directions() { + const asked = env("DIRECTIONS").split(/[\s,]+/).filter(Boolean); + const unknown = asked.filter(direction => !(direction in DIRECTIONS)); + + if (asked.length === 0 || unknown.length > 0) { + throw new ConfigError(`directions must list update, extract or both; got ${JSON.stringify(env("DIRECTIONS"))}`); + } + + return [ ...new Set(asked) ]; +} + +/** The documents, one path or glob per line, so paths may contain spaces. */ +async function documents() { + const found = []; + + for (const line of env("DOCUMENTS").split(/\r?\n/).map(entry => entry.trim()).filter(Boolean)) { + if (!/[*?[{]/.test(line)) { + if (!existsSync(line)) { + throw new ConfigError(`document ${line} does not exist`); + } + found.push(line); + continue; + } + + const matches = []; + for await (const match of glob(line)) { + matches.push(match.split("\\").join("/")); + } + + if (matches.length === 0) { + throw new ConfigError(`documents pattern ${line} matched no files`); + } + found.push(...matches.sort()); + } + + return [ ...new Set(found) ]; +} + +/** Run mdcode with these arguments and return its JSON envelope. */ +function mdcode(command, args) { + // bash passes every argument through "$@" untouched, so paths with spaces stay whole. + // --norc: bash reads ~/.bashrc when it guesses it runs over ssh, which can print or change PATH. + const run = spawnSync("bash", [ "--noprofile", "--norc", "-c", `${command} "$@"`, "mdcode", ...args ], { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }); + + try { + return JSON.parse(run.stdout); + } + catch { + throw new ConfigError(`mdcode did not produce a JSON result (exit ${run.status ?? run.signal}):\n${run.stderr || run.stdout || run.error?.message || ""}`.trimEnd()); + } +} + +/** The mdcode runs for one direction: one for update, one per document directory for extract. */ +function runs(direction, docs) { + const common = [ "--json", ...(flag("PROJECT") ? [ "--project" ] : []), ...(env("CONFIG") ? [ "--config", env("CONFIG") ] : []) ]; + + if (direction === "update") { + return [[ "update", "--check", "--continue-on-error", ...common, ...(env("BASE") ? [ "--base", env("BASE") ] : []), ...docs ]]; + } + + // --force compares existing whole files instead of reporting them skipped; --check writes nothing. + const extract = [ "extract", "--check", "--force", ...(flag("IGNORE_ANONYMOUS") ? [ "--ignore-anonymous" ] : []), ...common ]; + // Both directions resolve file= against one root: dir, else update's base. A + // configuration supplies its own outputRoot. + const root = env("DIR") || env("BASE"); + const configured = flag("PROJECT") || env("CONFIG") !== ""; + + if (root || configured || docs.length === 0) { + return [[ ...extract, ...(root ? [ "--dir", root ] : []), ...docs ]]; + } + + // Like update without --base, resolve each document's file= against its own directory. + const byDir = Map.groupBy(docs, doc => dirname(doc)); + return [ ...byDir ].map(([ dir, group ]) => [ ...extract, "--dir", dir, ...group ]); +} + +/** Escape text for a workflow command's message or property value. */ +function escape(text, property = false) { + const escaped = String(text).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); + return property ? escaped.replaceAll(":", "%3A").replaceAll(",", "%2C") : escaped; +} + +const cell = text => String(text ?? "").replaceAll("|", "\\|").replaceAll("\n", " "); + +async function main() { + const command = mdcodeCommand(); + const asked = directions(); + const project = flag("PROJECT") || env("CONFIG") !== ""; + const docs = await documents(); + + if (docs.length === 0 && !project) { + throw new ConfigError("documents is empty; list Markdown files or globs, one per line, or set project: true"); + } + + const problems = []; + + for (const direction of asked) { + for (const args of runs(direction, docs)) { + const envelope = mdcode(command, args); + + if (envelope.result === null && envelope.errors.some(({ code }) => code === "invalid_usage")) { + const message = envelope.errors.map(({ message }) => message).join("; "); + const hint = direction === "extract" && /--check/.test(message) ? " This mdcode has no extract --check; pin the action to a release that does." : ""; + throw new ConfigError(`mdcode ${args[0]} rejected its arguments: ${message}.${hint}`); + } + + problems.push(...envelope.errors.map(error => ({ direction, ...error }))); + } + } + + // The same drift seen from both sides is one problem. + const merged = []; + for (const problem of problems) { + const twin = merged.find(other => other.direction !== problem.direction && other.code === "out_of_sync" && problem.code === "out_of_sync" + && other.document === problem.document && other.line === problem.line); + + if (twin) { + twin.direction = "both"; + twin.messages.push(problem.message); + continue; + } + merged.push({ ...problem, messages: [ problem.message ] }); + } + + report(merged, asked, docs); + return merged.length === 0 ? 0 : 1; +} + +function report(problems, asked, docs) { + const workdir = env("WORKING_DIRECTORY") || "."; + const where = asked.map(direction => DIRECTIONS[direction].label).join(" and "); + + for (const problem of problems) { + const label = problem.direction === "both" ? "both directions" : DIRECTIONS[problem.direction].label; + const block = problem.name ? ` (${problem.name})` : ""; + const message = `${label}: ${problem.messages.join("; ")}${block}`; + const file = problem.document === undefined ? undefined : posix.normalize(posix.join(workdir, problem.document)); + const properties = [ + ...(file ? [ `file=${escape(file, true)}` ] : []), + ...(problem.line ? [ `line=${problem.line}` ] : []), + `title=${escape(`mdcode ${problem.code}`, true)}`, + ]; + + console.log(`::error ${properties.join(",")}::${escape(message)}`); + } + + const summary = []; + + if (problems.length === 0) { + const scope = docs.length === 0 ? "the configured documents" : `${docs.length} document(s)`; + console.log(`✓ ${scope} in sync: ${where}.`); + summary.push("## mdcode: in sync", "", `Checked ${scope}: ${where}.`); + } + else { + console.log(`✗ ${problems.length} problem(s) between Markdown code blocks and their files.`); + summary.push( + "## mdcode: out of sync", + "", + `${problems.length} problem(s). If the file is right, run \`${DIRECTIONS.update.fix} \`; if the block is right, run \`${DIRECTIONS.extract.fix} \`. Fix read errors in the block's \`file=\` or \`region=\`.`, + "", + "| Direction | Document | Line | Block | File | Problem |", + "| --- | --- | --- | --- | --- | --- |", + ...problems.map(problem => `| ${problem.direction === "both" ? "both" : problem.direction} | ${cell(problem.document)} | ${problem.line ?? ""} | ${cell(problem.name)} | ${cell(problem.path)} | ${cell(`${problem.code}: ${problem.messages.join("; ")}`)} |`), + ); + } + + if (env("GITHUB_STEP_SUMMARY")) { + appendFileSync(env("GITHUB_STEP_SUMMARY"), summary.join("\n") + "\n"); + } + + if (env("GITHUB_OUTPUT")) { + appendFileSync(env("GITHUB_OUTPUT"), `problems=${problems.length}\n`); + } +} + +try { + process.exitCode = await main(); +} +catch (error) { + if (!(error instanceof ConfigError)) { + throw error; + } + console.log(`::error title=mdcode check-sync::${escape(error.message)}`); + process.exitCode = 2; +} diff --git a/.github/workflows/ci_test.yml b/.github/workflows/ci_test.yml index f8b2e28..ed6afd3 100644 --- a/.github/workflows/ci_test.yml +++ b/.github/workflows/ci_test.yml @@ -30,3 +30,17 @@ jobs: - name: Run runnable examples run: pnpm docs:examples + + # Dogfood the consumer action against this branch's build: the release it + # would fetch from npm by default lacks features this branch may add. + - name: Check docs with the check-sync action + uses: ./.github/actions/check-sync + with: + documents: | + README.md + packages/mdcode/README.md + examples/CLI_EXAMPLES.md + TESTING.md + packages/mdcode/tests/examples/*/README.md + node-version: '' + mdcode-command: node packages/mdcode/dist/main.js diff --git a/.intent/review-state.json b/.intent/review-state.json index ba54608..7afd292 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,24 +3,24 @@ "baseline": "5254174a2a13060d110f34dbc0a4421b5302f5cb", "items": { "skill:packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": { - "fingerprint": "db81fcd519169f99174098c758528ce372b433bae5574fe7c5f57128118dea2a", + "fingerprint": "baf22faa40d851c4c4f1f775799ff2585558a0ca71090261d326ec836aa48c93", "snapshot": { "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "c657f3b7dd7cb7fe5ddd6efd014dea85b592a564d52dd82f59f7b618bbb93df7", - "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "bfbfe9fb88fb1bb1ef18eb55338632d2ac6408d023de7c05c60dc307c8626033", + "packages/mdcode/README.md": "a1e76ea88903f6d0c6f9eb14347dd01c4f837e2e50bd326cf1ddda94b1afa215", + "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", - "packages/mdcode/src/cli.ts": "bbad86849efb1e2818562c4fb1f790d8fbd283ee45eec9b3bcb3e63570442f13", + "packages/mdcode/src/cli.ts": "cd07a5c2e698e23738b746c87afad1edb158f1c778c87c09d573ac36bb794c40", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "8a1897da665e95e20a6e645565632cf4aff17c2c5cdc4f4fae3f904bdd3c074d", + "packages/mdcode/src/commands/extract.ts": "3f49ea1b950b62f8843654f6ee3629e87ac0e23b93a395a131656827871529ec", "packages/mdcode/src/commands/list.ts": "d919894dd08249bb1eb0d12bcd5c21016cb87c0a6cba484de7da492bbe3c5c75", "packages/mdcode/src/commands/run.ts": "2c35da6b7ee31feb34df303d532715299f6e6eb87f24d914637bb299f875b77c", "packages/mdcode/src/commands/update.ts": "0e3c7d632ca3a424e8c01d64b3bd5872fe8ec9c0856610e3e95ce28e5dfe67c8", "packages/mdcode/src/commands/validate.ts": "a71b2c43dbf84df343d84635e89a1341a2e4c8a0c328d89bccf2201bbeffcedb", "packages/mdcode/src/outline.ts": "56a871dc88cd65a82c5ded1bbfcea1fd516f093b8fc15ade6fe92da2bba9e7f8", "packages/mdcode/src/parser.ts": "d0595a4beea7239c7f68934998b59a21712c5a90d1f0c9eac35a2f6e32e41c60", - "packages/mdcode/src/region.ts": "91c2b92a90807034d15b84c164e8da81637fe4b4d7ae6e245d66c6d3140ed990", + "packages/mdcode/src/region.ts": "678935dfac7770e9416a16ac3d8408102b9386ad92e91d609a31e05ab24577a6", "packages/mdcode/src/result.ts": "696490ffa087c54d67935b46695f9fc42db35fafa810bf3d6feb38369ebac850", "packages/mdcode/tests/examples/factorial/README.md": "18659f56984ea189e8694f1414ef7765607983ec113fe534603b38cd26f032e5", "packages/mdcode/tests/examples/fibonacci/README.md": "57c88dca07d3d3d68233c87495cb238ce6f079a7702133640481ce82e094298b", @@ -32,35 +32,35 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "1d5875a70d51efab8c48ee12071e0d3d68fa66da", - "outcome": "no-change", - "reason": "The skill already states list/run/dump read one file and --file matches exactly; its `mdcode list --meta runnable=true README.md` example was broken by the variadic --meta and now works as written. README.md source changes fix examples to agree with the skill.", + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "outcome": "updated", + "reason": "Extract section gains `extract --check --force --ignore-anonymous` as the read-only preview and the --force caveat; safety table lists extract --check as writing nothing. Other guidance matches the changed extract (final newline kept on overwrite, symlink refused) and splice (no double indent) behaviour without edits.", "evidence": [ - "cli-integration.test.ts '--meta takes one key=value, so a file after it is still the file to read' failed on the variadic build and passes after; `mdcode list --json --meta runnable=true README.md` now returns the runnable block (it returned none before); a scan of packages/mdcode/README.md and examples/CLI_EXAMPLES.md finds no list/run/dump with several files or a directory and no glob --file value; skill task check still passes." + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." ] }, "planning:_artifacts": { - "fingerprint": "a9e25508205d66595ed0beaf9a482047bfcfaec4c44c1bca17a4c8143bd39057", + "fingerprint": "8ac4503938ddd3290670e3f6bd04fc39a768d2a87fbc3ff0af1cd2c6b273bfb1", "snapshot": { - "_artifacts/domain_map.yaml": "47b4f2603fd08462d894e310bc9f49d169af9f27e958a5528ea74f0cad2828cf", - "_artifacts/skill_spec.md": "6924a904a1406221d435ee89535804f767bfbaf46dd3394001fa4ad7ebbc3281", + "_artifacts/domain_map.yaml": "ceb4094bf4fa2cd63354e1c76cce3fec97d19c4a807a40b974646e1dbb00152b", + "_artifacts/skill_spec.md": "d0af268c94e3fefde165ce14f470c88ce52e7f8162c4c8632718fe0f959f5a23", "_artifacts/skill_tree.yaml": "2490b6a1d7a2abab698fe1a91e036ddec9dc76de5cfb15dedd8639ebf2875229", "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "c657f3b7dd7cb7fe5ddd6efd014dea85b592a564d52dd82f59f7b618bbb93df7", - "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "bfbfe9fb88fb1bb1ef18eb55338632d2ac6408d023de7c05c60dc307c8626033", + "packages/mdcode/README.md": "a1e76ea88903f6d0c6f9eb14347dd01c4f837e2e50bd326cf1ddda94b1afa215", + "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", - "packages/mdcode/src/cli.ts": "bbad86849efb1e2818562c4fb1f790d8fbd283ee45eec9b3bcb3e63570442f13", + "packages/mdcode/src/cli.ts": "cd07a5c2e698e23738b746c87afad1edb158f1c778c87c09d573ac36bb794c40", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "8a1897da665e95e20a6e645565632cf4aff17c2c5cdc4f4fae3f904bdd3c074d", + "packages/mdcode/src/commands/extract.ts": "3f49ea1b950b62f8843654f6ee3629e87ac0e23b93a395a131656827871529ec", "packages/mdcode/src/commands/list.ts": "d919894dd08249bb1eb0d12bcd5c21016cb87c0a6cba484de7da492bbe3c5c75", "packages/mdcode/src/commands/run.ts": "2c35da6b7ee31feb34df303d532715299f6e6eb87f24d914637bb299f875b77c", "packages/mdcode/src/commands/update.ts": "0e3c7d632ca3a424e8c01d64b3bd5872fe8ec9c0856610e3e95ce28e5dfe67c8", "packages/mdcode/src/commands/validate.ts": "a71b2c43dbf84df343d84635e89a1341a2e4c8a0c328d89bccf2201bbeffcedb", "packages/mdcode/src/outline.ts": "56a871dc88cd65a82c5ded1bbfcea1fd516f093b8fc15ade6fe92da2bba9e7f8", "packages/mdcode/src/parser.ts": "d0595a4beea7239c7f68934998b59a21712c5a90d1f0c9eac35a2f6e32e41c60", - "packages/mdcode/src/region.ts": "91c2b92a90807034d15b84c164e8da81637fe4b4d7ae6e245d66c6d3140ed990", + "packages/mdcode/src/region.ts": "678935dfac7770e9416a16ac3d8408102b9386ad92e91d609a31e05ab24577a6", "packages/mdcode/src/result.ts": "696490ffa087c54d67935b46695f9fc42db35fafa810bf3d6feb38369ebac850", "packages/mdcode/tests/examples/factorial/README.md": "18659f56984ea189e8694f1414ef7765607983ec113fe534603b38cd26f032e5", "packages/mdcode/tests/examples/fibonacci/README.md": "57c88dca07d3d3d68233c87495cb238ce6f079a7702133640481ce82e094298b", @@ -72,11 +72,11 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "1d5875a70d51efab8c48ee12071e0d3d68fa66da", + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", "outcome": "updated", - "reason": "domain_map.yaml gap for the README/CLI mismatch marked resolved; skill_spec.md gains batch 2 for #54 and the Remaining Gaps row is resolved. skill_tree.yaml unchanged.", + "reason": "domain_map.yaml covers extract --check; skill_spec.md adds batch 3 for #28. skill_tree.yaml unchanged.", "evidence": [ - "cli-integration.test.ts '--meta takes one key=value, so a file after it is still the file to read' failed on the variadic build and passes after; `mdcode list --json --meta runnable=true README.md` now returns the runnable block (it returned none before); a scan of packages/mdcode/README.md and examples/CLI_EXAMPLES.md finds no list/run/dump with several files or a directory and no glob --file value; skill task check still passes." + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." ] }, "source:.bumpy/agent-skill.md": { @@ -92,15 +92,15 @@ ] }, "source:TESTING.md": { - "fingerprint": "03b93608dd90211b2b9e704c6bac5bf63ff24919a57e4e5c6691d7463f506032", + "fingerprint": "9030c5b4778438eba7d65740bc0edcb86434669a165a2b2e8517326c7cfbe4e2", "snapshot": { - "TESTING.md": "2c4e27053f2b6e9f679688efd4bfe5a34568ada863771ace2e56674757508151" + "TESTING.md": "278678c85357a4f689d31fbe46192c687051aa0250c54922d5a4d8ed8669f26d" }, - "head": "5254174a2a13060d110f34dbc0a4421b5302f5cb", + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", "outcome": "out-of-scope", - "reason": "Contributor test-layout doc; it now lists the skill task check but the skill does not depend on it.", + "reason": "Contributor test-layout doc; lists the new action test.", "evidence": [ - "Diff adds one bullet describing skill-sync-markdown-code-blocks.test.ts and SKILL_TASK_DIR." + "One bullet added." ] }, "source:.bumpy/meta-flag.md": { @@ -116,15 +116,15 @@ ] }, "source:examples/CLI_EXAMPLES.md": { - "fingerprint": "eb007d8ec6029a55f5007980a18fc545b427467544d62833740034d498a160df", + "fingerprint": "afdf039600b52fd6a5b65b1cff64d8c7ef18f6782778553792073683ad0be8e0", "snapshot": { - "examples/CLI_EXAMPLES.md": "2a6329468cc318d2c03fce0daa08e48f5d2d0e11784830416287243209470a08" + "examples/CLI_EXAMPLES.md": "b9546828eaaa027339dbc08746374570ce25e8b0badbb29305af0d3436ca1fdb" }, - "head": "1d5875a70d51efab8c48ee12071e0d3d68fa66da", + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", "outcome": "no-change", - "reason": "Examples corrected to the CLI's real arity and exact --file matching; the skill already described that behaviour and does not cite this file.", + "reason": "Mapped source changed alongside the extract --check feature; the skill's guidance was updated separately where it applies and is otherwise unaffected.", "evidence": [ - "cli-integration.test.ts '--meta takes one key=value, so a file after it is still the file to read' failed on the variadic build and passes after; `mdcode list --json --meta runnable=true README.md` now returns the runnable block (it returned none before); a scan of packages/mdcode/README.md and examples/CLI_EXAMPLES.md finds no list/run/dump with several files or a directory and no glob --file value; skill task check still passes." + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." ] }, "source:packages/mdcode/src/types.ts": { @@ -150,6 +150,90 @@ "evidence": [ "Changed: packages/usage/tests/cli-integration.test.ts at 47705d8f2d87d0c8d1ecddbe4f5571b91cae5bd8" ] + }, + "source:.bumpy/extract-check-and-check-sync.md": { + "fingerprint": "7cfddd5a44de15f076ed440e0506acfc3693bd62188120a786f4b391f029f169", + "snapshot": { + ".bumpy/extract-check-and-check-sync.md": "9610f0a02be0256ec66d5a440997a5864d27c80a64c37bcad2028a77794fdfe6" + }, + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "outcome": "out-of-scope", + "reason": "Changelog entry for extract --check, the check-sync action and the extract fixes.", + "evidence": [ + "Minor bump file." + ] + }, + "source:.github/actions/check-sync/action.yml": { + "fingerprint": "05e5075c1199e61c25ea23a1db0987c85b78745395b92ef45bafc81dbc451aae", + "snapshot": { + ".github/actions/check-sync/action.yml": "538a6a01b4343e3391ab6b3a25b591e913307e6c7489a73d41759152fdd2feb2" + }, + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "outcome": "out-of-scope", + "reason": "The consumer check-sync action and this repo's CI dogfood step. The action is documented in the package README, not in the skill, which covers the mdcode CLI and library.", + "evidence": [ + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + ] + }, + "source:.github/actions/check-sync/check-sync.mjs": { + "fingerprint": "ed3e9eb0b0a2dd4053d6f44c3b386aed80b18b2daed1211e88a39ad93b8ac304", + "snapshot": { + ".github/actions/check-sync/check-sync.mjs": "315525933185c979d643900e854d21279e0cc68d6866d2c56a01f7aacf0466b5" + }, + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "outcome": "out-of-scope", + "reason": "The consumer check-sync action and this repo's CI dogfood step. The action is documented in the package README, not in the skill, which covers the mdcode CLI and library.", + "evidence": [ + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + ] + }, + "source:.github/workflows/ci_test.yml": { + "fingerprint": "633d0a805376c7bff0ee4668146747c0459872c11aafe07ba1bce26ddff3949d", + "snapshot": { + ".github/workflows/ci_test.yml": "f186ba538b1644520183404eeb13875272739d9fdef55eb80e7dea15158060f9" + }, + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "outcome": "out-of-scope", + "reason": "The consumer check-sync action and this repo's CI dogfood step. The action is documented in the package README, not in the skill, which covers the mdcode CLI and library.", + "evidence": [ + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + ] + }, + "source:packages/mdcode/src/commands/extract.test.ts": { + "fingerprint": "ccdc24d55cb6a24fed0cb87107018ce0fe72758347cf40c9a6f543b64ca7a9c9", + "snapshot": { + "packages/mdcode/src/commands/extract.test.ts": "b70a1b8787a93462dc1fcab3ec2d01cce507df9e982214ecc8bed2da23c0cad6" + }, + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "outcome": "no-change", + "reason": "Mapped source changed alongside the extract --check feature; the skill's guidance was updated separately where it applies and is otherwise unaffected.", + "evidence": [ + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + ] + }, + "source:packages/mdcode/src/region.test.ts": { + "fingerprint": "6319c2ce7f3639f5e00cf7a0da1ebc893e9e9ccf2b98f38d094771265f98d4eb", + "snapshot": { + "packages/mdcode/src/region.test.ts": "3ce89add3434fbfae23ec2a13454c7e72e89e8f5b1e26356e5bb19ffaf472c0d" + }, + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "outcome": "no-change", + "reason": "Mapped source changed alongside the extract --check feature; the skill's guidance was updated separately where it applies and is otherwise unaffected.", + "evidence": [ + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + ] + }, + "source:packages/usage/tests/check-sync-action.test.ts": { + "fingerprint": "b1380c7cca9aa430bac48d0a76896c505e73463aec08c200758608ef58a29565", + "snapshot": { + "packages/usage/tests/check-sync-action.test.ts": "90682ac5c209cf0edec389f6ddb06fffb22611a7ff6afda6df0b7b652b26e78b" + }, + "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "outcome": "no-change", + "reason": "Mapped source changed alongside the extract --check feature; the skill's guidance was updated separately where it applies and is otherwise unaffected.", + "evidence": [ + "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + ] } } } diff --git a/CLAUDE.md b/CLAUDE.md index 9eb851b..e37931c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,8 @@ TypeScript port of [szkiba/mdcode](https://github.com/szkiba/mdcode): keeps Mark - **Parser** (`src/parser.ts`): a custom line-by-line state machine instead of remark, so in-place updates keep exact character offsets. `scanFences()` is shared by `parse()` and `updateInfoStrings()` so block indices always agree. - **Mapping rules** (`src/commands/validate.ts`): `extract()` runs `planExtract()` before writing anything, and `update()` reads every `file=` through `readSource()`. `mdcode validate` reports the same rules without writing. With several documents, the CLI's `validateDocuments()` validates them all, plus `sharedTargetErrors()` across them, before `extract` writes any. -- **Writes**: `update()` never writes; the CLI decides, and only `--apply` writes the markdown. `watch()` writes only with `apply`. +- **Writes**: `update()` never writes; the CLI decides, and only `--apply` writes the markdown. `watch()` writes only with `apply`. `extract()` plans each target with `planTarget()` and writes it, or with `check` compares it instead. +- **Consumer GitHub Actions** (`.github/actions/check-sync/`): composite actions for other repositories, not this repo's CI. `setup-base` is internal. The script runs the mdcode-ts version in `packages/mdcode/package.json` at the action's ref; this repo's CI runs it with `mdcode-command` set to the workspace build. - **Config** (`src/config.ts`): `mdcode.config.json` is loaded only with `--project` or `--config`. Without either, input defaults to stdin. - **Contract** (`src/result.ts`): `COMMAND_NAMES` and `ERROR_CODES` are the source of truth for commands and error codes. diff --git a/TESTING.md b/TESTING.md index c2441f2..53bad6a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -53,6 +53,9 @@ Two packages, `packages/mdcode` (published as `mdcode-ts`) and `packages/usage`. - `watch.test.ts` spawns `mdcode watch` with the real file watcher and stops it with `SIGINT` - `check-docs-sync.test.ts` and `validate-snippets.test.ts` run the `examples/ci/` scripts against the built CLI, through an `mdcode` shim on `PATH` + - `check-sync-action.test.ts` runs the check-sync GitHub Action's script + (`.github/actions/check-sync/check-sync.mjs`) the way `action.yml` does, with inputs as + environment variables and `mdcode-command` pointing at the built CLI - `skill-sync-markdown-code-blocks.test.ts` grades the task in `tests/skills/sync-markdown-code-blocks/` for the shipped skill: it accepts the skill's solution and rejects the mistakes the skill warns about. Set `SKILL_TASK_DIR` to grade an agent's attempt in another directory instead diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index 4722304..a0fc37a 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -28,7 +28,7 @@ skills: - update plan, --diff, --apply, --check, --stdout, --base - list and validate for inspection - block selection with --name, --lang, --file, --meta - - extract with region splicing, --force, --ignore-anonymous, --dir + - extract with region splicing, --force, --ignore-anonymous, --dir, --check - run --allow-shell and dump - --transform modules and the update(), parse(), extract() library functions - --json envelope and exit codes diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index e19b48b..828106a 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -68,3 +68,9 @@ ships the `mdcode` CLI and a library API from the workspace package `packages/md - **Change:** `--meta` is now repeatable, one `key=value` per flag, instead of variadic. Before, it took every following argument, so `mdcode list --meta runnable=true README.md` read stdin and found nothing. That included the skill's own `--meta` example. A value may now contain `=`. The package README and `examples/CLI_EXAMPLES.md` now pass one file to `list`, `run` and `dump` and an exact `--file` value. The flags reference no longer calls `--file` a pattern. - **Guidance:** no change. The skill already said `list`, `run` and `dump` read one file and that `--file` is exact, and its `--meta` example is now correct as written. README.md remains a source; the parts that disagreed with the CLI are fixed. - **Checks:** `cli-integration.test.ts` adds "--meta takes one key=value, so a file after it is still the file to read". It failed before the change and passes after. The skill task check still passes. + +### Batch 3 — 2026-10-06, mdcode-ts 0.0.4 (issue #28, check-sync) + +- **Change:** new `extract --check` compares each target with what `extract` would write and writes nothing. `extract --force` keeps an overwritten file's final newline and refuses symlinked targets. A spliced region whose markers are indented is no longer indented a second time. New consumer GitHub Action `.github/actions/check-sync`. +- **Guidance:** the skill's Extract section adds `extract --check --force --ignore-anonymous` as the read-only preview, and notes that without `--force` existing whole files are reported as skipped (exit 2). The safety table lists `extract --check` among the commands that write nothing. The GitHub Action is not part of the skill; the package README documents it. +- **Checks:** `extract.test.ts` "extract: check" covers in sync, per-region drift, missing files and regions, trailing newlines, LF and CRLF round trips and symlinks. `region.test.ts` adds a read-then-splice round trip for indented markers. The skill task check still passes. diff --git a/examples/CLI_EXAMPLES.md b/examples/CLI_EXAMPLES.md index bca253b..c39c10b 100644 --- a/examples/CLI_EXAMPLES.md +++ b/examples/CLI_EXAMPLES.md @@ -267,6 +267,9 @@ mdcode extract README.md # Overwrite whole-file targets mdcode extract --force README.md + +# Would extracting change anything? Writes nothing; exit 1 lists each block that differs +mdcode extract --check --force --ignore-anonymous README.md ``` ### Basic Usage diff --git a/packages/mdcode/README.md b/packages/mdcode/README.md index 02255df..1f7f506 100644 --- a/packages/mdcode/README.md +++ b/packages/mdcode/README.md @@ -366,7 +366,8 @@ When the target file already exists: ``). Existing markers are matched in any of that language's comment styles, so `/* #region name */` in a JS file is spliced rather than duplicated. - **The block has no `region=`** → the file is skipped with a warning, since writing it would replace - the whole file. Use `--force` to overwrite. + the whole file. Use `--force` to overwrite. An overwritten file keeps its final newline, LF or + CRLF; a symlinked target is refused rather than replaced with a regular file. Otherwise, files that don't exist yet are created. @@ -511,6 +512,36 @@ mdcode extract --force README.md `--force` has no effect on region blocks — those always splice in place. +### Check Without Writing + +`--check` works out every target exactly as `extract` would, then compares it with the file on disk +instead of writing it. It exits 0 when every file already holds what `extract` would write, and 1 +with an `out_of_sync` error for each block whose part of a file would change. Nothing is written, +including with `--force`. + +```bash +# Would extracting change any file? Pass --force so existing whole files are compared, not skipped +mdcode extract --check --force --ignore-anonymous README.md +``` + +```text +✗ Out of sync: line 9: region two in src/b.ts differs from this block; extract would replace it +✗ Out of sync: line 13: src/new.ts does not exist; extract would create it +2 block(s) out of sync with their files. Run mdcode extract to write them, or mdcode update to bring the blocks up to date instead. +``` + +- A region target reports each region that differs, or that the file lacks and `extract` would + append. Regions the block matches are not reported. +- The comparison is exact. A file that differs from its block only in trailing newlines is out of + sync for `extract`, although `update --check` accepts it, because `extract` would rewrite them; the + message says `only trailing newlines differ`. +- Without `--force`, an existing whole-file target is reported as skipped, as `extract` would skip it, + and the command exits 2. +- `--check` cannot be combined with `--update-source`. + +`update --check` asks the other question: does each block show its file? CI that wants both +directions runs both, or uses the [check-sync GitHub Action](#github-action-check-sync). + ### Stdin Behavior with Update Source When using stdin with `--update-source`, the updated markdown is written to stdout: @@ -1474,6 +1505,76 @@ own step before publishing. mdcode-ts gates its own releases the same way; see its [RELEASING.md](https://github.com/adrianbrowning/mdcode-ts/blob/main/RELEASING.md). +### GitHub Action: check-sync + +The `check-sync` action fails a job when Markdown code blocks and the files they link to disagree, +in either direction, and writes nothing: + +- **files → Markdown** runs `mdcode update --check`: does each block show its file? +- **Markdown → files** runs `mdcode extract --check --force`: would extracting the blocks change a + file? See [Check Without Writing](#check-without-writing). + +The same drift seen from both sides is reported once, as `both`. Each problem becomes an error +annotation on the document and line, naming the direction, the block's `name=`, the file and the +region, and the job summary lists them all in a table. + +```yaml +name: Docs + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check-sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: adrianbrowning/mdcode-ts/.github/actions/check-sync@ # mdcode-ts@ + with: + documents: | + README.md + docs/*.md +``` + +Pin the action to a full commit SHA, and note the release it belongs to in a comment. The repository +publishes no moving `v1`-style tags: each release is tagged `mdcode-ts@`, and the commit that +tag points at is the one to pin. To find it: + +```bash +git ls-remote https://github.com/adrianbrowning/mdcode-ts 'refs/tags/mdcode-ts@*' +``` + +The action runs the mdcode-ts release that its commit belongs to, from npm (`npx --yes +mdcode-ts@`), so pinning the action pins the CLI too. A commit between releases runs the +last release, which may lack a flag the action needs; pin a release commit. + +| Input | Default | Meaning | +|-------|---------|---------| +| `documents` | | Markdown files to check, one path or glob per line, so paths may contain spaces | +| `directions` | `update extract` | Which directions to check | +| `base` | | For update, the directory `file=` resolves against (`--base`); default each document's own directory | +| `dir` | | For extract, the same (`--dir`); default `base`, else each document's own directory, so both directions read the same files | +| `ignore-anonymous` | `true` | For extract, skip blocks without `file=`, which link to no file | +| `project` | `false` | Use `mdcode.config.json` (`--project`): its documents when `documents` is empty, its `sourceRoot` and `outputRoot`, its filters | +| `config` | | Use this configuration file instead (`--config`) | +| `working-directory` | `.` | Where to run | +| `node-version` | `22` | Node.js to set up with `actions/setup-node`; empty uses the runner's | +| `mdcode-command` | | Run mdcode with this command instead, such as `npx mdcode` for the version in your lockfile | + +Its `problems` output is the number of problems found. The step exits 1 when there is any, and 2 +when the inputs are wrong, such as a document that does not exist or a pattern that matches nothing. + +The action needs only `contents: read`, and it is safe on pull requests from forks: it runs no code +from the Markdown, writes no file, and reads only files inside each document's directory or `base`, +under the [containment rules](#containment). Run it on `pull_request`, never `pull_request_target`, +so a fork's change runs without your repository's secrets. + + --- ## CLI Flags Reference @@ -1498,6 +1599,8 @@ Additional flags by command: - `--update-source` - Add file metadata to anonymous code blocks and update source - `--ignore-anonymous` - Skip blocks without file metadata (mutually exclusive with --update-source) - `--force` - Overwrite existing files whose blocks have no `region=` (skipped by default) +- `--check` - Write nothing; exit 1 when a target differs from what `extract` would write. See + [Check Without Writing](#check-without-writing) - `--project` - Load `mdcode.config.json` from the current directory; see [Project Configuration](#project-configuration) - `--config ` - Load this configuration file instead @@ -1654,7 +1757,7 @@ Each error has a `code` and a `message`. These fields are added when they apply: | `malformed_region` | A block's `region=` is not a valid name, or its markers are never closed, overlap, or do not nest | | `region_language_mismatch` | A block's `region=` is marked only in another language's comment syntax | | `missing_file_metadata` | `validate --strict`: a selected block has no `file=` | -| `out_of_sync` | `update --check` found a selected block that differs from its source | +| `out_of_sync` | `update --check` found a selected block that differs from its source, or `extract --check` found a target that differs from what it would write | | `command_failed` | `run`'s command exited non-zero for a block | | `unexpected_error` | Anything else | @@ -1747,7 +1850,8 @@ type ExtractEnvelope = Envelope<{ document: string | null; targets: Array<{ path: string; - action: "written" | "spliced" | "skipped"; + /** "unchanged" appears only with --check, which writes nothing: the other actions say what extract would do */ + action: "written" | "spliced" | "skipped" | "unchanged"; /** The blocks that target this file, in document order */ blocks: Array; /** The region= names written */ @@ -1847,7 +1951,10 @@ type DumpEnvelope = Envelope<{ untouched; `reason` says why, and each skipped target also adds an `extract_skipped` error. With `--update-source`, when a block gained `file=`: from stdin, `updatedSource` holds the updated markdown; from a file, the file is rewritten and `written` holds its path. A document that fails - stops the command; the documents before it have their entries. + stops the command; the documents before it have their entries. With `--check` nothing is written: + `unchanged` means the file already holds what `extract` would write, the other actions say what + `extract` would do, and each block whose part of a target would change adds an `out_of_sync` error + with `path` set to the target. A document that fails under `--check` does not stop the others. - `update` - One entry in `documents` per document, each with one entry per selected block, in document order. `changed` and `code` are the plan: which blocks would change, and the exact code each would get. Only `--apply` writes the markdown; `written` holds its path, or `null` when nothing @@ -2038,8 +2145,8 @@ Exit codes are the same with and without `--json`: | Code | Meaning | |------|---------| -| `0` | Success; for `update --check`, every selected block is in sync | -| `1` | Any error, including `update --check` finding a block out of sync, `validate` finding a problem, and `extract` refusing a target before writing | +| `0` | Success; for `update --check` and `extract --check`, everything is in sync | +| `1` | Any error, including `update --check` or `extract --check` finding drift, `validate` finding a problem, and `extract` refusing a target before writing | | `2` | `extract` skipped one or more targets | ### Changes from Earlier Versions @@ -2104,6 +2211,14 @@ Exit codes are the same with and without `--json`: argument, so `mdcode list --meta type=example README.md` read `README.md` as a second pair, read stdin instead, and found nothing. A value containing `=`, as in `--meta expr=a=b`, is now kept whole. +- `extract` splicing a region whose marker is indented no longer indents the body a second time. + `update` copies a region with its indentation, so extracting that block used to push every line + right by the marker's indent. A body whose first line already starts with the marker's indent is + now written as it stands; a dedented body is still indented to the marker. +- `extract --force` keeps an overwritten file's final newline (LF or CRLF). It used to drop it. A new + file is still written as the block's code stands. +- `extract --force` refuses a target that is a symlink, as region splices already did, instead of + replacing the link with a regular file. The target is skipped and `extract` exits 2. --- @@ -2370,14 +2485,17 @@ Write code blocks to files based on their `file` metadata. - **options.updateSource** - Add `file=` to anonymous blocks - **options.ignoreAnonymous** - Skip blocks without `file=` - **options.force** - Overwrite existing files whose blocks have no `region=` +- **options.check** - Write nothing; compare each target with what `extract` would write instead. See + [Check Without Writing](#check-without-writing) - **Returns** - Promise of `{ targets, updatedSource?, errors }`: one `ExtractTarget` per target file, - the markdown with `file=` added when `updateSource` added any, and one `extract_skipped` error per - skipped target. `extract` writes the target files but not the markdown. + the markdown with `file=` added when `updateSource` added any, one `extract_skipped` error per + skipped target and, with `check`, one `out_of_sync` error per block whose target would change. + `extract` writes the target files but not the markdown. - **Throws** - `MetadataError` when the document's metadata is invalid. When any block breaks a mapping rule checked by `validate()` for extract, it throws an `Error` whose `errors` array holds one `ResultError` per block (`unsafe_path`, `ambiguous_target`, `malformed_region`, `duplicate_region` or `region_language_mismatch`), and nothing is written. `updateSource` with - `ignoreAnonymous` throws an `Error` whose `code` is `invalid_usage`. + `ignoreAnonymous` or with `check` throws an `Error` whose `code` is `invalid_usage`. #### `validate(options: ValidateOptions): Promise` diff --git a/packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md b/packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md index 2d86156..cdf09f5 100644 --- a/packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md +++ b/packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md @@ -178,6 +178,7 @@ When writing scripts or CI that read mdcode's results, read mdcode extract --ignore-anonymous README.md # only blocks that have file= mdcode extract --dir out --ignore-anonymous README.md mdcode validate --for extract --dir out README.md # preview refusals, writes nothing +mdcode extract --check --force --ignore-anonymous README.md # would extract change a file? writes nothing ``` - A block with `region=` is spliced into an existing file. Code outside the region, and regions the @@ -190,6 +191,8 @@ mdcode validate --for extract --dir out README.md # preview refusals, writes n blocks in different documents of one run. Otherwise `extract` refuses with `ambiguous_target` and writes nothing. - Every target is checked before anything is written. A refusal exits 1 with no partial writes. +- `extract --check` exits 1 with an `out_of_sync` error per block whose file would change. Pass + `--force` with it, or existing whole files are reported as skipped (exit 2) instead of compared. ### Run or archive snippets @@ -215,7 +218,7 @@ on the command line is trusted. mdcode applies that split as follows: | Writes nothing and runs no commands | Needs explicit approval first | | --- | --- | -| `list`, `validate`, `update` (plan), `update --diff`, `update --check`, `update --stdout` | `update --apply`: rewrites the Markdown | +| `list`, `validate`, `update` (plan), `update --diff`, `update --check`, `update --stdout`, `extract --check` | `update --apply`: rewrites the Markdown | | | `extract`: writes files inside `--dir` (or the current directory) | | | `dump -o `: writes the archive | | | `run --allow-shell`: runs a shell command per block, often executing block code | diff --git a/packages/mdcode/src/cli.ts b/packages/mdcode/src/cli.ts index efc2fcd..0105bf5 100644 --- a/packages/mdcode/src/cli.ts +++ b/packages/mdcode/src/cli.ts @@ -198,6 +198,7 @@ type ExtractCliOptions = FilterCliOptions & ProjectCliOptions & { updateSource?: boolean; ignoreAnonymous?: boolean; force?: boolean; + check?: boolean; }; /** What extract did for one document, as it appears in the result's documents. */ @@ -407,6 +408,7 @@ export async function Execute( .option("--update-source", "Add file metadata to anonymous code blocks") .option("--ignore-anonymous", "Skip blocks without file metadata") .option("--force", "Overwrite existing files whose blocks have no region=") + .option("--check", "Exit 1 when a file differs from what extract would write, without writing") .option("--project", PROJECT_HELP) .option("--config ", CONFIG_HELP) .option("--json", "Print one versioned JSON result instead of text") @@ -416,6 +418,10 @@ export async function Execute( throw new CommandError("invalid_usage", "Cannot use --update-source and --ignore-anonymous together"); } + if (options.check && options.updateSource) { + throw new CommandError("invalid_usage", "Cannot use --check and --update-source together: --check writes nothing"); + } + const config = await loadProject(options); const documents = selectDocuments(files, config); const filter = mergeFilters(config?.filter, parseFilterOptions(options)); @@ -434,7 +440,7 @@ export async function Execute( errors: planned.errors, human: () => writeLines(stderr, [ ...planned.errors.map(error => `Error: ${error.document}: ${describeError(error)}`), - styleText("yellow", `Nothing was written for any of the ${documents.length} documents.`), + styleText("yellow", options.check ? `Nothing was checked for any of the ${documents.length} documents.` : `Nothing was written for any of the ${documents.length} documents.`), ]), }; } @@ -457,12 +463,17 @@ export async function Execute( updateSource: options.updateSource, ignoreAnonymous: options.ignoreAnonymous, force: options.force, + check: options.check, }); } catch (error: unknown) { - // Extract writes as it goes, so later documents are not started. errors.push(...inDocument(document, errorsFrom(error))); reports.push(() => writeLines(stderr, errorLines(error).map(line => `Error: ${at(line)}`))); + + // Extract writes as it goes, so later documents are not started. --check writes nothing. + if (options.check) { + continue; + } break; } @@ -481,8 +492,11 @@ export async function Execute( }); errors.push(...inDocument(document, skipped)); reports.push(() => { + const drift = skipped.filter(error => error.code === "out_of_sync"); + const refused = skipped.filter(error => error.code === "extract_skipped"); + if (!options.quiet) { - writeLines(stderr, formatExtract(result, options).map(at)); + writeLines(stderr, formatExtract(result, options).filter((_, index) => !options.check || result.targets[index]!.action === "unchanged" || result.targets[index]!.action === "skipped").map(at)); } if (updatedSource !== undefined) { @@ -494,9 +508,18 @@ export async function Execute( } } + // Reported even under --quiet, so a failing check always says why. + if (options.check) { + writeLines(stderr, drift.map(error => styleText("red", at(`✗ Out of sync: ${describeError(error)}`)))); + + if (drift.length > 0) { + stderr.write(styleText("yellow", at(`${drift.length} block(s) out of sync with their files. Run mdcode extract to write them, or mdcode update to bring the blocks up to date instead.`)) + "\n"); + } + } + // Reported even under --quiet, so a refusal is never silent. - if (skipped.length > 0) { - stderr.write(styleText("yellow", at(`⚠ Skipped ${skipped.length} file(s); nothing was written for them`)) + "\n"); + if (refused.length > 0) { + stderr.write(styleText("yellow", at(`⚠ Skipped ${refused.length} file(s); nothing was written for them`)) + "\n"); } }); } diff --git a/packages/mdcode/src/commands/extract.test.ts b/packages/mdcode/src/commands/extract.test.ts index 4decd0f..ce4511d 100644 --- a/packages/mdcode/src/commands/extract.test.ts +++ b/packages/mdcode/src/commands/extract.test.ts @@ -239,7 +239,7 @@ describe("extract: --force for non-region overwrites", () => { await extract({ source, outputDir: dir, force: true }); - assert.equal(await readFile(target, "utf-8"), "const replaced = true;", "force must overwrite"); + assert.equal(await readFile(target, "utf-8"), "const replaced = true;\n", "force must overwrite, keeping the file's final newline"); }); test("still creates a missing file without force", async () => { @@ -645,3 +645,108 @@ describe("extract: reporting", () => { assert.equal(result.updatedSource, "```sh\nls\n```\n\n```sh file=block-2.sh\npwd\n```\n"); }); }); + +describe("extract: check", () => { + const fence = (info: string, code: string): string => `\`\`\`${info}\n${code}\n\`\`\`\n\n`; + + /** Every file under dir with its content, to prove a check wrote nothing. */ + async function snapshot(dir: string): Promise> { + const entries = await readdir(dir, { recursive: true, withFileTypes: true }); + const files = entries.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name)).sort(); + return Object.fromEntries(await Promise.all(files.map(async file => [ file, await readFile(file, "utf-8") ]))); + } + + const drift = (result: ExtractResult): Array<{ line?: number; path?: string; message: string; }> => + result.errors.filter(({ code }) => code === "out_of_sync").map(({ line, path, message }) => ({ line, path, message })); + + test("passes, writing nothing, when every target already holds what extract would write", async () => { + const dir = await tempDir(); + await writeSource(dir, "whole.ts", "export const whole = 1;\n"); + await writeSource(dir, "crlf.ts", "export const crlf = 1;\r\n"); + await writeSource(dir, "regions.ts", "keep();\n// #region one\none();\n// #endregion\n// #region two\ntwo();\n// #endregion\n"); + const source = fence("ts file=whole.ts", "export const whole = 1;") + + fence("ts file=crlf.ts", "export const crlf = 1;") + + fence("ts file=regions.ts region=one", "one();") + + fence("ts file=regions.ts region=two", "two();"); + const before = await snapshot(dir); + + const result = await extract({ source, outputDir: dir, force: true, check: true }); + + assert.deepEqual(result.errors, []); + assert.deepEqual(result.targets.map(({ action }) => action), [ "unchanged", "unchanged", "unchanged" ]); + assert.deepEqual(await snapshot(dir), before); + }); + + test("reports each block whose part of a target would change, and writes nothing", async () => { + const dir = await tempDir(); + await writeSource(dir, "whole.ts", "export const whole = 2;\n"); + await writeSource(dir, "regions.ts", "// #region one\none();\n// #endregion\n// #region two\nTWO();\n// #endregion\n"); + const source = fence("ts file=whole.ts", "export const whole = 1;") + + fence("ts file=regions.ts region=one", "one();") + + fence("ts file=regions.ts region=two", "two();") + + fence("ts file=regions.ts region=three", "three();") + + fence("ts file=new.ts", "export const fresh = 1;"); + const before = await snapshot(dir); + + const result = await extract({ source, outputDir: dir, force: true, check: true }); + + assert.deepEqual(drift(result), [ + { line: 1, path: join(dir, "whole.ts"), message: `${join(dir, "whole.ts")} differs from this block; extract would overwrite it` }, + { line: 9, path: join(dir, "regions.ts"), message: `region two in ${join(dir, "regions.ts")} differs from this block; extract would replace it` }, + { line: 13, path: join(dir, "regions.ts"), message: `region three is not in ${join(dir, "regions.ts")}; extract would append it` }, + { line: 17, path: join(dir, "new.ts"), message: `${join(dir, "new.ts")} does not exist; extract would create it` }, + ], "region one is unchanged, so it is not reported"); + assert.deepEqual(result.targets.map(({ action }) => action), [ "written", "spliced", "written" ]); + assert.deepEqual(await snapshot(dir), before); + }); + + test("names a difference in trailing newlines, which extract would remove", async () => { + const dir = await tempDir(); + await writeSource(dir, "a.ts", "export const a = 1;\n\n\n"); + + const result = await extract({ source: fence("ts file=a.ts", "export const a = 1;"), outputDir: dir, force: true, check: true }); + + assert.match(drift(result)[0]!.message, /differs from this block \(only trailing newlines differ\)/); + }); + + test("without force, reports an existing whole-file target as skipped, as extract would", async () => { + const dir = await tempDir(); + await writeSource(dir, "a.ts", "export const a = 1;\n"); + + const result = await extract({ source: fence("ts file=a.ts", "export const a = 1;"), outputDir: dir, check: true }); + + assert.deepEqual(result.targets.map(({ action }) => action), [ "skipped" ]); + assert.deepEqual(result.errors.map(({ code }) => code), [ "extract_skipped" ]); + }); + + test("agrees with what extract then writes, keeping an LF or CRLF final newline", async () => { + const dir = await tempDir(); + await writeSource(dir, "lf.ts", "old();\n"); + await writeSource(dir, "crlf.ts", "old();\r\n"); + const source = fence("ts file=lf.ts", "fresh();") + fence("ts file=crlf.ts", "fresh();"); + + assert.equal(drift(await extract({ source, outputDir: dir, force: true, check: true })).length, 2); + + await extract({ source, outputDir: dir, force: true }); + + assert.equal(await readFile(join(dir, "lf.ts"), "utf-8"), "fresh();\n"); + assert.equal(await readFile(join(dir, "crlf.ts"), "utf-8"), "fresh();\r\n"); + assert.deepEqual((await extract({ source, outputDir: dir, force: true, check: true })).errors, [], "after extract, the check passes"); + }); + + test("refuses a forced whole-file overwrite through a symlink, and check reports it the same way", async () => { + const dir = await tempDir(); + await writeSource(dir, "real.ts", "export const a = 1;\n"); + await symlink("real.ts", join(dir, "link.ts")); + const source = fence("ts file=link.ts", "export const a = 2;"); + + const checked = await extract({ source, outputDir: dir, force: true, check: true }); + const written = await extract({ source, outputDir: dir, force: true }); + + for (const result of [ checked, written ]) { + assert.deepEqual(result.targets.map(({ action, reason }) => ({ action, reason })), [{ action: "skipped", reason: "target is a symlink; refusing to overwrite it" }]); + } + assert.equal(await readlink(join(dir, "link.ts")), "real.ts", "the link must stay a link"); + assert.equal(await readFile(join(dir, "real.ts"), "utf-8"), "export const a = 1;\n"); + }); +}); diff --git a/packages/mdcode/src/commands/extract.ts b/packages/mdcode/src/commands/extract.ts index 1170a1d..70af6c1 100644 --- a/packages/mdcode/src/commands/extract.ts +++ b/packages/mdcode/src/commands/extract.ts @@ -5,12 +5,12 @@ import { styleText } from "node:util"; import { parse, updateInfoStrings } from "../parser.ts"; import { isMissing } from "../paths.ts"; import type { RegionEdit } from "../region.ts"; -import { spliceRegions, wrapRegion } from "../region.ts"; +import { read as readRegion, spliceRegions, wrapRegion } from "../region.ts"; import type { BlockRef, ResultError } from "../result.ts"; -import { BlockFailure, blockRef, CommandError } from "../result.ts"; -import type { FilterOptions } from "../types.ts"; +import { BlockFailure, blockError, blockRef, CommandError } from "../result.ts"; +import type { Block, FilterOptions } from "../types.ts"; import { writeAtomic } from "../write.ts"; -import type { ExtractItem } from "./validate.ts"; +import type { ExtractGroup, ExtractItem } from "./validate.ts"; import { planExtract } from "./validate.ts"; export type ExtractOptions = { @@ -21,17 +21,24 @@ export type ExtractOptions = { updateSource?: boolean; ignoreAnonymous?: boolean; force?: boolean; + /** + * Write nothing; compare each target with what extract would write instead. + * Every block whose target would change gets an out_of_sync error. + */ + check?: boolean; }; -/** What extract did with one target file. */ +/** What extract did, or with `check` would do, with one target file. */ export type ExtractTarget = { /** The file= path joined onto outputDir. */ path: string; /** * `written`: created or overwritten whole. `spliced`: regions replaced or appended * in an existing file. `skipped`: left untouched; `reason` says why. + * `unchanged`: with `check` only, the file already holds what extract would write. + * With `check`, nothing is written: the other actions say what extract would do. */ - action: "written" | "spliced" | "skipped"; + action: "written" | "spliced" | "skipped" | "unchanged"; /** The blocks that target this file, in document order. */ blocks: Array; /** The region= names written, when the blocks are region blocks. */ @@ -44,7 +51,10 @@ export type ExtractResult = { targets: Array; /** The markdown with file= added to anonymous blocks; only with updateSource, and only when something was added. */ updatedSource?: string; - /** One extract_skipped error per skipped target. */ + /** + * One extract_skipped error per skipped target. With `check`, one + * out_of_sync error per block whose target would change. + */ errors: Array; }; @@ -62,8 +72,13 @@ export async function extract(options: ExtractOptions): Promise { updateSource = false, ignoreAnonymous = false, force = false, + check = false, } = options; + if (check && updateSource) { + throw new CommandError("invalid_usage", "Cannot use --check and --update-source together: --check writes nothing"); + } + // Validate mutual exclusivity if (updateSource && ignoreAnonymous) { throw new CommandError("invalid_usage", "Cannot use --update-source and --ignore-anonymous together"); @@ -108,29 +123,27 @@ export async function extract(options: ExtractOptions): Promise { }; // Validation leaves two shapes: one whole-file block, or region blocks only. - for (const { display, items } of groups) { - const regions = items[0]!.block.meta.region !== undefined; - const existing = await stat(display).catch(rethrowUnlessMissing); + for (const group of groups) { + const { display, items } = group; + // With check, the same plan is made and compared instead of written. + const planned = await planTarget(group, force); - if (existing !== undefined && regions) { - const refusal = await spliceInPlace(display, items); - - record(display, refusal === undefined ? "spliced" : "skipped", items, refusal); + if ("refusal" in planned) { + record(display, "skipped", items, planned.refusal); continue; } - if (existing !== undefined && !force) { - record(display, "skipped", items, "exists and has block(s) without region=. Use --force to overwrite."); + if (check) { + const drift = driftErrors(group, planned); + + record(display, drift.length === 0 ? "unchanged" : planned.action, items); + errors.push(...drift); continue; } - const content = regions - ? items.map(({ block }) => wrapRegion(block.lang, block.meta.region!, block.code)).join("\n") - : items[0]!.block.code; - await mkdir(dirname(display), { recursive: true }); - await writeAtomic(display, content, existing?.mode); - record(display, "written", items); + await writeAtomic(display, planned.content, planned.mode); + record(display, planned.action, items); } const result: ExtractResult = { targets, errors }; @@ -149,6 +162,10 @@ export function formatExtract({ targets }: Pick, optio } return targets.map(({ path, action, blocks, regions, reason }) => { + if (action === "unchanged") { + return styleText("green", `✓ In sync: ${path}`); + } + if (action === "skipped") { return styleText("yellow", `⚠ Skipped ${path}: ${reason}`); } @@ -162,16 +179,113 @@ export function formatExtract({ targets }: Pick, optio }); } +const WHOLE_FILE_EXISTS = "exists and has block(s) without region=. Use --force to overwrite."; + +/** What extract would write to one target, or why it would leave the target alone. */ +type PlannedTarget = + | { refusal: string; } + | { + action: "written" | "spliced"; + content: string; + /** The target's text before extract, or undefined when it does not exist yet. */ + before: string | undefined; + /** The existing file's mode, kept when it is replaced. */ + mode: number | undefined; + }; + +/** + * Work out what extract would write to one target, without writing it. An + * existing whole-file target is only replaced with `overwrite` (--force). + */ +async function planTarget({ display, items }: ExtractGroup, overwrite: boolean): Promise { + const regions = items[0]!.block.meta.region !== undefined; + const existing = await stat(display).catch(rethrowUnlessMissing); + + if (existing !== undefined && regions) { + return planSplice(display, items, existing.mode); + } + + if (existing !== undefined && !overwrite) { + return { refusal: WHOLE_FILE_EXISTS }; + } + + // writeAtomic() would replace the link with a regular file; never change what a link means. + if (existing !== undefined && (await lstat(display)).isSymbolicLink()) { + return { refusal: "target is a symlink; refusing to overwrite it" }; + } + + const before = existing === undefined ? undefined : await readFile(display, "utf-8"); + const content = regions + ? items.map(({ block }) => wrapRegion(block.lang, block.meta.region!, block.code)).join("\n") + : keepFinalNewline(items[0]!.block.code, before); + + return { action: "written", content, before, mode: existing?.mode }; +} + +/** + * A whole-file block's code as the new content of a file that held `before`. + * A block's code never ends with the newline that ends its last line, so an + * overwritten file keeps the final newline (LF or CRLF) it had; a new file is + * written as the code stands. + */ +function keepFinalNewline(code: string, before: string | undefined): string { + const eol = before === undefined ? "" : /\r?\n$/.exec(before)?.[0] ?? ""; + return code === "" || code.endsWith("\n") ? code : code + eol; +} + +/** + * One out_of_sync error per block whose part of the target would change. For + * region targets, that is each region whose body differs or is missing. + */ +function driftErrors({ display, items }: ExtractGroup, planned: Exclude): Array { + const { before, content } = planned; + + if (before === content) { + return []; + } + + const drift = (block: Block, message: string): ResultError => blockError(block, { code: "out_of_sync", message, path: display }); + + if (before === undefined) { + return items.map(({ block }) => drift(block, `${display} does not exist; extract would create it`)); + } + + // Text that differs only after its last line is the commonest drift, and the hardest to see. + const newlinesOnly = before.replace(/\n+$/, "") === content.replace(/\n+$/, "") ? " (only trailing newlines differ)" : ""; + + if (items[0]!.block.meta.region === undefined) { + return [ drift(items[0]!.block, `${display} differs from this block${newlinesOnly}; extract would overwrite it`) ]; + } + + const changed = items.flatMap(({ block }) => { + const name = block.meta.region!; + const was = readRegion(before, name, block.lang); + + if (!was.found) { + return [ drift(block, `region ${name} is not in ${display}; extract would append it`) ]; + } + + return was.content === readRegion(content, name, block.lang).content + ? [] + : [ drift(block, `region ${name} in ${display} differs from this block; extract would replace it`) ]; + }); + + // The regions match, so the splice changes the text around them, such as the file's last newline. + return changed.length > 0 + ? changed + : items.map(({ block }) => drift(block, `${display} would change around region ${block.meta.region!}${newlinesOnly}; extract would rewrite it`)); +} + /** * Splice every region block for one existing file in a single pass, appending - * any region the file does not already declare. Returns why the file was left - * untouched, or undefined when it was written. + * any region the file does not already declare. Returns the new text, or why + * extract would leave the file untouched. */ -async function spliceInPlace(target: string, items: Array): Promise { +async function planSplice(target: string, items: Array, mode: number): Promise { // rename() would replace a symlink with a regular file rather than write // through it; refuse outright so the link's meaning is never silently changed. if ((await lstat(target)).isSymbolicLink()) { - return "target is a symlink; refusing to splice through it"; + return { refusal: "target is a symlink; refusing to splice through it" }; } const raw = await readFile(target); @@ -183,7 +297,7 @@ async function spliceInPlace(target: string, items: Array): Promise existing = new TextDecoder("utf-8", { fatal: true }).decode(raw); } catch { - return "not valid UTF-8"; + return { refusal: "not valid UTF-8" }; } const edits = new Map( @@ -192,7 +306,7 @@ async function spliceInPlace(target: string, items: Array): Promise const result = spliceRegions(existing, edits); if (!result.ok) { - return spliceRefusal(result); + return { refusal: spliceRefusal(result) }; } let content = result.content; @@ -205,8 +319,7 @@ async function spliceInPlace(target: string, items: Array): Promise content = `${content.replace(/\n*$/, content.trim() === "" ? "" : "\n")}${separator}${wrapRegion(block.lang, name, block.code)}`; } - await writeAtomic(target, content, (await stat(target)).mode); - return undefined; + return { action: "spliced", content, before: existing, mode }; } /** Explain, in one clause, why a splice was refused: the file changed after validation. */ diff --git a/packages/mdcode/src/region.test.ts b/packages/mdcode/src/region.test.ts index 29e886f..afa4133 100644 --- a/packages/mdcode/src/region.test.ts +++ b/packages/mdcode/src/region.test.ts @@ -475,6 +475,16 @@ describe("region.replace refuses to destroy", () => { assert.equal(result.content, "def f():\n # #region body\n return 2\n # #endregion body\n"); }); + it("splices a body that already carries the marker's indent as it stands, so read and splice round-trip", () => { + const source = "function f(n) {\n // #region zero\n if (n < 1) {\n return 0\n }\n // #endregion\n // #region rest\n return n\n}\n// #endregion\n"; + + for (const name of [ "zero", "rest" ]) { + const result = replace(source, name, read(source, name, "js").content, "js"); + + assert.equal(result.content, source, `${name}: the indent read() kept must not be added a second time`); + } + }); + it("keeps a CRLF file free of mixed line endings", () => { const source = "const keep = 1;\r\n// #region a\r\nold\r\n// #endregion a\r\nconst tail = 2;\r\n"; diff --git a/packages/mdcode/src/region.ts b/packages/mdcode/src/region.ts index b681fc0..ecff4c6 100644 --- a/packages/mdcode/src/region.ts +++ b/packages/mdcode/src/region.ts @@ -473,9 +473,17 @@ export function spliceRegions(source: string, edits: ReadonlyMap bodyLine !== "") ?? ""; + const indent = first.startsWith(marker) ? "" : marker; + + for (const bodyLine of body) { if (bodyLine === "" && span.code === "") continue; out.push((bodyLine === "" ? "" : indent + bodyLine) + eol); } diff --git a/packages/usage/tests/check-sync-action.test.ts b/packages/usage/tests/check-sync-action.test.ts new file mode 100644 index 0000000..99c804a --- /dev/null +++ b/packages/usage/tests/check-sync-action.test.ts @@ -0,0 +1,201 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/** + * The check-sync GitHub Action's script (.github/actions/check-sync), run the + * way action.yml runs it: inputs as environment variables, in the caller's + * checkout, with mdcode-command pointing at the built CLI. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { delimiter, dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; + +import { cleanupTempDir, CLI_PATH, createTempDir } from "./test-utils.ts"; + +const SCRIPT = join(import.meta.dirname, "..", "..", "..", ".github", "actions", "check-sync", "check-sync.mjs"); +const MANIFEST = join(import.meta.dirname, "..", "..", "mdcode", "package.json"); + +const dirs: Array = []; + +after(async () => { + await Promise.all(dirs.map(cleanupTempDir)); +}); + +const fence = (info: string, code: string): string => `\`\`\`${info}\n${code}\n\`\`\`\n\n`; + +/** A checkout holding these files. */ +async function checkout(files: Record): Promise { + const dir = await createTempDir(); + dirs.push(dir); + + for (const [ path, content ] of Object.entries(files)) { + await mkdir(dirname(join(dir, path)), { recursive: true }); + await writeFile(join(dir, path), content, "utf-8"); + } + + return dir; +} + +/** Every file in the checkout with its content, to prove the action wrote nothing. */ +async function snapshot(dir: string): Promise> { + const entries = await readdir(dir, { recursive: true, withFileTypes: true }); + const files = entries.filter(entry => entry.isFile() && !entry.name.startsWith("action-")).map(entry => join(entry.parentPath, entry.name)).sort(); + return Object.fromEntries(await Promise.all(files.map(async file => [ file, await readFile(file, "utf-8") ]))); +} + +type ActionRun = { code: number | null; stdout: string; summary: string; outputs: string; }; + +/** Run the action's script in dir with these inputs. */ +async function action(dir: string, inputs: Record, extraEnv: Record = {}): Promise { + const summary = join(dir, "action-summary.md"); + const outputs = join(dir, "action-outputs.txt"); + const run = spawnSync(process.execPath, [ SCRIPT ], { + cwd: dir, + encoding: "utf-8", + env: { + PATH: [ dirname(process.execPath), "/usr/bin", "/bin" ].join(delimiter), + DIRECTIONS: "update extract", + IGNORE_ANONYMOUS: "true", + MDCODE_COMMAND: `"${process.execPath}" "${CLI_PATH}"`, + GITHUB_STEP_SUMMARY: summary, + GITHUB_OUTPUT: outputs, + ...inputs, + ...extraEnv, + }, + }); + const read = async (file: string): Promise => readFile(file, "utf-8").catch(() => ""); + + return { code: run.status, stdout: run.stdout + run.stderr, summary: await read(summary), outputs: await read(outputs) }; +} + +/** The annotations the run printed, as [file, line, message]. */ +function annotations(stdout: string): Array<[ string, string, string ]> { + return [ ...stdout.matchAll(/^::error file=([^,]*),line=(\d+),[^:]*::(.*)$/gm) ].map(([ , file, line, message ]) => [ file!, line!, message! ]); +} + +/** Two documents, one in a directory whose name has a space, linking whole files, regions and an outline. */ +const SYNCED = { + "src/greet.ts": "// #region greet\nexport function greet(name: string): string {\n return `Hello, ${name}!`;\n}\n// #endregion\n\nconsole.log(greet(\"docs\"));\n", + "src/math.ts": "export const add = (a: number, b: number): number => a + b;\n", + "README.md": fence("ts file=src/greet.ts region=greet name=greet", "export function greet(name: string): string {\n return `Hello, ${name}!`;\n}") + + fence("ts file=src/math.ts", "export const add = (a: number, b: number): number => a + b;") + + fence("ts file=src/greet.ts outline=true", "// #region greet\n// #endregion\n\nconsole.log(greet(\"docs\"));") + + fence("sh", "npm install"), + "my docs/hello world.ts": "export const hello = \"world\";\n", + "my docs/guide.md": fence("ts file=\"hello world.ts\" name=hello", "export const hello = \"world\";"), +}; + +describe("check-sync action", () => { + it("passes on a synchronised checkout, in both directions, and leaves every file as it was", async () => { + const dir = await checkout(SYNCED); + const before = await snapshot(dir); + + const run = await action(dir, { DOCUMENTS: "README.md\nmy docs/*.md" }); + + assert.equal(run.code, 0, run.stdout); + assert.match(run.stdout, /✓ 2 document\(s\) in sync: files → Markdown \(update\) and Markdown → files \(extract\)\./); + assert.match(run.summary, /^## mdcode: in sync/); + assert.match(run.outputs, /^problems=0$/m); + assert.deepEqual(await snapshot(dir), before); + }); + + it("reports a changed source in both directions, naming the document, line, block and file", async () => { + const dir = await checkout({ ...SYNCED, "my docs/hello world.ts": "export const hello = \"there\";\n" }); + const before = await snapshot(dir); + + const run = await action(dir, { DOCUMENTS: "README.md\nmy docs/guide.md" }); + + assert.equal(run.code, 1); + assert.deepEqual(annotations(run.stdout), [[ + "my docs/guide.md", + "1", + "both directions: out of sync with hello world.ts; my docs/hello world.ts differs from this block; extract would overwrite it (hello)", + ]]); + assert.match(run.summary, /\| both \| my docs\/guide\.md \| 1 \| hello \| hello world\.ts \| out_of_sync: /); + assert.match(run.outputs, /^problems=1$/m); + assert.deepEqual(await snapshot(dir), before, "a failing check writes nothing either"); + }); + + it("reports drift that only one direction can see", async () => { + const dir = await checkout({ + ...SYNCED, + // update shows a file's last newlines as one; extract would remove the extra ones. + "src/math.ts": "export const add = (a: number, b: number): number => a + b;\n\n\n", + // extract writes nothing for an outline, so only update sees the stale one. + "src/greet.ts": SYNCED["src/greet.ts"].replace("console.log(greet(\"docs\"));", "console.log(greet(\"you\"));"), + }); + + const run = await action(dir, { DOCUMENTS: "README.md" }); + + assert.equal(run.code, 1); + assert.deepEqual(annotations(run.stdout).map(([ , line, message ]) => [ line, message.split(":")[0] ]), [ + [ "11", "files → Markdown (update)" ], + [ "7", "Markdown → files (extract)" ], + ]); + assert.match(annotations(run.stdout)[1]![2], /only trailing newlines differ/); + }); + + it("reports a region the source lost as unreadable for update and as missing for extract", async () => { + const dir = await checkout({ ...SYNCED, "src/greet.ts": "export function greet() {}\n" }); + + const run = await action(dir, { DOCUMENTS: "README.md" }); + + assert.equal(run.code, 1); + assert.deepEqual(annotations(run.stdout).map(([ , line, message ]) => [ line, message.replace(/:.*/, "") ]), [ + [ "1", "files → Markdown (update)" ], + [ "11", "files → Markdown (update)" ], + [ "1", "Markdown → files (extract)" ], + ]); + assert.match(run.stdout, /title=mdcode missing_region::files → Markdown \(update\): region greet not found in src\/greet\.ts/); + assert.match(run.stdout, /region greet is not in src\/greet\.ts; extract would append it/); + }); + + it("checks only the directions asked for", async () => { + const dir = await checkout({ ...SYNCED, "src/math.ts": `${SYNCED["src/math.ts"]}\n\n` }); + + assert.equal((await action(dir, { DOCUMENTS: "README.md", DIRECTIONS: "update" })).code, 0, "update cannot see extra trailing newlines"); + assert.equal((await action(dir, { DOCUMENTS: "README.md", DIRECTIONS: "extract" })).code, 1); + }); + + it("resolves both directions against base when it is given", async () => { + const dir = await checkout({ + "src/a.ts": "export const a = 1;\n", + "docs/a.md": fence("ts file=src/a.ts", "export const a = 1;"), + }); + + assert.equal((await action(dir, { DOCUMENTS: "docs/a.md" })).code, 1, "docs/src/a.ts does not exist"); + assert.equal((await action(dir, { DOCUMENTS: "docs/a.md", BASE: "." })).code, 0); + }); + + it("fails as misconfigured when a document is missing, a pattern matches nothing or directions are unknown", async () => { + const dir = await checkout(SYNCED); + + for (const [ inputs, message ] of [ + [{ DOCUMENTS: "NOPE.md" }, "document NOPE.md does not exist" ], + [{ DOCUMENTS: "docs/*.md" }, "documents pattern docs/*.md matched no files" ], + [{ DOCUMENTS: "README.md", DIRECTIONS: "sideways" }, "directions must list update, extract or both" ], + [{ DOCUMENTS: "" }, "documents is empty" ], + ] as const) { + const run = await action(dir, inputs); + + assert.equal(run.code, 2, message); + assert.match(run.stdout, new RegExp(`^::error title=mdcode check-sync::${message.replaceAll("*", "\\*")}`, "m")); + } + }); + + it("runs the mdcode-ts release this action belongs to when mdcode-command is empty", async () => { + const dir = await checkout(SYNCED); + const bin = join(dir, "action-bin"); + const calls = join(dir, "action-npx-calls.txt"); + await mkdir(bin); + // A stand-in npx: record how it was called, then run the built CLI with the arguments after the package. + await writeFile(join(bin, "npx"), `#!/bin/sh\necho "$1 $2" >> "${calls}"\nshift 2\nexec "${process.execPath}" "${CLI_PATH}" "$@"\n`, "utf-8"); + await chmod(join(bin, "npx"), 0o755); + const { version } = JSON.parse(await readFile(MANIFEST, "utf-8")) as { version: string; }; + + const run = await action(dir, { DOCUMENTS: "README.md", MDCODE_COMMAND: "" }, { PATH: [ bin, dirname(process.execPath), "/usr/bin", "/bin" ].join(delimiter) }); + + assert.equal(run.code, 0, run.stdout); + assert.deepEqual((await readFile(calls, "utf-8")).trim().split("\n"), [ `--yes mdcode-ts@${version}`, `--yes mdcode-ts@${version}` ]); + }); +}); From b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e Mon Sep 17 00:00:00 2001 From: Adrian Elton-Browning Date: Tue, 6 Oct 2026 17:24:15 +0100 Subject: [PATCH 2/5] feat: extract --check and the check-sync GitHub Action (#28) --- .intent/review-state.json | 48 +++++++++---------- packages/mdcode/src/cli.ts | 3 +- packages/mdcode/src/commands/extract.test.ts | 3 +- packages/mdcode/src/commands/extract.ts | 2 +- .../usage/tests/check-sync-action.test.ts | 3 +- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/.intent/review-state.json b/.intent/review-state.json index 7afd292..5116e36 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,7 +3,7 @@ "baseline": "5254174a2a13060d110f34dbc0a4421b5302f5cb", "items": { "skill:packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": { - "fingerprint": "baf22faa40d851c4c4f1f775799ff2585558a0ca71090261d326ec836aa48c93", + "fingerprint": "223edffff3a853522d2ed1ee48f2557097e46f3e5acce072c242648916b7bfe7", "snapshot": { "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", @@ -11,9 +11,9 @@ "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", - "packages/mdcode/src/cli.ts": "cd07a5c2e698e23738b746c87afad1edb158f1c778c87c09d573ac36bb794c40", + "packages/mdcode/src/cli.ts": "635c980c65e34a5cc9061e1a423909fc18bcbc85f10ae2ff3e13fdddc021aedf", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "3f49ea1b950b62f8843654f6ee3629e87ac0e23b93a395a131656827871529ec", + "packages/mdcode/src/commands/extract.ts": "f31ad3f8bb66ea83cd189415446167c4905adf7e299a8b0f3821bc8e9115efce", "packages/mdcode/src/commands/list.ts": "d919894dd08249bb1eb0d12bcd5c21016cb87c0a6cba484de7da492bbe3c5c75", "packages/mdcode/src/commands/run.ts": "2c35da6b7ee31feb34df303d532715299f6e6eb87f24d914637bb299f875b77c", "packages/mdcode/src/commands/update.ts": "0e3c7d632ca3a424e8c01d64b3bd5872fe8ec9c0856610e3e95ce28e5dfe67c8", @@ -32,15 +32,15 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", - "outcome": "updated", - "reason": "Extract section gains `extract --check --force --ignore-anonymous` as the read-only preview and the --force caveat; safety table lists extract --check as writing nothing. Other guidance matches the changed extract (final newline kept on overwrite, symlink refused) and splice (no double indent) behaviour without edits.", + "head": "4fc4ec83eccda6aeb542cc6b552262701c8bdc16", + "outcome": "no-change", + "reason": "Formatting-only lint fixes (chained-call line breaks, import order); no guidance affected.", "evidence": [ - "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + "Changed: packages/mdcode/src/cli.ts, packages/mdcode/src/commands/extract.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" ] }, "planning:_artifacts": { - "fingerprint": "8ac4503938ddd3290670e3f6bd04fc39a768d2a87fbc3ff0af1cd2c6b273bfb1", + "fingerprint": "49d28691c65a5df54a9e0642d509a810240fd1f23832060997ce9e887d8306a2", "snapshot": { "_artifacts/domain_map.yaml": "ceb4094bf4fa2cd63354e1c76cce3fec97d19c4a807a40b974646e1dbb00152b", "_artifacts/skill_spec.md": "d0af268c94e3fefde165ce14f470c88ce52e7f8162c4c8632718fe0f959f5a23", @@ -51,9 +51,9 @@ "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", - "packages/mdcode/src/cli.ts": "cd07a5c2e698e23738b746c87afad1edb158f1c778c87c09d573ac36bb794c40", + "packages/mdcode/src/cli.ts": "635c980c65e34a5cc9061e1a423909fc18bcbc85f10ae2ff3e13fdddc021aedf", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "3f49ea1b950b62f8843654f6ee3629e87ac0e23b93a395a131656827871529ec", + "packages/mdcode/src/commands/extract.ts": "f31ad3f8bb66ea83cd189415446167c4905adf7e299a8b0f3821bc8e9115efce", "packages/mdcode/src/commands/list.ts": "d919894dd08249bb1eb0d12bcd5c21016cb87c0a6cba484de7da492bbe3c5c75", "packages/mdcode/src/commands/run.ts": "2c35da6b7ee31feb34df303d532715299f6e6eb87f24d914637bb299f875b77c", "packages/mdcode/src/commands/update.ts": "0e3c7d632ca3a424e8c01d64b3bd5872fe8ec9c0856610e3e95ce28e5dfe67c8", @@ -72,11 +72,11 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", - "outcome": "updated", - "reason": "domain_map.yaml covers extract --check; skill_spec.md adds batch 3 for #28. skill_tree.yaml unchanged.", + "head": "4fc4ec83eccda6aeb542cc6b552262701c8bdc16", + "outcome": "no-change", + "reason": "Formatting-only lint fixes (chained-call line breaks, import order); no guidance affected.", "evidence": [ - "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + "Changed: packages/mdcode/src/cli.ts, packages/mdcode/src/commands/extract.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" ] }, "source:.bumpy/agent-skill.md": { @@ -200,15 +200,15 @@ ] }, "source:packages/mdcode/src/commands/extract.test.ts": { - "fingerprint": "ccdc24d55cb6a24fed0cb87107018ce0fe72758347cf40c9a6f543b64ca7a9c9", + "fingerprint": "c59bde2eb1ca55a7adbe162b2adfe85ba7379365560c1b419116467613028a7b", "snapshot": { - "packages/mdcode/src/commands/extract.test.ts": "b70a1b8787a93462dc1fcab3ec2d01cce507df9e982214ecc8bed2da23c0cad6" + "packages/mdcode/src/commands/extract.test.ts": "1ea729856591a1ea78a44c834d250ab6af98455985434426ad60c174b30f34ea" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "head": "4fc4ec83eccda6aeb542cc6b552262701c8bdc16", "outcome": "no-change", - "reason": "Mapped source changed alongside the extract --check feature; the skill's guidance was updated separately where it applies and is otherwise unaffected.", + "reason": "Formatting-only lint fixes (chained-call line breaks, import order); no guidance affected.", "evidence": [ - "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + "Changed: packages/mdcode/src/commands/extract.test.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" ] }, "source:packages/mdcode/src/region.test.ts": { @@ -224,15 +224,15 @@ ] }, "source:packages/usage/tests/check-sync-action.test.ts": { - "fingerprint": "b1380c7cca9aa430bac48d0a76896c505e73463aec08c200758608ef58a29565", + "fingerprint": "f702d16f2c0ae7c47b12aa8c49fa2b66044ec27ef3a78407d0a1ab9940607f24", "snapshot": { - "packages/usage/tests/check-sync-action.test.ts": "90682ac5c209cf0edec389f6ddb06fffb22611a7ff6afda6df0b7b652b26e78b" + "packages/usage/tests/check-sync-action.test.ts": "d72e4db5c5c25fdf28c107c6fcfd896cab2e9e2a09fb7541bdcd6e8b178dc4e9" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "head": "4fc4ec83eccda6aeb542cc6b552262701c8bdc16", "outcome": "no-change", - "reason": "Mapped source changed alongside the extract --check feature; the skill's guidance was updated separately where it applies and is otherwise unaffected.", + "reason": "Formatting-only lint fixes (chained-call line breaks, import order); no guidance affected.", "evidence": [ - "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + "Changed: packages/usage/tests/check-sync-action.test.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" ] } } diff --git a/packages/mdcode/src/cli.ts b/packages/mdcode/src/cli.ts index 0105bf5..4f84868 100644 --- a/packages/mdcode/src/cli.ts +++ b/packages/mdcode/src/cli.ts @@ -496,7 +496,8 @@ export async function Execute( const refused = skipped.filter(error => error.code === "extract_skipped"); if (!options.quiet) { - writeLines(stderr, formatExtract(result, options).filter((_, index) => !options.check || result.targets[index]!.action === "unchanged" || result.targets[index]!.action === "skipped").map(at)); + writeLines(stderr, formatExtract(result, options).filter((_, index) => !options.check || result.targets[index]!.action === "unchanged" || result.targets[index]!.action === "skipped") + .map(at)); } if (updatedSource !== undefined) { diff --git a/packages/mdcode/src/commands/extract.test.ts b/packages/mdcode/src/commands/extract.test.ts index ce4511d..791b986 100644 --- a/packages/mdcode/src/commands/extract.test.ts +++ b/packages/mdcode/src/commands/extract.test.ts @@ -652,7 +652,8 @@ describe("extract: check", () => { /** Every file under dir with its content, to prove a check wrote nothing. */ async function snapshot(dir: string): Promise> { const entries = await readdir(dir, { recursive: true, withFileTypes: true }); - const files = entries.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name)).sort(); + const files = entries.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name)) + .sort(); return Object.fromEntries(await Promise.all(files.map(async file => [ file, await readFile(file, "utf-8") ]))); } diff --git a/packages/mdcode/src/commands/extract.ts b/packages/mdcode/src/commands/extract.ts index 70af6c1..6d630a6 100644 --- a/packages/mdcode/src/commands/extract.ts +++ b/packages/mdcode/src/commands/extract.ts @@ -7,7 +7,7 @@ import { isMissing } from "../paths.ts"; import type { RegionEdit } from "../region.ts"; import { read as readRegion, spliceRegions, wrapRegion } from "../region.ts"; import type { BlockRef, ResultError } from "../result.ts"; -import { BlockFailure, blockError, blockRef, CommandError } from "../result.ts"; +import { blockError, BlockFailure, blockRef, CommandError } from "../result.ts"; import type { Block, FilterOptions } from "../types.ts"; import { writeAtomic } from "../write.ts"; import type { ExtractGroup, ExtractItem } from "./validate.ts"; diff --git a/packages/usage/tests/check-sync-action.test.ts b/packages/usage/tests/check-sync-action.test.ts index 99c804a..5e07a78 100644 --- a/packages/usage/tests/check-sync-action.test.ts +++ b/packages/usage/tests/check-sync-action.test.ts @@ -39,7 +39,8 @@ async function checkout(files: Record): Promise { /** Every file in the checkout with its content, to prove the action wrote nothing. */ async function snapshot(dir: string): Promise> { const entries = await readdir(dir, { recursive: true, withFileTypes: true }); - const files = entries.filter(entry => entry.isFile() && !entry.name.startsWith("action-")).map(entry => join(entry.parentPath, entry.name)).sort(); + const files = entries.filter(entry => entry.isFile() && !entry.name.startsWith("action-")).map(entry => join(entry.parentPath, entry.name)) + .sort(); return Object.fromEntries(await Promise.all(files.map(async file => [ file, await readFile(file, "utf-8") ]))); } From 8fd753fe248a42f85ce3fd7d97624156fd74e410 Mon Sep 17 00:00:00 2001 From: Adrian Elton-Browning Date: Tue, 6 Oct 2026 17:27:04 +0100 Subject: [PATCH 3/5] fix(extract): --check compares whole files as bytes --- .intent/review-state.json | 30 ++++++++++---------- packages/mdcode/src/commands/extract.test.ts | 11 +++++++ packages/mdcode/src/commands/extract.ts | 14 ++++++--- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/.intent/review-state.json b/.intent/review-state.json index 5116e36..2ad315d 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,7 +3,7 @@ "baseline": "5254174a2a13060d110f34dbc0a4421b5302f5cb", "items": { "skill:packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": { - "fingerprint": "223edffff3a853522d2ed1ee48f2557097e46f3e5acce072c242648916b7bfe7", + "fingerprint": "365cb13f4a75671497c7323e0f14077d6366a576298356f28d2ee074f509ee7a", "snapshot": { "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", @@ -13,7 +13,7 @@ "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", "packages/mdcode/src/cli.ts": "635c980c65e34a5cc9061e1a423909fc18bcbc85f10ae2ff3e13fdddc021aedf", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "f31ad3f8bb66ea83cd189415446167c4905adf7e299a8b0f3821bc8e9115efce", + "packages/mdcode/src/commands/extract.ts": "8189da3fefdb3a9b865bc82eb7f667b3d5f8230f2be16c1780b88be30d4c72bd", "packages/mdcode/src/commands/list.ts": "d919894dd08249bb1eb0d12bcd5c21016cb87c0a6cba484de7da492bbe3c5c75", "packages/mdcode/src/commands/run.ts": "2c35da6b7ee31feb34df303d532715299f6e6eb87f24d914637bb299f875b77c", "packages/mdcode/src/commands/update.ts": "0e3c7d632ca3a424e8c01d64b3bd5872fe8ec9c0856610e3e95ce28e5dfe67c8", @@ -32,15 +32,15 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "4fc4ec83eccda6aeb542cc6b552262701c8bdc16", + "head": "b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e", "outcome": "no-change", - "reason": "Formatting-only lint fixes (chained-call line breaks, import order); no guidance affected.", + "reason": "extract compares whole-file targets byte for byte in check mode; no guidance affected (the skill already describes extract --check as exact).", "evidence": [ - "Changed: packages/mdcode/src/cli.ts, packages/mdcode/src/commands/extract.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" + "Changed: packages/mdcode/src/commands/extract.ts at b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e" ] }, "planning:_artifacts": { - "fingerprint": "49d28691c65a5df54a9e0642d509a810240fd1f23832060997ce9e887d8306a2", + "fingerprint": "0eb9de46f1083f69e3e48188d50aa796ef83a88e0daeecf35629a45c6b32d1a0", "snapshot": { "_artifacts/domain_map.yaml": "ceb4094bf4fa2cd63354e1c76cce3fec97d19c4a807a40b974646e1dbb00152b", "_artifacts/skill_spec.md": "d0af268c94e3fefde165ce14f470c88ce52e7f8162c4c8632718fe0f959f5a23", @@ -53,7 +53,7 @@ "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", "packages/mdcode/src/cli.ts": "635c980c65e34a5cc9061e1a423909fc18bcbc85f10ae2ff3e13fdddc021aedf", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "f31ad3f8bb66ea83cd189415446167c4905adf7e299a8b0f3821bc8e9115efce", + "packages/mdcode/src/commands/extract.ts": "8189da3fefdb3a9b865bc82eb7f667b3d5f8230f2be16c1780b88be30d4c72bd", "packages/mdcode/src/commands/list.ts": "d919894dd08249bb1eb0d12bcd5c21016cb87c0a6cba484de7da492bbe3c5c75", "packages/mdcode/src/commands/run.ts": "2c35da6b7ee31feb34df303d532715299f6e6eb87f24d914637bb299f875b77c", "packages/mdcode/src/commands/update.ts": "0e3c7d632ca3a424e8c01d64b3bd5872fe8ec9c0856610e3e95ce28e5dfe67c8", @@ -72,11 +72,11 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "4fc4ec83eccda6aeb542cc6b552262701c8bdc16", + "head": "b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e", "outcome": "no-change", - "reason": "Formatting-only lint fixes (chained-call line breaks, import order); no guidance affected.", + "reason": "extract compares whole-file targets byte for byte in check mode; no guidance affected (the skill already describes extract --check as exact).", "evidence": [ - "Changed: packages/mdcode/src/cli.ts, packages/mdcode/src/commands/extract.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" + "Changed: packages/mdcode/src/commands/extract.ts at b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e" ] }, "source:.bumpy/agent-skill.md": { @@ -200,15 +200,15 @@ ] }, "source:packages/mdcode/src/commands/extract.test.ts": { - "fingerprint": "c59bde2eb1ca55a7adbe162b2adfe85ba7379365560c1b419116467613028a7b", + "fingerprint": "cc671b28516a3e4626a3eaf32222e32d493887003e32c119e056fac56e95b788", "snapshot": { - "packages/mdcode/src/commands/extract.test.ts": "1ea729856591a1ea78a44c834d250ab6af98455985434426ad60c174b30f34ea" + "packages/mdcode/src/commands/extract.test.ts": "d80becf6c377bf7277baa39a88e64eeb1dcf02d87fbe8f527a3e205aa9f7fdd8" }, - "head": "4fc4ec83eccda6aeb542cc6b552262701c8bdc16", + "head": "b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e", "outcome": "no-change", - "reason": "Formatting-only lint fixes (chained-call line breaks, import order); no guidance affected.", + "reason": "extract compares whole-file targets byte for byte in check mode; no guidance affected (the skill already describes extract --check as exact).", "evidence": [ - "Changed: packages/mdcode/src/commands/extract.test.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" + "Changed: packages/mdcode/src/commands/extract.test.ts at b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e" ] }, "source:packages/mdcode/src/region.test.ts": { diff --git a/packages/mdcode/src/commands/extract.test.ts b/packages/mdcode/src/commands/extract.test.ts index 791b986..1e9da1b 100644 --- a/packages/mdcode/src/commands/extract.test.ts +++ b/packages/mdcode/src/commands/extract.test.ts @@ -735,6 +735,17 @@ describe("extract: check", () => { assert.deepEqual((await extract({ source, outputDir: dir, force: true, check: true })).errors, [], "after extract, the check passes"); }); + test("compares a whole file as bytes, so one that is not valid UTF-8 never passes as unchanged", async () => { + const dir = await tempDir(); + // 0xff is not UTF-8; read lossily it would look like the block's U+FFFD. + await writeFile(join(dir, "a.txt"), Buffer.from([ 0x78, 0xff, 0x0a ])); + + const result = await extract({ source: fence("txt file=a.txt", "x\uFFFD"), outputDir: dir, force: true, check: true }); + + assert.deepEqual(result.targets.map(({ action }) => action), [ "written" ]); + assert.equal(drift(result).length, 1); + }); + test("refuses a forced whole-file overwrite through a symlink, and check reports it the same way", async () => { const dir = await tempDir(); await writeSource(dir, "real.ts", "export const a = 1;\n"); diff --git a/packages/mdcode/src/commands/extract.ts b/packages/mdcode/src/commands/extract.ts index 6d630a6..d74226e 100644 --- a/packages/mdcode/src/commands/extract.ts +++ b/packages/mdcode/src/commands/extract.ts @@ -189,6 +189,8 @@ type PlannedTarget = content: string; /** The target's text before extract, or undefined when it does not exist yet. */ before: string | undefined; + /** Whether the target already holds exactly these bytes, so extract would change nothing. */ + unchanged: boolean; /** The existing file's mode, kept when it is replaced. */ mode: number | undefined; }; @@ -214,12 +216,15 @@ async function planTarget({ display, items }: ExtractGroup, overwrite: boolean): return { refusal: "target is a symlink; refusing to overwrite it" }; } - const before = existing === undefined ? undefined : await readFile(display, "utf-8"); + const raw = existing === undefined ? undefined : await readFile(display); + // Lossy, for messages only: bytes that are not UTF-8 are compared as bytes below. + const before = raw?.toString("utf-8"); const content = regions ? items.map(({ block }) => wrapRegion(block.lang, block.meta.region!, block.code)).join("\n") : keepFinalNewline(items[0]!.block.code, before); + const unchanged = raw !== undefined && raw.equals(Buffer.from(content, "utf-8")); - return { action: "written", content, before, mode: existing?.mode }; + return { action: "written", content, before, unchanged, mode: existing?.mode }; } /** @@ -240,7 +245,7 @@ function keepFinalNewline(code: string, before: string | undefined): string { function driftErrors({ display, items }: ExtractGroup, planned: Exclude): Array { const { before, content } = planned; - if (before === content) { + if (planned.unchanged) { return []; } @@ -319,7 +324,8 @@ async function planSplice(target: string, items: Array, mode: numbe content = `${content.replace(/\n*$/, content.trim() === "" ? "" : "\n")}${separator}${wrapRegion(block.lang, name, block.code)}`; } - return { action: "spliced", content, before: existing, mode }; + // existing was decoded strictly, so equal text is equal bytes. + return { action: "spliced", content, before: existing, unchanged: existing === content, mode }; } /** Explain, in one clause, why a splice was refused: the file changed after validation. */ From 778a87a0e1ae7a33fd71ee5cbb9adf13869544d5 Mon Sep 17 00:00:00 2001 From: Adrian Elton-Browning Date: Tue, 6 Oct 2026 17:29:09 +0100 Subject: [PATCH 4/5] fix(extract): --force refuses a whole-file target that is not valid UTF-8 --- .bumpy/extract-check-and-check-sync.md | 2 +- .intent/review-state.json | 46 ++++++++++---------- packages/mdcode/README.md | 8 ++-- packages/mdcode/src/commands/extract.test.ts | 15 ++++--- packages/mdcode/src/commands/extract.ts | 19 +++++--- 5 files changed, 53 insertions(+), 37 deletions(-) diff --git a/.bumpy/extract-check-and-check-sync.md b/.bumpy/extract-check-and-check-sync.md index cdfb1d3..8e73505 100644 --- a/.bumpy/extract-check-and-check-sync.md +++ b/.bumpy/extract-check-and-check-sync.md @@ -6,4 +6,4 @@ Added `mdcode extract --check`. It works out every target exactly as `extract` w Added the `check-sync` GitHub Action (`adrianbrowning/mdcode-ts/.github/actions/check-sync`). It fails a job when Markdown blocks and their files disagree in either direction, using `update --check` and `extract --check --force`, and writes nothing. Each problem becomes an annotation and a row in the job summary. -Fixed `extract` indenting a spliced region a second time when its markers are indented. `update` copies such a region with its indentation, so extracting it used to push every line right. `extract --force` now keeps an overwritten file's final newline, and refuses to replace a symlinked target with a regular file. +Fixed `extract` indenting a spliced region a second time when its markers are indented. `update` copies such a region with its indentation, so extracting it used to push every line right. `extract --force` now keeps an overwritten file's final newline, and refuses a target that is a symlink or not valid UTF-8, as region splices already did. diff --git a/.intent/review-state.json b/.intent/review-state.json index 2ad315d..f31829c 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,17 +3,17 @@ "baseline": "5254174a2a13060d110f34dbc0a4421b5302f5cb", "items": { "skill:packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": { - "fingerprint": "365cb13f4a75671497c7323e0f14077d6366a576298356f28d2ee074f509ee7a", + "fingerprint": "3a240b6e007de3d4c1db8507e4f2d4e35fad2788b7eada45181765ec8c560d17", "snapshot": { "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "a1e76ea88903f6d0c6f9eb14347dd01c4f837e2e50bd326cf1ddda94b1afa215", + "packages/mdcode/README.md": "4090c130b589f23882b7cf99f488528fdc5cdf6b4772c6d91b56a77aebba4e16", "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", "packages/mdcode/src/cli.ts": "635c980c65e34a5cc9061e1a423909fc18bcbc85f10ae2ff3e13fdddc021aedf", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "8189da3fefdb3a9b865bc82eb7f667b3d5f8230f2be16c1780b88be30d4c72bd", + "packages/mdcode/src/commands/extract.ts": "c798ac7b48031019c5c0d5b9dbee34f88c4c4fdb7b587b406c81a4aa37aab955", "packages/mdcode/src/commands/list.ts": "d919894dd08249bb1eb0d12bcd5c21016cb87c0a6cba484de7da492bbe3c5c75", "packages/mdcode/src/commands/run.ts": "2c35da6b7ee31feb34df303d532715299f6e6eb87f24d914637bb299f875b77c", "packages/mdcode/src/commands/update.ts": "0e3c7d632ca3a424e8c01d64b3bd5872fe8ec9c0856610e3e95ce28e5dfe67c8", @@ -32,28 +32,28 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e", + "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", "outcome": "no-change", - "reason": "extract compares whole-file targets byte for byte in check mode; no guidance affected (the skill already describes extract --check as exact).", + "reason": "extract --force now refuses non-UTF-8 whole-file targets like splices do; README notes updated. The skill describes extract at a level these refusals don't change.", "evidence": [ - "Changed: packages/mdcode/src/commands/extract.ts at b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e" + "Changed: packages/mdcode/README.md, packages/mdcode/src/commands/extract.ts at 8fd753fe248a42f85ce3fd7d97624156fd74e410" ] }, "planning:_artifacts": { - "fingerprint": "0eb9de46f1083f69e3e48188d50aa796ef83a88e0daeecf35629a45c6b32d1a0", + "fingerprint": "8a69bcf703e02764565bff53153f281369deccc14e5540753d0fd2059a636629", "snapshot": { "_artifacts/domain_map.yaml": "ceb4094bf4fa2cd63354e1c76cce3fec97d19c4a807a40b974646e1dbb00152b", "_artifacts/skill_spec.md": "d0af268c94e3fefde165ce14f470c88ce52e7f8162c4c8632718fe0f959f5a23", "_artifacts/skill_tree.yaml": "2490b6a1d7a2abab698fe1a91e036ddec9dc76de5cfb15dedd8639ebf2875229", "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "a1e76ea88903f6d0c6f9eb14347dd01c4f837e2e50bd326cf1ddda94b1afa215", + "packages/mdcode/README.md": "4090c130b589f23882b7cf99f488528fdc5cdf6b4772c6d91b56a77aebba4e16", "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", "packages/mdcode/src/cli.ts": "635c980c65e34a5cc9061e1a423909fc18bcbc85f10ae2ff3e13fdddc021aedf", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "8189da3fefdb3a9b865bc82eb7f667b3d5f8230f2be16c1780b88be30d4c72bd", + "packages/mdcode/src/commands/extract.ts": "c798ac7b48031019c5c0d5b9dbee34f88c4c4fdb7b587b406c81a4aa37aab955", "packages/mdcode/src/commands/list.ts": "d919894dd08249bb1eb0d12bcd5c21016cb87c0a6cba484de7da492bbe3c5c75", "packages/mdcode/src/commands/run.ts": "2c35da6b7ee31feb34df303d532715299f6e6eb87f24d914637bb299f875b77c", "packages/mdcode/src/commands/update.ts": "0e3c7d632ca3a424e8c01d64b3bd5872fe8ec9c0856610e3e95ce28e5dfe67c8", @@ -72,11 +72,11 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e", + "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", "outcome": "no-change", - "reason": "extract compares whole-file targets byte for byte in check mode; no guidance affected (the skill already describes extract --check as exact).", + "reason": "extract --force now refuses non-UTF-8 whole-file targets like splices do; README notes updated. The skill describes extract at a level these refusals don't change.", "evidence": [ - "Changed: packages/mdcode/src/commands/extract.ts at b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e" + "Changed: packages/mdcode/README.md, packages/mdcode/src/commands/extract.ts at 8fd753fe248a42f85ce3fd7d97624156fd74e410" ] }, "source:.bumpy/agent-skill.md": { @@ -152,15 +152,15 @@ ] }, "source:.bumpy/extract-check-and-check-sync.md": { - "fingerprint": "7cfddd5a44de15f076ed440e0506acfc3693bd62188120a786f4b391f029f169", + "fingerprint": "e8a5ebac5605d944f0bc71136ad0bccf3a017c0873533434a029a616cc623a6c", "snapshot": { - ".bumpy/extract-check-and-check-sync.md": "9610f0a02be0256ec66d5a440997a5864d27c80a64c37bcad2028a77794fdfe6" + ".bumpy/extract-check-and-check-sync.md": "f1f8377f276ca4efdd495680c50a4fd244c46b8985971d27d0b7169beccf2add" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", - "outcome": "out-of-scope", - "reason": "Changelog entry for extract --check, the check-sync action and the extract fixes.", + "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", + "outcome": "no-change", + "reason": "extract --force now refuses non-UTF-8 whole-file targets like splices do; README notes updated. The skill describes extract at a level these refusals don't change.", "evidence": [ - "Minor bump file." + "Changed: .bumpy/extract-check-and-check-sync.md at 8fd753fe248a42f85ce3fd7d97624156fd74e410" ] }, "source:.github/actions/check-sync/action.yml": { @@ -200,15 +200,15 @@ ] }, "source:packages/mdcode/src/commands/extract.test.ts": { - "fingerprint": "cc671b28516a3e4626a3eaf32222e32d493887003e32c119e056fac56e95b788", + "fingerprint": "cfef2d2213bfb2e4f7386cf8b79cfa723c3372e5190ecc225603358b73d335b7", "snapshot": { - "packages/mdcode/src/commands/extract.test.ts": "d80becf6c377bf7277baa39a88e64eeb1dcf02d87fbe8f527a3e205aa9f7fdd8" + "packages/mdcode/src/commands/extract.test.ts": "36a6baa0ddd1ff41f0f366b4c7bc45dbf66f6adb785eded1fd4ebc77ffd2f035" }, - "head": "b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e", + "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", "outcome": "no-change", - "reason": "extract compares whole-file targets byte for byte in check mode; no guidance affected (the skill already describes extract --check as exact).", + "reason": "extract --force now refuses non-UTF-8 whole-file targets like splices do; README notes updated. The skill describes extract at a level these refusals don't change.", "evidence": [ - "Changed: packages/mdcode/src/commands/extract.test.ts at b5da2ad7f9c3c7c4a1fba0bcca9a893d517d888e" + "Changed: packages/mdcode/src/commands/extract.test.ts at 8fd753fe248a42f85ce3fd7d97624156fd74e410" ] }, "source:packages/mdcode/src/region.test.ts": { diff --git a/packages/mdcode/README.md b/packages/mdcode/README.md index 1f7f506..2591fa8 100644 --- a/packages/mdcode/README.md +++ b/packages/mdcode/README.md @@ -367,7 +367,8 @@ When the target file already exists: name */` in a JS file is spliced rather than duplicated. - **The block has no `region=`** → the file is skipped with a warning, since writing it would replace the whole file. Use `--force` to overwrite. An overwritten file keeps its final newline, LF or - CRLF; a symlinked target is refused rather than replaced with a regular file. + CRLF. A target that is a symlink or not valid UTF-8 is refused, as for a splice, rather than + replaced. Otherwise, files that don't exist yet are created. @@ -2217,8 +2218,9 @@ Exit codes are the same with and without `--json`: now written as it stands; a dedented body is still indented to the marker. - `extract --force` keeps an overwritten file's final newline (LF or CRLF). It used to drop it. A new file is still written as the block's code stands. -- `extract --force` refuses a target that is a symlink, as region splices already did, instead of - replacing the link with a regular file. The target is skipped and `extract` exits 2. +- `extract --force` refuses a target that is a symlink or not valid UTF-8, as region splices already + did, instead of replacing the link with a regular file or re-encoding the bytes. The target is + skipped and `extract` exits 2. --- diff --git a/packages/mdcode/src/commands/extract.test.ts b/packages/mdcode/src/commands/extract.test.ts index 1e9da1b..dcde65a 100644 --- a/packages/mdcode/src/commands/extract.test.ts +++ b/packages/mdcode/src/commands/extract.test.ts @@ -735,15 +735,20 @@ describe("extract: check", () => { assert.deepEqual((await extract({ source, outputDir: dir, force: true, check: true })).errors, [], "after extract, the check passes"); }); - test("compares a whole file as bytes, so one that is not valid UTF-8 never passes as unchanged", async () => { + test("refuses a forced overwrite of a file that is not valid UTF-8, and check reports it the same way", async () => { const dir = await tempDir(); // 0xff is not UTF-8; read lossily it would look like the block's U+FFFD. - await writeFile(join(dir, "a.txt"), Buffer.from([ 0x78, 0xff, 0x0a ])); + const bytes = Buffer.from([ 0x78, 0xff, 0x0a ]); + await writeFile(join(dir, "a.txt"), bytes); + const source = fence("txt file=a.txt", "x\uFFFD"); - const result = await extract({ source: fence("txt file=a.txt", "x\uFFFD"), outputDir: dir, force: true, check: true }); + const checked = await extract({ source, outputDir: dir, force: true, check: true }); + const written = await extract({ source, outputDir: dir, force: true }); - assert.deepEqual(result.targets.map(({ action }) => action), [ "written" ]); - assert.equal(drift(result).length, 1); + for (const result of [ checked, written ]) { + assert.deepEqual(result.targets.map(({ action, reason }) => ({ action, reason })), [{ action: "skipped", reason: "not valid UTF-8" }]); + } + assert.deepEqual(await readFile(join(dir, "a.txt")), bytes, "the bytes must be left as they were"); }); test("refuses a forced whole-file overwrite through a symlink, and check reports it the same way", async () => { diff --git a/packages/mdcode/src/commands/extract.ts b/packages/mdcode/src/commands/extract.ts index d74226e..ece30b6 100644 --- a/packages/mdcode/src/commands/extract.ts +++ b/packages/mdcode/src/commands/extract.ts @@ -216,15 +216,24 @@ async function planTarget({ display, items }: ExtractGroup, overwrite: boolean): return { refusal: "target is a symlink; refusing to overwrite it" }; } - const raw = existing === undefined ? undefined : await readFile(display); - // Lossy, for messages only: bytes that are not UTF-8 are compared as bytes below. - const before = raw?.toString("utf-8"); + let before: string | undefined; + + if (existing !== undefined) { + try { + // As for a splice: a target that is not UTF-8 is refused, not silently re-encoded. + before = new TextDecoder("utf-8", { fatal: true }).decode(await readFile(display)); + } + catch { + return { refusal: "not valid UTF-8" }; + } + } + const content = regions ? items.map(({ block }) => wrapRegion(block.lang, block.meta.region!, block.code)).join("\n") : keepFinalNewline(items[0]!.block.code, before); - const unchanged = raw !== undefined && raw.equals(Buffer.from(content, "utf-8")); - return { action: "written", content, before, unchanged, mode: existing?.mode }; + // before was decoded strictly, so equal text is equal bytes. + return { action: "written", content, before, unchanged: before === content, mode: existing?.mode }; } /** From 4d93afd1ce7492752c22dad8cbee38412d9ee093 Mon Sep 17 00:00:00 2001 From: Adrian Elton-Browning Date: Thu, 8 Oct 2026 10:21:23 +0100 Subject: [PATCH 5/5] feat: update-readme GitHub Action (#28) (#58) --- .bumpy/update-readme-action.md | 5 + .github/actions/check-sync/check-sync.mjs | 86 +----- .github/actions/shared/mdcode.mjs | 99 ++++++ .github/actions/update-readme/action.yml | 97 ++++++ .../actions/update-readme/update-readme.mjs | 215 +++++++++++++ .intent/review-state.json | 96 ++++-- CLAUDE.md | 2 +- TESTING.md | 2 + packages/mdcode/README.md | 76 +++++ .../usage/tests/update-readme-action.test.ts | 289 ++++++++++++++++++ 10 files changed, 868 insertions(+), 99 deletions(-) create mode 100644 .bumpy/update-readme-action.md create mode 100644 .github/actions/shared/mdcode.mjs create mode 100644 .github/actions/update-readme/action.yml create mode 100755 .github/actions/update-readme/update-readme.mjs create mode 100644 packages/usage/tests/update-readme-action.test.ts diff --git a/.bumpy/update-readme-action.md b/.bumpy/update-readme-action.md new file mode 100644 index 0000000..ac81c65 --- /dev/null +++ b/.bumpy/update-readme-action.md @@ -0,0 +1,5 @@ +--- +mdcode-ts: minor +--- + +Added the `update-readme` GitHub Action (`adrianbrowning/mdcode-ts/.github/actions/update-readme`). It treats source files as authoritative: it runs `mdcode update --apply` on the selected documents in a temporary worktree at the base branch's tip, then opens one pull request holding only the Markdown changes, or refreshes the one it opened before. A rerun with nothing new pushes nothing. Once the base branch is in sync, it closes its pull request. It never pushes to the base branch and never writes a source file. diff --git a/.github/actions/check-sync/check-sync.mjs b/.github/actions/check-sync/check-sync.mjs index 6b5bba7..6c7510a 100755 --- a/.github/actions/check-sync/check-sync.mjs +++ b/.github/actions/check-sync/check-sync.mjs @@ -10,32 +10,16 @@ * directions are in sync, 1 when anything drifted or could not be read, 2 when * the action itself was misconfigured or mdcode could not run. */ -import { spawnSync } from "node:child_process"; -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { glob } from "node:fs/promises"; -import { dirname, join, posix } from "node:path"; +import { appendFileSync } from "node:fs"; +import { dirname, posix } from "node:path"; + +import { cell, ConfigError, configFlags, documents, env, escape, flag, mdcode, mdcodeCommand, runAction } from "../shared/mdcode.mjs"; const DIRECTIONS = { update: { label: "files → Markdown (update)", fix: "mdcode update --apply" }, extract: { label: "Markdown → files (extract)", fix: "mdcode extract --force" }, }; -const env = name => (process.env[name] ?? "").trim(); -const flag = name => /^(true|1|yes)$/i.test(env(name)); - -class ConfigError extends Error {} - -/** The command that runs mdcode: the input, else the release this action belongs to, from npm. */ -function mdcodeCommand() { - if (env("MDCODE_COMMAND")) { - return env("MDCODE_COMMAND"); - } - - const manifest = join(import.meta.dirname, "..", "..", "..", "packages", "mdcode", "package.json"); - const { name, version } = JSON.parse(readFileSync(manifest, "utf-8")); - return `npx --yes ${name}@${version}`; -} - /** The directions to check, from a space- or comma-separated list. */ function directions() { const asked = env("DIRECTIONS").split(/[\s,]+/).filter(Boolean); @@ -48,50 +32,9 @@ function directions() { return [ ...new Set(asked) ]; } -/** The documents, one path or glob per line, so paths may contain spaces. */ -async function documents() { - const found = []; - - for (const line of env("DOCUMENTS").split(/\r?\n/).map(entry => entry.trim()).filter(Boolean)) { - if (!/[*?[{]/.test(line)) { - if (!existsSync(line)) { - throw new ConfigError(`document ${line} does not exist`); - } - found.push(line); - continue; - } - - const matches = []; - for await (const match of glob(line)) { - matches.push(match.split("\\").join("/")); - } - - if (matches.length === 0) { - throw new ConfigError(`documents pattern ${line} matched no files`); - } - found.push(...matches.sort()); - } - - return [ ...new Set(found) ]; -} - -/** Run mdcode with these arguments and return its JSON envelope. */ -function mdcode(command, args) { - // bash passes every argument through "$@" untouched, so paths with spaces stay whole. - // --norc: bash reads ~/.bashrc when it guesses it runs over ssh, which can print or change PATH. - const run = spawnSync("bash", [ "--noprofile", "--norc", "-c", `${command} "$@"`, "mdcode", ...args ], { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }); - - try { - return JSON.parse(run.stdout); - } - catch { - throw new ConfigError(`mdcode did not produce a JSON result (exit ${run.status ?? run.signal}):\n${run.stderr || run.stdout || run.error?.message || ""}`.trimEnd()); - } -} - /** The mdcode runs for one direction: one for update, one per document directory for extract. */ function runs(direction, docs) { - const common = [ "--json", ...(flag("PROJECT") ? [ "--project" ] : []), ...(env("CONFIG") ? [ "--config", env("CONFIG") ] : []) ]; + const common = [ "--json", ...configFlags() ]; if (direction === "update") { return [[ "update", "--check", "--continue-on-error", ...common, ...(env("BASE") ? [ "--base", env("BASE") ] : []), ...docs ]]; @@ -113,14 +56,6 @@ function runs(direction, docs) { return [ ...byDir ].map(([ dir, group ]) => [ ...extract, "--dir", dir, ...group ]); } -/** Escape text for a workflow command's message or property value. */ -function escape(text, property = false) { - const escaped = String(text).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); - return property ? escaped.replaceAll(":", "%3A").replaceAll(",", "%2C") : escaped; -} - -const cell = text => String(text ?? "").replaceAll("|", "\\|").replaceAll("\n", " "); - async function main() { const command = mdcodeCommand(); const asked = directions(); @@ -212,13 +147,4 @@ function report(problems, asked, docs) { } } -try { - process.exitCode = await main(); -} -catch (error) { - if (!(error instanceof ConfigError)) { - throw error; - } - console.log(`::error title=mdcode check-sync::${escape(error.message)}`); - process.exitCode = 2; -} +await runAction("mdcode check-sync", main); diff --git a/.github/actions/shared/mdcode.mjs b/.github/actions/shared/mdcode.mjs new file mode 100644 index 0000000..77800b3 --- /dev/null +++ b/.github/actions/shared/mdcode.mjs @@ -0,0 +1,99 @@ +/** + * Helpers shared by this repository's consumer GitHub Actions (check-sync, + * update-readme). Each action's composite step runs its own script, which + * imports these. GitHub downloads the whole repository at the action's ref, + * so this file and packages/mdcode/package.json are always beside them. + */ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { glob } from "node:fs/promises"; +import { join } from "node:path"; + +/** An action input, from the environment variable action.yml maps it to. */ +export const env = name => (process.env[name] ?? "").trim(); + +/** A boolean action input. */ +export const flag = name => /^(true|1|yes)$/i.test(env(name)); + +/** The inputs are wrong or mdcode could not run: the action exits 2. */ +export class ConfigError extends Error {} + +/** The command that runs mdcode: the input, else the release this action belongs to, from npm. */ +export function mdcodeCommand() { + if (env("MDCODE_COMMAND")) { + return env("MDCODE_COMMAND"); + } + + const manifest = join(import.meta.dirname, "..", "..", "..", "packages", "mdcode", "package.json"); + const { name, version } = JSON.parse(readFileSync(manifest, "utf-8")); + return `npx --yes ${name}@${version}`; +} + +/** The documents input: one path or glob per line, so paths may contain spaces. */ +export async function documents() { + const found = []; + + for (const line of env("DOCUMENTS").split(/\r?\n/).map(entry => entry.trim()).filter(Boolean)) { + if (!/[*?[{]/.test(line)) { + if (!existsSync(line)) { + throw new ConfigError(`document ${line} does not exist`); + } + found.push(line); + continue; + } + + const matches = []; + for await (const match of glob(line)) { + matches.push(match.split("\\").join("/")); + } + + if (matches.length === 0) { + throw new ConfigError(`documents pattern ${line} matched no files`); + } + found.push(...matches.sort()); + } + + return [ ...new Set(found) ]; +} + +/** The flags that select a configuration file, from the project and config inputs. */ +export function configFlags() { + return [ ...(flag("PROJECT") ? [ "--project" ] : []), ...(env("CONFIG") ? [ "--config", env("CONFIG") ] : []) ]; +} + +/** Run mdcode with these arguments and return its JSON envelope. */ +export function mdcode(command, args) { + // bash passes every argument through "$@" untouched, so paths with spaces stay whole. + // --norc: bash reads ~/.bashrc when it guesses it runs over ssh, which can print or change PATH. + const run = spawnSync("bash", [ "--noprofile", "--norc", "-c", `${command} "$@"`, "mdcode", ...args ], { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }); + + try { + return JSON.parse(run.stdout); + } + catch { + throw new ConfigError(`mdcode did not produce a JSON result (exit ${run.status ?? run.signal}):\n${run.stderr || run.stdout || run.error?.message || ""}`.trimEnd()); + } +} + +/** Escape text for a workflow command's message or property value. */ +export function escape(text, property = false) { + const escaped = String(text).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); + return property ? escaped.replaceAll(":", "%3A").replaceAll(",", "%2C") : escaped; +} + +/** Text for one cell of a Markdown table. */ +export const cell = text => String(text ?? "").replaceAll("|", "\\|").replaceAll("\n", " "); + +/** Run a script's main(), turning a ConfigError into an annotation and exit code 2. */ +export async function runAction(title, main) { + try { + process.exitCode = await main(); + } + catch (error) { + if (!(error instanceof ConfigError)) { + throw error; + } + console.log(`::error title=${escape(title, true)}::${escape(error.message)}`); + process.exitCode = 2; + } +} diff --git a/.github/actions/update-readme/action.yml b/.github/actions/update-readme/action.yml new file mode 100644 index 0000000..6402b88 --- /dev/null +++ b/.github/actions/update-readme/action.yml @@ -0,0 +1,97 @@ +name: mdcode update README +description: Update Markdown code blocks from the source files they link to, and open or refresh one pull request with only those Markdown changes. + +inputs: + documents: + description: > + Markdown files to update, one path or glob per line (paths may contain + spaces). Leave empty with project or config to update the + configuration's documents. + default: '' + base: + description: > + The directory file= paths resolve against (--base). Default: each + document's own directory, or the configuration's sourceRoot. + default: '' + project: + description: Load mdcode.config.json from the working directory (--project). + default: 'false' + config: + description: Load this configuration file instead (--config). + default: '' + branch: + description: The branch the action force-pushes its commit to. Never the base branch. + default: mdcode/update-docs + base-branch: + description: The branch the pull request targets. + default: ${{ github.event.repository.default_branch }} + title: + description: Pull request title. + default: 'docs: update code blocks from their source files' + commit-message: + description: Commit message. + default: 'docs: update code blocks from their source files' + author-name: + description: Commit author name. + default: github-actions[bot] + author-email: + description: Commit author email. + default: 41898282+github-actions[bot]@users.noreply.github.com + token: + description: > + Token for pushing the branch and opening the pull request; needs + contents and pull-requests write. Pull requests opened with the default + GITHUB_TOKEN do not start workflows, so pass a GitHub App token or a + fine-grained personal access token for checks to run on them. + default: ${{ github.token }} + working-directory: + description: Directory to run in, relative to the workspace. + default: '.' + node-version: + description: Node.js version to set up (22.17 or later). Empty to use the runner's Node.js. + default: '22' + mdcode-command: + description: > + Command that runs mdcode. Default: `npx --yes mdcode-ts@`, the + release this action's ref belongs to. + default: '' + +outputs: + changed: + description: '`true` when a block changed and the pull request was opened or refreshed, else `false`.' + value: ${{ steps.update.outputs.changed }} + pull-request-number: + description: The pull request's number, when changed is true. + value: ${{ steps.update.outputs.pull-request-number }} + pull-request-url: + description: The pull request's URL, when changed is true. + value: ${{ steps.update.outputs.pull-request-url }} + +runs: + using: composite + steps: + - name: Set up Node.js + if: inputs.node-version != '' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + + - name: Update Markdown and open a pull request + id: update + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + DOCUMENTS: ${{ inputs.documents }} + BASE: ${{ inputs.base }} + PROJECT: ${{ inputs.project }} + CONFIG: ${{ inputs.config }} + BRANCH: ${{ inputs.branch }} + BASE_BRANCH: ${{ inputs.base-branch }} + TITLE: ${{ inputs.title }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + AUTHOR_NAME: ${{ inputs.author-name }} + AUTHOR_EMAIL: ${{ inputs.author-email }} + TOKEN: ${{ inputs.token }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + MDCODE_COMMAND: ${{ inputs.mdcode-command }} + run: node "$GITHUB_ACTION_PATH/update-readme.mjs" diff --git a/.github/actions/update-readme/update-readme.mjs b/.github/actions/update-readme/update-readme.mjs new file mode 100755 index 0000000..f6bcf3b --- /dev/null +++ b/.github/actions/update-readme/update-readme.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node +/** + * The update-readme action: treat linked source files as authoritative, run + * `mdcode update --apply` on the selected Markdown documents, and open or + * refresh one pull request that holds only those documents' changes. + * + * It works in a temporary worktree at the base branch's tip, so the caller's + * checkout is never touched and the pull request is always the update on top + * of base-branch. It never pushes to the base branch, never writes a source + * file (update writes only Markdown, and the commit is checked to hold only + * the documents update wrote), and pushes again only when the branch's + * content would change, so reruns are idempotent. + * + * Inputs arrive as environment variables (see action.yml). Exit 0 when the + * pull request is up to date or nothing needed updating, 1 when a block's + * file= or region= could not be read, 2 when the inputs are wrong or git, gh + * or mdcode failed. + */ +import { spawnSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { mkdtemp, realpath } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, posix } from "node:path"; + +import { ConfigError, configFlags, documents, env, escape, mdcode, mdcodeCommand, runAction } from "../shared/mdcode.mjs"; + +/** Run a command, returning its stdout; a failure is a ConfigError naming the command. */ +function exec(file, args, options = {}) { + const run = spawnSync(file, args, { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, ...options }); + + if (run.status !== 0) { + const shown = file === "git" ? args.filter(arg => !arg.startsWith("http.")).join(" ") : args.join(" "); + throw new ConfigError(`${file} ${shown} failed (exit ${run.status ?? run.signal}): ${(run.stderr || run.stdout || run.error?.message || "").trim()}`); + } + + return run.stdout; +} + +function remote() { + const server = (env("GITHUB_SERVER_URL") || "https://github.com").replace(/\/$/, ""); + return { server, url: `${server}/${env("GITHUB_REPOSITORY")}.git` }; +} + +/** git with the token as the only credential for the remote, replacing any actions/checkout persisted. */ +function git(args) { + const { server } = remote(); + const basic = Buffer.from(`x-access-token:${env("TOKEN")}`).toString("base64"); + return exec("git", [ "-c", `http.${server}/.extraheader=`, "-c", `http.${server}/.extraheader=AUTHORIZATION: basic ${basic}`, ...args ]); +} + +function gh(args) { + const { server } = remote(); + const host = new URL(server).host; + return exec("gh", args, { env: { ...process.env, GH_TOKEN: env("TOKEN"), ...(host === "github.com" ? {} : { GH_HOST: host }) } }); +} + +/** The open pull request from branch into base, if there is one. */ +function openPullRequest(branch, base) { + const found = JSON.parse(gh([ "pr", "list", "--repo", env("GITHUB_REPOSITORY"), "--head", branch, "--base", base, "--state", "open", "--json", "number,url" ])); + return found[0]; +} + +/** The pull request body: what changed, and how to reproduce it. */ +function body(results) { + const lines = results.flatMap(({ document, blocks }) => blocks + .filter(block => block.changed) + .map(block => `| ${document} | ${block.line} | ${block.name ?? ""} | ${block.read?.region ? `${block.read.file} (region ${block.read.region})` : block.read?.file ?? ""} |`)); + + return [ + "The code blocks below no longer matched the source files they link to, so `mdcode update --apply` brought them up to date. Only Markdown changed; no source file is touched.", + "", + "| Document | Line | Block | Source |", + "| --- | --- | --- | --- |", + ...lines, + "", + "Created by the mdcode-ts [update-readme](https://github.com/adrianbrowning/mdcode-ts/tree/main/.github/actions/update-readme) action. It refreshes this pull request whenever the blocks drift again.", + ].join("\n"); +} + +function output(values) { + if (env("GITHUB_OUTPUT")) { + appendFileSync(env("GITHUB_OUTPUT"), Object.entries(values).map(([ key, value ]) => `${key}=${value}\n`).join("")); + } +} + +async function main() { + const branch = env("BRANCH"); + const base = env("BASE_BRANCH"); + + if (!env("GITHUB_REPOSITORY") || !env("TOKEN")) { + throw new ConfigError("GITHUB_REPOSITORY and token are required"); + } + if (!branch || !base) { + throw new ConfigError("branch and base-branch must both be set"); + } + if (branch === base) { + throw new ConfigError(`branch and base-branch are both ${base}; the action only ever pushes to a branch of its own`); + } + + // Work in a clean worktree at the base branch's tip: the pull request is the + // update on top of base-branch, whatever the caller checked out or changed. + const { url } = remote(); + const tip = git([ "ls-remote", url, `refs/heads/${base}` ]).split(/\s/)[0]; + + if (!tip) { + throw new ConfigError(`base-branch ${base} does not exist in ${env("GITHUB_REPOSITORY")}`); + } + + const prefix = exec("git", [ "rev-parse", "--show-prefix" ]).trim(); + const tree = await realpath(await mkdtemp(join(tmpdir(), "mdcode-update-readme-"))); + const top = exec("git", [ "rev-parse", "--show-toplevel" ]).trim(); + git([ "fetch", "--quiet", "--depth=1", url, tip ]); + exec("git", [ "worktree", "add", "--quiet", "--detach", tree, tip ]); + const caller = process.cwd(); + + try { + process.chdir(join(tree, prefix)); + return await updateIn({ tree, top, prefix, tip, url, branch, base }); + } + finally { + process.chdir(caller); + exec("git", [ "worktree", "remove", "--force", tree ]); + } +} + +/** Update the documents in the worktree, then open or refresh the pull request. */ +async function updateIn({ tree, top, prefix, tip, url, branch, base }) { + const command = mdcodeCommand(); + const configured = configFlags().length > 0; + const docs = await documents(); + + if (docs.length === 0 && !configured) { + throw new ConfigError("documents is empty; list Markdown files or globs, one per line, or set project: true"); + } + + // update --apply writes Markdown only, and nothing at all when any block fails. + const envelope = mdcode(command, [ "update", "--apply", "--json", ...configFlags(), ...(env("BASE") ? [ "--base", env("BASE") ] : []), ...docs ]); + + if (envelope.result === null && envelope.errors.some(({ code }) => code === "invalid_usage")) { + throw new ConfigError(`mdcode update rejected its arguments: ${envelope.errors.map(({ message }) => message).join("; ")}`); + } + + if (envelope.errors.length > 0) { + const workdir = env("WORKING_DIRECTORY") || "."; + for (const error of envelope.errors) { + const file = error.document === undefined ? [] : [ `file=${escape(posix.normalize(posix.join(workdir, error.document)), true)}` ]; + // mdcode ran in the worktree; name the caller's checkout instead. + const message = error.message.replaceAll(tree, top); + console.log(`::error ${[ ...file, ...(error.line ? [ `line=${error.line}` ] : []), `title=${escape(`mdcode ${error.code}`, true)}` ].join(",")}::${escape(message)}`); + } + console.log("✗ No pull request: fix the blocks above, whose file= or region= could not be read."); + return 1; + } + + const results = envelope.result?.documents ?? []; + const written = results.map(({ written }) => written).filter(Boolean); + + if (written.length === 0) { + const stale = openPullRequest(branch, base); + if (stale) { + gh([ "pr", "close", String(stale.number), "--repo", env("GITHUB_REPOSITORY"), "--comment", `The code blocks are in sync with their source files on ${base}, so this pull request is no longer needed.` ]); + console.log(`Closed #${stale.number}: the blocks are already in sync.`); + } + else { + console.log("✓ Every code block is in sync with its source file; nothing to do."); + } + output({ changed: "false" }); + return 0; + } + + const author = `${env("AUTHOR_NAME")} <${env("AUTHOR_EMAIL")}>`; + exec("git", [ "add", "--", ...written ]); + exec("git", [ "-c", `user.name=${env("AUTHOR_NAME")}`, "-c", `user.email=${env("AUTHOR_EMAIL")}`, "commit", "--quiet", "--author", author, "-m", env("COMMIT_MESSAGE") ]); + + // git names committed paths from the repository root; mdcode names them from the working directory. + const expected = written.map(path => posix.normalize(posix.join(prefix, path))); + const committed = exec("git", [ "-c", "core.quotePath=false", "diff", "--name-only", "-z", tip, "HEAD" ]).split("\0").filter(Boolean); + const unexpected = committed.filter(path => !expected.includes(path)); + if (unexpected.length > 0) { + throw new ConfigError(`the commit would change ${unexpected.join(", ")}, which mdcode did not write; refusing to push`); + } + + // Idempotent: a branch that already holds this content on this base is left alone. + const existing = git([ "ls-remote", url, `refs/heads/${branch}` ]).split(/\s/)[0]; + let pushed = true; + + if (existing) { + git([ "fetch", "--quiet", "--depth=2", url, existing ]); + pushed = exec("git", [ "rev-parse", `${existing}^{tree}`, `${existing}~1` ]) !== exec("git", [ "rev-parse", "HEAD^{tree}", "HEAD~1" ]); + } + + if (pushed) { + git([ "push", "--quiet", "--force", url, `HEAD:refs/heads/${branch}` ]); + } + + const title = env("TITLE"); + const text = body(results); + const open = openPullRequest(branch, base); + let pr = open; + + if (open) { + gh([ "pr", "edit", String(open.number), "--repo", env("GITHUB_REPOSITORY"), "--title", title, "--body", text ]); + console.log(`${pushed ? "Refreshed" : "Already up to date:"} #${open.number} ${open.url}`); + } + else { + const created = gh([ "pr", "create", "--repo", env("GITHUB_REPOSITORY"), "--base", base, "--head", branch, "--title", title, "--body", text ]).trim(); + pr = { number: Number(created.split("/").pop()), url: created }; + console.log(`Opened #${pr.number} ${pr.url}`); + } + + output({ changed: "true", "pull-request-number": pr.number, "pull-request-url": pr.url }); + return 0; +} + +await runAction("mdcode update-readme", main); diff --git a/.intent/review-state.json b/.intent/review-state.json index f31829c..17895e0 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,11 +3,11 @@ "baseline": "5254174a2a13060d110f34dbc0a4421b5302f5cb", "items": { "skill:packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": { - "fingerprint": "3a240b6e007de3d4c1db8507e4f2d4e35fad2788b7eada45181765ec8c560d17", + "fingerprint": "11ab7659c004b4781c934e177be639495f75452eedf9d4f89755d508d356d327", "snapshot": { "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "4090c130b589f23882b7cf99f488528fdc5cdf6b4772c6d91b56a77aebba4e16", + "packages/mdcode/README.md": "65c58f7c68fd5894bc7add8bcee17678ef283689881de5961844e1d08a9b2dab", "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", @@ -32,22 +32,22 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", "outcome": "no-change", - "reason": "extract --force now refuses non-UTF-8 whole-file targets like splices do; README notes updated. The skill describes extract at a level these refusals don't change.", + "reason": "The update-readme action is a GitHub Action for consumer repositories, documented in the package README; the skill covers the mdcode CLI and library, whose behaviour this PR does not change.", "evidence": [ - "Changed: packages/mdcode/README.md, packages/mdcode/src/commands/extract.ts at 8fd753fe248a42f85ce3fd7d97624156fd74e410" + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." ] }, "planning:_artifacts": { - "fingerprint": "8a69bcf703e02764565bff53153f281369deccc14e5540753d0fd2059a636629", + "fingerprint": "6f769696dbf92b036c34be3cb3eaa1fe9bf942b467e5e4295f146b5ecb5e854b", "snapshot": { "_artifacts/domain_map.yaml": "ceb4094bf4fa2cd63354e1c76cce3fec97d19c4a807a40b974646e1dbb00152b", "_artifacts/skill_spec.md": "d0af268c94e3fefde165ce14f470c88ce52e7f8162c4c8632718fe0f959f5a23", "_artifacts/skill_tree.yaml": "2490b6a1d7a2abab698fe1a91e036ddec9dc76de5cfb15dedd8639ebf2875229", "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "4090c130b589f23882b7cf99f488528fdc5cdf6b4772c6d91b56a77aebba4e16", + "packages/mdcode/README.md": "65c58f7c68fd5894bc7add8bcee17678ef283689881de5961844e1d08a9b2dab", "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", "packages/mdcode/skills/sync-markdown-code-blocks/references/json-results.md": "ccbcb240d796eccc02c6f1974573df1f8a9052ea37808b78a15420f9e66f1f63", "packages/mdcode/skills/sync-markdown-code-blocks/references/transformers.md": "b35f5caf45b6334d21e534facb2d22a76c52f576d1640e3a5732fd1d6119cf13", @@ -72,11 +72,11 @@ "packages/usage/tests/skills/sync-markdown-code-blocks/fixture/src/greet.ts": "a2938e7cf0fa1f29393bd2ac1cb3e1f7c8a6fe0ff4c9ea8b1c47becffd4fa168", "packages/usage/tests/skills/sync-markdown-code-blocks/task.md": "69f373db5a34c8165e1abe370e63230a6652c1412f2fa44a413dfd8534d84dbc" }, - "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", "outcome": "no-change", - "reason": "extract --force now refuses non-UTF-8 whole-file targets like splices do; README notes updated. The skill describes extract at a level these refusals don't change.", + "reason": "No change to the skill's scope or sources; the actions are outside the skill.", "evidence": [ - "Changed: packages/mdcode/README.md, packages/mdcode/src/commands/extract.ts at 8fd753fe248a42f85ce3fd7d97624156fd74e410" + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." ] }, "source:.bumpy/agent-skill.md": { @@ -92,11 +92,11 @@ ] }, "source:TESTING.md": { - "fingerprint": "9030c5b4778438eba7d65740bc0edcb86434669a165a2b2e8517326c7cfbe4e2", + "fingerprint": "680e4a4d43cd4572bd9ec1c70ca508df660053cf59e1be891ce9ebf761e0982b", "snapshot": { - "TESTING.md": "278678c85357a4f689d31fbe46192c687051aa0250c54922d5a4d8ed8669f26d" + "TESTING.md": "87b36c71f041671f105a4a9cca8ad35996e38994c83bc728bcc69daf0b803528" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", "outcome": "out-of-scope", "reason": "Contributor test-layout doc; lists the new action test.", "evidence": [ @@ -176,15 +176,15 @@ ] }, "source:.github/actions/check-sync/check-sync.mjs": { - "fingerprint": "ed3e9eb0b0a2dd4053d6f44c3b386aed80b18b2daed1211e88a39ad93b8ac304", + "fingerprint": "e97c4c84e100765da8af88f7e18366f8915919473be3e52d255e567189e0596f", "snapshot": { - ".github/actions/check-sync/check-sync.mjs": "315525933185c979d643900e854d21279e0cc68d6866d2c56a01f7aacf0466b5" + ".github/actions/check-sync/check-sync.mjs": "cd8d334364e28378b2cc3b852cbb97df8f570ae4ea321574a96ac76b5ddeb76f" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", "outcome": "out-of-scope", - "reason": "The consumer check-sync action and this repo's CI dogfood step. The action is documented in the package README, not in the skill, which covers the mdcode CLI and library.", + "reason": "Consumer GitHub Action code (update-readme, and check-sync's helpers moved to a shared module); documented in the package README, not the skill.", "evidence": [ - "extract.test.ts 'extract: check' (6 tests) and region.test.ts round-trip test pass; check-sync-action.test.ts (8 tests) passes; `mdcode extract --check --force --ignore-anonymous` verified on a temp project: exit 0 in sync, exit 1 with per-block out_of_sync, files unchanged; dogfood: check-sync over this repo's 6 documents passes in both directions, and a real extract now leaves packages/mdcode/tests/examples/fibonacci/fibonacci.js unchanged." + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." ] }, "source:.github/workflows/ci_test.yml": { @@ -234,6 +234,66 @@ "evidence": [ "Changed: packages/usage/tests/check-sync-action.test.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" ] + }, + "source:.bumpy/update-readme-action.md": { + "fingerprint": "7327c13e923d273071e55d1789d8f3abcb596a88fa0bbbfb3435c5de3146938f", + "snapshot": { + ".bumpy/update-readme-action.md": "d9c50c66442cf7645cda5900d6666f8ad359adf3f791766e2b8f2200c27804eb" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "out-of-scope", + "reason": "Changelog entry for the update-readme action.", + "evidence": [ + "Minor bump file." + ] + }, + "source:.github/actions/shared/mdcode.mjs": { + "fingerprint": "2aa44aa5e73e1e28d8acc5060ec641ebe72b44fe972436702d42307a00351ade", + "snapshot": { + ".github/actions/shared/mdcode.mjs": "3dacf99cc572c312a1e0227d4d067b3788a10be1e4da1faa392bd800327c3de7" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "out-of-scope", + "reason": "Consumer GitHub Action code (update-readme, and check-sync's helpers moved to a shared module); documented in the package README, not the skill.", + "evidence": [ + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." + ] + }, + "source:.github/actions/update-readme/action.yml": { + "fingerprint": "f009588e84a1aec0d66156f3a42e5c1598e9245c5c33a97865ab56d7afa83161", + "snapshot": { + ".github/actions/update-readme/action.yml": "4b7c76787aa8df89915f8abfb5c56f9a7cc392985147afb816e72024a0d64503" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "out-of-scope", + "reason": "Consumer GitHub Action code (update-readme, and check-sync's helpers moved to a shared module); documented in the package README, not the skill.", + "evidence": [ + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." + ] + }, + "source:.github/actions/update-readme/update-readme.mjs": { + "fingerprint": "1f6db015cbb035b89c1264382970d90b963788f956ecd1c84b28eabda1e63eee", + "snapshot": { + ".github/actions/update-readme/update-readme.mjs": "9db00951da5a94b6cd955ee502491a59a75808b879381764bf8f95e4cce24abc" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "out-of-scope", + "reason": "Consumer GitHub Action code (update-readme, and check-sync's helpers moved to a shared module); documented in the package README, not the skill.", + "evidence": [ + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." + ] + }, + "source:packages/usage/tests/update-readme-action.test.ts": { + "fingerprint": "a6e12e91559b6c5a00363ee37709ef74d5ccdee0b3cb49e17398e7a6eebc3bc4", + "snapshot": { + "packages/usage/tests/update-readme-action.test.ts": "b71e4314794dd384a4c7825afe07957e6bffab9619933bf590553dc3bb74e8c9" + }, + "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "outcome": "no-change", + "reason": "Tests for the consumer action; no skill guidance affected.", + "evidence": [ + "update-readme-action.test.ts (8 tests) passes against a local bare repository and a stand-in gh: PR holds only the two documents update wrote; sources untouched; caller checkout unchanged; rerun pushes nothing; refresh on main's new tip; behind checkout still bases on tip; in-sync closes the PR; unreadable file= opens nothing; branch==base exits 2. check-sync-action.test.ts (8 tests) still passes after moving its helpers to .github/actions/shared/mdcode.mjs." + ] } } } diff --git a/CLAUDE.md b/CLAUDE.md index e37931c..bc09155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ TypeScript port of [szkiba/mdcode](https://github.com/szkiba/mdcode): keeps Mark - **Parser** (`src/parser.ts`): a custom line-by-line state machine instead of remark, so in-place updates keep exact character offsets. `scanFences()` is shared by `parse()` and `updateInfoStrings()` so block indices always agree. - **Mapping rules** (`src/commands/validate.ts`): `extract()` runs `planExtract()` before writing anything, and `update()` reads every `file=` through `readSource()`. `mdcode validate` reports the same rules without writing. With several documents, the CLI's `validateDocuments()` validates them all, plus `sharedTargetErrors()` across them, before `extract` writes any. - **Writes**: `update()` never writes; the CLI decides, and only `--apply` writes the markdown. `watch()` writes only with `apply`. `extract()` plans each target with `planTarget()` and writes it, or with `check` compares it instead. -- **Consumer GitHub Actions** (`.github/actions/check-sync/`): composite actions for other repositories, not this repo's CI. `setup-base` is internal. The script runs the mdcode-ts version in `packages/mdcode/package.json` at the action's ref; this repo's CI runs it with `mdcode-command` set to the workspace build. +- **Consumer GitHub Actions** (`.github/actions/check-sync/`, `.github/actions/update-readme/`): composite actions for other repositories, not this repo's CI; their shared code is `.github/actions/shared/mdcode.mjs`. `setup-base` is internal. The scripts run the mdcode-ts version in `packages/mdcode/package.json` at the action's ref; this repo's CI runs check-sync with `mdcode-command` set to the workspace build. - **Config** (`src/config.ts`): `mdcode.config.json` is loaded only with `--project` or `--config`. Without either, input defaults to stdin. - **Contract** (`src/result.ts`): `COMMAND_NAMES` and `ERROR_CODES` are the source of truth for commands and error codes. diff --git a/TESTING.md b/TESTING.md index 53bad6a..d26ac53 100644 --- a/TESTING.md +++ b/TESTING.md @@ -56,6 +56,8 @@ Two packages, `packages/mdcode` (published as `mdcode-ts`) and `packages/usage`. - `check-sync-action.test.ts` runs the check-sync GitHub Action's script (`.github/actions/check-sync/check-sync.mjs`) the way `action.yml` does, with inputs as environment variables and `mdcode-command` pointing at the built CLI + - `update-readme-action.test.ts` runs the update-readme action's script against a local bare + repository standing in for GitHub, with a stand-in `gh` on `PATH` that records its calls - `skill-sync-markdown-code-blocks.test.ts` grades the task in `tests/skills/sync-markdown-code-blocks/` for the shipped skill: it accepts the skill's solution and rejects the mistakes the skill warns about. Set `SKILL_TASK_DIR` to grade an agent's attempt in another directory instead diff --git a/packages/mdcode/README.md b/packages/mdcode/README.md index 2591fa8..c8e28d2 100644 --- a/packages/mdcode/README.md +++ b/packages/mdcode/README.md @@ -1575,6 +1575,82 @@ from the Markdown, writes no file, and reads only files inside each document's d under the [containment rules](#containment). Run it on `pull_request`, never `pull_request_target`, so a fork's change runs without your repository's secrets. +### GitHub Action: update-readme + +The `update-readme` action treats the source files as authoritative. It runs `mdcode update --apply` +on the selected documents and opens one pull request holding only their Markdown changes, or +refreshes the one it opened before: + +```yaml +name: Update docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-docs + +jobs: + update-readme: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: adrianbrowning/mdcode-ts/.github/actions/update-readme@ # mdcode-ts@ + with: + documents: | + README.md + docs/*.md +``` + +What it does, in order: + +1. Checks out the tip of `base-branch` (default: the repository's default branch) in a temporary git + worktree, so the caller's checkout is never changed and the update always sits on that tip. +2. Runs `mdcode update --apply` there. Only Markdown is written; source files never are, and the + commit is refused if it would change any file other than the documents `update` wrote. +3. Commits to `branch` (default `mdcode/update-docs`) and force-pushes it, unless the branch already + holds exactly this change on this tip, so a rerun with nothing new pushes nothing. +4. Opens a pull request from `branch` into `base-branch`, or updates the title and body of the open + one. The body lists every block that changed and the file it came from. + +When every block is already in sync, it pushes nothing and closes its open pull request, if there is +one: that pull request no longer matches the sources. When a block's `file=` or `region=` cannot be +read, it opens nothing and exits 1 with an annotation per block. It never pushes to `base-branch`; +setting `branch` to the same name is an error. + +| Input | Default | Meaning | +|-------|---------|---------| +| `documents` | | Markdown files to update, one path or glob per line | +| `base` | | The directory `file=` resolves against (`--base`); default each document's own directory | +| `project`, `config` | | Use `mdcode.config.json` or this configuration file, as for check-sync | +| `branch` | `mdcode/update-docs` | The branch the action owns and force-pushes | +| `base-branch` | the default branch | The branch the pull request targets | +| `title`, `commit-message` | `docs: update code blocks from their source files` | | +| `author-name`, `author-email` | `github-actions[bot]` | The commit's author | +| `token` | `github.token` | Pushes the branch and opens the pull request | +| `working-directory`, `node-version`, `mdcode-command` | | As for check-sync. A relative `mdcode-command` path resolves inside the temporary worktree | + +Outputs: `changed` (`true` when the pull request was opened or refreshed), `pull-request-number` and +`pull-request-url`. + +Permissions and safety: + +- The job needs `contents: write` and `pull-requests: write`, and with the default `github.token` the + repository setting *Allow GitHub Actions to create and approve pull requests* must be on. +- Pull requests opened with `github.token` do not start workflows, so your checks will not run on + them. To have them run, pass a GitHub App token (for example from `actions/create-github-app-token`) + or a fine-grained personal access token with contents and pull requests write as `token`. +- Run it on `push` to your default branch, a `schedule` or `workflow_dispatch`: events whose + Markdown has already been reviewed. Never run it on `pull_request_target` or on a fork's code; the + action holds a write token, and a pull request's Markdown decides which files `update` reads. +- It runs no code from the Markdown and offers no `--transform`. It reads only files inside each + document's directory or `base`, under the [containment rules](#containment). --- diff --git a/packages/usage/tests/update-readme-action.test.ts b/packages/usage/tests/update-readme-action.test.ts new file mode 100644 index 0000000..7053a06 --- /dev/null +++ b/packages/usage/tests/update-readme-action.test.ts @@ -0,0 +1,289 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/** + * The update-readme GitHub Action's script (.github/actions/update-readme), + * run the way action.yml runs it, against a local bare repository standing in + * for GitHub and a stand-in `gh` that records its calls. + */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { delimiter, dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; + +import { cleanupTempDir, CLI_PATH, createTempDir } from "./test-utils.ts"; + +const SCRIPT = join(import.meta.dirname, "..", "..", "..", ".github", "actions", "update-readme", "update-readme.mjs"); +const BRANCH = "mdcode/update-docs"; + +const dirs: Array = []; + +after(async () => { + await Promise.all(dirs.map(cleanupTempDir)); +}); + +const fence = (info: string, code: string): string => `\`\`\`${info}\n${code}\n\`\`\`\n`; + +/** A stand-in gh: records each call, and keeps open pull requests in a JSON file. */ +const FAKE_GH = `#!${process.execPath} +const { appendFileSync, existsSync, readFileSync, writeFileSync } = require("node:fs"); +const args = process.argv.slice(2); +const state = process.env.FAKE_GH_STATE; +const prs = existsSync(state) ? JSON.parse(readFileSync(state, "utf-8")) : []; +appendFileSync(process.env.FAKE_GH_LOG, JSON.stringify({ args, token: process.env.GH_TOKEN }) + "\\n"); +const value = flag => args[args.indexOf(flag) + 1]; +if (args[1] === "list") { + console.log(JSON.stringify(prs.filter(pr => pr.head === value("--head") && pr.base === value("--base")))); +} +else if (args[1] === "create") { + const number = prs.length + 1; + prs.push({ number, url: "https://github.com/owner/repo/pull/" + number, head: value("--head"), base: value("--base"), title: value("--title"), body: value("--body") }); + writeFileSync(state, JSON.stringify(prs)); + console.log(prs.at(-1).url); +} +else if (args[1] === "edit") { + Object.assign(prs.find(pr => pr.number === Number(args[2])), { title: value("--title"), body: value("--body") }); + writeFileSync(state, JSON.stringify(prs)); +} +else if (args[1] === "close") { + writeFileSync(state, JSON.stringify(prs.filter(pr => pr.number !== Number(args[2])))); +} +`; + +type Repo = { dir: string; work: string; remote: string; bin: string; }; + +/** A bare "GitHub" repository with main holding these files, and a clone of it checked out at main. */ +async function repository(files: Record): Promise { + const dir = await createTempDir(); + dirs.push(dir); + const remote = join(dir, "server", "owner", "repo.git"); + const work = join(dir, "work"); + const bin = join(dir, "bin"); + + await mkdir(remote, { recursive: true }); + await mkdir(bin); + await writeFile(join(bin, "gh"), FAKE_GH, "utf-8"); + await chmod(join(bin, "gh"), 0o755); + execFileSync("git", [ "init", "--quiet", "--bare", "--initial-branch=main", remote ]); + execFileSync("git", [ "clone", "--quiet", remote, work ], { stdio: "ignore" }); + await commit({ dir, work, remote, bin }, files, "initial"); + + return { dir, work, remote, bin }; +} + +function git(repo: Repo, ...args: Array): string { + return execFileSync("git", [ "-c", "user.name=Test", "-c", "user.email=test@example.com", ...args ], { cwd: repo.work, encoding: "utf-8" }).trim(); +} + +/** Commit these files to main and push it, as a developer would. */ +async function commit(repo: Repo, files: Record, message: string): Promise { + for (const [ path, content ] of Object.entries(files)) { + await mkdir(dirname(join(repo.work, path)), { recursive: true }); + await writeFile(join(repo.work, path), content, "utf-8"); + } + git(repo, "add", "--all"); + git(repo, "commit", "--quiet", "-m", message); + git(repo, "push", "--quiet", "origin", "HEAD:refs/heads/main"); +} + +/** The commit a branch of the remote points at, or "" when it does not exist. */ +function remoteBranch(repo: Repo, branch: string): string { + return execFileSync("git", [ "ls-remote", repo.remote, `refs/heads/${branch}` ], { encoding: "utf-8" }).split(/\s/)[0]!; +} + +function show(repo: Repo, ref: string, path: string): string { + return execFileSync("git", [ "--git-dir", repo.remote, "show", `${ref}:${path}` ], { encoding: "utf-8" }); +} + +function changedFiles(repo: Repo, from: string, to: string): Array { + return execFileSync("git", [ "--git-dir", repo.remote, "diff", "--name-only", from, to ], { encoding: "utf-8" }).trim() + .split("\n") + .filter(Boolean); +} + +type GhCall = { args: Array; token: string; }; + +async function ghCalls(repo: Repo): Promise> { + const log = await readFile(join(repo.dir, "gh.log"), "utf-8").catch(() => ""); + return log.trim().split("\n") + .filter(Boolean) + .map(line => JSON.parse(line) as GhCall); +} + +async function openPrs(repo: Repo): Promise> { + return JSON.parse(await readFile(join(repo.dir, "gh.json"), "utf-8").catch(() => "[]")) as Array<{ number: number; title: string; body: string; }>; +} + +type ActionRun = { code: number | null; stdout: string; outputs: Record; }; + +async function action(repo: Repo, inputs: Record = {}): Promise { + const outputs = join(repo.dir, `outputs-${Date.now()}-${Math.random()}.txt`); + const run = spawnSync(process.execPath, [ SCRIPT ], { + cwd: repo.work, + encoding: "utf-8", + env: { + PATH: [ repo.bin, dirname(process.execPath), "/usr/bin", "/bin" ].join(delimiter), + HOME: repo.dir, + GITHUB_SERVER_URL: `file://${join(repo.dir, "server")}`, + GITHUB_REPOSITORY: "owner/repo", + GITHUB_OUTPUT: outputs, + FAKE_GH_STATE: join(repo.dir, "gh.json"), + FAKE_GH_LOG: join(repo.dir, "gh.log"), + DOCUMENTS: "README.md\nmy docs/*.md", + BRANCH, + BASE_BRANCH: "main", + TITLE: "docs: update code blocks", + COMMIT_MESSAGE: "docs: update code blocks", + AUTHOR_NAME: "github-actions[bot]", + AUTHOR_EMAIL: "bot@example.com", + TOKEN: "test-token", + MDCODE_COMMAND: `"${process.execPath}" "${CLI_PATH}"`, + ...inputs, + }, + }); + const raw = await readFile(outputs, "utf-8").catch(() => ""); + + return { + code: run.status, + stdout: run.stdout + run.stderr, + outputs: Object.fromEntries(raw.trim().split("\n") + .filter(Boolean) + .map(line => line.split(/=(.*)/s).slice(0, 2) as [ string, string ])), + }; +} + +const GREET = "// #region greet\nexport const greet = (name: string): string => `Hello, ${name}!`;\n// #endregion\n"; +const SYNCED = { + "src/greet.ts": GREET, + "my docs/hello world.ts": "export const hello = 1;\n", + "README.md": fence("ts file=src/greet.ts region=greet name=greet", "export const greet = (name: string): string => `Hello, ${name}!`;"), + "my docs/guide.md": fence("ts file=\"hello world.ts\" name=hello", "export const hello = 1;"), +}; +const STALE = { "src/greet.ts": GREET.replace("Hello", "Hi"), "my docs/hello world.ts": "export const hello = 2;\n" }; + +describe("update-readme action", () => { + it("opens a pull request holding only the Markdown, leaving sources and the caller's checkout alone", async () => { + const repo = await repository(SYNCED); + await commit(repo, STALE, "change the sources"); + const main = git(repo, "rev-parse", "HEAD"); + // Unrelated work in the caller's checkout must not reach the pull request. + await writeFile(join(repo.work, "notes.txt"), "scratch\n", "utf-8"); + git(repo, "add", "notes.txt"); + + const run = await action(repo); + + assert.equal(run.code, 0, run.stdout); + const head = remoteBranch(repo, BRANCH); + assert.deepEqual(changedFiles(repo, main, head), [ "README.md", "my docs/guide.md" ], "only the documents update wrote"); + assert.match(show(repo, head, "README.md"), /`Hi, \$\{name\}!`/); + assert.equal(show(repo, head, "src/greet.ts"), STALE["src/greet.ts"], "the source is untouched"); + assert.equal(execFileSync("git", [ "--git-dir", repo.remote, "rev-parse", `${head}~1` ], { encoding: "utf-8" }).trim(), main, "the commit sits on main's tip"); + assert.equal(remoteBranch(repo, "main"), main, "main is never pushed to"); + assert.equal(git(repo, "rev-parse", "HEAD"), main); + assert.equal(git(repo, "status", "--porcelain"), "A notes.txt", "the caller's checkout is as it was"); + assert.deepEqual(run.outputs, { "changed": "true", "pull-request-number": "1", "pull-request-url": "https://github.com/owner/repo/pull/1" }); + + const [ pr ] = await openPrs(repo); + assert.match(pr!.body, /\| README\.md \| 1 \| greet \| src\/greet\.ts \(region greet\) \|/); + assert.match(pr!.body, /\| my docs\/guide\.md \| 1 \| hello \| hello world\.ts \|/); + assert.ok((await ghCalls(repo)).every(({ token }) => token === "test-token")); + }); + + it("is idempotent: a rerun with nothing new pushes nothing and opens no second pull request", async () => { + const repo = await repository(SYNCED); + await commit(repo, STALE, "change the sources"); + + await action(repo); + const first = remoteBranch(repo, BRANCH); + const again = await action(repo); + + assert.equal(again.code, 0, again.stdout); + assert.match(again.stdout, /Already up to date: #1/); + assert.equal(remoteBranch(repo, BRANCH), first); + assert.equal((await openPrs(repo)).length, 1); + assert.equal((await ghCalls(repo)).filter(({ args }) => args[1] === "create").length, 1); + }); + + it("refreshes the same pull request on top of main's new tip when the sources change again", async () => { + const repo = await repository(SYNCED); + await commit(repo, STALE, "change the sources"); + await action(repo); + const first = remoteBranch(repo, BRANCH); + + await commit(repo, { "src/greet.ts": GREET.replace("Hello", "Hey") }, "change again"); + const main = git(repo, "rev-parse", "HEAD"); + const run = await action(repo); + + const head = remoteBranch(repo, BRANCH); + assert.equal(run.code, 0, run.stdout); + assert.match(run.stdout, /Refreshed #1/); + assert.notEqual(head, first); + assert.equal(execFileSync("git", [ "--git-dir", repo.remote, "rev-parse", `${head}~1` ], { encoding: "utf-8" }).trim(), main); + assert.match(show(repo, head, "README.md"), /`Hey, \$\{name\}!`/); + assert.equal((await openPrs(repo)).length, 1); + }); + + it("uses the base branch's tip even when the checkout is behind it", async () => { + const repo = await repository(SYNCED); + const behind = git(repo, "rev-parse", "HEAD"); + await commit(repo, STALE, "change the sources"); + const main = git(repo, "rev-parse", "HEAD"); + git(repo, "checkout", "--quiet", behind); + + const run = await action(repo); + + assert.equal(run.code, 0, run.stdout); + assert.equal(execFileSync("git", [ "--git-dir", repo.remote, "rev-parse", `${remoteBranch(repo, BRANCH)}~1` ], { encoding: "utf-8" }).trim(), main); + }); + + it("does nothing on a checkout whose blocks are already in sync", async () => { + const repo = await repository(SYNCED); + + const run = await action(repo); + + assert.equal(run.code, 0, run.stdout); + assert.match(run.stdout, /✓ Every code block is in sync with its source file; nothing to do\./); + assert.deepEqual(run.outputs, { changed: "false" }); + assert.equal(remoteBranch(repo, BRANCH), ""); + assert.deepEqual((await ghCalls(repo)).map(({ args }) => args[1]), [ "list" ], "it only looks for a pull request to close"); + }); + + it("closes its pull request once main is in sync, and pushes nothing", async () => { + const repo = await repository(SYNCED); + await commit(repo, STALE, "change the sources"); + await action(repo); + const branch = remoteBranch(repo, BRANCH); + + // Someone merges the update by hand. + git(repo, "fetch", "--quiet", "origin", BRANCH); + git(repo, "merge", "--quiet", "--ff-only", "FETCH_HEAD"); + git(repo, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + const run = await action(repo); + + assert.equal(run.code, 0, run.stdout); + assert.match(run.stdout, /Closed #1: the blocks are already in sync\./); + assert.deepEqual(run.outputs, { changed: "false" }); + assert.deepEqual(await openPrs(repo), []); + assert.equal(remoteBranch(repo, BRANCH), branch, "nothing is pushed"); + }); + + it("opens no pull request when a block's file= cannot be read", async () => { + const repo = await repository({ ...SYNCED, "README.md": `${SYNCED["README.md"]}\n${fence("ts file=src/missing.ts", "x")}` }); + await commit(repo, STALE, "change the sources"); + + const run = await action(repo); + + assert.equal(run.code, 1); + assert.match(run.stdout, /^::error file=README\.md,line=5,title=mdcode read_failed::src\/missing\.ts does not exist in \/.*\/work; /m); + assert.equal(remoteBranch(repo, BRANCH), ""); + assert.deepEqual(await ghCalls(repo), []); + }); + + it("refuses to use the base branch as its own branch", async () => { + const repo = await repository(SYNCED); + + const run = await action(repo, { BRANCH: "main" }); + + assert.equal(run.code, 2); + assert.match(run.stdout, /^::error title=mdcode update-readme::branch and base-branch are both main/m); + }); +});