From bb5beb461201fe58a26702c7fd5cdb5bb0748d95 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 23 Jul 2026 00:08:22 -0400 Subject: [PATCH] chore: pay down dogfood lint debt - replace node:path with URL APIs in init/sync/fixture - throw coded ToolsError instead of generic Error - oxlintrc overrides: config files and rules/** exempt from import/no-default-export - Fixture.write/append accept strings (matches runtime behavior) - consolidate duplicate knip config into knip.jsonc (package.json#knip was being silently ignored) - raise vitest testTimeout to 15s for binary-spawning integration tests --- .changeset/dogfood-debt.md | 10 ++++++++ knip.jsonc | 12 ++++++++- oxlintrc.json | 8 ++++++ package.json | 16 +----------- src/commands/init.ts | 5 ++-- src/commands/sync.ts | 15 ++++------- src/commands/test.ts | 4 +-- src/test-utils/fixture.ts | 13 +++++++--- src/test-utils/vitest.config.ts | 3 +++ src/utils.ts | 45 ++++++++++++++++++++++++++++++++- 10 files changed, 96 insertions(+), 35 deletions(-) create mode 100644 .changeset/dogfood-debt.md diff --git a/.changeset/dogfood-debt.md b/.changeset/dogfood-debt.md new file mode 100644 index 0000000..9724eba --- /dev/null +++ b/.changeset/dogfood-debt.md @@ -0,0 +1,10 @@ +--- +'@bomb.sh/tools': minor +--- + +Makes `bsh` pass its own lint and widens the test fixture API + +- The shared oxlint config now exempts `*.config.*` files and oxlint JS plugins (`rules/**`) from `import/no-default-export` — config files and plugins legitimately require default exports. +- `Fixture.write()` and `Fixture.append()` in `@bomb.sh/tools/test-utils` now accept plain strings (previously typed `Uint8Array`-only, which contradicted the runtime behavior). +- `bsh test` raises the default vitest `testTimeout` to 15s — integration tests spawn real oxlint/knip binaries, which can exceed 5s on a loaded machine. +- Internal: replaces all `node:path` usage with URL APIs, consolidates the duplicate knip config (`package.json#knip` was silently ignored in favor of `knip.jsonc`), and throws a coded `ToolsError` instead of a generic `Error`. diff --git a/knip.jsonc b/knip.jsonc index fe3a134..154c629 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -4,7 +4,17 @@ "src/index.ts", "src/test-utils/*.ts" ], + "ignore": [ + "rules/**" + ], "ignoreDependencies": [ - "@tanstack/intent" + "@bomb.sh/args", + "@tanstack/intent", + "@typescript/native-preview", + "oxfmt", + "oxlint", + "publint", + "tsdown", + "vitest" ] } diff --git a/oxlintrc.json b/oxlintrc.json index bf0b0d5..07c10f4 100644 --- a/oxlintrc.json +++ b/oxlintrc.json @@ -6,6 +6,14 @@ "correctness": "error", "suspicious": "warn" }, + "overrides": [ + { + "files": ["**/*.config.{ts,mts,js,mjs,cjs}", "rules/**/*.{js,ts}"], + "rules": { + "import/no-default-export": "off" + } + } + ], "rules": { "no-restricted-imports": [ "error", diff --git a/package.json b/package.json index 037c621..4cd2eeb 100644 --- a/package.json +++ b/package.json @@ -82,19 +82,5 @@ "node": "22.14.0" }, "packageManager": "pnpm@10.34.3", - "pnpm": {}, - "knip": { - "ignore": [ - "rules/**" - ], - "ignoreDependencies": [ - "@bomb.sh/args", - "@typescript/native-preview", - "oxfmt", - "oxlint", - "publint", - "tsdown", - "vitest" - ] - } + "pnpm": {} } diff --git a/src/commands/init.ts b/src/commands/init.ts index 3ecbee6..75111c0 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,5 +1,4 @@ import { readFile, writeFile } from 'node:fs/promises'; -import { basename } from 'node:path'; import { cwd } from 'node:process'; import { pathToFileURL } from 'node:url'; import { x } from 'tinyexec'; @@ -10,7 +9,9 @@ export async function init(ctx: CommandContext) { const cwdUrl = pathToFileURL(`${cwd()}/`); // `.` scaffolds into the current directory; otherwise clone into a new `.//`. const inPlace = _name === '.'; - const name = inPlace ? basename(cwd()) : _name; + const name = inPlace + ? decodeURIComponent(cwdUrl.pathname.split('/').filter(Boolean).pop() ?? '.') + : _name; const target = inPlace ? '.' : name; const dest = inPlace ? cwdUrl : new URL(`./${name}/`, cwdUrl); const gigetArgs = ['giget@latest', 'gh:bombshell-dev/template', target]; diff --git a/src/commands/sync.ts b/src/commands/sync.ts index bfc83e3..67c1504 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,11 +1,11 @@ import { readlink, rm, symlink } from 'node:fs/promises'; import { findPackageJSON } from 'node:module'; -import { dirname, isAbsolute, relative, resolve } from 'node:path'; import { cwd, env, platform } from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { NodeHfs } from '@humanfs/node'; import { parse } from 'ultramatter'; import type { CommandContext } from '../context.ts'; +import { relativeUrlPath, resolveLinkTarget } from '../utils.ts'; const hfs = new NodeHfs(); @@ -21,7 +21,7 @@ export async function sync(_ctx: CommandContext): Promise { return; } - const root = pathToFileURL(`${dirname(parentPkg)}/`); + const root = new URL('./', pathToFileURL(parentPkg)); const source = new URL('../../skills/', import.meta.url); if (!(await hfs.isDirectory(source))) { @@ -55,7 +55,6 @@ export async function copySkills(options: { source: URL; dest: URL }): Promise { text(file: string | URL): Promise; json(file: string | URL): Promise; + /** Strings are UTF-8 encoded automatically. */ + write(file: string | URL, contents: string | Uint8Array): Promise; + /** Strings are UTF-8 encoded automatically. */ + append(file: string | URL, contents: string | Uint8Array): Promise; } /** @@ -103,13 +106,15 @@ function isFileTree(value: unknown): value is FileTree { function scopeHfs(inner: NodeHfs, base: URL): ScopedHfsImpl { const r = (p: string | URL) => new URL(`./${p}`, base); const r2 = (a: string | URL, b: string | URL) => [r(a), r(b)] as const; + const encoder = new TextEncoder(); + const bytes = (c: string | Uint8Array) => (typeof c === 'string' ? encoder.encode(c) : c); return { text: (p: string | URL) => inner.text(r(p)), json: (p: string | URL) => inner.json(r(p)), bytes: (p) => inner.bytes(r(p)), - write: (p, c) => inner.write(r(p), c), - append: (p, c) => inner.append(r(p), c), + write: (p, c) => inner.write(r(p), bytes(c)), + append: (p, c) => inner.append(r(p), bytes(c)), isFile: (p) => inner.isFile(r(p)), isDirectory: (p) => inner.isDirectory(r(p)), createDirectory: (p) => inner.createDirectory(r(p)), @@ -155,7 +160,7 @@ export async function createFixture(files: FileTree): Promise { .replace(/^-|-$/g, ''); const root = new URL(`${prefix}-`, `file://${tmpdir()}/`); const path = await mkdtemp(fileURLToPath(root)); - const base = pathToFileURL(path + sep); + const base = new URL(`${pathToFileURL(path).href}/`); const inner = new NodeHfs(); const scoped = scopeHfs(inner, base); diff --git a/src/test-utils/vitest.config.ts b/src/test-utils/vitest.config.ts index ffcbf9e..aff097e 100644 --- a/src/test-utils/vitest.config.ts +++ b/src/test-utils/vitest.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { exclude: ['dist/**', 'node_modules/**'], + // oxlint/knip spawn real binaries in integration tests; 5s is not + // enough headroom on a loaded machine. + testTimeout: 15_000, env: { FORCE_COLOR: '1', }, diff --git a/src/utils.ts b/src/utils.ts index f02e0ec..653a1f1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,48 @@ -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; export function local(file: string) { return fileURLToPath(new URL(`../node_modules/.bin/${file}`, import.meta.url)); } + +/** Error class for `bsh` failures. Carries a stable, greppable code. */ +export class ToolsError extends Error { + code: string; + + constructor(message: string, code: string) { + super(message); + this.name = 'ToolsError'; + this.code = code; + } +} + +/** + * Compute a relative path from one directory URL to another, for APIs that + * require a string path (e.g. `fs.symlink` targets). POSIX-style — forward + * slashes are accepted by every platform Node supports for this purpose. + */ +export function relativeUrlPath(from: URL, to: URL): string { + const fromParts = from.pathname.split('/').filter(Boolean); + const toParts = to.pathname.split('/').filter(Boolean); + let common = 0; + while ( + common < fromParts.length && + common < toParts.length && + fromParts[common] === toParts[common] + ) { + common++; + } + const ups = fromParts.length - common; + const parts = [...Array(ups).fill('..'), ...toParts.slice(common)]; + return parts.map(decodeURIComponent).join('/'); +} + +/** + * Resolve a symlink target (as returned by `fs.readlink`, which may be + * relative to the link's directory) to an absolute URL. + */ +export function resolveLinkTarget(linkDir: URL, target: string): URL { + if (target.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(target)) { + return pathToFileURL(target); + } + return new URL(target, linkDir); +}