From 1bf1ec1e6b50f0864e22117c5db23315300c1e15 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 13:29:37 -0700 Subject: [PATCH 1/5] Harden CI: read-only token, macOS and Windows, lint and format everything Give the workflow a read-only token, don't persist checkout credentials, cancel superseded PR runs, and add timeouts. Run tests and the package smoke test on Ubuntu, macOS and Windows with Node 22 and 24, with autocrlf off so fixtures keep LF. Move lint, format check and typecheck into one job. Lint src, test, scripts and config files (fixtures ignored) and fix what that found; add format:check and ignore dist, coverage and fixtures in Prettier. Skip read-permission tests on Windows, where chmod can't revoke reads. --- .github/workflows/ci.yml | 54 +++++++++++++++++++++++++++++----- .prettierignore | 3 ++ README.md | 1 + SKILL.md | 18 +++++++----- eslint.config.js | 15 ++++++++-- package.json | 7 +++-- scripts/smoke-test-package.mjs | 7 +++-- test/check-discovery.test.ts | 2 -- test/cli.test.ts | 22 +++++++------- test/extract.test.ts | 6 ++-- test/formatter.test.ts | 3 +- test/integration.test.ts | 10 +++---- test/parser.test.ts | 12 ++------ test/snippet-directive.test.ts | 2 +- test/sync-plan.test.ts | 2 +- tsconfig.eslint.json | 8 +++++ 16 files changed, 115 insertions(+), 57 deletions(-) create mode 100644 tsconfig.eslint.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0866437..50f73ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,24 +5,28 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: - test: - name: Test + lint: + name: Lint runs-on: ubuntu-latest - strategy: - matrix: - node-version: [22, 24] + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v6 + with: + persist-credentials: false - name: Set up mise uses: jdx/mise-action@v3 with: cache: true - mise_toml: | - [tools] - node = "${{ matrix.node-version }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -32,9 +36,43 @@ jobs: - name: Run linter run: npm run lint + - name: Check formatting + run: npm run format:check + - name: Typecheck run: npm run typecheck + test: + name: Test (${{ matrix.os }}, Node ${{ matrix.node-version }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [22, 24] + steps: + - name: Keep line endings as committed + run: git config --global core.autocrlf false + + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up mise + uses: jdx/mise-action@v3 + with: + cache: true + mise_toml: | + [tools] + node = "${{ matrix.node-version }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies + run: npm ci + - name: Build run: npm run build diff --git a/.prettierignore b/.prettierignore index 1b763b1..ba08fbc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,4 @@ +dist/ +coverage/ +test/fixtures/ CHANGELOG.md diff --git a/README.md b/README.md index a1af7b8..78dca8d 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ syntax as a local path: ````markdown ```ts snippet=https://raw.githubusercontent.com/example/project/main/app.ts#L10-L20 + ``` ```` diff --git a/SKILL.md b/SKILL.md index 315ef48..1ea17a0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -59,15 +59,19 @@ Use a fence info string with a `snippet=` metadata token: ````markdown ```bash snippet=path/to/file.sh + ``` ```ts snippet=src/utils/auth.ts#L10-L20 + ``` ```ts snippet=src/utils/auth.ts#L5 + ``` ```ts snippet=src/utils/auth.ts#L5- + ``` ```` @@ -75,13 +79,13 @@ Paths are relative to `snippetRoot` in `.markdown-coderc.json` (the runtime defa ## Commands -| Command | Description | -|---------|-------------| -| `npx markdown-code init` | Create `.markdown-coderc.json` and `snippets/` directory | -| `npx markdown-code extract` | Extract code blocks to snippet files and insert directives (Workflow A) | -| `npx markdown-code sync` | Populate markdown code blocks from snippet files (Workflow B) | -| `npx markdown-code check` | Verify markdown matches snippet files — exits non-zero if drift or an error-severity missing snippet is detected | -| `npx markdown-code eject` | Remove snippet directives, snippets, and config after confirmation | +| Command | Description | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `npx markdown-code init` | Create `.markdown-coderc.json` and `snippets/` directory | +| `npx markdown-code extract` | Extract code blocks to snippet files and insert directives (Workflow A) | +| `npx markdown-code sync` | Populate markdown code blocks from snippet files (Workflow B) | +| `npx markdown-code check` | Verify markdown matches snippet files — exits non-zero if drift or an error-severity missing snippet is detected | +| `npx markdown-code eject` | Remove snippet directives, snippets, and config after confirmation | ## CI Integration diff --git a/eslint.config.js b/eslint.config.js index 1f33cbf..435211e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,13 +4,16 @@ import tseslint from "@typescript-eslint/eslint-plugin"; import tsParser from "@typescript-eslint/parser"; export default [ + { + ignores: ["dist/**", "coverage/**", "test/fixtures/**"], + }, js.configs.recommended, { - files: ["src/**/*.ts"], + files: ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.mjs", "*.config.ts"], languageOptions: { parser: tsParser, parserOptions: { - project: "./tsconfig.json", + project: "./tsconfig.eslint.json", }, globals: { ...globals.node, @@ -42,7 +45,7 @@ export default [ "max-len": ["error", { code: 100, ignoreUrls: true }], // TypeScript-specific rules (Airbnb-inspired) - "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-unused-vars": ["error", { ignoreRestSiblings: true }], "@typescript-eslint/no-use-before-define": "error", "@typescript-eslint/no-shadow": "error", "@typescript-eslint/prefer-optional-chain": "error", @@ -52,4 +55,10 @@ export default [ "@typescript-eslint/explicit-module-boundary-types": "off", }, }, + { + files: ["test/**/*.ts"], + rules: { + "max-len": "off", + }, + }, ]; \ No newline at end of file diff --git a/package.json b/package.json index acc11f1..3784a6c 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,10 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "format": "prettier --write \"**/*.ts\" \"**/*.md\"", - "lint": "eslint src", - "lint:fix": "eslint src --fix", + "format": "prettier --write \"**/*.{ts,mjs,md}\"", + "format:check": "prettier --check \"**/*.{ts,mjs,md}\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "prepublishOnly": "npm run build", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/smoke-test-package.mjs b/scripts/smoke-test-package.mjs index 5407b8e..217ae8c 100644 --- a/scripts/smoke-test-package.mjs +++ b/scripts/smoke-test-package.mjs @@ -105,11 +105,14 @@ try { mdCode('md-code', ['check'], project); const exported = run( - 'node', + process.execPath, [ '--input-type=module', '--eval', - "const m = await import('markdown-code'); console.log([typeof m.syncMarkdownFiles, m.VERSION].join(' '));", + [ + "const m = await import('markdown-code');", + "console.log(typeof m.syncMarkdownFiles + ' ' + m.VERSION);", + ].join(' '), ], project, ); diff --git a/test/check-discovery.test.ts b/test/check-discovery.test.ts index d5f8ced..eb278fc 100644 --- a/test/check-discovery.test.ts +++ b/test/check-discovery.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { join } from 'node:path'; import { Project } from 'fixturify-project'; import { discoverCodeBlocks } from '../src/sync.js'; -import { handler } from '../src/commands/check.js'; describe('discovery functionality', () => { let project: Project; diff --git a/test/cli.test.ts b/test/cli.test.ts index 2ed2dbb..dc0830b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,14 +1,14 @@ import path from 'node:path'; import { readFileSync, existsSync, chmodSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createBintastic, BintasticProject } from 'bintastic'; import { loadScenario } from './fixtures/index.js'; import { VERSION } from '../src/version.js'; function normalizeOutput(output: string, baseDir: string): string { - return output - .replace(/\x1b\[[0-9;]*m/g, '') + return stripVTControlCharacters(output) .replaceAll(realpathSync(baseDir), '') .replace(new RegExp(baseDir, 'g'), ''); } @@ -91,7 +91,7 @@ describe('CLI', () => { expect(result.exitCode).toEqual(0); expect(result.stdout).toMatchInlineSnapshot( - `"Configuration file already exists at custom.json"`, + '"Configuration file already exists at custom.json"', ); expect( readFileSync(path.join(project.baseDir, 'custom.json'), 'utf-8'), @@ -209,7 +209,7 @@ describe('CLI', () => { path.join(project.baseDir, 'snippets', 'example.js'), 'utf-8', ); - expect(existingFile).toMatchInlineSnapshot(`"console.log("existing");"`); + expect(existingFile).toMatchInlineSnapshot('"console.log("existing");"'); }); }); @@ -422,7 +422,7 @@ const updated = "old"; expect(result.exitCode).toEqual(1); expect( normalizeOutput(result.stderr, project.baseDir), - ).toMatchInlineSnapshot(`""`); + ).toMatchInlineSnapshot('""'); }); it('uses check command', async () => { @@ -468,7 +468,7 @@ const test = true; expect(result.exitCode).toEqual(1); expect( normalizeOutput(result.stderr, project.baseDir), - ).toMatchInlineSnapshot(`""`); + ).toMatchInlineSnapshot('""'); }); it('fails on missing files in check mode by default', async () => { @@ -487,7 +487,7 @@ old content expect(result.exitCode).toEqual(1); expect( normalizeOutput(result.stderr, project.baseDir), - ).toMatchInlineSnapshot(`""`); + ).toMatchInlineSnapshot('""'); expect(normalizeOutput(result.stdout, project.baseDir)) .toMatchInlineSnapshot(` "Checking markdown files... @@ -566,7 +566,7 @@ old content expect(result.exitCode).toEqual(1); expect(result.stderr).toMatchInlineSnapshot( - `"Config file not found: nonexistent.json"`, + '"Config file not found: nonexistent.json"', ); }); @@ -731,7 +731,7 @@ old content }); describe('error handling', () => { - describe.skipIf(process.getuid?.() === 0)( + describe.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( 'unreadable Markdown files', () => { async function writeProjectWithUnreadableFile() { @@ -777,7 +777,7 @@ old content expect(result.exitCode).toEqual(1); expect(normalizeOutput(result.stderr, project.baseDir)).toMatch( - /^Errors:\n Error processing \/locked\.md: Error: EACCES/, + /^Errors:\n {2}Error processing \/locked\.md: Error: EACCES/, ); expect(result.stderr).toContain('Snippet file not found: nope.js'); expect(result.stdout).toMatchInlineSnapshot(` @@ -1359,7 +1359,7 @@ old content expect(result.exitCode).toEqual(1); expect(result.stderr).toMatchInlineSnapshot( - `"Config: snippetRoot must be a non-empty string"`, + '"Config: snippetRoot must be a non-empty string"', ); expect(result.stdout).toBe(''); expect(existsSync(path.join(project.baseDir, 'custom.json'))).toBe(true); diff --git a/test/extract.test.ts b/test/extract.test.ts index e6a233f..f88bbcf 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -28,7 +28,8 @@ describe('extractSnippets', () => { }); it('extracts snippets and updates markdown', async () => { - const md = `# Example\n\n\`\`\`ts\nconsole.log('ts');\n\`\`\`\n\n\`\`\`js\nconsole.log('js');\n\`\`\``; + const md = + "# Example\n\n```ts\nconsole.log('ts');\n```\n\n```js\nconsole.log('js');\n```"; const filePath = join(testDir, 'example.md'); await project.write({ 'example.md': md }); @@ -86,7 +87,7 @@ describe('extractSnippets', () => { }); it('skips languages not in includeExtensions', async () => { - const md = `\`\`\`ts\nconsole.log('ts');\n\`\`\`\n\n\`\`\`py\nprint('py')\n\`\`\``; + const md = "```ts\nconsole.log('ts');\n```\n\n```py\nprint('py')\n```"; const filePath = join(testDir, 'skip.md'); await project.write({ 'skip.md': md }); @@ -156,7 +157,6 @@ Content with newline: console.log('has newline'); \`\`\``; - const filePath = join(testDir, 'newlines.md'); await project.write({ 'newlines.md': md }); await extractSnippets(baseConfig); diff --git a/test/formatter.test.ts b/test/formatter.test.ts index 46730ad..8d07579 100644 --- a/test/formatter.test.ts +++ b/test/formatter.test.ts @@ -1,3 +1,4 @@ +import { stripVTControlCharacters } from 'node:util'; import { describe, it, expect } from 'vitest'; import { format, @@ -180,7 +181,7 @@ describe('formatDiscovery', () => { '/project', ); - expect(result.replace(/\x1b\[[0-9;]*m/g, '')).toMatchInlineSnapshot(` + expect(stripVTControlCharacters(result)).toMatchInlineSnapshot(` "Found 1 code block in 1 file not yet managed by markdown-code: README.md 1 code block (ts)" `); diff --git a/test/integration.test.ts b/test/integration.test.ts index 2cb3476..b4eee2d 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -637,8 +637,8 @@ old content }); it('should prioritize project root files over snippetRoot files', async () => { - const projectFileContent = `console.log("From project root");`; - const snippetFileContent = `console.log("From snippet root");`; + const projectFileContent = 'console.log("From project root");'; + const snippetFileContent = 'console.log("From snippet root");'; const markdownContent = `# Priority Test @@ -670,8 +670,8 @@ old content }); it('should handle relative paths from markdown file location', async () => { - const helperContent = `export const helper = () => "Helper";`; - const utilContent = `export const util = () => "Util";`; + const helperContent = 'export const helper = () => "Helper";'; + const utilContent = 'export const util = () => "Util";'; const markdownContent = `# Relative Paths Test @@ -715,7 +715,7 @@ old content }); it('should fall back to snippetRoot when file not found at project root', async () => { - const snippetContent = `console.log("Only in snippets");`; + const snippetContent = 'console.log("Only in snippets");'; const markdownContent = `# Fallback Test diff --git a/test/parser.test.ts b/test/parser.test.ts index 51fd0df..d00723d 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { join, dirname } from 'node:path'; import { writeFile, unlink, rmdir } from 'node:fs/promises'; import { Project } from 'fixturify-project'; @@ -162,7 +162,6 @@ plain describe('loadSnippetContent', () => { it('should load content from file', async () => { const content = 'export const test = "hello";'; - const filePath = join(testDir, 'test.ts'); await project.write({ 'test.ts': content }); const config = { @@ -179,8 +178,6 @@ plain it('should handle nested paths', async () => { const content = 'nested file content'; - const nestedDir = join(testDir, 'nested'); - const filePath = join(nestedDir, 'file.js'); await project.write({ nested: { 'file.js': content } }); const config = { @@ -250,12 +247,7 @@ plain it.skipIf(process.platform === 'win32')( 'should reject symlink escape outside workingDir', async () => { - const { - symlink, - unlink: fsUnlink, - mkdir, - } = await import('node:fs/promises'); - const { basename } = await import('node:path'); + const { symlink, unlink: fsUnlink } = await import('node:fs/promises'); const config = { snippetRoot: '.', diff --git a/test/snippet-directive.test.ts b/test/snippet-directive.test.ts index 699a81b..c4b3e2b 100644 --- a/test/snippet-directive.test.ts +++ b/test/snippet-directive.test.ts @@ -338,7 +338,7 @@ describe('snippet directive formatting', () => { ); it('rejects references containing both quote characters', () => { - expect(() => formatSnippetDirective({ filePath: `a"b'c.ts` })).toThrow( + expect(() => formatSnippetDirective({ filePath: 'a"b\'c.ts' })).toThrow( 'both quote characters', ); }); diff --git a/test/sync-plan.test.ts b/test/sync-plan.test.ts index 8c0a939..3c2876c 100644 --- a/test/sync-plan.test.ts +++ b/test/sync-plan.test.ts @@ -145,7 +145,7 @@ describe('planSync', () => { ); }); - it.skipIf(process.getuid?.() === 0)( + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( 'reports files that cannot be read', async () => { const readme = join(project.baseDir, 'README.md'); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..0a233d8 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*", "test/**/*", "scripts/**/*", "*.config.ts"], + "exclude": ["node_modules", "dist", "test/fixtures"] +} From 208daf3fa8a0af664210a98bea67465104aff11c Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 13:37:36 -0700 Subject: [PATCH 2/5] Fix snippet containment and globbing on Windows and symlinked working dirs allowedSnippetRoots compared a realpath'd snippet against roots that weren't realpath'd, so every snippet was rejected as path traversal when the working directory path went through a symlink or a Windows 8.3 short name. Realpath the working directory and map the snippet root onto it lexically, so a symlinked snippets directory is still not followed. Route all Markdown globbing through src/glob.ts, which converts backslashes to glob separators on Windows and returns platform-native paths. Make test path normalization portable. --- src/glob.ts | 22 +++++++++++ src/snippet-resolution.ts | 17 +++++++-- src/sync-plan.ts | 12 +++--- src/sync.ts | 22 +++++------ test/cli.test.ts | 7 +++- test/eject.test.ts | 8 +++- test/glob.test.ts | 67 +++++++++++++++++++++++++++++++++ test/snippet-resolution.test.ts | 30 +++++++++++++-- 8 files changed, 156 insertions(+), 29 deletions(-) create mode 100644 src/glob.ts create mode 100644 test/glob.test.ts diff --git a/src/glob.ts b/src/glob.ts new file mode 100644 index 0000000..687583a --- /dev/null +++ b/src/glob.ts @@ -0,0 +1,22 @@ +import { resolve } from 'node:path'; +import fg from 'fast-glob'; + +export function toGlobPattern( + pattern: string, + platform: NodeJS.Platform = process.platform, +): string { + return platform === 'win32' ? pattern.replaceAll('\\', '/') : pattern; +} + +export async function findMarkdownFiles( + markdownGlob: string, + excludeGlob: ReadonlyArray, + workingDir: string, +): Promise> { + const files = await fg(toGlobPattern(markdownGlob), { + ignore: excludeGlob.map((pattern) => toGlobPattern(pattern)), + cwd: workingDir, + absolute: true, + }); + return files.map((file) => resolve(file)); +} diff --git a/src/snippet-resolution.ts b/src/snippet-resolution.ts index a1fa501..192a8a9 100644 --- a/src/snippet-resolution.ts +++ b/src/snippet-resolution.ts @@ -1,5 +1,5 @@ import { readFile, realpath } from 'node:fs/promises'; -import { resolve, dirname, isAbsolute } from 'node:path'; +import { resolve, dirname, isAbsolute, relative } from 'node:path'; import type { Issue, RuntimeConfig, SnippetDirective } from './types.js'; import type { DocumentCodeBlock } from './markdown-document.js'; import { fileExists, isInWorkingDir } from './utils.js'; @@ -34,10 +34,19 @@ export function isManagedCodeBlock( return codeBlock.directive !== undefined; } -function allowedSnippetRoots(config: RuntimeConfig): Array { +async function allowedSnippetRoots( + config: RuntimeConfig, +): Promise> { const workingDir = resolve(config.workingDir); const snippetRoot = resolve(workingDir, config.snippetRoot || '.'); - return snippetRoot !== workingDir ? [workingDir, snippetRoot] : [workingDir]; + const realWorkingDir = await realpath(workingDir).catch(() => workingDir); + const realSnippetRoot = resolve( + realWorkingDir, + relative(workingDir, snippetRoot), + ); + return realSnippetRoot !== realWorkingDir + ? [realWorkingDir, realSnippetRoot] + : [realWorkingDir]; } function countLines(content: string): number { @@ -152,7 +161,7 @@ async function readLocalSnippet( }; } - if (!isInWorkingDir(realPath, allowedSnippetRoots(config))) { + if (!isInWorkingDir(realPath, await allowedSnippetRoots(config))) { return { ok: false, step: 'contain' }; } diff --git a/src/sync-plan.ts b/src/sync-plan.ts index 98a2a63..bbe69a3 100644 --- a/src/sync-plan.ts +++ b/src/sync-plan.ts @@ -1,4 +1,4 @@ -import fg from 'fast-glob'; +import { findMarkdownFiles } from './glob.js'; import type { Issue, RuntimeConfig } from './types.js'; import { readMarkdownDocument } from './parser.js'; import { createIssue } from './issues.js'; @@ -80,11 +80,11 @@ async function* planFiles( export async function planSync(config: RuntimeConfig): Promise { let markdownFiles: Array; try { - markdownFiles = await fg(config.markdownGlob, { - ignore: config.excludeGlob, - cwd: config.workingDir, - absolute: true, - }); + markdownFiles = await findMarkdownFiles( + config.markdownGlob, + config.excludeGlob, + config.workingDir, + ); } catch (error) { return { status: 'error', diff --git a/src/sync.ts b/src/sync.ts index f4bafdb..6bb7787 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,7 +1,7 @@ import { writeFile, mkdir } from 'node:fs/promises'; import { join, resolve, basename, extname } from 'node:path'; import { createRequire } from 'node:module'; -import fg from 'fast-glob'; +import { findMarkdownFiles } from './glob.js'; import { fileExists, isInWorkingDir } from './utils.js'; import type { RuntimeConfig, @@ -167,11 +167,11 @@ export async function extractSnippets( } try { - const markdownFiles = await fg(config.markdownGlob, { - ignore: config.excludeGlob, - cwd: workingDir ?? process.cwd(), - absolute: true, - }); + const markdownFiles = await findMarkdownFiles( + config.markdownGlob, + config.excludeGlob, + workingDir ?? process.cwd(), + ); for (const filePath of markdownFiles) { try { @@ -267,11 +267,11 @@ export async function discoverCodeBlocks( }; try { - const markdownFiles = await fg(markdownGlob, { - ignore: excludeGlob, - cwd: workingDir, - absolute: true, - }); + const markdownFiles = await findMarkdownFiles( + markdownGlob, + excludeGlob, + workingDir, + ); for (const filePath of markdownFiles) { try { diff --git a/test/cli.test.ts b/test/cli.test.ts index dc0830b..70a7312 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -8,9 +8,12 @@ import { loadScenario } from './fixtures/index.js'; import { VERSION } from '../src/version.js'; function normalizeOutput(output: string, baseDir: string): string { - return stripVTControlCharacters(output) + const normalized = stripVTControlCharacters(output) .replaceAll(realpathSync(baseDir), '') - .replace(new RegExp(baseDir, 'g'), ''); + .replaceAll(baseDir, ''); + return process.platform === 'win32' + ? normalized.replaceAll('\\', '/') + : normalized; } describe('CLI', () => { diff --git a/test/eject.test.ts b/test/eject.test.ts index ee3a881..1f25e88 100644 --- a/test/eject.test.ts +++ b/test/eject.test.ts @@ -130,10 +130,14 @@ console.log("no snippet directive"); // Manually import the internal function for testing const { parseMarkdownFile } = await import('../src/parser.js'); - const fg = await import('fast-glob'); + const { findMarkdownFiles } = await import('../src/glob.js'); // Test the core removal logic - const markdownFiles = await fg.default(baseConfig.markdownGlob); + const markdownFiles = await findMarkdownFiles( + baseConfig.markdownGlob, + [], + testDir, + ); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( diff --git a/test/glob.test.ts b/test/glob.test.ts new file mode 100644 index 0000000..d7b2788 --- /dev/null +++ b/test/glob.test.ts @@ -0,0 +1,67 @@ +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Project } from 'fixturify-project'; +import { findMarkdownFiles, toGlobPattern } from '../src/glob.js'; + +describe('toGlobPattern', () => { + it('turns Windows separators into glob separators on Windows', () => { + expect(toGlobPattern('docs\\**\\*.md', 'win32')).toBe('docs/**/*.md'); + expect(toGlobPattern('C:\\work\\**\\*.md', 'win32')).toBe( + 'C:/work/**/*.md', + ); + }); + + it('leaves patterns alone elsewhere, where a backslash is an escape', () => { + expect(toGlobPattern('docs/\\[draft\\].md', 'linux')).toBe( + 'docs/\\[draft\\].md', + ); + expect(toGlobPattern('docs/**/*.md', 'darwin')).toBe('docs/**/*.md'); + }); +}); + +describe('findMarkdownFiles', () => { + let project: Project; + + beforeEach(async () => { + project = new Project(); + await project.write({ + 'README.md': '', + docs: { 'guide.md': '', 'draft.md': '' }, + 'notes.txt': '', + }); + }); + + afterEach(() => { + project.dispose(); + }); + + it('returns absolute, platform-native paths', async () => { + const files = await findMarkdownFiles( + '**/*.md', + ['docs/draft.md'], + project.baseDir, + ); + + expect(files.sort()).toEqual( + [ + join(project.baseDir, 'README.md'), + join(project.baseDir, 'docs', 'guide.md'), + ].sort(), + ); + }); + + it('accepts an absolute pattern built with platform paths', async () => { + const files = await findMarkdownFiles( + join(project.baseDir, 'docs', '*.md'), + [], + project.baseDir, + ); + + expect(files.sort()).toEqual( + [ + join(project.baseDir, 'docs', 'draft.md'), + join(project.baseDir, 'docs', 'guide.md'), + ].sort(), + ); + }); +}); diff --git a/test/snippet-resolution.test.ts b/test/snippet-resolution.test.ts index 3a5654a..9d42477 100644 --- a/test/snippet-resolution.test.ts +++ b/test/snippet-resolution.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { symlinkSync } from 'node:fs'; +import { realpathSync, symlinkSync } from 'node:fs'; import { join } from 'node:path'; import { Project } from 'fixturify-project'; import { MarkdownDocument } from '../src/markdown-document.js'; @@ -66,10 +66,20 @@ describe('resolveSnippet', () => { } function withoutTmp(resolution: SnippetResolution): SnippetResolution { - return JSON.parse( - JSON.stringify(resolution) + const replacePaths = (text: string) => { + const replaced = text + .replaceAll(realpathSync(project.baseDir), '') .replaceAll(project.baseDir, '') - .replaceAll(outside.baseDir, ''), + .replaceAll(realpathSync(outside.baseDir), '') + .replaceAll(outside.baseDir, ''); + return process.platform === 'win32' + ? replaced.replaceAll('\\', '/') + : replaced; + }; + return JSON.parse( + JSON.stringify(resolution, (_key, value: unknown) => + typeof value === 'string' ? replacePaths(value) : value, + ), ); } @@ -81,6 +91,18 @@ describe('resolveSnippet', () => { }); }); + it('resolves snippets when the working directory path goes through a symlink', async () => { + const link = join(outside.baseDir, 'project-link'); + symlinkSync(project.baseDir, link, 'junction'); + + expect( + await resolveFor('snippet=folder/nested.js', { workingDir: link }), + ).toEqual({ status: 'resolved', content: 'nested' }); + expect( + await resolveFor('snippet=only-in-root.js', { workingDir: link }), + ).toEqual({ status: 'resolved', content: 'from snippet root' }); + }); + it('selects a line range', async () => { expect(await resolveFor('snippet=app.js#L3-L4')).toEqual({ status: 'resolved', From ecdbd1a40714dd748931fb97db4f048ca869c17a Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 13:41:32 -0700 Subject: [PATCH 3/5] Glob eject's files through findMarkdownFiles; make two tests portable eject still called fast-glob directly. The absolute-path traversal test now uses a file that exists on every platform, and the ENOTDIR case is POSIX-only. --- src/commands/eject.ts | 12 +++++----- test/cli.test.ts | 9 ++++---- test/eject.test.ts | 40 ++++++++++++++++++++++++--------- test/snippet-resolution.test.ts | 35 ++++++++++++++++------------- 4 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/commands/eject.ts b/src/commands/eject.ts index 6372737..a8f90c5 100644 --- a/src/commands/eject.ts +++ b/src/commands/eject.ts @@ -2,7 +2,7 @@ import { writeFile, rm, unlink, realpath } from 'node:fs/promises'; import { parse as parsePath, resolve } from 'node:path'; import { createInterface } from 'node:readline/promises'; import type { ArgumentsCamelCase, Argv } from 'yargs'; -import fg from 'fast-glob'; +import { findMarkdownFiles } from '../glob.js'; import { configExists, configPathOrDefault, @@ -63,11 +63,11 @@ export async function removeSnippetDirectives(config: RuntimeConfig) { }; try { - const markdownFiles = await fg(config.markdownGlob, { - ignore: config.excludeGlob, - cwd: config.workingDir, - absolute: true, - }); + const markdownFiles = await findMarkdownFiles( + config.markdownGlob, + config.excludeGlob, + config.workingDir, + ); for (const filePath of markdownFiles) { try { diff --git a/test/cli.test.ts b/test/cli.test.ts index 70a7312..5779119 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -806,10 +806,11 @@ old content }, ); - it('handles file system errors gracefully', async () => { + it('rejects an absolute snippet path outside the project', async () => { + const outsideFile = fileURLToPath(import.meta.url).replaceAll('\\', '/'); const markdownContent = `# Test -\`\`\`js snippet=/etc/passwd +\`\`\`js snippet=${outsideFile} malicious content \`\`\``; @@ -820,8 +821,8 @@ malicious content const result = await runBin(); expect(result.exitCode).toEqual(1); - expect(normalizeOutput(result.stderr, project.baseDir)).toContain( - 'invalid-path Path traversal attempt detected: /etc/passwd', + expect(stripVTControlCharacters(result.stderr)).toContain( + `invalid-path Path traversal attempt detected: ${outsideFile}`, ); }); diff --git a/test/eject.test.ts b/test/eject.test.ts index 1f25e88..612c1a1 100644 --- a/test/eject.test.ts +++ b/test/eject.test.ts @@ -203,9 +203,13 @@ console.log("no directives"); }); const { parseMarkdownFile } = await import('../src/parser.js'); - const fg = await import('fast-glob'); + const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await fg.default(baseConfig.markdownGlob); + const markdownFiles = await findMarkdownFiles( + baseConfig.markdownGlob, + [], + testDir, + ); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( @@ -234,9 +238,13 @@ interface Config {} }); const { parseMarkdownFile } = await import('../src/parser.js'); - const fg = await import('fast-glob'); + const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await fg.default(baseConfig.markdownGlob); + const markdownFiles = await findMarkdownFiles( + baseConfig.markdownGlob, + [], + testDir, + ); expect(markdownFiles).toHaveLength(2); for (const filePath of markdownFiles) { @@ -264,9 +272,13 @@ interface Test {} }); const { parseMarkdownFile } = await import('../src/parser.js'); - const fg = await import('fast-glob'); + const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await fg.default(baseConfig.markdownGlob); + const markdownFiles = await findMarkdownFiles( + baseConfig.markdownGlob, + [], + testDir, + ); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); @@ -300,9 +312,13 @@ function test() { }); const { parseMarkdownFile } = await import('../src/parser.js'); - const fg = await import('fast-glob'); + const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await fg.default(baseConfig.markdownGlob); + const markdownFiles = await findMarkdownFiles( + baseConfig.markdownGlob, + [], + testDir, + ); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( @@ -539,9 +555,13 @@ interface MyType {} }); const { parseMarkdownFile } = await import('../src/parser.js'); - const fg = await import('fast-glob'); + const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await fg.default(baseConfig.markdownGlob); + const markdownFiles = await findMarkdownFiles( + baseConfig.markdownGlob, + [], + testDir, + ); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( diff --git a/test/snippet-resolution.test.ts b/test/snippet-resolution.test.ts index 9d42477..986e0ee 100644 --- a/test/snippet-resolution.test.ts +++ b/test/snippet-resolution.test.ts @@ -262,22 +262,25 @@ describe('resolveSnippet', () => { }); }); - it('reports a path that cannot be accessed', async () => { - const resolution = await resolveFor( - `snippet=${join(project.baseDir, 'app.js', 'child.js')}`, - ); - - expect(withoutTmp(resolution)).toMatchObject({ - status: 'failed', - issue: { - type: 'load-failed', - ruleId: 'snippet-load-error', - message: expect.stringMatching( - /^Error accessing snippet \/app\.js\/child\.js: ENOTDIR/, - ), - }, - }); - }); + it.skipIf(process.platform === 'win32')( + 'reports a path that cannot be accessed (ENOTDIR is POSIX-only)', + async () => { + const resolution = await resolveFor( + `snippet=${join(project.baseDir, 'app.js', 'child.js')}`, + ); + + expect(withoutTmp(resolution)).toMatchObject({ + status: 'failed', + issue: { + type: 'load-failed', + ruleId: 'snippet-load-error', + message: expect.stringMatching( + /^Error accessing snippet \/app\.js\/child\.js: ENOTDIR/, + ), + }, + }); + }, + ); it('reports a snippet that cannot be read', async () => { const resolution = await resolveFor('snippet=folder'); From 080bb26fd1e097b9b7177b2b0c409d16176be844 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 13:43:34 -0700 Subject: [PATCH 4/5] Compare extract results with platform paths in the eject workflow test --- test/eject.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/test/eject.test.ts b/test/eject.test.ts index 612c1a1..9f60531 100644 --- a/test/eject.test.ts +++ b/test/eject.test.ts @@ -393,16 +393,12 @@ function createUser(name: string, age: number): User { // Step 1: Run extract to create snippets and directives const extractResult = await extractSnippets(runtimeConfig); - expect(extractResult).toMatchInlineSnapshot(` - { - "errors": [], - "extracted": [ - "${testDir}/README.md", - ], - "snippetsCreated": 2, - "warnings": [], - } - `); + expect(extractResult).toEqual({ + errors: [], + extracted: [join(testDir, 'README.md')], + snippetsCreated: 2, + warnings: [], + }); const markdownAfterExtract = readFileSync( join(testDir, 'README.md'), From 46ff37c5349fce0cdac37d633f54eb00ab082ca9 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 13:59:47 -0700 Subject: [PATCH 5/5] Address second-pass review feedback Only map the snippet root lexically onto the real working directory when it's inside the working directory; realpath an outside root on its own, so a symlinked working dir can't allow an unconfigured directory. Pass findMarkdownFiles an options object. Document backslashes in Windows globs. Keep LF via .gitattributes instead of a CI step. Update AGENTS.md and CONTRIBUTING.md, and name skip reasons in tests. --- .gitattributes | 2 ++ .github/workflows/ci.yml | 3 -- AGENTS.md | 4 +-- CONTRIBUTING.md | 3 ++ README.md | 5 +++ src/commands/eject.ts | 6 +--- src/glob.ts | 16 +++++++--- src/snippet-resolution.ts | 13 +++++--- src/sync-plan.ts | 6 +--- src/sync.ts | 13 ++++---- test/cli.test.ts | 2 +- test/eject.test.ts | 54 +++++++++++++++------------------ test/glob.test.ts | 20 ++++++------ test/snippet-resolution.test.ts | 48 +++++++++++++++++++++++++++++ test/sync-plan.test.ts | 2 +- 15 files changed, 123 insertions(+), 74 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50f73ed..813c727 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,9 +52,6 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] node-version: [22, 24] steps: - - name: Keep line endings as committed - run: git config --global core.autocrlf false - - name: Checkout repository uses: actions/checkout@v6 with: diff --git a/AGENTS.md b/AGENTS.md index e0e432f..dfaffde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Follow these guidelines when modifying the repository. ## Setup -- Use **Node.js 22.16** (or the version specified in `mise.toml`). Node 18+ is required. +- Use **Node.js 22.16** (or the version specified in `mise.toml`). Node 22+ is required (`engines` in `package.json`). - Install dependencies with `npm install`. - Run the TypeScript build with `npm run build` (or `npm run dev` for watch mode). - Run the linter with `npm run lint` (use `npm run lint:fix` to autofix). @@ -40,7 +40,7 @@ Follow these guidelines when modifying the repository. ## Continuous Integration -- GitHub Actions (`.github/workflows/ci.yml`) install dependencies, run linting, typecheck, build the project, and execute tests on Node 22 and 24. +- GitHub Actions (`.github/workflows/ci.yml`) run lint, `format:check` and typecheck once, then build, test and `test:package` on Ubuntu, macOS and Windows with Node 22 and 24. Keep paths, globs and test expectations portable (use `path.join`, not `/`). ## Useful Scripts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f3fe9c..8647a9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,9 @@ npm run build # Lint code npm run lint + +# Check formatting (npm run format to fix) +npm run format:check ``` ## Project Structure diff --git a/README.md b/README.md index 78dca8d..f96bf82 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,11 @@ Create a `.markdown-coderc.json` file in your project root: | **includeExtensions** | File extensions to consider for snippets and extraction | `[".ts", ".js", ".tsx", ".jsx", ".py", ".rb", ".go", ".rs", ".java", ".cpp", ".c", ".cs", ".php", ".sh", ".bash", ".zsh", ".fish", ".json", ".yaml", ".yml", ".toml", ".xml", ".html", ".css", ".scss", ".less", ".sql", ".md", ".txt"]` | | **missingSnippetSeverity** | Severity for missing local snippet files (`error` or `warning`) | `"error"` | +On Windows, `\` in `markdownGlob` and `excludeGlob` is treated as a path +separator, so `docs\**\*.md` works. To match a literal glob character such as +`[` or `(` there, use a bracket expression (`docs/[[]draft].md`) instead of a +backslash escape. + ## Eject Safety `eject` is destructive: it removes snippet directives, deletes the configured diff --git a/src/commands/eject.ts b/src/commands/eject.ts index a8f90c5..0013094 100644 --- a/src/commands/eject.ts +++ b/src/commands/eject.ts @@ -63,11 +63,7 @@ export async function removeSnippetDirectives(config: RuntimeConfig) { }; try { - const markdownFiles = await findMarkdownFiles( - config.markdownGlob, - config.excludeGlob, - config.workingDir, - ); + const markdownFiles = await findMarkdownFiles(config); for (const filePath of markdownFiles) { try { diff --git a/src/glob.ts b/src/glob.ts index 687583a..45ffe24 100644 --- a/src/glob.ts +++ b/src/glob.ts @@ -8,11 +8,17 @@ export function toGlobPattern( return platform === 'win32' ? pattern.replaceAll('\\', '/') : pattern; } -export async function findMarkdownFiles( - markdownGlob: string, - excludeGlob: ReadonlyArray, - workingDir: string, -): Promise> { +export interface MarkdownFileQuery { + markdownGlob: string; + excludeGlob: ReadonlyArray; + workingDir: string; +} + +export async function findMarkdownFiles({ + markdownGlob, + excludeGlob, + workingDir, +}: MarkdownFileQuery): Promise> { const files = await fg(toGlobPattern(markdownGlob), { ignore: excludeGlob.map((pattern) => toGlobPattern(pattern)), cwd: workingDir, diff --git a/src/snippet-resolution.ts b/src/snippet-resolution.ts index 192a8a9..b4c04fe 100644 --- a/src/snippet-resolution.ts +++ b/src/snippet-resolution.ts @@ -34,16 +34,19 @@ export function isManagedCodeBlock( return codeBlock.directive !== undefined; } +function realpathOrSelf(path: string): Promise { + return realpath(path).catch(() => path); +} + async function allowedSnippetRoots( config: RuntimeConfig, ): Promise> { const workingDir = resolve(config.workingDir); const snippetRoot = resolve(workingDir, config.snippetRoot || '.'); - const realWorkingDir = await realpath(workingDir).catch(() => workingDir); - const realSnippetRoot = resolve( - realWorkingDir, - relative(workingDir, snippetRoot), - ); + const realWorkingDir = await realpathOrSelf(workingDir); + const realSnippetRoot = isInWorkingDir(snippetRoot, workingDir) + ? resolve(realWorkingDir, relative(workingDir, snippetRoot)) + : await realpathOrSelf(snippetRoot); return realSnippetRoot !== realWorkingDir ? [realWorkingDir, realSnippetRoot] : [realWorkingDir]; diff --git a/src/sync-plan.ts b/src/sync-plan.ts index bbe69a3..59f51b3 100644 --- a/src/sync-plan.ts +++ b/src/sync-plan.ts @@ -80,11 +80,7 @@ async function* planFiles( export async function planSync(config: RuntimeConfig): Promise { let markdownFiles: Array; try { - markdownFiles = await findMarkdownFiles( - config.markdownGlob, - config.excludeGlob, - config.workingDir, - ); + markdownFiles = await findMarkdownFiles(config); } catch (error) { return { status: 'error', diff --git a/src/sync.ts b/src/sync.ts index 6bb7787..028169d 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -167,11 +167,10 @@ export async function extractSnippets( } try { - const markdownFiles = await findMarkdownFiles( - config.markdownGlob, - config.excludeGlob, - workingDir ?? process.cwd(), - ); + const markdownFiles = await findMarkdownFiles({ + ...config, + workingDir: workingDir ?? process.cwd(), + }); for (const filePath of markdownFiles) { try { @@ -267,11 +266,11 @@ export async function discoverCodeBlocks( }; try { - const markdownFiles = await findMarkdownFiles( + const markdownFiles = await findMarkdownFiles({ markdownGlob, excludeGlob, workingDir, - ); + }); for (const filePath of markdownFiles) { try { diff --git a/test/cli.test.ts b/test/cli.test.ts index 5779119..46c5e2b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -735,7 +735,7 @@ old content describe('error handling', () => { describe.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'unreadable Markdown files', + 'unreadable Markdown files (needs chmod; POSIX and non-root only)', () => { async function writeProjectWithUnreadableFile() { await project.write({ diff --git a/test/eject.test.ts b/test/eject.test.ts index 9f60531..bea3ad3 100644 --- a/test/eject.test.ts +++ b/test/eject.test.ts @@ -133,11 +133,10 @@ console.log("no snippet directive"); const { findMarkdownFiles } = await import('../src/glob.js'); // Test the core removal logic - const markdownFiles = await findMarkdownFiles( - baseConfig.markdownGlob, - [], - testDir, - ); + const markdownFiles = await findMarkdownFiles({ + ...baseConfig, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( @@ -205,11 +204,10 @@ console.log("no directives"); const { parseMarkdownFile } = await import('../src/parser.js'); const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await findMarkdownFiles( - baseConfig.markdownGlob, - [], - testDir, - ); + const markdownFiles = await findMarkdownFiles({ + ...baseConfig, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( @@ -240,11 +238,10 @@ interface Config {} const { parseMarkdownFile } = await import('../src/parser.js'); const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await findMarkdownFiles( - baseConfig.markdownGlob, - [], - testDir, - ); + const markdownFiles = await findMarkdownFiles({ + ...baseConfig, + workingDir: testDir, + }); expect(markdownFiles).toHaveLength(2); for (const filePath of markdownFiles) { @@ -274,11 +271,10 @@ interface Test {} const { parseMarkdownFile } = await import('../src/parser.js'); const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await findMarkdownFiles( - baseConfig.markdownGlob, - [], - testDir, - ); + const markdownFiles = await findMarkdownFiles({ + ...baseConfig, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); @@ -314,11 +310,10 @@ function test() { const { parseMarkdownFile } = await import('../src/parser.js'); const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await findMarkdownFiles( - baseConfig.markdownGlob, - [], - testDir, - ); + const markdownFiles = await findMarkdownFiles({ + ...baseConfig, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( @@ -553,11 +548,10 @@ interface MyType {} const { parseMarkdownFile } = await import('../src/parser.js'); const { findMarkdownFiles } = await import('../src/glob.js'); - const markdownFiles = await findMarkdownFiles( - baseConfig.markdownGlob, - [], - testDir, - ); + const markdownFiles = await findMarkdownFiles({ + ...baseConfig, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( diff --git a/test/glob.test.ts b/test/glob.test.ts index d7b2788..b825129 100644 --- a/test/glob.test.ts +++ b/test/glob.test.ts @@ -36,11 +36,11 @@ describe('findMarkdownFiles', () => { }); it('returns absolute, platform-native paths', async () => { - const files = await findMarkdownFiles( - '**/*.md', - ['docs/draft.md'], - project.baseDir, - ); + const files = await findMarkdownFiles({ + markdownGlob: '**/*.md', + excludeGlob: ['docs/draft.md'], + workingDir: project.baseDir, + }); expect(files.sort()).toEqual( [ @@ -51,11 +51,11 @@ describe('findMarkdownFiles', () => { }); it('accepts an absolute pattern built with platform paths', async () => { - const files = await findMarkdownFiles( - join(project.baseDir, 'docs', '*.md'), - [], - project.baseDir, - ); + const files = await findMarkdownFiles({ + markdownGlob: join(project.baseDir, 'docs', '*.md'), + excludeGlob: [], + workingDir: project.baseDir, + }); expect(files.sort()).toEqual( [ diff --git a/test/snippet-resolution.test.ts b/test/snippet-resolution.test.ts index 986e0ee..b0f2598 100644 --- a/test/snippet-resolution.test.ts +++ b/test/snippet-resolution.test.ts @@ -103,6 +103,54 @@ describe('resolveSnippet', () => { ).toEqual({ status: 'resolved', content: 'from snippet root' }); }); + describe('with a symlinked working directory and a snippet root outside it', () => { + let base: Project; + let link: string; + + beforeEach(async () => { + base = new Project(); + await base.write({ + shared: { 's.js': 'configured root' }, + real: { a: { b: {}, shared: { 's.js': 'unconfigured' } } }, + }); + link = join(base.baseDir, 'link'); + symlinkSync(join(base.baseDir, 'real', 'a', 'b'), link, 'junction'); + }); + + afterEach(() => { + base.dispose(); + }); + + it('allows the configured root', async () => { + expect( + await resolveFor('snippet=s.js', { + workingDir: link, + snippetRoot: '../shared', + }), + ).toEqual({ status: 'resolved', content: 'configured root' }); + expect( + await resolveFor('snippet=s.js', { + workingDir: link, + snippetRoot: join(base.baseDir, 'shared'), + }), + ).toEqual({ status: 'resolved', content: 'configured root' }); + }); + + it('does not allow the same relative path under the link target', async () => { + const unconfigured = join(base.baseDir, 'real', 'a', 'shared', 's.js'); + + expect( + await resolveFor(`snippet=${unconfigured}`, { + workingDir: link, + snippetRoot: '../shared', + }), + ).toMatchObject({ + status: 'failed', + issue: { ruleId: 'path-traversal' }, + }); + }); + }); + it('selects a line range', async () => { expect(await resolveFor('snippet=app.js#L3-L4')).toEqual({ status: 'resolved', diff --git a/test/sync-plan.test.ts b/test/sync-plan.test.ts index 3c2876c..48bb1c4 100644 --- a/test/sync-plan.test.ts +++ b/test/sync-plan.test.ts @@ -146,7 +146,7 @@ describe('planSync', () => { }); it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'reports files that cannot be read', + 'reports files that cannot be read (needs chmod; POSIX and non-root only)', async () => { const readme = join(project.baseDir, 'README.md'); chmodSync(readme, 0o000);