From 73dc571d6261b46081bf930855242dcfc91a2e67 Mon Sep 17 00:00:00 2001 From: Adrian Elton-Browning Date: Thu, 8 Oct 2026 16:45:41 +0100 Subject: [PATCH] feat(extract)!: extract blocks without file= only with --update-source (#26) --- .bumpy/anonymous-only-with-update-source.md | 5 + .bumpy/library-cli-boundary.md | 2 +- .github/actions/check-sync/action.yml | 4 - .github/actions/check-sync/check-sync.mjs | 3 +- .intent/review-state.json | 142 +++++++++++------- _artifacts/domain_map.yaml | 12 +- _artifacts/skill_spec.md | 6 + examples/CLI_EXAMPLES.md | 2 +- examples/ci/validate-snippets.mjs | 18 ++- packages/mdcode/README.md | 62 ++++---- .../skills/sync-markdown-code-blocks/SKILL.md | 19 ++- packages/mdcode/src/cli.ts | 18 +-- packages/mdcode/src/commands/extract.test.ts | 17 ++- packages/mdcode/src/commands/extract.ts | 23 ++- packages/mdcode/src/commands/validate.test.ts | 6 +- packages/mdcode/src/commands/validate.ts | 22 +-- .../usage/tests/check-sync-action.test.ts | 1 - packages/usage/tests/cli-integration.test.ts | 3 +- packages/usage/tests/json-contract.test.ts | 6 +- packages/usage/tests/validate.test.ts | 10 +- 20 files changed, 217 insertions(+), 164 deletions(-) create mode 100644 .bumpy/anonymous-only-with-update-source.md diff --git a/.bumpy/anonymous-only-with-update-source.md b/.bumpy/anonymous-only-with-update-source.md new file mode 100644 index 0000000..110443d --- /dev/null +++ b/.bumpy/anonymous-only-with-update-source.md @@ -0,0 +1,5 @@ +--- +mdcode-ts: minor +--- + +Breaking: `extract` no longer writes blocks without `file=` by default. A plain `mdcode extract README.md` used to write every untagged block as `block-.` beside the README; now it writes only the blocks that have `file=`. To extract anonymous blocks, pass `--update-source` (`updateSource: true`), which also adds their generated `file=` to the Markdown. `--ignore-anonymous` is removed from `extract` and `validate`, and so is the library's `ignoreAnonymous` option; drop it, since skipping is now the default. `validate --for extract` reports anonymous blocks with `path: null`. The check-sync GitHub Action drops its `ignore-anonymous` input. diff --git a/.bumpy/library-cli-boundary.md b/.bumpy/library-cli-boundary.md index 92ce1be..f38f3c6 100644 --- a/.bumpy/library-cli-boundary.md +++ b/.bumpy/library-cli-boundary.md @@ -2,4 +2,4 @@ mdcode-ts: minor --- -Removed `transform()` and `transformWithFunction()`, which printed every block to the console and `transform()` read stdin. `update({ source, transformer, filter })` replaces `transformWithFunction()` but reads each `file=` first, so a missing file is a `read_failed` error (thrown, or collected with `continueOnError`); use `walk()` to transform without reading files. Added `update({ onBlock })` to report each block as it finishes, and `extract()` now throws an `invalid_usage` error for `updateSource` with `ignoreAnonymous`. +Removed `transform()` and `transformWithFunction()`, which printed every block to the console and `transform()` read stdin. `update({ source, transformer, filter })` replaces `transformWithFunction()` but reads each `file=` first, so a missing file is a `read_failed` error (thrown, or collected with `continueOnError`); use `walk()` to transform without reading files. Added `update({ onBlock })` to report each block as it finishes. diff --git a/.github/actions/check-sync/action.yml b/.github/actions/check-sync/action.yml index 9f5810e..44b4a69 100644 --- a/.github/actions/check-sync/action.yml +++ b/.github/actions/check-sync/action.yml @@ -25,9 +25,6 @@ inputs: 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' @@ -69,7 +66,6 @@ runs: 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 }} diff --git a/.github/actions/check-sync/check-sync.mjs b/.github/actions/check-sync/check-sync.mjs index 6c7510a..36856f0 100755 --- a/.github/actions/check-sync/check-sync.mjs +++ b/.github/actions/check-sync/check-sync.mjs @@ -41,7 +41,8 @@ function runs(direction, 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 ]; + // Blocks without file= link to no file, and extract leaves them out. + const extract = [ "extract", "--check", "--force", ...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"); diff --git a/.intent/review-state.json b/.intent/review-state.json index 809241e..f82cdef 100644 --- a/.intent/review-state.json +++ b/.intent/review-state.json @@ -3,21 +3,21 @@ "baseline": "5254174a2a13060d110f34dbc0a4421b5302f5cb", "items": { "skill:packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": { - "fingerprint": "11ab7659c004b4781c934e177be639495f75452eedf9d4f89755d508d356d327", + "fingerprint": "88fc53cdc77700d5a3faf1d02d161a495a300902716186be53bae71b8401ccc8", "snapshot": { "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", - "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "65c58f7c68fd5894bc7add8bcee17678ef283689881de5961844e1d08a9b2dab", - "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", + "examples/ci/validate-snippets.mjs": "a1559370a4c829f097823290a6a0345b710619b8a2cd6dd56dae7f32fe271822", + "packages/mdcode/README.md": "e848ff4c76a3afc79ac534febd60c463e0cfa7a4c96b6460e413aff93eeaa8f9", + "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "d373b34f843f33cf7e61e017c29f5a392daaa1ed000ad6c55aac5f061d2da7f1", "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/cli.ts": "3d3d6d61f98f88435a0730c94d8d4eea369d6899e3edd8858d8ba8a096c68745", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "c798ac7b48031019c5c0d5b9dbee34f88c4c4fdb7b587b406c81a4aa37aab955", + "packages/mdcode/src/commands/extract.ts": "84e4e148693c72bf3681683b3f0e808ca47b3f7e594528647b636c7f025be4c6", "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/commands/validate.ts": "6174d077d8b6521bdde97649e32f34c7fd7fc3b89f0f8c313aa3709b00912b73", "packages/mdcode/src/outline.ts": "56a871dc88cd65a82c5ded1bbfcea1fd516f093b8fc15ade6fe92da2bba9e7f8", "packages/mdcode/src/parser.ts": "d0595a4beea7239c7f68934998b59a21712c5a90d1f0c9eac35a2f6e32e41c60", "packages/mdcode/src/region.ts": "678935dfac7770e9416a16ac3d8408102b9386ad92e91d609a31e05ab24577a6", @@ -32,32 +32,32 @@ "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": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", - "outcome": "no-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.", + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", + "outcome": "updated", + "reason": "Extract section drops --ignore-anonymous, says blocks without file= are skipped, and describes --update-source as the only way they become block- files; the 'plain extract on a README' mistake becomes 'extract --update-source on a README'.", "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." + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." ] }, "planning:_artifacts": { - "fingerprint": "bd7d260802faa273a66c0c86057b84d8012084e689adea10cbc3e0664f2bd848", + "fingerprint": "039274bf0ca5e78cbf05bcf5b326e82a3f29d77701bc362a39de676dbaf0791f", "snapshot": { - "_artifacts/domain_map.yaml": "ceb4094bf4fa2cd63354e1c76cce3fec97d19c4a807a40b974646e1dbb00152b", - "_artifacts/skill_spec.md": "d0af268c94e3fefde165ce14f470c88ce52e7f8162c4c8632718fe0f959f5a23", + "_artifacts/domain_map.yaml": "ebf5df25aa719ab2a029668673605fc5f4ede936177e989eeb56d6509da146a7", + "_artifacts/skill_spec.md": "cc63a652482fe74b6caaf0f3798312b67d5315f346fd1e0e9622edf21d655f27", "_artifacts/skill_tree.yaml": "dd7871f46459a8985bccdf8bd485eaa86366baf0eb76ddff2fa8da69a382ac2c", "examples/ci/check-docs-sync.mjs": "7a1dc943571637883dfc28de98825c3c595d6c5a5561239373bacf1dfbf1dc72", - "examples/ci/validate-snippets.mjs": "d177fba60f830179c285a25ea0a878d98dc044684114447f687366907e9e722d", - "packages/mdcode/README.md": "65c58f7c68fd5894bc7add8bcee17678ef283689881de5961844e1d08a9b2dab", - "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "b553026d702b291bc829c0b76de1f2c75fcc7a5ffe62af6084e782cb0d120566", + "examples/ci/validate-snippets.mjs": "a1559370a4c829f097823290a6a0345b710619b8a2cd6dd56dae7f32fe271822", + "packages/mdcode/README.md": "e848ff4c76a3afc79ac534febd60c463e0cfa7a4c96b6460e413aff93eeaa8f9", + "packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md": "d373b34f843f33cf7e61e017c29f5a392daaa1ed000ad6c55aac5f061d2da7f1", "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/cli.ts": "3d3d6d61f98f88435a0730c94d8d4eea369d6899e3edd8858d8ba8a096c68745", "packages/mdcode/src/commands/dump.ts": "c51ca44f0159c59b849c5cb725e2de36ca5ef0578356bc9f921330ce8677d346", - "packages/mdcode/src/commands/extract.ts": "c798ac7b48031019c5c0d5b9dbee34f88c4c4fdb7b587b406c81a4aa37aab955", + "packages/mdcode/src/commands/extract.ts": "84e4e148693c72bf3681683b3f0e808ca47b3f7e594528647b636c7f025be4c6", "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/commands/validate.ts": "6174d077d8b6521bdde97649e32f34c7fd7fc3b89f0f8c313aa3709b00912b73", "packages/mdcode/src/outline.ts": "56a871dc88cd65a82c5ded1bbfcea1fd516f093b8fc15ade6fe92da2bba9e7f8", "packages/mdcode/src/parser.ts": "d0595a4beea7239c7f68934998b59a21712c5a90d1f0c9eac35a2f6e32e41c60", "packages/mdcode/src/region.ts": "678935dfac7770e9416a16ac3d8408102b9386ad92e91d609a31e05ab24577a6", @@ -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": "a3a017dfd464bc684bfec8a991698e268e65452e", + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", "outcome": "updated", - "reason": "skill_tree.yaml gains review.ignore for .bumpy/** and packages/mdcode/CHANGELOG.md: release bookkeeping that bumpy's Version Packages PR deletes or writes, and that never informs the skill. Skill sources are unchanged and still reviewed.", + "reason": "domain_map.yaml covers --update-source instead of --ignore-anonymous and updates the failure mode; skill_spec.md adds batch 4 for #26.", "evidence": [ - "Changed: _artifacts/skill_tree.yaml at a3a017dfd464bc684bfec8a991698e268e65452e" + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." ] }, "source:.bumpy/agent-skill.md": { @@ -116,15 +116,15 @@ ] }, "source:examples/CLI_EXAMPLES.md": { - "fingerprint": "afdf039600b52fd6a5b65b1cff64d8c7ef18f6782778553792073683ad0be8e0", + "fingerprint": "bbc4823c6fe150a5e9a351335d93939307e13acb9a97d0eb453ba92aa60ad599", "snapshot": { - "examples/CLI_EXAMPLES.md": "b9546828eaaa027339dbc08746374570ce25e8b0badbb29305af0d3436ca1fdb" + "examples/CLI_EXAMPLES.md": "09f99c1182de1c550e070640843757a9a636b28c68500318d668247bebc15e06" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", "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": "Tests and examples updated for the new extract default; the skill's guidance was updated separately.", "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." + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." ] }, "source:packages/mdcode/src/types.ts": { @@ -140,15 +140,15 @@ ] }, "source:packages/usage/tests/cli-integration.test.ts": { - "fingerprint": "66381fd8482be689bdb8f5b47081edc43d913907e75825d6becb7bc9cdd0ad51", + "fingerprint": "23c59d4b395fbde27898d5ace0189f8c61ae705afe20b8e4dc3c591af3dae56a", "snapshot": { - "packages/usage/tests/cli-integration.test.ts": "b6d519515c11fbca0632b50925c02a9af55f11a4928e0e0f10d462a9856cda38" + "packages/usage/tests/cli-integration.test.ts": "3200a12dcc9f14632a96d414701987d818d20260b97cf3ce52985337946cdce9" }, - "head": "47705d8f2d87d0c8d1ecddbe4f5571b91cae5bd8", + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", "outcome": "no-change", - "reason": "Whitespace-only lint fix to the --meta table test (array bracket spacing); no guidance affected.", + "reason": "Tests and examples updated for the new extract default; the skill's guidance was updated separately.", "evidence": [ - "Changed: packages/usage/tests/cli-integration.test.ts at 47705d8f2d87d0c8d1ecddbe4f5571b91cae5bd8" + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." ] }, "source:.bumpy/extract-check-and-check-sync.md": { @@ -164,27 +164,27 @@ ] }, "source:.github/actions/check-sync/action.yml": { - "fingerprint": "05e5075c1199e61c25ea23a1db0987c85b78745395b92ef45bafc81dbc451aae", + "fingerprint": "4633beb721055377ae7166cf6598d40f6da32ba3a45f84f8923ea130b565810e", "snapshot": { - ".github/actions/check-sync/action.yml": "538a6a01b4343e3391ab6b3a25b591e913307e6c7489a73d41759152fdd2feb2" + ".github/actions/check-sync/action.yml": "45fa535d9b192a9524b9b9d9abbdb8b91b8a463a337becd1756174df5dc417d1" }, - "head": "96c394e80f4b69ee75e1d0d0456218a62ffac326", + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", "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": "check-sync action drops its ignore-anonymous input; the action is 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." + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." ] }, "source:.github/actions/check-sync/check-sync.mjs": { - "fingerprint": "e97c4c84e100765da8af88f7e18366f8915919473be3e52d255e567189e0596f", + "fingerprint": "f9750bfe01ded8d4932bd617b0309fd896a817c87415705f9b69d05ffa92ca31", "snapshot": { - ".github/actions/check-sync/check-sync.mjs": "cd8d334364e28378b2cc3b852cbb97df8f570ae4ea321574a96ac76b5ddeb76f" + ".github/actions/check-sync/check-sync.mjs": "6de5a4f6aa9c5875595b8036987b0424e603ba8f969fd536f4f4640c4b987902" }, - "head": "778a87a0e1ae7a33fd71ee5cbb9adf13869544d5", + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", "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.", + "reason": "check-sync action drops its ignore-anonymous input; the action is 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." + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." ] }, "source:.github/workflows/ci_test.yml": { @@ -200,15 +200,15 @@ ] }, "source:packages/mdcode/src/commands/extract.test.ts": { - "fingerprint": "cfef2d2213bfb2e4f7386cf8b79cfa723c3372e5190ecc225603358b73d335b7", + "fingerprint": "54af3480d3671ccbb615faf12503eca07dc27c5dd1626c4e31a5347cd6f91e6a", "snapshot": { - "packages/mdcode/src/commands/extract.test.ts": "36a6baa0ddd1ff41f0f366b4c7bc45dbf66f6adb785eded1fd4ebc77ffd2f035" + "packages/mdcode/src/commands/extract.test.ts": "a97ea3bcc69d0d261b087ffc6b5c56bf473c3acd2d23bdfcf52ab9014f88aa46" }, - "head": "8fd753fe248a42f85ce3fd7d97624156fd74e410", + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", "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": "Tests and examples updated for the new extract default; the skill's guidance was updated separately.", "evidence": [ - "Changed: packages/mdcode/src/commands/extract.test.ts at 8fd753fe248a42f85ce3fd7d97624156fd74e410" + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." ] }, "source:packages/mdcode/src/region.test.ts": { @@ -224,15 +224,15 @@ ] }, "source:packages/usage/tests/check-sync-action.test.ts": { - "fingerprint": "f702d16f2c0ae7c47b12aa8c49fa2b66044ec27ef3a78407d0a1ab9940607f24", + "fingerprint": "75a2f182819133181aa0db953e7407ec9d8c4f8326c92dbec11011520fdd362a", "snapshot": { - "packages/usage/tests/check-sync-action.test.ts": "d72e4db5c5c25fdf28c107c6fcfd896cab2e9e2a09fb7541bdcd6e8b178dc4e9" + "packages/usage/tests/check-sync-action.test.ts": "ee39509593297a62b89f305545241c8b51fb990b1fe0e10dabd8ea7807ece6a0" }, - "head": "4fc4ec83eccda6aeb542cc6b552262701c8bdc16", + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", "outcome": "no-change", - "reason": "Formatting-only lint fixes (chained-call line breaks, import order); no guidance affected.", + "reason": "Tests and examples updated for the new extract default; the skill's guidance was updated separately.", "evidence": [ - "Changed: packages/usage/tests/check-sync-action.test.ts at 4fc4ec83eccda6aeb542cc6b552262701c8bdc16" + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." ] }, "source:.bumpy/update-readme-action.md": { @@ -306,6 +306,42 @@ "evidence": [ "Changed: .out-of-scope/extract-shrink-guard.md at 238c32e5534770f961ab48e46c81674d88bec474" ] + }, + "source:packages/mdcode/src/commands/validate.test.ts": { + "fingerprint": "b0fbe8e0059223783ee9d032d018f11b85ba57e0f30b53a8acfa72fc6fecfb43", + "snapshot": { + "packages/mdcode/src/commands/validate.test.ts": "e42ae6884b5c5bd7dbf0209a507c16a68b30fa3109df059fead5e20af040fe1d" + }, + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", + "outcome": "no-change", + "reason": "Tests and examples updated for the new extract default; the skill's guidance was updated separately.", + "evidence": [ + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." + ] + }, + "source:packages/usage/tests/json-contract.test.ts": { + "fingerprint": "a796cd3e9899c47bf315c0b5a03d8e1f33f371825da5236118c849f2bac9897f", + "snapshot": { + "packages/usage/tests/json-contract.test.ts": "d7a4c63906892ce06b4cef1e4f56cd23c9e9017fd18acdfa861bba61f3b9451a" + }, + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", + "outcome": "no-change", + "reason": "Tests and examples updated for the new extract default; the skill's guidance was updated separately.", + "evidence": [ + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." + ] + }, + "source:packages/usage/tests/validate.test.ts": { + "fingerprint": "03c3611e077f8311c2b2d65f0891d80d9bb9faa89a3499e42336d9cfff8b3a20", + "snapshot": { + "packages/usage/tests/validate.test.ts": "8cd2fb392759c4116675c0a1cb65c036f4a1e50415877b7983e56e0bb797b684" + }, + "head": "9b1b05204515bd82b49c250920335623dcfe3f3a", + "outcome": "no-change", + "reason": "Tests and examples updated for the new extract default; the skill's guidance was updated separately.", + "evidence": [ + "Built CLI: plain `extract README.md` wrote only a.js (exit 0, no block-* files); `--update-source` wrote block-2.sh and block-3.json and added their file=; two documents with an untagged sh block: plain extract exits 0 and writes nothing, `--update-source` exits 1 with ambiguous_target for both and leaves both documents unchanged; `--ignore-anonymous` is an unknown option for extract and validate; passing `ignoreAnonymous` to extract() is TS2353. pnpm test passes; docs:examples passes (6 runnable blocks via validate-snippets on stdin with --update-source)." + ] } } } diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index a0fc37a..220644c 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, --check + - extract with region splicing, --force, --update-source, --dir, --check - run --allow-shell and dump - --transform modules and the update(), parse(), extract() library functions - --json envelope and exit codes @@ -65,13 +65,13 @@ skills: source: src/cli.ts (UPDATE_MODES); packages/usage/tests/skill-sync-markdown-code-blocks.test.ts priority: HIGH status: active - - mistake: Plain extract on a README - mechanism: every block without file= is written as block-N. into --dir + - mistake: extract --update-source on a README + mechanism: every block without file= is written as block-N. into --dir, and the Markdown is rewritten to point at it wrong_pattern: | - mdcode extract README.md + mdcode extract --update-source README.md correct_pattern: | - mdcode validate --for extract README.md && mdcode extract --ignore-anonymous README.md - source: src/commands/validate.ts (planExtract) + mdcode validate --for extract README.md && mdcode extract README.md + source: src/commands/extract.ts (updateSource) priority: HIGH status: active - mistake: Overwriting existing files with --force diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index 828106a..1f85349 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -74,3 +74,9 @@ ships the `mdcode` CLI and a library API from the workspace package `packages/md - **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. + +### Batch 4 — 2026-10-08, mdcode-ts 0.0.4 (issue #26) + +- **Change:** `extract` skips blocks without `file=` unless `--update-source` is given, which writes them as `block-.` and adds that `file=` to the Markdown. `--ignore-anonymous` and the library's `ignoreAnonymous` are removed. `validate --for extract` reports anonymous blocks with `path: null`. The check-sync action drops its `ignore-anonymous` input. +- **Guidance:** the Extract section's examples drop `--ignore-anonymous` and describe `--update-source` as the only way anonymous blocks become files. The "plain extract on a README" common mistake is replaced by "extract --update-source on a README". The domain map's failure mode is updated to match. +- **Checks:** `extract.test.ts` "skips blocks without file= unless updateSource names them in the markdown"; `validate.test.ts` counts generated names only with `updateSource`; usage tests for the JSON contract, `validate --for extract`, `--meta` parsing and `validate-snippets` (which now pipes each document to `extract --update-source` so its Markdown is never rewritten). The skill task check still passes. diff --git a/examples/CLI_EXAMPLES.md b/examples/CLI_EXAMPLES.md index c39c10b..0c632e5 100644 --- a/examples/CLI_EXAMPLES.md +++ b/examples/CLI_EXAMPLES.md @@ -269,7 +269,7 @@ mdcode extract README.md 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 +mdcode extract --check --force README.md ``` ### Basic Usage diff --git a/examples/ci/validate-snippets.mjs b/examples/ci/validate-snippets.mjs index 68e093a..133d769 100755 --- a/examples/ci/validate-snippets.mjs +++ b/examples/ci/validate-snippets.mjs @@ -29,7 +29,7 @@ // 2 at least one document could not be validated (extract failed, no // runnable=true blocks anywhere, or mdcode not found, bad arguments) import { spawn, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { constants, tmpdir } from "node:os"; import { basename, join, relative, resolve } from "node:path"; @@ -78,7 +78,21 @@ function parseArguments(argv) { /** Extract one document's runnable blocks into workspace; returns the files written, or why it could not. */ function extractRunnable(document, workspace) { - const run = spawnSync("mdcode", [ "extract", "--meta", "runnable=true", "--dir", workspace, "--json", document ], { + let markdown; + + try { + markdown = readFileSync(document, "utf8"); + } + catch (error) { + // The same form as mdcode's own io_error, which it reported when it read the document itself. + return { problems: [ `io_error: ${error.message}` ] }; + } + + // Runnable blocks rarely have file=, and extract writes such a block only with + // --update-source. The document goes in on stdin so that mode never rewrites + // it: the Markdown with file= added comes back in the JSON result instead. + const run = spawnSync("mdcode", [ "extract", "--update-source", "--meta", "runnable=true", "--dir", workspace, "--json" ], { + input: markdown, encoding: "utf8", maxBuffer: Infinity, }); diff --git a/packages/mdcode/README.md b/packages/mdcode/README.md index c8e28d2..905490a 100644 --- a/packages/mdcode/README.md +++ b/packages/mdcode/README.md @@ -338,7 +338,9 @@ mdcode list -l js -f app.test.js docs/guide.md ## Extract Command -Extract code blocks to files based on their `file` metadata. +Extract code blocks to files based on their `file` metadata. A block without `file=` (an anonymous +block) links to no file, so `extract` skips it; pass [`--update-source`](#update-source-with-generated-filenames) +to extract anonymous blocks too. Extract is non-destructive. Before it writes anything, it checks every target (see [Validate Command](#validate-command)). When any block breaks a rule, nothing is written and `extract` @@ -350,8 +352,8 @@ exits 1 with an error per block: as `ambiguous_target`. Two spellings of one file (`./a.ts` and `a.ts`, or a symlinked directory inside `--dir`) count as one file. With several documents, the same rule applies to blocks in different documents that write one file, so `docs/a.md` and `docs/b.md` cannot both write - `src/x.ts` whole, nor both write region `one` of it. Anonymous blocks count too: `block-1.sh` from - two documents is one file. + `src/x.ts` whole, nor both write region `one` of it. With `--update-source`, anonymous blocks count + too: `block-1.sh` from two documents is one file. - **An existing file's markers for a declared region are broken** → a region that is never closed or overlaps another is `malformed_region`, one opened more than once is `duplicate_region`, and one marked only in another language's comment syntax is `region_language_mismatch`. An invalid @@ -380,8 +382,9 @@ Otherwise, files that don't exist yet are created. as an `unsafe_path` error. Every target is checked before anything is written, so when any is refused, nothing is written and `extract` exits 1. To write into `../../shared-tests`, point `--dir` higher and write `file=` relative to it. -- **No `file=`** → written as `block-N.` directly inside `--dir`. An existing symlink of that - name that leads out of `--dir` is refused too. +- **No `file=`, with `--update-source`** → written as `block-N.` directly inside `--dir`. An + existing symlink of that name that leads out of `--dir` is refused too. Without `--update-source`, + the block is skipped. Whenever a file is skipped (an existing file without `--force`, a symlinked target, or a target that is not valid UTF-8), `extract` prints a summary (even under `--quiet`) and exits with status 2. @@ -452,7 +455,9 @@ mdcode extract -l python -m type=example -d ./examples docs/TUTORIAL.md ### Update Source with Generated Filenames -When extracting anonymous blocks (blocks without `file` metadata), automatically add the generated filename back to the markdown source: +`--update-source` also extracts anonymous blocks (blocks without `file` metadata), each as +`block-.`, and adds that generated filename back to the markdown, so every extracted file is +linked to its block. Without it, anonymous blocks are skipped: ```bash # Extract and update README with file metadata @@ -484,19 +489,7 @@ This enables bidirectional sync workflow: 2. Modify extracted files: `nano block-1.sh` 3. Update markdown: `mdcode update --apply README.md` -### Skip Anonymous Blocks - -Only extract blocks that have explicit `file` metadata, ignoring anonymous blocks: - -```bash -# Only extract blocks with file= attribute -mdcode extract --ignore-anonymous README.md - -# With filters and custom directory -mdcode extract --ignore-anonymous -l js -d ./src docs/API.md -``` - -**Note:** The flags `--update-source` and `--ignore-anonymous` are mutually exclusive. Using both will result in an error. +`--update-source` cannot be combined with `--check`, which writes nothing. ### Force Overwrite @@ -522,7 +515,7 @@ 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 +mdcode extract --check --force README.md ``` ```text @@ -1560,7 +1553,6 @@ last release, which may lack a flag the action needs; pin a release commit. | `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 | @@ -1673,8 +1665,8 @@ Additional flags by command: - `-d, --dir ` - Directory that `file=` paths resolve against and must stay inside (default: the configuration's `outputRoot`, else the current directory) - `-q, --quiet` - Suppress status messages -- `--update-source` - Add file metadata to anonymous code blocks and update source -- `--ignore-anonymous` - Skip blocks without file metadata (mutually exclusive with --update-source) +- `--update-source` - Also extract blocks without `file=`, each as `block-.`, and add that + `file=` to them in the markdown. Without it, those blocks are skipped - `--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) @@ -1704,7 +1696,6 @@ Additional flags by command: - `--strict` - Require `file=` metadata on every selected block - `--base ` - With `--for update`: as for `update` - `-d, --dir ` - With `--for extract`: as for `extract` -- `--ignore-anonymous` - With `--for extract`: skip blocks without `file=`, as `extract` does - `--project`, `--config ` - As for `update` and `extract` **watch:** @@ -1739,8 +1730,8 @@ document an agent produced. It treats the markdown as untrusted and whoever runs - `update` reads a `file=` only when it resolves inside the base: the markdown file's directory by default, the current directory for stdin, or the directory given with `--base`. -- `extract` writes a target only when it resolves inside `--dir`, and that includes the generated - `block-N.` names. +- `extract` writes a target only when it resolves inside `--dir`, and that includes the + `block-N.` names `--update-source` generates. - For `update` and `extract`, an absolute `file=`, one that climbs out with `..`, and one that leads out through an existing symlink anywhere along the path, the file itself included, are refused as `unsafe_path`. The check runs before anything is read or written. @@ -2045,8 +2036,8 @@ type DumpEnvelope = Envelope<{ nothing, and a document that never got a result has no entry. - `validate` - `operation` is the command the documents were checked for. One entry in `documents` per document, each with one entry per selected block, in document order. `path` is the file the block - maps to: for `extract`, the target joined onto `--dir`, with the generated `block-N` name for a - block without `file=`; for `update`, its `file=` as written; `null` when it maps to no file. `valid` + maps to: for `extract`, the target joined onto `--dir`, or `null` for a block without `file=`, which + `extract` skips; for `update`, its `file=` as written; `null` when it maps to no file. `valid` is `false` when an error concerns the block, and each problem adds an error whose code names the rule. - `run` - One entry per selected block, with the command's exit code and output. Each block whose @@ -2297,6 +2288,12 @@ Exit codes are the same with and without `--json`: - `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. +- `extract` no longer writes blocks without `file=` by default. A plain `mdcode extract README.md` + used to write every untagged block as `block-.` beside the README; now it writes only + blocks that have `file=`, as `--ignore-anonymous` did. `--ignore-anonymous` is removed from + `extract` and `validate`, and so is the library's `ignoreAnonymous` option: drop it. To extract + anonymous blocks, pass `--update-source` (`updateSource: true`), which also writes their generated + `file=` into the markdown. `validate --for extract` reports such blocks with `path: null`. --- @@ -2560,8 +2557,8 @@ Write code blocks to files based on their `file` metadata. - **options.filter** - Optional filter criteria - **options.outputDir** - Directory that `file=` paths resolve against and must stay inside (default: '.') -- **options.updateSource** - Add `file=` to anonymous blocks -- **options.ignoreAnonymous** - Skip blocks without `file=` +- **options.updateSource** - Also extract blocks without `file=`, each as `block-.`, and add + that `file=` to them in `updatedSource`. Without it, those blocks are skipped - **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) @@ -2573,7 +2570,7 @@ Write code blocks to files based on their `file` metadata. 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` or with `check` throws an `Error` whose `code` is `invalid_usage`. + `check` throws an `Error` whose `code` is `invalid_usage`. #### `validate(options: ValidateOptions): Promise` @@ -2585,7 +2582,8 @@ name, where they exist, but never writes. See [Validate Command](#validate-comma - **options.filter** - Optional filter criteria - **options.base** - `extract`'s `outputDir`, or `update`'s `basePath` (default: '.') - **options.strict** - Require `file=` on every selected block -- **options.ignoreAnonymous** - For `"extract"`, leave out blocks without `file=` +- **options.updateSource** - For `"extract"`, also map blocks without `file=` to the `block-` + files `extract` writes for them with `updateSource`. Without it, they map to no file (`path: null`) - **Returns** - Promise of `{ blocks, errors }`: one `ValidatedBlock` (`name`, `line`, `lang`, `path`, `region?`, `valid`) per selected block, and one `ResultError` per broken rule, in document order - **Throws** - `MetadataError` when the document's metadata is invalid diff --git a/packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md b/packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md index cdf09f5..56312ae 100644 --- a/packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md +++ b/packages/mdcode/skills/sync-markdown-code-blocks/SKILL.md @@ -172,21 +172,23 @@ When writing scripts or CI that read mdcode's results, read ### Extract blocks to files -`extract` goes the other way: it writes each block with `file=` to that file. +`extract` goes the other way: it writes each block with `file=` to that file. Blocks without `file=` +are skipped. ```bash -mdcode extract --ignore-anonymous README.md # only blocks that have file= -mdcode extract --dir out --ignore-anonymous README.md +mdcode extract README.md # only blocks that have file= +mdcode extract --dir out 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 +mdcode extract --check --force 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 Markdown doesn't mention, are kept. A region the file lacks is appended. - An existing file whose block has no `region=` is **skipped** (exit 2) unless you pass `--force`, which overwrites the whole file. -- Without `--ignore-anonymous`, every block without `file=` is written as `block-.` in - `--dir`. That is usually not what you want in a README. +- `--update-source` also writes every block without `file=` as `block-.` in `--dir` and + adds that `file=` to the block in the Markdown. Use it only when the user wants the snippets + turned into files. - Blocks that share a target must each have their own `region=`, in one language. That includes blocks in different documents of one run. Otherwise `extract` refuses with `ambiguous_target` and writes nothing. @@ -242,8 +244,9 @@ path from the Markdown itself. `update --apply`. (`src/commands/update.ts`) - **HIGH: treating the plan as success.** `mdcode update README.md` exits 0 and writes nothing. Pass `--apply` to write, and `--check` to verify. (`src/cli.ts`, update modes) -- **HIGH: plain `extract` on a README.** It writes `block-.` for every block without - `file=`. Pass `--ignore-anonymous`, and run `validate --for extract` first. +- **HIGH: `extract --update-source` on a README.** It writes `block-.` for every block + without `file=` and rewrites the Markdown to point at them. Run plain `extract` unless the user + asked for that. - **HIGH: relying on `--force`.** It overwrites a whole existing file. Prefer `region=` blocks, which splice in place, and use `--force` only when the user wants the file replaced. - **MEDIUM: a glob or directory in a selector or file argument.** `--file` matches `file=` exactly diff --git a/packages/mdcode/src/cli.ts b/packages/mdcode/src/cli.ts index 4f84868..326673f 100644 --- a/packages/mdcode/src/cli.ts +++ b/packages/mdcode/src/cli.ts @@ -128,7 +128,7 @@ function extractDir(dir: string | undefined, config: ProjectConfig | undefined): async function validateDocuments( documents: Array, operation: ValidateOperation, - options: Pick & { base: (document: Document) => string; } + options: Pick & { base: (document: Document) => string; } ): Promise<{ results: Array; errors: Array; lines: Array; }> { const { base, ...shared } = options; const several = documents.length > 1; @@ -196,7 +196,6 @@ type ExtractCliOptions = FilterCliOptions & ProjectCliOptions & { dir?: string; quiet?: boolean; updateSource?: boolean; - ignoreAnonymous?: boolean; force?: boolean; check?: boolean; }; @@ -216,7 +215,6 @@ type ValidateCliOptions = FilterCliOptions & ProjectCliOptions & { strict?: boolean; base?: string; dir?: string; - ignoreAnonymous?: boolean; }; /** @@ -405,8 +403,7 @@ export async function Execute( .option("-n, --name ", NAME_FLAG_HELP, collect) .option("-d, --dir ", "Directory file= paths resolve against and must stay inside; absolute paths and paths leading out, including through symlinks, are refused (default: the configuration's outputRoot, else the current directory)") .option("-q, --quiet", "Suppress status messages") - .option("--update-source", "Add file metadata to anonymous code blocks") - .option("--ignore-anonymous", "Skip blocks without file metadata") + .option("--update-source", "Also extract blocks without file metadata, as block-., and add that file= to them in the markdown (without it they are skipped)") .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) @@ -414,10 +411,6 @@ export async function Execute( .option("--json", "Print one versioned JSON result instead of text") .action(async (files: Array, options: ExtractCliOptions) => { await perform("extract", options.json, async () => { - if (options.updateSource && options.ignoreAnonymous) { - 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"); } @@ -432,7 +425,7 @@ export async function Execute( // documents writing one file, leaves every document's targets untouched. // One document plans for itself inside extract(). if (several) { - const planned = await validateDocuments(documents, "extract", { filter, base: () => outputDir, ignoreAnonymous: options.ignoreAnonymous }); + const planned = await validateDocuments(documents, "extract", { filter, base: () => outputDir, updateSource: options.updateSource }); if (planned.errors.length > 0) { return { @@ -461,7 +454,6 @@ export async function Execute( filter, outputDir, updateSource: options.updateSource, - ignoreAnonymous: options.ignoreAnonymous, force: options.force, check: options.check, }); @@ -832,7 +824,6 @@ export async function Execute( .option("--strict", "Require file= metadata on every selected block") .option("--base ", "With --for update: directory file= paths resolve against and must stay inside, as for update") .option("-d, --dir ", "With --for extract: directory file= targets resolve against and must stay inside, as for extract") - .option("--ignore-anonymous", "With --for extract: skip blocks without file metadata, as extract does") .option("--project", PROJECT_HELP) .option("--config ", CONFIG_HELP) .option("--json", "Print one versioned JSON result instead of text") @@ -840,7 +831,7 @@ export async function Execute( await perform("validate", options.json, async () => { const operation = options.for; const misplaced = operation === "update" - ? [ options.dir === undefined ? "" : "--dir", options.ignoreAnonymous ? "--ignore-anonymous" : "" ] + ? [ options.dir === undefined ? "" : "--dir" ] : [ options.base === undefined ? "" : "--base" ]; if (misplaced.some(flag => flag !== "")) { @@ -855,7 +846,6 @@ export async function Execute( filter, base: document => operation === "extract" ? extractDir(options.dir, config) : updateBase(options.base, config, document), strict: options.strict, - ignoreAnonymous: options.ignoreAnonymous, }); const checkedBlocks = results.reduce((count, result) => count + result.blocks.length, 0); diff --git a/packages/mdcode/src/commands/extract.test.ts b/packages/mdcode/src/commands/extract.test.ts index dcde65a..e3091b0 100644 --- a/packages/mdcode/src/commands/extract.test.ts +++ b/packages/mdcode/src/commands/extract.test.ts @@ -269,11 +269,20 @@ describe("extract: block names", () => { assert.deepEqual(await readdir(dir), []); }); - test("refuses updateSource with ignoreAnonymous as invalid_usage, and writes nothing", async () => { + test("skips blocks without file= unless updateSource names them in the markdown", async () => { const dir = await tempDir(); + const source = "```js file=a.js\n1\n```\n\n```sh\nls\n```\n\n```json\n{}\n```\n"; - await assert.rejects(extract({ source: "```js\n1\n```\n", outputDir: dir, updateSource: true, ignoreAnonymous: true }), { code: "invalid_usage" }); - assert.deepEqual(await readdir(dir), []); + const skipped = await extract({ source, outputDir: dir }); + + assert.deepEqual(skipped.targets.map(({ path }) => path), [ join(dir, "a.js") ]); + assert.deepEqual(await readdir(dir), [ "a.js" ], "no block- file is written"); + assert.equal(skipped.updatedSource, undefined); + + const named = await extract({ source, outputDir: dir, updateSource: true, force: true }); + + assert.deepEqual((await readdir(dir)).sort(), [ "a.js", "block-2.sh", "block-3.json" ]); + assert.equal(named.updatedSource, "```js file=a.js\n1\n```\n\n```sh file=block-2.sh\nls\n```\n\n```json file=block-3.json\n{}\n```\n"); }); }); @@ -327,7 +336,7 @@ describe("extract: file= outside the output directory", () => { // The generated name derives from the language tag; a path-like tag must not steer it. const source = "```sh\necho hi\n```\n\n```../../evil\npwned\n```\n"; - const result = await extract({ source, outputDir: out }); + const result = await extract({ source, outputDir: out, updateSource: true }); assert.deepEqual(written(result), [ join(out, "block-1.sh"), join(out, "block-2.txt") ]); assert.deepEqual((await readdir(out)).sort(), [ "block-1.sh", "block-2.txt" ]); diff --git a/packages/mdcode/src/commands/extract.ts b/packages/mdcode/src/commands/extract.ts index ece30b6..c368322 100644 --- a/packages/mdcode/src/commands/extract.ts +++ b/packages/mdcode/src/commands/extract.ts @@ -18,8 +18,11 @@ export type ExtractOptions = { filter?: FilterOptions; /** Directory file= paths resolve against and must stay inside (default: the current directory). */ outputDir?: string; + /** + * Also extract blocks without file=: each is written as `block-.` and + * gets that file= in `updatedSource`. Without it, those blocks are skipped. + */ updateSource?: boolean; - ignoreAnonymous?: boolean; force?: boolean; /** * Write nothing; compare each target with what extract would write instead. @@ -70,7 +73,6 @@ export async function extract(options: ExtractOptions): Promise { filter, outputDir = ".", updateSource = false, - ignoreAnonymous = false, force = false, check = false, } = options; @@ -79,16 +81,9 @@ export async function extract(options: ExtractOptions): Promise { 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"); - } - - let blocks = parse({ source, filter }); - - if (ignoreAnonymous) { - blocks = blocks.filter(b => b.meta.file); - } + // A block without file= is written only when --update-source names it in + // the markdown, so no extracted file is left linked to nothing. + const blocks = parse({ source, filter }).filter(block => updateSource || block.meta.file !== undefined); // Every target is checked before anything is written, so one hostile or // ambiguous file= cannot ride along with a batch of legitimate ones. @@ -156,9 +151,9 @@ export async function extract(options: ExtractOptions): Promise { } /** Human-readable progress lines for an extract result, as the CLI prints them on stderr. */ -export function formatExtract({ targets }: Pick, options: { ignoreAnonymous?: boolean; }): Array { +export function formatExtract({ targets }: Pick, options: { updateSource?: boolean; }): Array { if (targets.length === 0) { - return [ styleText("yellow", options.ignoreAnonymous ? "No code blocks with file metadata found." : "No code blocks found to extract.") ]; + return [ styleText("yellow", options.updateSource ? "No code blocks found to extract." : "No code blocks with file metadata found.") ]; } return targets.map(({ path, action, blocks, regions, reason }) => { diff --git a/packages/mdcode/src/commands/validate.test.ts b/packages/mdcode/src/commands/validate.test.ts index 21074ac..7c9344d 100644 --- a/packages/mdcode/src/commands/validate.test.ts +++ b/packages/mdcode/src/commands/validate.test.ts @@ -70,12 +70,12 @@ describe("validate for extract: blocks sharing a target", () => { } }); - test("counts generated names as targets, unless anonymous blocks are ignored", async () => { + test("counts generated names as targets only with updateSource, as extract does", async () => { const dir = await project(); const source = block("sh") + block("sh file=block-1.sh"); - assert.deepEqual((await findings({ source, operation: "extract", base: dir })).map(({ code }) => code), [ "ambiguous_target", "ambiguous_target" ]); - assert.deepEqual(await findings({ source, operation: "extract", base: dir, ignoreAnonymous: true }), []); + assert.deepEqual(await findings({ source, operation: "extract", base: dir }), [], "extract skips the block without file="); + assert.deepEqual((await findings({ source, operation: "extract", base: dir, updateSource: true })).map(({ code }) => code), [ "ambiguous_target", "ambiguous_target" ]); }); }); diff --git a/packages/mdcode/src/commands/validate.ts b/packages/mdcode/src/commands/validate.ts index 0c0f11a..c6b75fa 100644 --- a/packages/mdcode/src/commands/validate.ts +++ b/packages/mdcode/src/commands/validate.ts @@ -29,16 +29,20 @@ export interface ValidateOptions { base?: string; /** Require file= on every selected block. */ strict?: boolean; - /** extract only: leave out blocks without file=, as extract does with ignoreAnonymous. */ - ignoreAnonymous?: boolean; + /** + * extract only: also map blocks without file= to the `block-.` files + * extract writes for them with updateSource. Without it they map to no file, + * as extract skips them. + */ + updateSource?: boolean; } export interface ValidatedBlock extends BlockRef { lang: string; /** * The file the block maps to: for extract, the target joined onto the base, - * generated for a block without file=; for update, its file= as written. - * null when the block maps to no file. + * generated for a block without file= when updateSource is set; for update, + * its file= as written. null when the block maps to no file. */ path: string | null; region?: string; @@ -66,12 +70,8 @@ export interface ValidatedDocument { * @throws {MetadataError} when the document's metadata is invalid */ export async function validate(options: ValidateOptions): Promise { - const { source, operation, filter, base = ".", strict = false, ignoreAnonymous = false } = options; - let blocks = parse({ source, filter }); - - if (operation === "extract" && ignoreAnonymous) { - blocks = blocks.filter(block => block.meta.file !== undefined); - } + const { source, operation, filter, base = ".", strict = false, updateSource = false } = options; + const blocks = parse({ source, filter }); const errors: Array = []; const paths = new Map(); @@ -83,7 +83,7 @@ export async function validate(options: ValidateOptions): Promise block.meta.file !== undefined), base); for (const { display, items } of plan.groups) { for (const { block } of items) paths.set(block, display); diff --git a/packages/usage/tests/check-sync-action.test.ts b/packages/usage/tests/check-sync-action.test.ts index 5e07a78..d2d4081 100644 --- a/packages/usage/tests/check-sync-action.test.ts +++ b/packages/usage/tests/check-sync-action.test.ts @@ -56,7 +56,6 @@ async function action(dir: string, inputs: Record, extraEnv: Rec 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, diff --git a/packages/usage/tests/cli-integration.test.ts b/packages/usage/tests/cli-integration.test.ts index 630a71c..a84ebec 100644 --- a/packages/usage/tests/cli-integration.test.ts +++ b/packages/usage/tests/cli-integration.test.ts @@ -265,7 +265,8 @@ const y = 2; [[ "list" ], blocks ], [[ "update" ], inDocuments("blocks") ], [[ "validate", "--for", "extract" ], inDocuments("blocks") ], - [[ "extract", "-d", "out" ], inDocuments("targets") ], + // The block has no file=, so extract writes it only with --update-source. + [[ "extract", "-d", "out", "--update-source" ], inDocuments("targets") ], [[ "run", "--allow-shell", "true" ], blocks ], [[ "dump", "-o", "out.tar" ], envelope => envelope.result.files as Array<{ name: string; }> ], ]; diff --git a/packages/usage/tests/json-contract.test.ts b/packages/usage/tests/json-contract.test.ts index 5afdbab..8b181bb 100644 --- a/packages/usage/tests/json-contract.test.ts +++ b/packages/usage/tests/json-contract.test.ts @@ -115,7 +115,7 @@ describe("--json contract", () => { }); describe("extract", () => { - it("reports each target it wrote", async () => { + it("reports each target it wrote, skipping blocks without file=", async () => { const dir = await tempDir(); const { envelope } = await runJson("extract", [ "-d", dir ], { stdin: DOC }); @@ -124,10 +124,10 @@ describe("--json contract", () => { document: null, targets: [ { path: join(dir, "greet.js"), action: "written", blocks: [{ name: "greet", line: 3 }], regions: [] }, - { path: join(dir, "block-2.sh"), action: "written", blocks: [{ name: null, line: 7 }], regions: [] }, ], }], }); + assert.deepEqual(await readdir(dir), [ "greet.js" ], "the block without file= is written only with --update-source"); assert.equal(await readFile(join(dir, "greet.js"), "utf-8"), "console.log('hi');"); }); @@ -163,7 +163,7 @@ describe("--json contract", () => { }); it("reports conflicting flags as invalid_usage before reading input", async () => { - const { envelope } = await runJson("extract", [ "--update-source", "--ignore-anonymous" ], { stdin: DOC }); + const { envelope } = await runJson("extract", [ "--update-source", "--check" ], { stdin: DOC }); assert.deepEqual(envelope.errors.map(error => error.code), [ "invalid_usage" ]); assert.equal(envelope.result, null); diff --git a/packages/usage/tests/validate.test.ts b/packages/usage/tests/validate.test.ts index 806c9ed..128dc7b 100644 --- a/packages/usage/tests/validate.test.ts +++ b/packages/usage/tests/validate.test.ts @@ -71,7 +71,7 @@ describe("mdcode validate", () => { { line: 1, path: "src.js", valid: false }, { line: 5, path: "out.ts", valid: false }, { line: 9, path: "out.ts", valid: false }, - { line: 13, path: "block-4.sh", valid: true }, + { line: 13, path: null, valid: true }, ]); assert.deepEqual(envelope.errors.map(({ document, code, line, path }: Record) => ({ document, code, line, path })), [ { document: "doc.md", code: "duplicate_region", line: 1, path: "src.js" }, @@ -80,17 +80,17 @@ describe("mdcode validate", () => { ]); }); - it("--strict refuses blocks without file=, and passes once the selection has none", async () => { + it("--strict refuses blocks without file=, which extract would skip", async () => { const dir = await project(); const strict = await execCli([ "validate", "--for", "extract", "--strict", "--json", "--lang", "sh", "doc.md" ], { cwd: dir }); assert.equal(strict.exitCode, 1); assert.deepEqual(JSON.parse(strict.stdout).errors.map(({ code, line }: Record) => ({ code, line })), [{ code: "missing_file_metadata", line: 13 }]); - const clean = await execCli([ "validate", "--for", "extract", "--strict", "--lang", "sh", "--ignore-anonymous", "doc.md" ], { cwd: dir }); + const lenient = await execCli([ "validate", "--for", "extract", "--lang", "sh", "doc.md" ], { cwd: dir }); - assert.equal(clean.exitCode, 0); - assert.match(clean.stderr, /^✓ 0 block\(s\) ready for extract\.$/m); + assert.equal(lenient.exitCode, 0); + assert.match(lenient.stderr, /^✓ 1 block\(s\) ready for extract\.$/m); }); it("refuses flags that belong to the other operation", async () => {