Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/agents/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { promisify } from 'node:util';

import { describe, expect, it } from 'vitest';

import { isRecord } from '../lib/type-guards.ts';

const execFileAsync = promisify(execFile);

const CLI_PATH = new URL('../cli.ts', import.meta.url).pathname;
Expand All @@ -15,7 +17,12 @@ interface ExecError {

/** Type guard for child_process exec errors. */
function isExecError(error: unknown): error is ExecError {
return typeof error === 'object' && error !== null && 'stdout' in error && 'stderr' in error && 'code' in error;
return (
isRecord(error) &&
typeof error.stdout === 'string' &&
typeof error.stderr === 'string' &&
typeof error.code === 'number'
);
}

interface CliResult {
Expand Down
16 changes: 6 additions & 10 deletions packages/agents/src/commands/__tests__/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveContentDir } from '../../lib/content-resolver.ts';
import { readManifest } from '../../lib/manifest.ts';
import { getManifestPath } from '../../lib/manifest.ts';
import { isRecord } from '../../lib/type-guards.ts';
import type { InstallOptions } from '../../lib/types.ts';
import { installCommand } from '../install.ts';

describe('installCommand', () => {
describe(installCommand, () => {
let tempDir: string;

beforeEach(async () => {
Expand Down Expand Up @@ -291,8 +292,7 @@ describe('installCommand', () => {
expect(typeof parsed).toBe('object');
expect(parsed !== null).toBe(true);

// Narrow to record shape at runtime to satisfy no-type-assertions lint rule
if (typeof parsed !== 'object' || parsed === null || !('prompts' in parsed)) {
if (!isRecord(parsed) || !('prompts' in parsed)) {
throw new Error('Expected parsed YAML to have a prompts key');
}
const doc = parsed;
Expand Down Expand Up @@ -582,9 +582,7 @@ describe('installCommand', () => {
const prompts: Array<unknown> = doc.prompts;

// Find the single-quoted synthetic skill entry
const singleQuotedEntry = prompts.find(
(e) => typeof e === 'object' && e !== null && 'name' in e && e.name === 'synthetic-quoted',
);
const singleQuotedEntry = prompts.find((e) => isRecord(e) && e.name === 'synthetic-quoted');
if (typeof singleQuotedEntry !== 'object' || singleQuotedEntry === null || !('description' in singleQuotedEntry)) {
throw new Error('Expected synthetic-quoted entry with description');
}
Expand All @@ -593,9 +591,7 @@ describe('installCommand', () => {
expect(singleQuotedEntry.description).toBe("A skill with an apostrophe: it's useful");

// Find the double-quoted synthetic skill entry
const doubleQuotedEntry = prompts.find(
(e) => typeof e === 'object' && e !== null && 'name' in e && e.name === 'synthetic-double-quoted',
);
const doubleQuotedEntry = prompts.find((e) => isRecord(e) && e.name === 'synthetic-double-quoted');
if (typeof doubleQuotedEntry !== 'object' || doubleQuotedEntry === null || !('description' in doubleQuotedEntry)) {
throw new Error('Expected synthetic-double-quoted entry with description');
}
Expand All @@ -618,7 +614,7 @@ describe('installCommand', () => {
expect(content).toContain('~/.claude/skills/_data/naming-conventions.md');

// No remaining relative ../_data/ Markdown link references should exist
expect(content).not.toMatch(/\]\(\.\.\/_data\//);
expect(content).not.toMatch(/]\(\.\.\/_data\//);
});

it('preserves anchor fragments in rewritten paths', async () => {
Expand Down
29 changes: 6 additions & 23 deletions packages/agents/src/commands/generate-label-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { pathExists } from '@codeassembly/kb-core/filesystem';

import { isEnoent, isRecord } from '../lib/type-guards.ts';

/** Canonical mapping from commit type keys to human-readable label values. */
const TYPE_MAP: Readonly<Record<string, string>> = {
ai: 'ai',
Expand Down Expand Up @@ -68,13 +72,6 @@ async function deriveScopes(workingDir: string): Promise<Record<string, string>>
return scopes;
}

/** Checks whether a path exists on disk. */
async function pathExists(filePath: string): Promise<boolean> {
return stat(filePath)
.then(() => true)
.catch(() => false);
}

/**
* Reads the installed `@williamthorsen/release-kit` version by walking up from this
* module's location, looking for `node_modules/@williamthorsen/release-kit/package.json`
Expand All @@ -92,7 +89,7 @@ export async function readReleaseKitVersion(): Promise<string> {
let dir = thisDir;
for (;;) {
const candidate = path.join(dir, 'node_modules', '@williamthorsen', 'release-kit', 'package.json');
if (await pathExists(candidate)) {
if (await pathExists(candidate, { treatErrorsAsAbsent: true })) {
const raw = await readFile(candidate, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (isReleaseKitPackageJson(parsed)) {
Expand All @@ -109,14 +106,7 @@ export async function readReleaseKitVersion(): Promise<string> {

/** Type guard: `value` is release-kit's `package.json` (matches name and has a string `version`). */
function isReleaseKitPackageJson(value: unknown): value is { readonly name: string; readonly version: string } {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
value.name === RELEASE_KIT_PACKAGE_NAME &&
'version' in value &&
typeof value.version === 'string'
);
return isRecord(value) && value.name === RELEASE_KIT_PACKAGE_NAME && typeof value.version === 'string';
}

/**
Expand Down Expand Up @@ -171,10 +161,3 @@ Targets:
Options:
--force Overwrite an existing file`);
}

/**
* Type guard that checks whether an error is a Node.js ENOENT error.
*/
function isEnoent(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
13 changes: 2 additions & 11 deletions packages/agents/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { rewritePathsInDirectory, rewritePathsInFile } from '../lib/path-rewriter.js';
import { PLATFORMS, resolvePlatformIds, resolvePlatformPaths } from '../lib/platform.js';
import { loadToolMapping, rewriteToolNames } from '../lib/tool-name-rewriter.js';
import { isEnoent, isErrorCode } from '../lib/type-guards.ts';
import type {
AgentsManifest,
InstallOptions,
Expand Down Expand Up @@ -858,19 +859,9 @@ async function installPlatformGuidance(
return entries;
}

/**
* Type guard that checks whether an error is a Node.js ENOENT error.
*/
function isEnoent(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}

/** True when a `readFile` of `SKILL.md` raised `ENOENT` (file absent) or `ENOTDIR` (parent segment is a regular file). */
function isMissingSkill(error: unknown): boolean {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return false;
}
return error.code === 'ENOENT' || error.code === 'ENOTDIR';
return isErrorCode(error, 'ENOENT') || isErrorCode(error, 'ENOTDIR');
}

/**
Expand Down
16 changes: 2 additions & 14 deletions packages/agents/src/derive-session-context/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

import { isEnoent, isRecord } from '../lib/type-guards.ts';
import { composeManifest } from './compose-manifest.ts';
import { readPreferences } from './read-preferences.ts';
import type { BranchManifest } from './types.ts';
Expand Down Expand Up @@ -150,7 +151,7 @@ async function tryReadManifest(filePath: string): Promise<BranchManifest | null>
try {
text = await readFile(filePath, 'utf8');
} catch (error) {
if (isEnoentError(error)) {
if (isEnoent(error)) {
return null;
}
throw error;
Expand Down Expand Up @@ -206,19 +207,6 @@ function isStringOrNull(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}

/** True when `error` carries the Node `ENOENT` errno. */
function isEnoentError(error: unknown): boolean {
if (!isRecord(error)) {
return false;
}
return error.code === 'ENOENT';
}

/** Narrows `value` to a plain object with unknown property values. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** Resolves the current branch name via `git -C {cwd} branch --show-current`. */
async function resolveCurrentBranch(cwd: string): Promise<string> {
try {
Expand Down
18 changes: 2 additions & 16 deletions packages/agents/src/derive-session-context/read-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import path from 'node:path';

import { parse as parseYaml } from 'yaml';

import { isEnoent, isRecord } from '../lib/type-guards.ts';
import type { PreferencesReadResult, ResolvedPreferences } from './types.ts';

/**
Expand Down Expand Up @@ -57,7 +58,7 @@ async function readOptionalYaml(filePath: string): Promise<{ value: unknown } |
try {
text = await readFile(filePath, 'utf8');
} catch (error) {
if (isEnoentError(error)) {
if (isEnoent(error)) {
return null;
}
throw error;
Expand Down Expand Up @@ -227,18 +228,3 @@ function formatValue(value: unknown): string {
return String(value);
}
}

/** Narrows `value` to a plain object with unknown property values. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** True when `error` carries the Node `ENOENT` errno. */
function isEnoentError(error: unknown): boolean {
if (!isRecord(error)) {
return false;
}
return error.code === 'ENOENT';
}

// endregion | Helpers
25 changes: 2 additions & 23 deletions packages/agents/src/kb-add/write-note.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { randomBytes } from 'node:crypto';
import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
import { join, resolve, sep } from 'node:path';

import type { Frontmatter } from '@codeassembly/kb-core';
import { pathExists } from '@codeassembly/kb-core/filesystem';
import { writeFrontmatter } from '@codeassembly/kb-core/frontmatter';

/** Successful write: the absolute path the note landed at. */
Expand Down Expand Up @@ -117,28 +118,6 @@ async function atomicWrite(input: { targetPath: string; content: string }): Prom
}
}

/**
* Returns true when a file or directory exists at the path. ENOENT is the only "not found" signal — any other
* `stat` error (permission denied, I/O error, etc.) is re-thrown so the caller sees the real failure rather
* than a false "no collision, safe to write" answer.
*/
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (error) {
if (isEnoent(error)) {
return false;
}
throw error;
}
}

/** Returns true when `error` is a Node ENOENT filesystem error. */
function isEnoent(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}

/**
* Returns true when `targetDir` resolves to a location inside the KB root (or to the root itself).
* Compares lexically resolved paths so `..` segments are caught before any directory is created or any
Expand Down
6 changes: 1 addition & 5 deletions packages/agents/src/kb-curate/apply/canonicalize-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';

import { isRecord } from '../../lib/type-guards.ts';
import type { AppliedFix } from '../types.ts';

/** Milliseconds to wait for the `kb-edit` subprocess before killing it and failing the fix. */
Expand Down Expand Up @@ -121,9 +122,4 @@ const runNode: RetagRunner = (args) =>
});
});

/** Narrows a value to a plain object with unknown property values. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

// endregion | Helpers
11 changes: 2 additions & 9 deletions packages/agents/src/kb-edit/load-note.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ParsedNote } from '@codeassembly/kb-core';
import { parseNote } from '@codeassembly/kb-core/frontmatter';

import { isEnoent } from '../lib/type-guards.ts';

/** Successful load: a fully-parsed note with valid frontmatter. */
export interface LoadSuccess {
ok: true;
Expand Down Expand Up @@ -51,12 +53,3 @@ export async function loadNote(input: { path: string }): Promise<LoadOutcome> {

return { ok: true, note: parsed };
}

// region | Helpers

/** Returns true when `error` is a Node ENOENT filesystem error. */
function isEnoent(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}

// endregion | Helpers
Loading
Loading