Skip to content

Commit e4ad9d2

Browse files
authored
chore: pay down dogfood lint debt (#56)
- 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
1 parent 7fe9353 commit e4ad9d2

10 files changed

Lines changed: 96 additions & 35 deletions

File tree

.changeset/dogfood-debt.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@bomb.sh/tools': minor
3+
---
4+
5+
Makes `bsh` pass its own lint and widens the test fixture API
6+
7+
- 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.
8+
- `Fixture.write()` and `Fixture.append()` in `@bomb.sh/tools/test-utils` now accept plain strings (previously typed `Uint8Array`-only, which contradicted the runtime behavior).
9+
- `bsh test` raises the default vitest `testTimeout` to 15s — integration tests spawn real oxlint/knip binaries, which can exceed 5s on a loaded machine.
10+
- 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`.

knip.jsonc

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@
44
"src/index.ts",
55
"src/test-utils/*.ts"
66
],
7+
"ignore": [
8+
"rules/**"
9+
],
710
"ignoreDependencies": [
8-
"@tanstack/intent"
11+
"@bomb.sh/args",
12+
"@tanstack/intent",
13+
"@typescript/native-preview",
14+
"oxfmt",
15+
"oxlint",
16+
"publint",
17+
"tsdown",
18+
"vitest"
919
]
1020
}

oxlintrc.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
"correctness": "error",
77
"suspicious": "warn"
88
},
9+
"overrides": [
10+
{
11+
"files": ["**/*.config.{ts,mts,js,mjs,cjs}", "rules/**/*.{js,ts}"],
12+
"rules": {
13+
"import/no-default-export": "off"
14+
}
15+
}
16+
],
917
"rules": {
1018
"no-restricted-imports": [
1119
"error",

package.json

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,5 @@
8282
"node": "22.14.0"
8383
},
8484
"packageManager": "pnpm@10.34.3",
85-
"pnpm": {},
86-
"knip": {
87-
"ignore": [
88-
"rules/**"
89-
],
90-
"ignoreDependencies": [
91-
"@bomb.sh/args",
92-
"@typescript/native-preview",
93-
"oxfmt",
94-
"oxlint",
95-
"publint",
96-
"tsdown",
97-
"vitest"
98-
]
99-
}
85+
"pnpm": {}
10086
}

src/commands/init.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { readFile, writeFile } from 'node:fs/promises';
2-
import { basename } from 'node:path';
32
import { cwd } from 'node:process';
43
import { pathToFileURL } from 'node:url';
54
import { x } from 'tinyexec';
@@ -10,7 +9,9 @@ export async function init(ctx: CommandContext) {
109
const cwdUrl = pathToFileURL(`${cwd()}/`);
1110
// `.` scaffolds into the current directory; otherwise clone into a new `./<name>/`.
1211
const inPlace = _name === '.';
13-
const name = inPlace ? basename(cwd()) : _name;
12+
const name = inPlace
13+
? decodeURIComponent(cwdUrl.pathname.split('/').filter(Boolean).pop() ?? '.')
14+
: _name;
1415
const target = inPlace ? '.' : name;
1516
const dest = inPlace ? cwdUrl : new URL(`./${name}/`, cwdUrl);
1617
const gigetArgs = ['giget@latest', 'gh:bombshell-dev/template', target];

src/commands/sync.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { readlink, rm, symlink } from 'node:fs/promises';
22
import { findPackageJSON } from 'node:module';
3-
import { dirname, isAbsolute, relative, resolve } from 'node:path';
43
import { cwd, env, platform } from 'node:process';
54
import { fileURLToPath, pathToFileURL } from 'node:url';
65
import { NodeHfs } from '@humanfs/node';
76
import { parse } from 'ultramatter';
87
import type { CommandContext } from '../context.ts';
8+
import { relativeUrlPath, resolveLinkTarget } from '../utils.ts';
99

1010
const hfs = new NodeHfs();
1111

@@ -21,7 +21,7 @@ export async function sync(_ctx: CommandContext): Promise<void> {
2121
return;
2222
}
2323

24-
const root = pathToFileURL(`${dirname(parentPkg)}/`);
24+
const root = new URL('./', pathToFileURL(parentPkg));
2525
const source = new URL('../../skills/', import.meta.url);
2626

2727
if (!(await hfs.isDirectory(source))) {
@@ -55,7 +55,6 @@ export async function copySkills(options: { source: URL; dest: URL }): Promise<S
5555
await hfs.createDirectory(dest);
5656
await pruneStaleLinks({ dest, source, keep });
5757

58-
const destDirPath = fileURLToPath(dest);
5958
const linkType = platform === 'win32' ? 'junction' : 'dir';
6059

6160
for (const name of keep) {
@@ -64,10 +63,10 @@ export async function copySkills(options: { source: URL; dest: URL }): Promise<S
6463
// Use a path without a trailing slash. macOS rejects a trailing-slash link
6564
// path with ENOENT, and `rm` on a trailing-slash directory symlink follows
6665
// the link and deletes the source rather than unlinking the symlink itself.
67-
const linkPath = resolve(destDirPath, name);
66+
const linkPath = fileURLToPath(new URL(name, dest));
6867
await rm(linkPath, { recursive: true, force: true });
6968

70-
const target = relative(destDirPath, fileURLToPath(srcDir));
69+
const target = relativeUrlPath(dest, srcDir);
7170
await symlink(target, linkPath, linkType);
7271

7372
const content = await hfs.text(new URL('SKILL.md', srcDir));
@@ -90,18 +89,14 @@ async function pruneStaleLinks(options: {
9089
const { dest, source, keep } = options;
9190
if (!(await hfs.isDirectory(dest))) return;
9291

93-
const destPath = fileURLToPath(dest);
94-
const sourcePath = fileURLToPath(source);
95-
9692
for await (const entry of hfs.list(dest)) {
9793
if (!entry.isSymlink) continue;
9894
if (keep.has(entry.name)) continue;
9995

10096
const linkPath = fileURLToPath(new URL(entry.name, dest));
10197
try {
10298
const target = await readlink(linkPath);
103-
const absTarget = isAbsolute(target) ? target : resolve(destPath, target);
104-
if (absTarget.startsWith(sourcePath)) {
99+
if (resolveLinkTarget(dest, target).href.startsWith(source.href)) {
105100
await hfs.deleteAll(linkPath);
106101
}
107102
} catch {

src/commands/test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
22
import { fileURLToPath } from 'node:url';
33
import { x } from 'tinyexec';
44
import type { CommandContext } from '../context.ts';
5-
import { local } from '../utils.ts';
5+
import { local, ToolsError } from '../utils.ts';
66

77
function resolveConfig(): string {
88
// Built output (.mjs) or source (.ts)
@@ -11,7 +11,7 @@ function resolveConfig(): string {
1111
const path = fileURLToPath(url);
1212
if (existsSync(path)) return path;
1313
}
14-
throw new Error('Could not resolve vitest.config file');
14+
throw new ToolsError('Could not resolve vitest.config file', 'VITEST_CONFIG_NOT_FOUND');
1515
}
1616

1717
export async function test(ctx: CommandContext) {

src/test-utils/fixture.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { mkdtemp, symlink as fsSymlink } from 'node:fs/promises';
22
import { tmpdir } from 'node:os';
3-
import { sep } from 'node:path';
43
import { fileURLToPath, pathToFileURL } from 'node:url';
54
import { NodeHfs } from '@humanfs/node';
65
import type { HfsImpl } from '@humanfs/types';
@@ -9,6 +8,10 @@ import { expect, onTestFinished } from 'vitest';
98
interface ScopedHfsImpl extends Required<HfsImpl> {
109
text(file: string | URL): Promise<string | undefined>;
1110
json(file: string | URL): Promise<unknown | undefined>;
11+
/** Strings are UTF-8 encoded automatically. */
12+
write(file: string | URL, contents: string | Uint8Array): Promise<void>;
13+
/** Strings are UTF-8 encoded automatically. */
14+
append(file: string | URL, contents: string | Uint8Array): Promise<void>;
1215
}
1316

1417
/**
@@ -103,13 +106,15 @@ function isFileTree(value: unknown): value is FileTree {
103106
function scopeHfs(inner: NodeHfs, base: URL): ScopedHfsImpl {
104107
const r = (p: string | URL) => new URL(`./${p}`, base);
105108
const r2 = (a: string | URL, b: string | URL) => [r(a), r(b)] as const;
109+
const encoder = new TextEncoder();
110+
const bytes = (c: string | Uint8Array) => (typeof c === 'string' ? encoder.encode(c) : c);
106111

107112
return {
108113
text: (p: string | URL) => inner.text(r(p)),
109114
json: (p: string | URL) => inner.json(r(p)),
110115
bytes: (p) => inner.bytes(r(p)),
111-
write: (p, c) => inner.write(r(p), c),
112-
append: (p, c) => inner.append(r(p), c),
116+
write: (p, c) => inner.write(r(p), bytes(c)),
117+
append: (p, c) => inner.append(r(p), bytes(c)),
113118
isFile: (p) => inner.isFile(r(p)),
114119
isDirectory: (p) => inner.isDirectory(r(p)),
115120
createDirectory: (p) => inner.createDirectory(r(p)),
@@ -155,7 +160,7 @@ export async function createFixture(files: FileTree): Promise<Fixture> {
155160
.replace(/^-|-$/g, '');
156161
const root = new URL(`${prefix}-`, `file://${tmpdir()}/`);
157162
const path = await mkdtemp(fileURLToPath(root));
158-
const base = pathToFileURL(path + sep);
163+
const base = new URL(`${pathToFileURL(path).href}/`);
159164

160165
const inner = new NodeHfs();
161166
const scoped = scopeHfs(inner, base);

src/test-utils/vitest.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { defineConfig } from 'vitest/config';
44
export default defineConfig({
55
test: {
66
exclude: ['dist/**', 'node_modules/**'],
7+
// oxlint/knip spawn real binaries in integration tests; 5s is not
8+
// enough headroom on a loaded machine.
9+
testTimeout: 15_000,
710
env: {
811
FORCE_COLOR: '1',
912
},

src/utils.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,48 @@
1-
import { fileURLToPath } from 'node:url';
1+
import { fileURLToPath, pathToFileURL } from 'node:url';
22

33
export function local(file: string) {
44
return fileURLToPath(new URL(`../node_modules/.bin/${file}`, import.meta.url));
55
}
6+
7+
/** Error class for `bsh` failures. Carries a stable, greppable code. */
8+
export class ToolsError extends Error {
9+
code: string;
10+
11+
constructor(message: string, code: string) {
12+
super(message);
13+
this.name = 'ToolsError';
14+
this.code = code;
15+
}
16+
}
17+
18+
/**
19+
* Compute a relative path from one directory URL to another, for APIs that
20+
* require a string path (e.g. `fs.symlink` targets). POSIX-style — forward
21+
* slashes are accepted by every platform Node supports for this purpose.
22+
*/
23+
export function relativeUrlPath(from: URL, to: URL): string {
24+
const fromParts = from.pathname.split('/').filter(Boolean);
25+
const toParts = to.pathname.split('/').filter(Boolean);
26+
let common = 0;
27+
while (
28+
common < fromParts.length &&
29+
common < toParts.length &&
30+
fromParts[common] === toParts[common]
31+
) {
32+
common++;
33+
}
34+
const ups = fromParts.length - common;
35+
const parts = [...Array<string>(ups).fill('..'), ...toParts.slice(common)];
36+
return parts.map(decodeURIComponent).join('/');
37+
}
38+
39+
/**
40+
* Resolve a symlink target (as returned by `fs.readlink`, which may be
41+
* relative to the link's directory) to an absolute URL.
42+
*/
43+
export function resolveLinkTarget(linkDir: URL, target: string): URL {
44+
if (target.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(target)) {
45+
return pathToFileURL(target);
46+
}
47+
return new URL(target, linkDir);
48+
}

0 commit comments

Comments
 (0)