diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..30ab299 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +link-workspace-packages=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f8d728..ee6f3cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.1] - 2026-02-06 (@git-stunts/alfred) + +### Changed + +- Version bump to keep lockstep alignment with the Alfred package family (no API changes). + ## [0.10.0] - 2026-02-04 (@git-stunts/alfred) ### Changed @@ -168,6 +174,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.1] - 2026-02-06 (@git-stunts/alfred-live) + +### Added + +- JSONL command channel fuzz-style test for randomized junk input handling. +- `alfredctl` JSONL output integration test against the command channel. + ## [0.10.0] - 2026-02-04 (@git-stunts/alfred-live) ### Added diff --git a/README.md b/README.md index 0993724..280bdc7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Alfred is a **policy engine** for async resilience: composable, observable, test ## Latest Release -`v0.10.0` (2026-02-04) — JSONL command channel + `alfredctl` CLI for the control plane. +`v0.10.1` (2026-02-06) — JSONL command channel test coverage improvements. ## Package Badges diff --git a/alfred-live/CHANGELOG.md b/alfred-live/CHANGELOG.md index 91eade3..9724bf3 100644 --- a/alfred-live/CHANGELOG.md +++ b/alfred-live/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.1] - 2026-02-06 + +### Added + +- JSONL command channel fuzz-style test for randomized junk input handling. +- `alfredctl` JSONL output integration test against the command channel. + ## [0.10.0] - 2026-02-04 ### Added diff --git a/alfred-live/jsr.json b/alfred-live/jsr.json index 5833f38..f2a7056 100644 --- a/alfred-live/jsr.json +++ b/alfred-live/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-live", - "version": "0.10.0", + "version": "0.10.1", "description": "In-memory control plane for Alfred: adaptive values, config registry, command router.", "license": "Apache-2.0", "exports": { diff --git a/alfred-live/package.json b/alfred-live/package.json index 33e7d57..6854949 100644 --- a/alfred-live/package.json +++ b/alfred-live/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-live", - "version": "0.10.0", + "version": "0.10.1", "description": "In-memory control plane for Alfred: adaptive values, config registry, command router.", "type": "module", "sideEffects": false, @@ -16,7 +16,7 @@ "alfredctl": "./bin/alfredctl.js" }, "dependencies": { - "@git-stunts/alfred": "0.10.0" + "@git-stunts/alfred": "0.10.1" }, "engines": { "node": ">=20.0.0" diff --git a/alfred-live/test/unit/alfredctl.test.js b/alfred-live/test/unit/alfredctl.test.js new file mode 100644 index 0000000..6ad130e --- /dev/null +++ b/alfred-live/test/unit/alfredctl.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { Adaptive } from '../../src/adaptive.js'; +import { CommandRouter } from '../../src/router.js'; +import { ConfigRegistry } from '../../src/registry.js'; +import { executeCommandLine } from '../../src/command-envelope.js'; + +const alfredctlPath = fileURLToPath(new URL('../../bin/alfredctl.js', import.meta.url)); + +function runAlfredctl(args) { + return spawnSync(process.execPath, [alfredctlPath, ...args], { + encoding: 'utf8', + }); +} + +describe('alfredctl', () => { + it('emits JSONL commands executable by the command channel', () => { + const output = runAlfredctl(['write', 'retry/count', '5', '--id', 'cmd-1']); + + expect(output.status).toBe(0); + expect(output.stdout).not.toBe(''); + + const line = output.stdout.trim(); + const envelope = JSON.parse(line); + expect(envelope).toEqual({ + id: 'cmd-1', + cmd: 'write_config', + args: { path: 'retry/count', value: '5' }, + }); + + const registry = new ConfigRegistry(); + const retryCount = new Adaptive(3); + registry.register('retry/count', retryCount, { + parse: (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error('retry/count must be numeric'); + } + return parsed; + }, + format: (value) => value.toString(), + }); + + const router = new CommandRouter(registry); + const resultLine = executeCommandLine(router, line); + expect(resultLine.ok).toBe(true); + if (!resultLine.ok) return; + const result = JSON.parse(resultLine.data); + expect(result.ok).toBe(true); + expect(result.data.path).toBe('retry/count'); + expect(result.data.formatted).toBe('5'); + }); +}); diff --git a/alfred-live/test/unit/command-envelope.test.js b/alfred-live/test/unit/command-envelope.test.js index 53adce4..5b4dbb3 100644 --- a/alfred-live/test/unit/command-envelope.test.js +++ b/alfred-live/test/unit/command-envelope.test.js @@ -13,6 +13,38 @@ function parseJsonLine(line) { return JSON.parse(line); } +function mulberry32(seed) { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let result = Math.imul(state ^ (state >>> 15), 1 | state); + result ^= result + Math.imul(result ^ (result >>> 7), 61 | result); + return ((result ^ (result >>> 14)) >>> 0) / 4294967296; + }; +} + +function randomInt(rand, min, max) { + return Math.floor(rand() * (max - min + 1)) + min; +} + +function randomString(rand, length) { + const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/:.*'; + let value = ''; + for (let i = 0; i < length; i += 1) { + value += alphabet[randomInt(rand, 0, alphabet.length - 1)]; + } + return value; +} + +function randomPath(rand) { + const segments = randomInt(rand, 1, 4); + const parts = []; + for (let i = 0; i < segments; i += 1) { + parts.push(randomString(rand, randomInt(rand, 1, 8))); + } + return parts.join('/'); +} + describe('command envelope', () => { it('round-trips encode/decode', () => { const envelope = { @@ -86,4 +118,86 @@ describe('command envelope', () => { expect(result.ok).toBe(false); expect(result.error.code).toBe('INVALID_COMMAND'); }); + + it('handles randomized JSONL and junk input without throwing', () => { + const registry = new ConfigRegistry(); + const counter = new Adaptive(1); + registry.register('retry/count', counter, { + parse: (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error('retry/count must be numeric'); + } + return parsed; + }, + format: (value) => value.toString(), + }); + const router = new CommandRouter(registry); + + const rand = mulberry32(0xade1f00d); + const generators = [ + (i) => + JSON.stringify({ + id: `cmd-${i}`, + cmd: 'read_config', + args: { path: rand() > 0.6 ? 'retry/count' : randomPath(rand) }, + }), + (i) => + JSON.stringify({ + id: `cmd-${i}`, + cmd: 'write_config', + args: { path: rand() > 0.6 ? 'retry/count' : randomPath(rand), value: '5' }, + }), + (i) => + JSON.stringify({ + id: `cmd-${i}`, + cmd: 'list_config', + args: rand() > 0.5 ? { prefix: randomPath(rand) } : {}, + }), + (_i) => + JSON.stringify({ + id: '', + cmd: 'read_config', + args: { path: randomPath(rand) }, + }), + (i) => + JSON.stringify({ + id: `cmd-${i}`, + cmd: 'read_config', + args: { path: 123 }, + }), + (i) => + JSON.stringify({ + id: `cmd-${i}`, + cmd: 'read_config', + args: { path: 'retry/count', extra: true }, + }), + (i) => + JSON.stringify({ + id: `cmd-${i}`, + cmd: 'unknown', + args: {}, + }), + () => JSON.stringify({ foo: 'bar' }), + () => JSON.stringify([1, 2, 3]), + () => JSON.stringify(42), + () => '{ "bad": ', + () => '{', + () => 'not-json', + () => '', + () => ' ', + ]; + + for (let i = 0; i < 1000; i += 1) { + const generator = generators[randomInt(rand, 0, generators.length - 1)]; + const line = generator(i); + const resultLine = executeCommandLine(router, line); + expect(resultLine.ok).toBe(true); + if (!resultLine.ok) continue; + const result = parseJsonLine(resultLine.data); + expect(typeof result.id).toBe('string'); + expect(result.id.length).toBeGreaterThan(0); + expect(typeof result.ok).toBe('boolean'); + } + }); }); diff --git a/alfred/CHANGELOG.md b/alfred/CHANGELOG.md index 9d8e364..5380046 100644 --- a/alfred/CHANGELOG.md +++ b/alfred/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.1] - 2026-02-06 + +### Changed + +- Version bump to keep lockstep alignment with the Alfred package family (no API changes). + ## [0.10.0] - 2026-02-04 ### Changed diff --git a/alfred/jsr.json b/alfred/jsr.json index aae75e2..f944604 100644 --- a/alfred/jsr.json +++ b/alfred/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred", - "version": "0.10.0", + "version": "0.10.1", "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", "license": "Apache-2.0", "exports": { diff --git a/alfred/package.json b/alfred/package.json index 0699cd5..8bb2b4d 100644 --- a/alfred/package.json +++ b/alfred/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred", - "version": "0.10.0", + "version": "0.10.1", "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", "type": "module", "sideEffects": false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c182906..548098c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: alfred-live: dependencies: '@git-stunts/alfred': - specifier: 0.10.0 + specifier: 0.10.1 version: link:../alfred devDependencies: '@eslint/js':