Skip to content

feat: Add region-based in-place splicing and force overwrite option for extract - #24

Open
adrianbrowning wants to merge 6 commits into
mainfrom
21-extract-destructive-full-file-overwrite-instead-of-in-place-region-splice-aliased-file-paths-clobber-each-other
Open

feat: Add region-based in-place splicing and force overwrite option for extract#24
adrianbrowning wants to merge 6 commits into
mainfrom
21-extract-destructive-full-file-overwrite-instead-of-in-place-region-splice-aliased-file-paths-clobber-each-other

Conversation

@adrianbrowning

Copy link
Copy Markdown
Owner

No description provided.

@github-actions

github-actionsBot commented Sep 5, 2026

Copy link
Copy Markdown

bumpy-frog

The changes in this PR will be included in the next version bump.

minor Minor releases

  • mdcode-ts 0.0.4 → 0.1.0

Bump files in this PR

Click here if you want to add another bump file to this PR


This comment is maintained by bumpy.

@github-actions

Copy link
Copy Markdown

❌ ESLint Check Failed

Click to see details

Style


> mdcode@0.0.1 lint:s /home/runner/work/mdcode-ts/mdcode-ts
> pnpm -r lint:s
Scope: 2 of 3 workspace projects
packages/mdcode lint:s$ eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/commands/extract.test.ts
packages/mdcode lint:s: 1:1 error Run autofix to sort these imports! simple-import-sort/imports
packages/mdcode lint:s: 12:36 error Functions that return promises must be async @typescript-eslint/promise-function-async
packages/mdcode lint:s: 28:1 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: 29:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: 64:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: 91:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: 124:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: 154:1 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: 162:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: 172:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: 181:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:s: ✖ 11 problems (11 errors, 0 warnings)
packages/mdcode lint:s: 2 errors and 0 warnings potentially fixable with the '--fix' option.
packages/mdcode lint:s: Failed
/home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode:
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL mdcode-ts@0.0.4 lint:s: 'eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0'
Exit status 1
ELIFECYCLE Command failed with exit code 1.

Correctness


> mdcode@0.0.1 lint:esl /home/runner/work/mdcode-ts/mdcode-ts
> pnpm -r lint:esl
Scope: 2 of 3 workspace projects
packages/mdcode lint:esl$ eslint "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:esl: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/commands/extract.test.ts
packages/mdcode lint:esl: 12:36 error Functions that return promises must be async @typescript-eslint/promise-function-async
packages/mdcode lint:esl: 28:1 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: 29:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: 64:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: 91:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: 124:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: 154:1 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: 162:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: 172:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: 181:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the 'void' operator @typescript-eslint/no-floating-promises
packages/mdcode lint:esl: ✖ 10 problems (10 errors, 0 warnings)
packages/mdcode lint:esl: 1 error and 0 warnings potentially fixable with the '--fix' option.
packages/mdcode lint:esl: Failed
/home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode:
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL mdcode-ts@0.0.4 lint:esl: 'eslint "src/**/*.{j,t}s{,x}" --cache --max-warnings=0'
Exit status 1
ELIFECYCLE Command failed with exit code 1.

View workflow run

- extract.test.ts: sort imports, async map callback, disable
no-floating-promises file-wide (matches parser/region tests)
- add bumpy bump file for the extract feature (minor)
- region.ts: single markerPatterns() helper for read/replace, accept
block-comment prefixes when a lang is given, add regionMarker() that
closes /* */ and <!-- --> markers
- extract.ts: drop its divergent getCommentStyle copy and use
regionMarker(), fixing /* #region */ markers being duplicated instead
of spliced and html markers being written with // prefixes
@github-actions

Copy link
Copy Markdown

⚠️ ESLint Check Warnings

Click to see details

Style


&gt; mdcode@0.0.1 lint:s /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:s
Scope: 2 of 3 workspace projects
packages/mdcode lint:s$ eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:s: Done
packages/usage lint:s$ eslint --config .eslintrc.style.json "{tests,fixtures,examples}/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/usage lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/usage lint:s: Done

Correctness


&gt; mdcode@0.0.1 lint:esl /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:esl
Scope: 2 of 3 workspace projects
packages/mdcode lint:esl$ eslint "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:esl: Done
packages/usage lint:esl$ eslint "{tests,fixtures,examples}/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/usage lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/usage lint:esl: Done

View workflow run

Dropping publishConfig.name means the package publishes as mdcode-ts, so
'npm install mdcode' / 'pnpm dlx mdcode' now resolve the upstream fork
rather than this package. README also advertised @mdcode/mdcode and the
JSDoc examples @gcm/mdcode, neither of which was ever published.
The CLI command stays 'mdcode' (bin name is unchanged).
@github-actions

Copy link
Copy Markdown

⚠️ ESLint Check Warnings

Click to see details

Style


&gt; mdcode@0.0.1 lint:s /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:s
Scope: 2 of 3 workspace projects
packages/mdcode lint:s$ eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:s: Done
packages/usage lint:s$ eslint --config .eslintrc.style.json "{tests,fixtures,examples}/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/usage lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/usage lint:s: Done

Correctness


&gt; mdcode@0.0.1 lint:esl /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:esl
Scope: 2 of 3 workspace projects
packages/mdcode lint:esl$ eslint "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:esl: Done
packages/usage lint:esl$ eslint "{tests,fixtures,examples}/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/usage lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/usage lint:esl: Done

View workflow run

Addresses the PR review on the region-splice/--force branch. `region.replace()`
became the code that rewrites real source files on this branch, and every latent
weakness in it was newly destructive.
region.ts: replace the line-at-a-time state machine with per-language span
discovery plus a single application pass. Extents are found once per distinct
language, so cost scales with languages involved rather than regions. The splice
now refuses, returning the source byte-identical, when a region is never closed,
is closed by a marker naming another region, is closed while a region opened
inside it is still open, appears more than once, nests inside one of the same
name, or overlaps another requested region. Previously each of these truncated
the file to EOF or doubled a body while reporting success. Inserted bodies are
re-indented to their marker and take its line ending. Comment styles are modelled
as open/close pairs and cover shell, SQL, CSS, semicolon and markup languages, so
markers are no longer written as `//` into a zsh script. Region names are
validated before interpolation.
extract.ts: group targets by resolved path so aliased `file=` spellings are one
group and one write (issue #21), confine writes to realpath(--dir), refuse
final-component symlinks, refuse targets that are not valid UTF-8, narrow the
existence probe to ENOENT, refuse groups mixing region= with whole-file blocks,
skip outline=true blocks, and write via temp file + rename with the target's
permission bits preserved.
cli.ts: report skipped files even under --quiet and exit 2, so `extract -q` can
no longer write nothing and look green in CI. ExtractOptions/ExtractResult are
exported and the commander options bag is typed.
update.ts: respect read()'s found flag; without it an unterminated or missing
region silently emptied the markdown block.
Docs: document skip-by-default and --force, retire the "100% CLI compatibility"
and "exit codes match" claims, make the three worked recipes force-explicit, fix
the README block name collision, correct TESTING.md and CLAUDE.md, and fix
test:watch (`node -w` is not a valid flag).
@github-actions

Copy link
Copy Markdown

⚠️ ESLint Check Warnings

Click to see details

Style


&gt; mdcode@0.0.1 lint:s /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:s
Scope: 2 of 3 workspace projects
packages/mdcode lint:s$ eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/commands/extract.ts
packages/mdcode lint:s: 155:42 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 155:59 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 159:36 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 241:90 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/commands/update.test.ts
packages/mdcode lint:s: 2:1 error Run autofix to sort these imports! simple-import-sort/imports
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/region.test.ts
packages/mdcode lint:s: 407:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 407:84 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 407:86 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 417:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 417:84 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 417:86 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 504:43 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 505:43 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 506:43 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 529:47 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 530:43 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 551:51 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 552:51 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 564:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 564:86 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 564:88 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 573:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 573:83 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 573:85 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 582:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 582:86 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 582:88 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/region.ts
packages/mdcode lint:s: 207:39 error Unexpected parentheses around single function argument @stylistic/arrow-parens
packages/mdcode lint:s: 479:49 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 479:90 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 479:92 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: ✖ 31 problems (31 errors, 0 warnings)
packages/mdcode lint:s: 31 errors and 0 warnings potentially fixable with the '--fix' option.
packages/mdcode lint:s: Failed
/home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode:
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL mdcode-ts@0.0.4 lint:s: 'eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0'
Exit status 1
ELIFECYCLE Command failed with exit code 1.

Correctness


&gt; mdcode@0.0.1 lint:esl /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:esl
Scope: 2 of 3 workspace projects
packages/mdcode lint:esl$ eslint "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:esl: Done
packages/usage lint:esl$ eslint "{tests,fixtures,examples}/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/usage lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/usage lint:esl: Done

View workflow run

…dary
Follow-ups found while reviewing the previous commit.
extract: several blocks each claiming to be the whole of one file are only
compatible if byte-identical. Previously the first one won and the rest were
discarded silently; now a disagreement is refused and reported, matching how
mixed region/whole-file groups are already handled. Identical duplicates still
write, since there is nothing to disagree about.
Remove ExtractOptions.sourcePath: it was accepted, passed by the CLI, and never
read. It became visible when the type was exported, so drop it rather than
publish a knob that does nothing.
Document the inode tradeoff on writeAtomic: rename() replaces the file, so hard
links keep the old contents and xattrs/ACLs are not carried over. Accepted
deliberately, and it is also why the splice path refuses symlinks outright rather
than relying on rename's behaviour.
docs: state that `update` honours any path a block's `file=` names, including
paths outside the markdown's directory, because that path is an explicit
instruction from the markdown's author. Record the consequence — update will
inline any file the process can read — and the resulting rule: treat untrusted
markdown like untrusted code. The asymmetry with extract, which is confined to
--dir, is now written down rather than implied.
@github-actions

Copy link
Copy Markdown

⚠️ ESLint Check Warnings

Click to see details

Style


&gt; mdcode@0.0.1 lint:s /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:s
Scope: 2 of 3 workspace projects
packages/mdcode lint:s$ eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/commands/extract.ts
packages/mdcode lint:s: 154:42 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 154:59 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 158:36 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 247:90 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/commands/update.test.ts
packages/mdcode lint:s: 2:1 error Run autofix to sort these imports! simple-import-sort/imports
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/region.test.ts
packages/mdcode lint:s: 407:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 407:84 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 407:86 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 417:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 417:84 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 417:86 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 504:43 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 505:43 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 506:43 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 529:47 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 530:43 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 551:51 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 552:51 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 564:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 564:86 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 564:88 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 573:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 573:83 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 573:85 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 582:51 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 582:86 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 582:88 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: /home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode/src/region.ts
packages/mdcode lint:s: 207:39 error Unexpected parentheses around single function argument @stylistic/arrow-parens
packages/mdcode lint:s: 479:49 error There should be no space after '[' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 479:90 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: 479:92 error There should be no space before ']' @stylistic/array-bracket-spacing
packages/mdcode lint:s: ✖ 31 problems (31 errors, 0 warnings)
packages/mdcode lint:s: 31 errors and 0 warnings potentially fixable with the '--fix' option.
packages/mdcode lint:s: Failed
/home/runner/work/mdcode-ts/mdcode-ts/packages/mdcode:
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL mdcode-ts@0.0.4 lint:s: 'eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0'
Exit status 1
ELIFECYCLE Command failed with exit code 1.

Correctness


&gt; mdcode@0.0.1 lint:esl /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:esl
Scope: 2 of 3 workspace projects
packages/mdcode lint:esl$ eslint "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:esl: Done
packages/usage lint:esl$ eslint "{tests,fixtures,examples}/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/usage lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/usage lint:esl: Done

View workflow run

@github-actions

Copy link
Copy Markdown

⚠️ ESLint Check Warnings

Click to see details

Style


&gt; mdcode@0.0.1 lint:s /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:s
Scope: 2 of 3 workspace projects
packages/mdcode lint:s$ eslint --config .eslintrc.style.json "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:s: Done
packages/usage lint:s$ eslint --config .eslintrc.style.json "{tests,fixtures,examples}/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/usage lint:s: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/usage lint:s: Done

Correctness


&gt; mdcode@0.0.1 lint:esl /home/runner/work/mdcode-ts/mdcode-ts
&gt; pnpm -r lint:esl
Scope: 2 of 3 workspace projects
packages/mdcode lint:esl$ eslint "src/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/mdcode lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/mdcode lint:esl: Done
packages/usage lint:esl$ eslint "{tests,fixtures,examples}/**/*.{j,t}s{,x}" --cache --max-warnings=0
packages/usage lint:esl: [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: 'npm i baseline-browser-mapping@latest -D'
packages/usage lint:esl: Done

View workflow run

@adrianbrowning

Copy link
Copy Markdown
OwnerAuthor
📖 How to use this review
  • 🔴 Critical = Blocking issues, must fix before merge
  • 🟡 High Priority = Should fix, impacts quality/security
  • 💡 Observations = Nice to have, consider for future
  • Positives = What's working well

Click any section to expand/collapse details.

Produced by 9 domain reviewers (security, bug, thermo/maintainability, holistic, perf, TypeScript, testing, devops, scope) whose findings were then re-verified against the working tree by 2 independent validators; 17 unsubstantiated findings were dropped.


🎯 Executive Summary

Status: ❌ Changes Required
Critical Issues: 2 blocking
High Priority: 13 issues
Observations: 36 suggestions

📋 Quick Stats
CategoryCount
Critical (blocking)2
High priority13
Observations36
Positive highlights7

The engineering here is genuinely careful — per-line EOL preservation, canonical path grouping, an explicit refusal catalogue, atomic writes with mode preservation, and unusually strong tests. Two things block merge: the new path-containment rule refuses the exact file= spellings that #21 reports, so the headline scenario the branch is named after never runs; and process.exit(2) immediately after a stdout.write truncates piped --update-source output.


🔴 Critical Issues (Must Fix Before Merge)

Issue 1: New file= containment rule refuses the exact paths issue #21 reports

File: packages/mdcode/src/commands/extract.ts:128-132

Problem: extract now skips any block whose file= resolves outside outputDir (default .), reporting file= must stay inside . and forcing exit 2. Issue #21 — the issue this branch is named after — reports its "most damaging" failure using verbatim file=../../shared-tests/readme/where.ts and file=../usage-sqlite-v7/tests/readme/where.ts. With the default outputDir of ., escapesRoot(root, display) computes relative(cwd, resolve("../shared-tests/...")), which starts with ../, so those blocks are refused.

Reproduced end to end: mdcode extract README.md against file=../shared-tests/readme/where.ts prints ⚠ Skipped ../shared-tests/readme/where.ts: file= must stay inside ., leaves the target byte-identical, and exits 2.

Impact: The reported scenario now fails closed instead of destructively — strictly safer than the data loss in #21 — but it does not splice, so the PR does not fix the reported case under the reporter's own invocation. The rule is also a third behaviour change beyond the title's "region-splice plus --force", and it is undocumented: absent from .bumpy/extract-region-splice-force.md, absent from packages/mdcode/README.md:296-307's "Extract is non-destructive" list, and mentioned only obliquely inside examples/CLI_EXAMPLES.md's update trust-boundary section.

Fix: Either (a) scope the check so an explicitly written relative file= is still honoured (confine only generated/anonymous targets, or gate escapes behind a flag), or (b) keep the rule and document it as a breaking change in the bump file, the README refusal list, and the extract --help text — and say in the release notes that #21's reporter must pass a wider -d.

🤖 AI Fix Prompt
In packages/mdcode/src/commands/extract.ts, the containment check at lines 128-146
refuses any file= that resolves outside outputDir (default "."). Issue #21 — the
issue this branch fixes — uses file=../../shared-tests/readme/where.ts, so the
reported scenario is now skipped with exit 2 instead of spliced.
Decide and implement one of:
(a) Honour an explicitly written relative file= (only confine generated names for
anonymous blocks), keeping the absolute-path refusal; or
(b) Keep the rule and document it: add the refusal to
.bumpy/extract-region-splice-force.md, to the "Extract is non-destructive"
list in packages/mdcode/README.md around line 296, and to the extract
--help/--dir description in packages/mdcode/src/cli.ts.
Add a test in packages/mdcode/src/commands/extract.test.ts covering the chosen
behaviour for file=../outside/target.ts with the default outputDir.
Issue 2: process.exit(2) truncates piped --update-source output

File: packages/mdcode/src/cli.ts:166-172

Problem: stdout.write(result.updatedSource) runs at cli.ts:161-162, then process.exit(2) fires at cli.ts:171 whenever anything was skipped. Writes to process.stdout are asynchronous when it is a pipe, and process.exit() terminates without draining them.

Measured on this branch: a 102 KB markdown of 4000 anonymous blocks plus one pre-existing target — nothing skipped → 177810 bytes on stdout; one file skipped → exactly 65536 bytes (one pipe buffer). The rewritten markdown is silently truncated mid-document. The ⚠ Skipped N file(s) line on stderr is lost the same way when stderr is a pipe — and that message's entire purpose is to reach CI.

Impact: mdcode extract --update-source -q < README.md | tee new.md produces a corrupted markdown file. That is a worse data-loss mode than the whole-file overwrite this PR set out to prevent, and it defeats the stated goal that "a pipeline cannot mistake 'wrote nothing' for success". It also means the exported Execute(args, stdout, stderr) — public via export * from "./cli.ts" and the ./cli subpath — kills its host process.

Fix: process.exitCode = 2 and fall out of the action.

Code:

if(result.skippedFiles.length>0){stderr.write(styleText("yellow",`⚠ Skipped ${result.skippedFiles.length} file(s); nothing was written for them\n`));process.exitCode=2;}
🤖 AI Fix Prompt
Fix output truncation in packages/mdcode/src/cli.ts around lines 166-172.
Problem: process.exit(2) runs immediately after stdout.write(result.updatedSource)
at line 161. Pipe writes to process.stdout are async, so process.exit discards
anything still buffered — the rewritten markdown is truncated at 64 KiB.
Required changes:
- Replace `process.exit(2)` with `process.exitCode = 2;` and let the action return.
- Audit the other process.exit call sites in this file that follow a stdout/stderr
write and convert them the same way.
Expected outcome: a skipped extract still exits 2, but the full --update-source
markdown and the stderr skip summary are flushed first. Add a usage-package test
that pipes a large markdown through `extract --update-source` with one skipped
target and asserts the received bytes equal the expected document length.

🟡 High Priority Issues

13 issues - click to expand
Issue 3: Two blocks naming the same region= silently discard one body

File: packages/mdcode/src/commands/extract.ts:246-249

Reproduced: two typescript file=a.ts region=alpha blocks with different bodies. new Map(items.map(...)) is keyed on the region name, so only the last survives; extract prints ✓ Updated 2 region(s) in a.ts and exits 0 with the first body gone. The unmatched-append loop at line 261 compounds it — items.find(...) picks the first block with that name while the splice used the last. On a missing target, the sibling path at line 191 emits two identical #region alpha markers, which every later extract then refuses as appears more than once, so the file can never be updated again.

Impact: Silent data loss on the PR's headline path, with a success message and exit 0 — the same class of failure #21 reports. The adjacent guard at line 171 already refuses whole-file blocks that disagree, so the omission looks unintentional.

Fix:

constnames=withRegion.map(item=>item.block.meta.region!);if(newSet(names).size!==names.length){skip(display,"two blocks for this file declare the same region=");continue;}
Issue 4: Indentation accumulates on every updateextract round trip

File: packages/mdcode/src/region.ts:458-465

Problem: spliceRegions prefixes every inserted body line with the start marker's own indent (lines 460, 464), while read() returns the body with its original indentation intact (line 283). The two are not inverses.

Reproduced: an indented Python region run through read + spliceRegions three times moved the body from 4 → 8 → 12 → 16 spaces. The pre-PR replace() inserted the body verbatim, so this is introduced here.

Impact: For Python this changes program meaning; everywhere else it corrupts formatting monotonically. The documented update → edit → extract workflow is not idempotent.

Fix: Dedent the body by its own minimum indent before re-indenting, or strip the common leading indent in read() so the two operations compose.

Issue 5: A region already present in the pre-PR comment syntax is silently duplicated

File: packages/mdcode/src/commands/extract.ts:258-264

Problem: markerScanner only accepts markers in the block language's own syntax. A target written by the pre-PR extract (which fell back to // for sql, css, lua, lisp, asm, md) reports its region as unmatched, so lines 258-264 append a second region in the new syntax and leave the stale one in place — then report success.

Impact: Directly contradicts the changelog's promise that a target whose region is "named inconsistently" is left byte-identical. Every file the previous release wrote for those languages gains a duplicate region on first re-extract.

Fix: When a region is unmatched but a marker with that name exists in any recognised syntax, skip with a reason instead of appending.

Issue 6: update's outline mode cannot read the markers extract now writes

File: packages/mdcode/src/outline.ts:22-37 (consumer at packages/mdcode/src/commands/update.ts:46)

Problem: update.ts:46 calls the separate, unchanged outline.ts matcher with no lang argument. That matcher requires two adjacent punctuation characters (${specialChars}{2}).

Verified by execution: it rejects # #region foo (python/sh/bash/yaml/toml/ruby/dockerfile), ; #region foo (lisp/clj/asm) and <!-- #region foo --> (html/xml/vue/svelte/md) — all styles wrapRegion now writes — accepting only //, --, /* and ##. The repo already ships an affected fixture: packages/usage/fixtures/src/strings.py uses # #region reverse.

Impact: An outline=true block over a file extract just wrote throws "no region markers found", is logged as ✗ Failed to read, and silently keeps its stale content. Meanwhile the lang-aware region.outline(source, lang?) added in this PR has no production caller.

Fix: Call region.outline(fileContent, block.lang) from update.ts, delete src/outline.ts, and repoint packages/usage/tests/outline.test.ts. One definition of what a region marker is.

Issue 7: update's new missing-region throw is swallowed and exits 0

File: packages/mdcode/src/commands/update.ts:55-62

Problem: The new throw new Error("region ... not found or not closed in ...") at line 57 sits inside the same try whose catch at lines 77-81 logs ✗ Failed to read <file>: ... and continues. update() returns only a string, so the CLI writes the markdown back in place and exits 0.

Impact: Three defects in one: the error surfaces under a misleading "Failed to read" prefix; the block silently retains stale content (pre-PR it rendered empty — wrong, but visible in the diff); and a docs-sync CI job running mdcode update stays green while the markdown is out of date. This is exactly the hazard the PR argues against for extract, left in place for the sibling command. It is also a contract change with no bump-file entry and no README/CLI_EXAMPLES change.

Fix: Give update a failure channel mirroring ExtractResult.skippedFiles, exit non-zero from the CLI, and record the contract change in the bump file.

Issue 8: outline=true blocks are now silently dropped by extract

File: packages/mdcode/src/commands/extract.ts:109-113

Problem: The grouping loop continues on block.meta.outline === "true" before the block is grouped — no skip(), no skippedFiles entry, therefore no exit 2. Pre-PR these blocks were written like any other. extract.test.ts:406-428 pins the new silence rather than making it observable.

Impact: Inconsistent with every other refusal in this function, which is deliberately observable. packages/mdcode/README.md:1082 still lists outline among extraction metadata and README.md:1112-1119 heads an "Outline Extraction" section.

Fix: Route these through skip() with a reason, or state in the README that outline=true is update-only.

Issue 9: Insecure temp file in writeAtomic — predictable name, no O_EXCL, follows symlinks

File: packages/mdcode/src/commands/extract.ts:300-303

Problem: The temp path is derived from the markdown-declared basename plus process.pid (.{basename}.mdcode-{pid}) and created with writeFile's default w flag — no O_EXCL, follows an existing symlink, truncates a stale temp from a crashed run.

Impact: An attacker able to create entries in the output directory (shared CI workspace, -d /tmp/... as the docs suggest) pre-plants that exact predictable name as a symlink; block content is then written through it to any path the process can write — defeating the --dir confinement this PR advertises.

Fix: random suffix (crypto.randomBytes(6).toString("hex")) plus writeFile(temp, content, { flag: "wx", mode: 0o600 }).

Issue 10: ExtractResult drops the skip reason, forcing console monkey-patching in tests

File: packages/mdcode/src/commands/extract.ts:94-99

Problem: The skip closure both records state and writes to console.error; quiet/skip are threaded into spliceInPlace as a reporter bag (line 222); spliceRefusal (line 276) formats region-domain wording inside the extract command from three arrays region.ts produced. ExtractResult.skippedFiles: Array<string> throws the reason away — so cli.ts:169 must invent a second, reason-less message, and the tests must intercept the global (extract.test.ts:502-508, update.test.tscaptureStderr) to observe why a target was refused. The CLI/library seam is crossed inconsistently: Execute is handed stdout/stderr explicitly, and extract bypasses them.

Impact: ExtractResult is now published surface (index.ts:12), so a library consumer can report a count but never a reason. One command's output splits across two destinations.

Fix: skippedFiles: Array<{ path: string; reason: string }>; spliceInPlace returns { ok: true } | { ok: false; reason: string }; render in cli.ts from the injected stderr. That deletes the skip closure, the reporters bag, quiet from the library API, six if (!quiet) console.error sites, and the console mocks in two test files.

Issue 11: Bump file omits five shipped behaviour changes

File: .bumpy/extract-region-splice-force.md:14-21

Problem: Verified omissions, all shipped in this PR: the file=-outside---dir refusal (extract.ts:128-146), the silent outline=true drop (109-113), the new "N whole-file blocks disagree about its contents" refusal (169-174), update's new throw (update.ts:56-62), and the removal of ExtractOptions.sourcePath. The symlink, non-UTF-8, unclosed, duplicated and mixed cases are covered.

Impact: Consumers hit contract changes that never reach the changelog. The entry also opens with "Behaviour change:" and adds a new exit code but is not marked BREAKING — minor is defensible only because the package is at 0.0.4, where ^0.0.4 cannot reach 0.1.0.

Fix: Add the missing refusal paths and mark the entry BREAKING.

Issue 12: Empty PR body, no Closes #21

File: PR body

Problem: +1879/-308 across 16 files with a completely empty body: no Why/What/Verification, no Impact or Migration note for the new no-overwrite default or exit code 2, and no Closes/Fixes reference. Issue #21 is still OPEN, linked only by the branch name, which GitHub does not act on — merging will not close it.

Fix: Add a body with Why / What / Impact / Verification and Closes #21.

Issue 13: RegionSpliceResult is four parallel failure arrays instead of a discriminated union

File: packages/mdcode/src/region.ts:37-49, 434-442

Problem: The result carries four parallel arrays plus an ok boolean derived from three of them (line 434), and a spliced array with no production reader (only region.test.ts:510). ok: boolean on a flat record narrows nothing: content is the untouched original on failure yet freely readable, and spliceRefusal (extract.ts:276-286) accepts a structural shape every successful result also satisfies — called with one it returns the nonsense string regions overlap. extract.ts then re-branches over the same three arrays to rebuild one sentence, so region.ts discards the reason and the extract command re-derives region-grammar wording. A fifth refusal reason must be added in three places.

Fix: { ok: true; content: string; spliced: ...; unmatched: ... } | { ok: false; reason: RegionSpliceRefusal }, with the message owned by region.ts.

Issue 14: replace() is test-only and its closed field collapses three distinct refusals

File: packages/mdcode/src/region.ts:477-486

Problem: replace() has no production callers — only region.test.ts — yet this PR adds a closed field to its result type purely for tests and routes roughly ten new engine behaviour tests (region.test.ts:362-484) through the shim. closed: unclosed.length === 0 collapses three refusals: a duplicated or overlapping region returns found: true, closed: true with untouched content, indistinguishable from a successful no-op replace.

Fix: Delete replace() and test spliceRegions directly, or surface the refusal reason it currently drops.

Issue 15: The per-target policy decision has no seam, so every branch needs a real temp tree

File: packages/mdcode/src/commands/extract.ts:158-206

Problem: One loop inside a ~160-line function interleaves the stat probe, four policy decisions (mixed region/whole-file, disagreeing whole-file blocks, splice-existing, exists-without-force), content assembly, the write and the success message. The decision is a pure function of (items, exists, force).

Impact: All 21 tests in extract.test.ts must build a real filesystem tree to reach branches that need no filesystem at all.

Fix: Extract decideTarget(items, exists, force): { action: "splice" | "write" | "skip"; reason?: string } and unit-test it directly; keep IO in the loop.


💡 Observations & Suggestions

36 observations - click to expand
Correctness & robustness (10)
  1. writeAtomic leaves the temp file behind on failureextract.ts:300-311 has no try/finally; any rejection from writeFile, chmod or rename (EISDIR, ENOSPC, EPERM, EXDEV) leaves a hidden .{name}.mdcode-{pid} holding block content and aborts the run after earlier targets were written. extract.test.ts only asserts the success path leaves no temp.
  2. Directory targets throw instead of skippingstat's result is consumed only for existence and mode (extract.ts:160), never isDirectory(). A file= naming an existing directory reaches readFile (EISDIR, line 233) or rename-over-directory with --force; the raw error escapes extract. Treat isDirectory() as a skip reason, and validate declared for empty/./NUL.
  3. --force replaces a symlinked target instead of refusing — the whole-file path never lstats: stat follows the link, its mode is copied onto the replacement, and rename replaces the link with a regular file (extract.ts:185-194). The splice path deliberately refuses symlinks (extract.ts:228-231) and the changelog advertises that refusal — the two write paths disagree.
  4. Byte-identical targets are still skipped, with exit 2 — the guard fires on existence alone (extract.ts:185-188) with no content comparison, so re-running extract on an unchanged document writes nothing, warns per target and exits 2. Compare content before skipping to keep the documented workflow idempotent in CI.
  5. --update-source records metadata for targets that were never written — the generated file=block-N.ext is recorded while grouping (extract.ts:118-124), before the group is known to be writable. If it is later skipped, cli.ts:152-164 still rewrites the markdown to assert a file the run deliberately refused to create.
  6. Temp permission window + setuid propagation — content is written with the umask default and only chmoded afterwards (extract.ts:303-306), so data destined for a 0600 target is group/world-readable for that window; the 0o7777 restore mask also copies setuid/setgid/sticky from the replaced target onto a file whose contents come from markdown. Create with mode: 0o600, mask with 0o777.
  7. TOCTOU between the confinement check and the write — the realpath escape check runs in the grouping loop (extract.ts:141-146); writes happen in the later loop (194, 266), so the window spans all remaining block processing. Re-verify immediately before writeAtomic, or write relative to a directory fd.
  8. Block bodies are spliced verbatim, so a block can inject markersregion.ts:462-465 and wrapRegion (505-507) write the replacement with no scan for embedded #region/#endregion. A body containing // #endregion alpha closes the region early and orphans the remainder. Region names are validated for exactly this reason; bodies are not.
  9. Appended regions are always LF-terminatedwrapRegion hardcodes \n, so appending to a CRLF file introduces mixed line endings even though the splice itself preserves per-line EOLs (extract.ts:262-269).
  10. ✓ Updated N region(s) is reported even when regions were appended, not spliced — or when none matched (extract.ts:269). Report appended vs spliced separately.
Design & types (9)
  1. Two language registries, one hand-maintained commentLANG_COMMENT_STYLES (region.ts:80) is documented as "kept in step with getExtensionForLang" with no mechanism. The stated invariant holds (all 37 extension keys have a style), but 38 languages the comment table knows (lua, hs, elm, ada, lisp, clj, asm, less, vue, svelte, jsx, tsx, dart, scala, jsonc, toml, ini, dockerfile, perl, r, elixir, text, …) have no extension, so anonymous blocks in them land as block-N.txt; and the fallbacks disagree — an unknown lang gets .txt with // markers while txt itself gets # markers. One languages.ts owning { ext, comments } per language deletes extract.ts:341-383.
  2. markerScanner hardcodes the terminator it claims to modelCommentStyle.close exists (region.ts:53-56) but the scanner maps only style.open and inlines (?:\*/|-->)? (region.ts:223-228). The data model describes writing but not matching.
  3. RegionEdit.lang keys work by raw spellingnamesByLang keys on edit.lang ?? "" (region.ts:345-350) while getCommentStyle collapses ts/typescript, js/javascript, py/python, sh/bash/zsh and every unknown token onto the identical object. Blocks spelled ts and typescript drive two identical full passes; the doc comment's "cost scales with the number of languages involved" should read "distinct spellings".
  4. BlockRef.index is dead — written while grouping (extract.ts:27-30), never read again; BlockRef.block is also a hand-written structural subset { meta; lang; code } of the parser's Block rather than the type itself.
  5. Four non-null assertions re-assert an invariant the caller establishedblock.meta.region! (extract.ts:191, 247), items.find(...)! (261), items[0]! (192). Encode withRegion.length === items.length in the type so a reordering of the group checks is a compile error, not a runtime undefined key.
  6. The new region API is unreachable from the published packageRegionEdit, RegionSpliceResult, CommentStyle, getCommentStyle, isValidRegionName, spliceRegions, regionMarker, wrapRegion are newly exported from the module, but index.ts has no export * from "./region.ts" and package.json's zshy exports are only . and ./cli. CommentStyles (region.ts:59) is also unexported, so getCommentStyle's return type cannot be named.
  7. ExtractCliOptions is an unchecked annotation and mistypes --meta — commander@14 types the handler as action(fn: (this: this, ...args: any[]) => …) (typings/index.d.ts:626), so the doc comment's "a renamed flag is a compile error" is false: renaming --force still compiles and options.force becomes undefined. And -m, --meta <key=value...> is variadic, yielding string[], not the declared Record<string, string> — only parseFilterOptions's Array.isArray guard hides it (cli.ts:34-43, 60).
  8. Two rules for separating region envelopes — the fresh-file path joins wrapRegion outputs with "\n" (extract.ts:190-192) producing a blank line between regions; the append path (262-263) uses exactly one newline. The same markdown formats differently depending on whether the target existed.
  9. ExtractOptions.sourcePath removal is unnoted — the field is dropped and cli.ts stops passing it, in the same change that first publishes ExtractOptions/ExtractResult via index.ts:12. Harmless in-repo (it was unread), but it belongs in the bump file.
Performance (6)
  1. Per-block mkdir + realpath in the grouping loopextract.ts:139-141 runs both once per block, serialized, though both are keyed on dirname(display). Pre-PR, mkdir ran once per distinct target. For the headline case — N region= blocks into one file — this goes from 1 mkdir to N mkdir + N realpath. Memoize a Map<string, Promise<string>> on dirname(display).
  2. mkdir runs before every group-level skip decision — so a run that writes nothing and exits 2 still leaves freshly created empty directories behind (extract.ts:137-142 vs the skips at 164, 171, 185 and inside spliceInPlace). extract is not read-only when it refuses.
  3. splitLines is a measured ~4x regression over the split("\n") it replaced — lookbehind split plus a per-line /\r?\n$/.exec() plus an object and two strings per line (region.ts:206-210). read and spliceRegions both call it, and update calls read per block. A manual indexOf("\n") scan with a charCodeAt CRLF check measured 4.6x faster with byte-identical output.
  4. RawLine discards the original line and both consumers rebuild itline.text + line.eol at region.ts:313 and 454, one extra full-line allocation per line per pass. Carry the raw line.
  5. Third stat for one value — the caller already holds full Stats in existing (extract.ts:160), spliceInPlace calls lstat (228), then stat again (266) only to recover .mode. For a confirmed non-symlink the lstat result already carries it.
  6. The append loop re-copies the whole file per unmatched regionextract.ts:260-264 evaluates content.trim() twice and content.replace(/\n*$/, …) once per iteration (~4 full-file copies), and items.find(...) rescans each time, inside a 130-character expression at exactly the point where trailing-newline handling is subtle. Build an array and join.
Tests (6)
  1. Assert exit code 2, not merely non-zeropackages/usage/tests/cli-integration.test.ts:235-238 uses assert.notStrictEqual(result.exitCode, 0). The extract action has three process.exit sites (1 for the flag conflict, 1 in the catch, 2 for skips), so a regression turning a skip into a thrown error keeps this green while breaking the contract documented at CLI_EXAMPLES.md:852-853.
  2. The invalid-region-name skip is untested — nothing in extract.test.ts exercises isValidRegionName returning false (extract.ts:134-137); region.test.ts:598-600 only covers wrapRegion throwing directly. Delete that guard and a name like evil */ x reaches wrapRegion, turning a clean skip into an Error and exit 1.
  3. Permission preservation is pinned only on the splice pathextract.test.ts:382-404 chmods 0o755 and asserts it survives a splice, but nothing exercises the --force whole-file overwrite at extract.ts:194, a separate existing?.mode call site.
  4. The absolute-path refusal does not assert skippedFiles — its sibling relative-escape test does (extract.test.ts:260); "refuses an absolute file=" (274-278) only checks the outside file is untouched and extractedFiles is empty, so the refusal could stop producing exit 2 with the test still green.
  5. The comment-style fallback assertion is weakregion.test.ts:591-596 asserts deepEqual(getCommentStyle("nosuchlang"), getCommentStyle("ts")). It can fail if the fallback diverges, but it pins a relationship rather than a value, so it passes vacuously if C_FAMILY itself changes. Assert the literal [{open:"//",close:""},{open:"/*",close:" */"}].
  6. Three copies of the same temp-directory scaffoldingextract.test.ts:10-27 and update.test.ts:9-24 each define equivalent tempDir/writeSource helpers, a module-level dirs array and an after() cleanup, plus their own console capture; cli-integration.test.ts adds a third mkdtemp idiom. One shared helper removes three copies of the same cleanup contract.
Docs, scope & hygiene (5)
  1. --base-path does not existexamples/CLI_EXAMPLES.md:316-321 says the resolved file= "is not confined to --base-path", but mdcode update's options are only -l/--lang, -f/--file, -m/--meta, -t/--transform, -q/--quiet, --stdout (cli.ts:221-229); the base path is derived internally as file ? dirname(resolve(file)) : process.cwd() (cli.ts:258). Reword to reference the markdown file's own directory. Separately: that documented unconfined read is an exfiltration primitive — file=../../.env inlines secrets into the markdown — so consider a warning when a resolved file= escapes the markdown's directory.
  2. CLAUDE.md's extract bullet is stale after a refresh that touched the file — this PR updates the package list and test-organisation sections but leaves CLAUDE.md:70 reading "extract - Write blocks to filesystem based on file metadata", with no mention of the skip default, --force, region splicing, or exit 2. It is the agent-facing contract for this repo.
  3. The README workflow example now fails on a second runCLI_EXAMPLES.md gained --force at lines 904, 963 and 994, but packages/mdcode/README.md:1200-1202 still shows mdcode extract -d ./readme README.md. Run twice, that snippet warns, writes nothing and exits 2 — exactly what the new "Force Overwrite" section (README.md:421-434) describes.
  4. Exit code 2 is absent from --help — a new public CLI contract (every other failure path uses 1), described in CLI_EXAMPLES.md:852-853 but nowhere in mdcode extract --help; the --force description (cli.ts:126-128) and README.md:815-820's flag list are both silent. The "N whole-file blocks disagree about its contents" refusal (extract.ts:169-174) is likewise missing from the README's three-outcome list at 296-306.
  5. Split the tooling and package-rename churn — the test-glob quoting fix plus -w--watch, the full TESTING.md rewrite, CLAUDE.md's removal of test counts and packages/example references, and the mdcode-ts rename across CLAUDE.md, CLI_EXAMPLES.md, README.md, src/index.ts:30 and src/types.ts:96 are all tangential to region-splice + --force. 7b9a7ab "docs: point install and import docs at the published name mdcode-ts" is already an isolated commit, so the split is cheap. Also fold away c04f047 "chore: Run lint:fix" and d175587 "fix: satisfy CI lint/bumpy checks". Unrelated: the untracked, unignored WATCHDOG.yml at repo root is machine-local harness config — the next git add -A commits it.

✅ Positive Observations

What's working well - click to expand
  1. The core fix is right and thorough.spliceRegions locates each region with its own block's comment syntax, discovers extents once per language, applies them in a single pass, re-indents to the start marker, and takes that marker's line ending — surrounding code and untouched regions survive, which is precisely what #21 failure 1 and 2 asked for.
  2. Aliased path collapsing is solved properly. Grouping on realpath(parent) + basename (extract.ts:332-336) collapses aliased spellings without requiring the file to exist — including the directory-symlink case from extract: destructive full-file overwrite instead of in-place region splice; aliased file= paths clobber each other #21 failure 3 — so one file gets one write instead of racing writes. The comment explaining why the parent is canonicalised is exactly the right depth.
  3. An explicit, principled refusal catalogue. Symlink, invalid UTF-8 (with TextDecoder({ fatal: true }) rather than a lossy decode), unclosed region, duplicated region, overlapping regions, mixed region=/whole-file groups, disagreeing whole-file bodies — each with a stated rationale, each leaving the target byte-identical.
  4. Atomic write with permission preservation, and the writeAtomic doc comment (extract.ts:287-299) honestly documents the inode/hard-link/xattr tradeoff and explains why the splice path refuses symlinks rather than relying on rename. That is the standard this repo should hold every module to.
  5. Region names are validated against a strict charset (region.ts:170, 186-188) so a name can never terminate a block or markup comment early — the injection vector is closed by construction.
  6. Test quality is genuinely high.mkdtemp per test, after() cleanup, mock.restoreAll() in finally, no cwd mutation, byte-identical assertions on refusal paths, idempotent re-runs, per-language comment syntax, permission bits, aliased paths collapsing, and no-temp-file-left-behind. The package.json glob change was verified non-vacuous: node expands ./{tests,src}/**/*.test.ts to all five files including the two new ones.
  7. The failure mode of the one Critical path is safe. The containment rule makes extract: destructive full-file overwrite instead of in-place region splice; aliased file= paths clobber each other #21's scenario fail closed (skip + exit 2) rather than destructively — the wrong outcome, but the safe direction.

📝 Required Actions

Blocking (must fix):

High priority (should fix):

  • extract.ts:246-249 — refuse a group where two blocks declare the same region=
  • region.ts:458-465 — make splice re-indentation the inverse of read()
  • extract.ts:258-264 — skip instead of appending a duplicate when the region exists in another comment syntax
  • update.ts:46 — route outline mode through region.outline(source, lang); delete src/outline.ts
  • update.ts:55-62 — give update a failure channel and a non-zero exit; record the contract change
  • extract.ts:109-113 — make the outline=true drop observable, or document it as update-only
  • extract.ts:300-303{ flag: "wx", mode: 0o600 } plus a random temp suffix
  • extract.ts:94-99 — return { path, reason } from the library; render in cli.ts
  • .bumpy/extract-region-splice-force.md — add the five missing behaviour changes; mark BREAKING
  • PR body — add Why/What/Impact/Verification and Closes #21
  • region.ts:37-49 — discriminated union on ok; own the refusal message
  • region.ts:477-486 — delete replace() or surface the reasons it drops
  • extract.ts:158-206 — extract the per-target policy decision as a pure function
All recommended actions

Observations (consider):

  • try/finally unlink in writeAtomic; skip directory targets; refuse symlinks under --force too
  • Skip byte-identical targets without exit 2; record --update-source metadata only for written targets
  • Create the temp at 0o600 and mask restored mode with 0o777; re-verify confinement immediately before the write
  • Collapse the two language registries into one languages.ts; derive the marker terminator from CommentStyle.close
  • Memoize mkdir + realpath per directory and move mkdir after the skip decisions; rewrite splitLines as an index scan; key namesByLang on the resolved comment style; reuse the existing stat
  • Fix ExtractCliOptions (meta?: Array<string>, drop the false compile-error claim); export or privatise the new region API; drop BlockRef.index
  • assert.strictEqual(exitCode, 2); cover the invalid-region-name skip; pin mode on the --force path; assert skippedFiles in the absolute-path test; assert the literal fallback comment style; share the temp-dir test helper
  • Docs: remove the nonexistent --base-path; refresh the CLAUDE.md extract bullet; add --force to the README workflow; document exit 2 in --help and the disagreeing-blocks refusal in the README
  • Split the package-rename and tooling churn into its own PR; gitignore or remove the untracked WATCHDOG.yml

Verdict

CHANGES REQUESTED

The region-splice engine is well designed and the refusal catalogue, atomic write and test suite are all above the bar for this repo — but the PR does not yet fix the case it was opened for, and it introduces a new silent-truncation path on piped output.

Top three fixes:

  1. extract.ts:128-146 — the containment rule refuses file=../../shared-tests/readme/where.ts, the exact spelling in #21; the reported scenario skips and exits 2 instead of splicing.
  2. cli.ts:171process.exit(2) after stdout.write truncates --update-source output at one pipe buffer (measured: 177810 → 65536 bytes).
  3. extract.ts:246-249 — two blocks naming the same region= silently discard one body while reporting success and exiting 0.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

extract: destructive full-file overwrite instead of in-place region splice; aliased file= paths clobber each other

1 participant

@adrianbrowning