diff --git a/packages/agents/src/capture-event/cli.ts b/packages/agents/src/capture-event/cli.ts index a855b520..2792de9c 100644 --- a/packages/agents/src/capture-event/cli.ts +++ b/packages/agents/src/capture-event/cli.ts @@ -14,6 +14,8 @@ import { ulid } from 'ulid'; import { formatUtcTimestamp } from '../kb-shared/note-helpers.ts'; import { resolveStoreByName } from '../kb-shared/resolve-store-by-name.ts'; +import { parseTagList } from '../kb-shared/tag-helpers.ts'; +import { readAll } from '../lib/stream-helpers.ts'; import { isEnoent } from '../lib/type-guards.ts'; import { prepareEvent } from './prepare-event.ts'; import type { CaptureContext, CaptureResult, ParsedArgs } from './types.ts'; @@ -211,14 +213,6 @@ function matchValueFlag(arg: string): { key: ValueFlag; inlineValue: string | nu return null; } -/** Splits a comma-separated tag string into individual tags, dropping empties and trimming whitespace. */ -function parseTagList(value: string): string[] { - return value - .split(',') - .map((tag) => tag.trim()) - .filter((tag) => tag.length > 0); -} - /** * Resolves the `owner/name` git remote at `cwd`, best-effort. Prefers the `origin` remote and falls back to the first * listed remote when `origin` is absent. Both SSH (`git@host:owner/name.git`) and HTTPS (`https://host/owner/name.git`) @@ -261,7 +255,11 @@ async function resolveRemoteUrl(cwd: string): Promise { } } -/** Normalizes an SSH or HTTPS git remote URL to `owner/name`, or `undefined` when it cannot be parsed. */ +/** + * Normalizes an SSH or HTTPS git remote URL to `owner/name`, or `undefined` when it cannot be parsed. + * + * @internal - Exported to allow testing. + */ export function normalizeRemoteUrl(url: string): string | undefined { const withoutSuffix = url.replace(/\.git$/, ''); @@ -287,16 +285,4 @@ function takeOwnerName(path: string): string | undefined { return segments.slice(-2).join('/'); } -/** Reads a readable stream to completion as a UTF-8 string. */ -async function readAll(stream: Readable): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - if (!Buffer.isBuffer(chunk)) { - throw new TypeError('readAll: expected Buffer chunks (stream must be in binary mode)'); - } - chunks.push(chunk); - } - return Buffer.concat(chunks).toString('utf8'); -} - // endregion | Helpers diff --git a/packages/agents/src/kb-add/cli.ts b/packages/agents/src/kb-add/cli.ts index 5ce90a52..d1896e7b 100644 --- a/packages/agents/src/kb-add/cli.ts +++ b/packages/agents/src/kb-add/cli.ts @@ -11,6 +11,8 @@ import { loadSchema } from '@codeassembly/kb/schema'; import { loadAliases } from '@codeassembly/kb/tags'; import { resolveWritableKb } from '../kb-shared/resolve-writable-kb.ts'; +import { parseTagList } from '../kb-shared/tag-helpers.ts'; +import { readAll } from '../lib/stream-helpers.ts'; import { prepareNote } from './prepare-note.ts'; import type { AddResult, ParsedArgs } from './types.ts'; import { writeNote } from './write-note.ts'; @@ -251,27 +253,4 @@ async function loadAliasesWithWarning(input: { kbRoot: KbRoot }): Promise tag.trim()) - .filter((tag) => tag.length > 0); -} - -/** - * Reads a readable stream to completion as a UTF-8 string. Callers pass `process.stdin` (binary mode) or a - * `Readable.from([Buffer])`, both of which emit `Buffer` chunks. - */ -async function readAll(stream: Readable): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - if (!Buffer.isBuffer(chunk)) { - throw new TypeError('readAll: expected Buffer chunks (stream must be in binary mode)'); - } - chunks.push(chunk); - } - return Buffer.concat(chunks).toString('utf8'); -} - // endregion | Helpers diff --git a/packages/agents/src/kb-edit/cli.ts b/packages/agents/src/kb-edit/cli.ts index 23b3194f..64bc3ec1 100644 --- a/packages/agents/src/kb-edit/cli.ts +++ b/packages/agents/src/kb-edit/cli.ts @@ -13,6 +13,8 @@ import { loadAliases } from '@codeassembly/kb/tags'; import type { ResolvedKb } from '../kb-shared/resolve-writable-kb.ts'; import { resolveWritableKb } from '../kb-shared/resolve-writable-kb.ts'; +import { parseTagList } from '../kb-shared/tag-helpers.ts'; +import { readAll } from '../lib/stream-helpers.ts'; import { commitSupersede } from './commit-supersede.ts'; import { loadNote } from './load-note.ts'; import { append } from './operations/append.ts'; @@ -417,18 +419,6 @@ async function runSupersedeWith(input: { return success; } -/** Reads a readable stream to completion as a UTF-8 string. */ -async function readAll(stream: Readable): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - if (!Buffer.isBuffer(chunk)) { - throw new TypeError('readAll: expected Buffer chunks (stream must be in binary mode)'); - } - chunks.push(chunk); - } - return Buffer.concat(chunks).toString('utf8'); -} - /** A captured operation flag: its canonical name plus the value (if any) that followed it. */ interface SelectedOp { name: OperationName; @@ -563,12 +553,4 @@ function matchOperationFlag( return null; } -/** Splits a comma-separated tag string into individual tags, dropping empties and trimming whitespace. */ -function parseTagList(value: string): string[] { - return value - .split(',') - .map((tag) => tag.trim()) - .filter((tag) => tag.length > 0); -} - // endregion | Helpers diff --git a/packages/agents/src/kb-shared/__tests__/tag-helpers.test.ts b/packages/agents/src/kb-shared/__tests__/tag-helpers.test.ts new file mode 100644 index 00000000..53259180 --- /dev/null +++ b/packages/agents/src/kb-shared/__tests__/tag-helpers.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTagList } from '../tag-helpers.ts'; + +describe(parseTagList, () => { + it('splits a comma-separated string into individual tags', () => { + expect(parseTagList('alpha,beta,gamma')).toEqual(['alpha', 'beta', 'gamma']); + }); + + it('trims surrounding whitespace from each tag', () => { + expect(parseTagList(' alpha , beta ,gamma ')).toEqual(['alpha', 'beta', 'gamma']); + }); + + it('drops empty segments from leading, trailing, and doubled commas', () => { + expect(parseTagList(',alpha,,beta,')).toEqual(['alpha', 'beta']); + }); + + it('returns a single-element list for a value with no commas', () => { + expect(parseTagList('alpha')).toEqual(['alpha']); + }); + + it('returns an empty list for an empty string', () => { + expect(parseTagList('')).toEqual([]); + }); + + it('returns an empty list for whitespace and commas only', () => { + expect(parseTagList(' , , ')).toEqual([]); + }); +}); diff --git a/packages/agents/src/kb-shared/tag-helpers.ts b/packages/agents/src/kb-shared/tag-helpers.ts new file mode 100644 index 00000000..f758f485 --- /dev/null +++ b/packages/agents/src/kb-shared/tag-helpers.ts @@ -0,0 +1,10 @@ +/** + * Splits a comma-separated tag string into individual tags, dropping empties and trimming whitespace. + * @internal + */ +export function parseTagList(value: string): string[] { + return value + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); +} diff --git a/packages/agents/src/lib/__tests__/stream-helpers.test.ts b/packages/agents/src/lib/__tests__/stream-helpers.test.ts new file mode 100644 index 00000000..81969c8e --- /dev/null +++ b/packages/agents/src/lib/__tests__/stream-helpers.test.ts @@ -0,0 +1,27 @@ +import { Readable } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import { readAll } from '../stream-helpers.ts'; + +describe(readAll, () => { + it('concatenates multiple Buffer chunks into a single UTF-8 string', async () => { + const stream = Readable.from([Buffer.from('hello '), Buffer.from('world')]); + await expect(readAll(stream)).resolves.toBe('hello world'); + }); + + it('decodes multi-byte UTF-8 sequences split across chunk boundaries', async () => { + const encoded = Buffer.from('café', 'utf8'); + const stream = Readable.from([encoded.subarray(0, 4), encoded.subarray(4)]); + await expect(readAll(stream)).resolves.toBe('café'); + }); + + it('returns an empty string for a stream with no chunks', async () => { + await expect(readAll(Readable.from([]))).resolves.toBe(''); + }); + + it('throws a TypeError when a chunk is not a Buffer', async () => { + const stream = Readable.from(['not-a-buffer'], { objectMode: true }); + await expect(readAll(stream)).rejects.toThrow(TypeError); + }); +}); diff --git a/packages/agents/src/lib/stream-helpers.ts b/packages/agents/src/lib/stream-helpers.ts new file mode 100644 index 00000000..3c7cd73d --- /dev/null +++ b/packages/agents/src/lib/stream-helpers.ts @@ -0,0 +1,17 @@ +import type { Readable } from 'node:stream'; + +/** + * Reads a readable stream to completion as a UTF-8 string. Callers pass `process.stdin` (binary mode) or a + * `Readable.from([Buffer])`, both of which emit `Buffer` chunks. + * @internal + */ +export async function readAll(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + if (!Buffer.isBuffer(chunk)) { + throw new TypeError('readAll: expected Buffer chunks (stream must be in binary mode)'); + } + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/packages/agents/src/update-jira-ticket/cli.ts b/packages/agents/src/update-jira-ticket/cli.ts index e0a26d1b..da8d546d 100644 --- a/packages/agents/src/update-jira-ticket/cli.ts +++ b/packages/agents/src/update-jira-ticket/cli.ts @@ -8,23 +8,11 @@ import { realpathSync } from 'node:fs'; import process from 'node:process'; -import type { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; +import { readAll } from '../lib/stream-helpers.ts'; import { check } from './check.ts'; -/** Read every chunk of `stream` and concatenate into a single UTF-8 string. */ -async function readAll(stream: Readable): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - if (!Buffer.isBuffer(chunk)) { - throw new TypeError('readAll: expected Buffer chunks (stream must be in binary mode)'); - } - chunks.push(chunk); - } - return Buffer.concat(chunks).toString('utf8'); -} - /** Top-level entry: read stdin, run the check, emit JSON. */ async function main(): Promise { try {