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 0866437..813c727 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,40 @@ 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: 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/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 a1af7b8..f96bf82 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 + ``` ```` @@ -285,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/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/src/commands/eject.ts b/src/commands/eject.ts index 6372737..0013094 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,7 @@ 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); for (const filePath of markdownFiles) { try { diff --git a/src/glob.ts b/src/glob.ts new file mode 100644 index 0000000..45ffe24 --- /dev/null +++ b/src/glob.ts @@ -0,0 +1,28 @@ +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 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, + absolute: true, + }); + return files.map((file) => resolve(file)); +} diff --git a/src/snippet-resolution.ts b/src/snippet-resolution.ts index a1fa501..b4c04fe 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,22 @@ export function isManagedCodeBlock( return codeBlock.directive !== undefined; } -function allowedSnippetRoots(config: RuntimeConfig): Array { +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 || '.'); - return snippetRoot !== workingDir ? [workingDir, snippetRoot] : [workingDir]; + const realWorkingDir = await realpathOrSelf(workingDir); + const realSnippetRoot = isInWorkingDir(snippetRoot, workingDir) + ? resolve(realWorkingDir, relative(workingDir, snippetRoot)) + : await realpathOrSelf(snippetRoot); + return realSnippetRoot !== realWorkingDir + ? [realWorkingDir, realSnippetRoot] + : [realWorkingDir]; } function countLines(content: string): number { @@ -152,7 +164,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..59f51b3 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,7 @@ 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); } catch (error) { return { status: 'error', diff --git a/src/sync.ts b/src/sync.ts index f4bafdb..028169d 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,10 +167,9 @@ 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, + workingDir: workingDir ?? process.cwd(), }); for (const filePath of markdownFiles) { @@ -267,10 +266,10 @@ 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) { 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..46c5e2b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,16 +1,19 @@ 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, '') + const normalized = stripVTControlCharacters(output) .replaceAll(realpathSync(baseDir), '') - .replace(new RegExp(baseDir, 'g'), ''); + .replaceAll(baseDir, ''); + return process.platform === 'win32' + ? normalized.replaceAll('\\', '/') + : normalized; } describe('CLI', () => { @@ -91,7 +94,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 +212,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 +425,7 @@ const updated = "old"; expect(result.exitCode).toEqual(1); expect( normalizeOutput(result.stderr, project.baseDir), - ).toMatchInlineSnapshot(`""`); + ).toMatchInlineSnapshot('""'); }); it('uses check command', async () => { @@ -468,7 +471,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 +490,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 +569,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,8 +734,8 @@ old content }); describe('error handling', () => { - describe.skipIf(process.getuid?.() === 0)( - 'unreadable Markdown files', + describe.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'unreadable Markdown files (needs chmod; POSIX and non-root only)', () => { async function writeProjectWithUnreadableFile() { await project.write({ @@ -777,7 +780,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(` @@ -803,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 \`\`\``; @@ -817,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}`, ); }); @@ -1359,7 +1363,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/eject.test.ts b/test/eject.test.ts index ee3a881..bea3ad3 100644 --- a/test/eject.test.ts +++ b/test/eject.test.ts @@ -130,10 +130,13 @@ 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, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( @@ -199,9 +202,12 @@ 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, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( @@ -230,9 +236,12 @@ 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, + workingDir: testDir, + }); expect(markdownFiles).toHaveLength(2); for (const filePath of markdownFiles) { @@ -260,9 +269,12 @@ 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, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); @@ -296,9 +308,12 @@ 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, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( @@ -373,16 +388,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'), @@ -535,9 +546,12 @@ 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, + workingDir: testDir, + }); const filePath = markdownFiles[0]; const markdownFile = await parseMarkdownFile(filePath); const codeBlocksWithSnippets = markdownFile.codeBlocks.filter( 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/glob.test.ts b/test/glob.test.ts new file mode 100644 index 0000000..b825129 --- /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({ + markdownGlob: '**/*.md', + excludeGlob: ['docs/draft.md'], + workingDir: 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({ + markdownGlob: join(project.baseDir, 'docs', '*.md'), + excludeGlob: [], + workingDir: project.baseDir, + }); + + expect(files.sort()).toEqual( + [ + join(project.baseDir, 'docs', 'draft.md'), + join(project.baseDir, 'docs', 'guide.md'), + ].sort(), + ); + }); +}); 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/snippet-resolution.test.ts b/test/snippet-resolution.test.ts index 3a5654a..b0f2598 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,66 @@ 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' }); + }); + + 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', @@ -240,22 +310,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'); diff --git a/test/sync-plan.test.ts b/test/sync-plan.test.ts index 8c0a939..48bb1c4 100644 --- a/test/sync-plan.test.ts +++ b/test/sync-plan.test.ts @@ -145,8 +145,8 @@ describe('planSync', () => { ); }); - it.skipIf(process.getuid?.() === 0)( - 'reports files that cannot be read', + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'reports files that cannot be read (needs chmod; POSIX and non-root only)', async () => { const readme = join(project.baseDir, 'README.md'); chmodSync(readme, 0o000); 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"] +}