From e199f0b37e8b0a653a75e12d42f46e6153a202f9 Mon Sep 17 00:00:00 2001 From: Thanh Chau <1320427+thannous@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:37:38 +0200 Subject: [PATCH] Document complex API boundaries --- CONTRIBUTING.fr.md | 13 +++ CONTRIBUTING.md | 12 +++ prototype/src/profile-workflow.js | 40 +++++++ scripts/lib/hid-device.mjs | 87 +++++++++++++-- scripts/lib/hid-frame.mjs | 87 ++++++++++----- scripts/lib/hid-lighting.mjs | 98 +++++++++++++---- scripts/lib/thread-slots.mjs | 101 ++++++++++++++++-- shared/input-profile.mjs | 110 ++++++++++++++----- shared/thread-status-palette.mjs | 11 +- tests/api-docs.test.mjs | 171 ++++++++++++++++++++++++++++++ 10 files changed, 635 insertions(+), 95 deletions(-) create mode 100644 tests/api-docs.test.mjs diff --git a/CONTRIBUTING.fr.md b/CONTRIBUTING.fr.md index 4a57245..bacbe8a 100644 --- a/CONTRIBUTING.fr.md +++ b/CONTRIBUTING.fr.md @@ -40,6 +40,19 @@ Chaque preset stable sépare : Les schémas communs se trouvent dans `profiles/schema/v1/`. La piste BLE reste séparée sous `ble/`. +## Documenter les API complexes + +Les API exportées aux frontières matérielles et d'intégrité des données exigent +un JSDoc adjacent. Documenter les entrées, sorties, erreurs stables, effets de +bord et règles de préservation ; ne pas répéter l'implémentation ligne par ligne. +Les frontières protégées couvrent actuellement le framing et les sessions HID, +les charges d'éclairage, les transformations AppSense/profile, la navigation des +emplacements de session et le workflow de profil du GUI. + +`tests/api-docs.test.mjs` importe ces modules frontières et refuse les callables +et constantes exportés sans contrat formel. Mettre à jour la liste explicite des +modules du test lorsqu'une nouvelle frontière est introduite. + ## Préparer l'environnement Prérequis : Node.js 18 ou version ultérieure. Les dépendances de validation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32f6ede..c8a4d09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,18 @@ Every stable preset separates: The common schemas live in `profiles/schema/v1/`. The BLE track stays separate under `ble/`. +## Documenting complex APIs + +Exported APIs at hardware and data-integrity boundaries require adjacent JSDoc. +Document inputs, outputs, stable errors, side effects and preservation rules; +do not repeat the implementation line by line. The guarded boundaries currently +cover HID framing and sessions, lighting payloads, AppSense/profile transforms, +session-slot navigation and the GUI profile workflow. + +`tests/api-docs.test.mjs` imports these boundary modules and rejects an exported +callable or constant without a formal contract. Update the test's explicit +module list when a new boundary module is introduced. + ## Preparing the environment Prerequisite: Node.js 18 or newer. The validation dependencies are pinned by diff --git a/prototype/src/profile-workflow.js b/prototype/src/profile-workflow.js index 9c84b5d..1e2d838 100644 --- a/prototype/src/profile-workflow.js +++ b/prototype/src/profile-workflow.js @@ -7,6 +7,16 @@ import { import { parseOptionalNonNegativeInteger } from "./configurator-state.js"; import { LOCALES } from "./i18n/index.js"; +/** + * Normalizes an imported profile for the configurator. A missing Claude layer + * is synthesized from a compatible template; every other inspection failure is + * preserved for the caller to present. + * + * @param {object} parsed Parsed Work Louder Input profile export. + * @returns {{source: object, info: object, layerCreated: {templateName: string}|null, derived: {mapping: object, assigned: number}}} + * Prepared immutable workflow input and its derived mapping. + * @throws {Error} When the profile is unsafe or cannot supply a layer template. + */ export function prepareImportedProfile(parsed) { let source = parsed; let layerCreated = null; @@ -30,6 +40,14 @@ export function prepareImportedProfile(parsed) { }; } +/** + * Computes a browser-compatible SHA-256 digest without making hashing a hard + * requirement. Unavailable or rejected Web Crypto returns an empty string. + * + * @param {string} text UTF-8 text to hash. + * @param {Crypto} [crypto] Injectable Web Crypto implementation. + * @returns {Promise} Lowercase hexadecimal digest, or `""` on failure. + */ export async function sha256Hex(text, crypto = globalThis.window?.crypto) { try { const digest = await crypto?.subtle?.digest( @@ -45,6 +63,18 @@ export async function sha256Hex(text, crypto = globalThis.window?.crypto) { } } +/** + * Builds the reviewed downloadable profile and binds its exact serialized JSON + * to a SHA-256 fingerprint when Web Crypto is available. + * + * @param {object} source Prepared official profile export. + * @param {object} mapping Canonical configurator mapping. + * @param {{claude: string, base: string}} appSenseIds User-entered local ids. + * @param {Crypto} [crypto] Injectable Web Crypto implementation. + * @returns {Promise<{json: string, sha: string, report: object}>} Download data + * and the transformer's preservation report. + * @throws {Error} When profile generation violates a safety invariant. + */ export async function createProfileReview(source, mapping, appSenseIds, crypto) { const { profile, report } = buildInputProfile(source, mapping, { requireAppSense: false, @@ -55,6 +85,16 @@ export async function createProfileReview(source, mapping, appSenseIds, crypto) return { json, sha: await sha256Hex(json, crypto), report }; } +/** + * Converts parser and domain failures into a stable, localized UI error shape. + * Unknown `Error` instances retain their message; non-errors use the generic + * invalid-file translation. + * + * @param {unknown} error Failure raised while loading or transforming a profile. + * @param {(key: string) => string} t Locale translator. + * @returns {{message: string, code: string|null}} User-facing message and + * optional stable domain code. + */ export function describeProfileError(error, t) { if (error instanceof SyntaxError) { return { message: t("errors.invalidJson"), code: null }; diff --git a/scripts/lib/hid-device.mjs b/scripts/lib/hid-device.mjs index 7e3d6f5..c94e09a 100644 --- a/scripts/lib/hid-device.mjs +++ b/scripts/lib/hid-device.mjs @@ -24,14 +24,26 @@ import { } from "./hid-frame.mjs"; import { METHODS } from "./hid-lighting.mjs"; +/** USB vendor id reported by the Codex Micro vendor interface. */ export const VENDOR_ID = 0x303a; + +/** USB product id reported by the Codex Micro vendor interface. */ export const PRODUCT_ID = 0x8360; + +/** Vendor usage page carrying the JSON-RPC transport. */ export const VENDOR_USAGE_PAGE = 0xff00; const CALL_TIMEOUT_MS = 10000; + +/** Minimum delay between RPC calls required for reliable firmware handling. */ export const CALL_SPACING_MS = 50; +/** Error carrying a stable transport code suitable for CLI and GUI handling. */ export class DeviceError extends Error { + /** + * @param {string} code Stable machine-readable failure code. + * @param {string} [message] Human-readable detail; defaults to the code. + */ constructor(code, message) { super(message ?? code); this.name = "DeviceError"; @@ -39,8 +51,12 @@ export class DeviceError extends Error { } } -// Lazy import: node-hid is a native optionalDependency. The error message has to -// say what to do, not only that it is missing. +/** + * Loads the optional native HID dependency only when hardware access is used. + * + * @returns {Promise} Loaded `node-hid` module. + * @throws {DeviceError} With `HID_UNAVAILABLE` when the dependency cannot load. + */ export async function loadHid() { try { return await import("node-hid"); @@ -52,8 +68,13 @@ export async function loadHid() { } } -// Lists the Codex Micro vendor interfaces. The keyboard exposes several HID -// collections; only usage page 0xFF00 carries the RPC channel. +/** + * Tests whether a `node-hid` descriptor is the Codex Micro vendor collection. + * The keyboard collection is deliberately excluded even when VID/PID match. + * + * @param {object|null|undefined} device HID descriptor returned by `node-hid`. + * @returns {boolean} Whether the descriptor carries the vendor RPC channel. + */ export function isCodexVendorInterface(device) { return ( device?.vendorId === VENDOR_ID && @@ -62,6 +83,12 @@ export function isCodexVendorInterface(device) { ); } +/** + * Enumerates only Codex Micro vendor RPC interfaces. + * + * @returns {Promise} Matching `node-hid` device descriptors. + * @throws {DeviceError} When the optional HID dependency is unavailable. + */ export async function listInterfaces() { const hid = await loadHid(); return hid.devices().filter(isCodexVendorInterface); @@ -75,9 +102,11 @@ async function openHandle(path) { return hid.HIDAsync.open(path); } -// RPC session: paced sequential queue, responses correlated by id, notifications -// dispatched, foreign writes detected. One session = one request in flight, the -// way the firmware expects it. +/** + * Paced RPC session over one HID handle. Calls are serialized, responses are + * correlated by id, notifications are dispatched, and foreign lighting writes + * are surfaced to the optional coexistence callback. + */ export class DeviceSession { #handle; #assembler = createLineAssembler(); @@ -91,6 +120,13 @@ export class DeviceSession { #closed = false; #lastCallStartedAt = 0; + /** + * @param {{on: Function, write: Function, close: Function}} handle Open HID handle. + * @param {object} [options] Session callbacks. + * @param {(method: string, response: unknown) => void|Promise} [options.onForeignWrite] + * Called for orphan responses to known lighting methods. + * @param {(line: string) => void} [options.onDebugLine] Called for firmware debug lines. + */ constructor(handle, { onForeignWrite, onDebugLine } = {}) { this.#handle = handle; this.#onForeignWrite = onForeignWrite ?? null; @@ -103,6 +139,16 @@ export class DeviceSession { }); } + /** + * Opens the requested interface, or the first matching Codex Micro interface. + * + * @param {object} [options] Device selection and callbacks. + * @param {string} [options.path] Exact `node-hid` path to open. + * @param {(method: string, response: unknown) => void|Promise} [options.onForeignWrite] + * @param {(line: string) => void} [options.onDebugLine] + * @returns {Promise} Ready, event-wired session. + * @throws {DeviceError} With `DEVICE_NOT_FOUND` when no interface is available. + */ static async open({ path, ...options } = {}) { const target = path ?? (await listInterfaces()).at(0)?.path; if (!target) { @@ -114,6 +160,14 @@ export class DeviceSession { return new DeviceSession(await openHandle(target), options); } + /** + * Registers one handler for a firmware notification method. + * A newer handler for the same method replaces the previous one. + * + * @param {string} method Notification method name. + * @param {(params: unknown) => void} handler Notification consumer. + * @returns {() => void} Idempotent unsubscriber for this exact handler. + */ onNotification(method, handler) { this.#notifyHandlers.set(method, handler); return () => { @@ -121,9 +175,16 @@ export class DeviceSession { }; } - // Queues a call and waits for its response. Tasks run one at a time with - // CALL_SPACING_MS of spacing, since the firmware handles commands in a - // trickle. + /** + * Queues one RPC call and resolves only its correlated response. Calls run + * sequentially with {@link CALL_SPACING_MS} between start times. + * + * @param {string} method Firmware method name. + * @param {unknown} [params] Method parameters; `null` when omitted. + * @param {number} [id] Explicit request id, primarily for deterministic tests. + * @returns {Promise} Parsed firmware response envelope. + * @throws {DeviceError} Via rejection on timeout, write, RPC, or disconnect failure. + */ call(method, params = null, id = createRpcId()) { return new Promise((resolve, reject) => { this.#queue.push({ method, params, id, resolve, reject }); @@ -227,6 +288,12 @@ export class DeviceSession { for (const task of this.#queue.splice(0)) task.reject(error); } + /** + * Rejects queued and in-flight calls, then closes the underlying HID handle. + * Calling it after the HID stack has already closed is safe. + * + * @returns {Promise} Resolves after the close attempt completes. + */ async close() { this.#closed = true; this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Session closed.")); diff --git a/scripts/lib/hid-frame.mjs b/scripts/lib/hid-frame.mjs index cc44d2c..5f1a66b 100644 --- a/scripts/lib/hid-frame.mjs +++ b/scripts/lib/hid-frame.mjs @@ -8,26 +8,43 @@ import { randomInt } from "node:crypto"; -// 64-byte report: [0] report id, [1] channel, [2] length of the chunk carried by -// THIS report, [3..63] UTF-8 payload. +/** Size in bytes of every vendor HID report, including its three-byte header. */ export const REPORT_SIZE = 64; + +/** Report identifier used by the Codex Micro vendor channel. */ export const REPORT_ID = 0x06; + +/** Channel identifier carrying firmware debug lines. */ export const CHANNEL_DEBUG = 1; + +/** Channel identifier carrying JSON-RPC traffic. */ export const CHANNEL_RPC = 2; + +/** Maximum UTF-8 payload carried by one report after the three-byte header. */ export const CHUNK_PAYLOAD = REPORT_SIZE - 3; // 61 bytes per report -// The call id is bounded by the firmware, which rejects values outside -// [0, 999). Short ids also avoid pushing a request from one report to two for -// the sake of a few bytes. +/** + * Exclusive upper bound for RPC identifiers accepted by the firmware. + * Short identifiers also keep small requests inside a single HID report. + */ export const RPC_ID_LIMIT = 999; +/** + * Creates an RPC identifier inside the firmware-supported range `[0, 999)`. + * + * @returns {number} A random integer suitable for a request envelope. + */ export function createRpcId() { return randomInt(0, RPC_ID_LIMIT); } -// The channel replaces every non-ASCII character with its \uXXXX escape (or the -// surrogate pair beyond the BMP). Lighting payloads are already ASCII in -// practice; the escaping is applied to match the format. +/** + * Escapes every non-ASCII code point using the representation expected by the + * vendor channel, including surrogate pairs beyond the BMP. + * + * @param {string} text Text to encode inside a request. + * @returns {string} ASCII-only text containing `\\uXXXX` escapes. + */ export function escapeUnicode(text) { return text.replace(/[^\x00-\x7F]/gu, (char) => { const codePoint = char.codePointAt(0); @@ -40,8 +57,13 @@ export function escapeUnicode(text) { }); } -// The channel's JSON-RPC envelope: { method, params, id }. `params` is null when -// the method expects none. +/** + * Builds and validates the minified JSON-RPC request envelope sent over HID. + * + * @param {{method: string, params?: unknown, id: number}} request Request data. + * @returns {string} An ASCII-only serialized `{method, params, id}` envelope. + * @throws {Error} When the method is empty or the id is outside `[0, 999)`. + */ export function buildRequest({ method, params = null, id }) { if (typeof method !== "string" || !method) throw new Error("Expected an RPC method name."); if (!Number.isInteger(id) || id < 0 || id >= RPC_ID_LIMIT) { @@ -50,9 +72,13 @@ export function buildRequest({ method, params = null, id }) { return escapeUnicode(JSON.stringify({ method, params, id })); } -// Splits a message into 64-byte reports. A message longer than CHUNK_PAYLOAD is -// fragmented into consecutive reports that repeat the same header; only the -// length byte varies. An empty message emits nothing. +/** + * Splits a UTF-8 RPC message into fixed-size vendor HID reports. + * Consecutive fragments repeat the same header; an empty message emits none. + * + * @param {string} message Serialized request to frame. + * @returns {Buffer[]} Ordered 64-byte reports ready for `node-hid`. + */ export function encodeFrames(message) { const buffer = Buffer.from(message, "utf8"); const frames = []; @@ -70,8 +96,13 @@ export function encodeFrames(message) { return frames; } -// Extracts channel and payload from an incoming report. The buffer delivered by -// the HID stack includes the report id in byte 0, as it does on the way out. +/** + * Decodes the header and UTF-8 payload of one incoming vendor report. + * + * @param {Buffer|Uint8Array|number[]} report Report including its id byte. + * @returns {{channel: number, length: number, payload: string}} Decoded fragment. + * @throws {Error} When the report is shorter than the three-byte header. + */ export function decodeReport(report) { const data = Buffer.isBuffer(report) ? report : Buffer.from(report); if (data.length < 3) throw new Error(`HID report too short: ${data.length} byte(s).`); @@ -84,10 +115,13 @@ export function decodeReport(report) { }; } -// Reassembles the stream of reports into lines. The device terminates each -// message with a newline; a long message arrives fragmented over several -// reports and is only complete at the final newline. Each channel has its own -// buffer: debug logs do not mix into the RPC. +/** + * Creates a stateful assembler that separates channels and emits complete, + * trimmed lines only after the firmware's newline delimiter is received. + * + * @returns {(report: Buffer|Uint8Array|number[]) => Array<{channel: number, line: string}>} + * A report consumer whose buffers are private to this assembler instance. + */ export function createLineAssembler() { const buffers = new Map(); return function push(report) { @@ -99,13 +133,14 @@ export function createLineAssembler() { }; } -// Rebuilds JSON-RPC messages from the lines of the RPC channel. A document can -// itself arrive over several lines (indented JSON): accumulate until parsing -// succeeds. Three shapes on the channel: -// -// - response: { "result": …, "id": n } (id also carried as "i") -// - notification: { "method": "v.oai.hid", "params": … } (also "m"/"p") -// - invalid: neither id nor method — buffer dropped +/** + * Creates a stateful JSON accumulator for response, notification, and invalid + * messages. Indented JSON is retained until parsing succeeds; stray prefixes + * before the first object are discarded. + * + * @returns {(text: string) => ({kind: "response", id: string, method: string|null, raw: string, parsed: object}|{kind: "notification", method: string, params: unknown, raw: string, parsed: object}|{kind: "invalid", raw: string, parsed: object}|null)} + * A line consumer that returns `null` while a JSON document is incomplete. + */ export function createRpcAccumulator() { let pending = ""; return function push(text) { diff --git a/scripts/lib/hid-lighting.mjs b/scripts/lib/hid-lighting.mjs index 7f7d6ee..126ef4e 100644 --- a/scripts/lib/hid-lighting.mjs +++ b/scripts/lib/hid-lighting.mjs @@ -13,6 +13,27 @@ import { SLOT_CONTROLS, STATE_COLORS, STATES } from "./thread-slots.mjs"; +/** + * @typedef {object} ThreadLightingInput + * @property {number} id Firmware slot identifier. + * @property {string|number} [color] Packed RGB integer or `#RRGGBB` string. + * @property {number} [brightness] Brightness in the inclusive range `[0, 1]`. + * @property {number} [effect] One of the numeric values in {@link EFFECTS}. + * @property {number} [speed] Animation speed in the inclusive range `[0, 1]`. + * @property {boolean} [syncKeysLighting] Whether the slot follows the key zone. + * @property {boolean} [syncAmbientLighting] Whether the slot follows the ambient zone. + */ + +/** + * @typedef {object} ZoneLightingInput + * @property {number} effect Firmware effect identifier. + * @property {number} brightness Brightness in the inclusive range `[0, 1]`. + * @property {number} speed Animation speed in the inclusive range `[0, 1]`. + * @property {unknown} magic Firmware field preserved verbatim. + * @property {string|number} color Packed RGB integer or `#RRGGBB` string. + */ + +/** Vendor RPC method names used by lighting and HID notifications. */ export const METHODS = Object.freeze({ threadsLighting: "v.oai.thstatus", rgbConfig: "v.oai.rgbcfg", @@ -20,8 +41,10 @@ export const METHODS = Object.freeze({ notifyJoystick: "v.oai.rad", }); -// Firmware animation effects. `solid` is the only useful one for a steady state -// light; the others are exposed for free-form use. +/** + * Firmware animation effect identifiers. `solid` is the stable-state effect; + * the other values are available for explicit free-form lighting commands. + */ export const EFFECTS = Object.freeze({ off: 0, solid: 1, @@ -32,14 +55,19 @@ export const EFFECTS = Object.freeze({ shallowBreath: 6, }); -// Slot → thread id mapping on the channel. CONFIRMED on hardware by -// `node scripts/lighting.mjs probe --delay=3000`, which lights the keys one by -// one: the sequence of ids 0 to 5 follows exactly the physical order of -// SLOT_CONTROLS — the two keys of the top row, left to right, then the four of -// the next row. +/** + * Slot-to-thread-id mapping confirmed on hardware. The sequence `0..5` follows + * {@link SLOT_CONTROLS}: two top-row keys, then four keys on the next row. + */ export const SLOT_THREAD_IDS = Object.freeze(SLOT_CONTROLS.map((_, index) => index)); -// "#D97757" → 0xD97757. The channel expects a packed RGB integer. +/** + * Converts a CSS-style colour or validates an existing packed RGB integer. + * + * @param {string|number} color `#RRGGBB`, `RRGGBB`, or an integer in `[0, 0xFFFFFF]`. + * @returns {number} Packed RGB value expected by the firmware. + * @throws {Error} When the value is outside the supported colour format. + */ export function colorToInt(color) { if (typeof color === "number" && Number.isInteger(color) && color >= 0 && color <= 0xffffff) { return color; @@ -56,9 +84,15 @@ function clampUnit(name, value) { return value; } -// One per-slot lighting entry. Only `id` is required; every optional field -// omitted leaves the corresponding parameter unchanged. The minified keys -// (`c`, `b`, `e`, `s`, `sk`, `sa`) are the channel's own format. +/** + * Builds one partial `v.oai.thstatus` entry. Omitted optional fields remain + * unchanged on the device; returned keys use the firmware's minified format. + * + * @param {ThreadLightingInput} input Slot update in repository-facing names. + * @returns {{id: number, c?: number, b?: number, e?: number, s?: number, sk?: number, sa?: number}} + * Firmware-facing partial update. + * @throws {Error} When an id, colour, effect, brightness, or speed is invalid. + */ export function threadEntry({ id, color, brightness, effect, speed, syncKeysLighting, syncAmbientLighting }) { if (!Number.isInteger(id) || id < 0) throw new Error(`Expected an integer thread id ≥ 0: ${id}`); const entry = { id }; @@ -74,13 +108,24 @@ export function threadEntry({ id, color, brightness, effect, speed, syncKeysLigh return entry; } -// v.oai.thstatus parameters: an array of entries, one per slot. +/** + * Builds the parameter array for `v.oai.thstatus`. + * + * @param {ThreadLightingInput[]} entries Repository-facing slot updates. + * @returns {Array} Firmware-facing entries in the original order. + */ export function threadsLightingParams(entries) { return entries.map(threadEntry); } -// One v.oai.rgbcfg zone. All five fields are required: the method describes a -// complete zone configuration, not a partial update. +/** + * Builds one complete `v.oai.rgbcfg` zone; unlike thread entries, no field is + * optional because the method replaces the whole zone configuration. + * + * @param {ZoneLightingInput} input Complete zone description. + * @returns {{e: number, b: number, s: number, m: unknown, c: number}} Minified zone. + * @throws {Error} When colour, brightness, or speed is invalid. + */ export function zoneSide({ effect, brightness, speed, magic, color }) { return { e: effect, @@ -91,13 +136,25 @@ export function zoneSide({ effect, brightness, speed, magic, color }) { }; } +/** + * Builds the two-zone parameter object for `v.oai.rgbcfg`. + * + * @param {{ambient: ZoneLightingInput, keys: ZoneLightingInput}} input Zone inputs. + * @returns {{ambient: object, keys: object}} Firmware-facing complete configuration. + */ export function rgbConfigParams({ ambient, keys }) { return { ambient: zoneSide(ambient), keys: zoneSide(keys) }; } -// Translates the six thread-status slots into thstatus entries. A free slot is -// unlit (brightness 0); the others carry their state colour from the repository -// palette, as a solid effect. +/** + * Translates the six session slots into solid lighting entries. Free slots are + * explicitly unlit; known states use the shared repository palette. + * + * @param {Array<{state?: string}|null>} rows Exactly six session-slot rows. + * @param {{brightness?: number}} [options] Brightness for non-free slots. + * @returns {Array} Six `v.oai.thstatus` entries in physical key order. + * @throws {Error} When the slot count or brightness is invalid. + */ export function slotsToThreadEntries(rows, { brightness = 1 } = {}) { if (!Array.isArray(rows) || rows.length !== SLOT_CONTROLS.length) { throw new Error(`Expected ${SLOT_CONTROLS.length} slots.`); @@ -110,7 +167,12 @@ export function slotsToThreadEntries(rows, { brightness = 1 } = {}) { }); } -// Turns the six slots off without touching the other parameters. +/** + * Builds partial entries that turn all six Agent slots off without changing + * their colours, effects, speeds, or zone synchronization. + * + * @returns {Array} Six brightness-only `v.oai.thstatus` entries. + */ export function allOffParams() { return SLOT_THREAD_IDS.map((id) => threadEntry({ id, brightness: 0 })); } diff --git a/scripts/lib/thread-slots.mjs b/scripts/lib/thread-slots.mjs index f2623cd..1ad1a1c 100644 --- a/scripts/lib/thread-slots.mjs +++ b/scripts/lib/thread-slots.mjs @@ -16,11 +16,50 @@ // the state to `running` forever; the roster catches that. Conversely the roster // publishes no state at all; the hooks deliver it instantly. +/** + * @typedef {object} SessionSlot + * @property {string} sessionId Claude Code session identifier. + * @property {string} state One of the shared `STATES` values. + * @property {number} [updatedAt] Timestamp of the last state transition. + * @property {string} [name] Display name from the official roster. + * @property {string} [cwd] Session working directory. + * @property {number} [pid] Session process identifier. + * @property {string} [tty] Terminal identifier reported by `ps`. + * @property {string} [terminalApp] Supported terminal application name. + * @property {string} [entrypoint] Claude Code host entrypoint. + * @property {string} [hostSessionId] Host grouping id, not a navigation target. + */ + +/** + * @typedef {object} SlotSnapshot + * @property {1} version Snapshot schema version. + * @property {Array} slots Six physical Agent-key assignments. + * @property {Record} pending Hook states awaiting roster membership. + * @property {number} dropped Pending states evicted from the bounded map. + * @property {number} overflow Live sessions that could not claim a slot. + */ + +/** + * @typedef {object} NavigationTarget + * @property {"empty"|"terminal"|"resume"|"desktop"|"unsupported"} kind Route decision. + * @property {string} [sessionId] Claude Code session identifier. + * @property {string} [tty] Terminal identifier for AppleScript focus. + * @property {string|null} [app] Terminal application to focus. + * @property {string} [url] Undocumented Claude Desktop resume URL. + * @property {string|null} [cwd] Working directory for a closed-session resume command. + * @property {string|null} [name] Session display name. + * @property {string|null} [entrypoint] Host entrypoint when no route is available. + * @property {string|null} [hostSessionId] Non-addressable host grouping id. + * @property {string} [reason] Evidence-backed explanation for an unsupported route. + */ + +/** Number of physical Agent keys available for session assignment. */ export const SLOT_COUNT = 6; -// The six Agent keys, in physical reading order: top row (two keys) then the -// next row (four keys). The ids come from KEY_CONTROL_LOCATIONS in -// shared/input-profile.mjs. +/** + * Six Agent controls in physical reading order: two top-row keys followed by + * four on the next row. Ids originate in `KEY_CONTROL_LOCATIONS`. + */ export const SLOT_CONTROLS = Object.freeze(["key-9", "key-10", "key-5", "key-6", "key-7", "key-8"]); // Re-exported from shared/, where they are the single source of truth: the GUI @@ -42,6 +81,13 @@ const BLOCKING_NOTIFICATIONS = new Set([ // held in a bounded pending map rather than given a slot. const MAX_PENDING = 32; +/** + * Converts one Claude hook payload into a lighting state transition. + * Non-blocking notifications and unrecognized events deliberately return null. + * + * @param {object|null|undefined} event Normalized hook event. + * @returns {string|null} A shared `STATES` value, or null for no transition. + */ export function stateFromHookEvent(event) { switch (event?.event) { case "SessionStart": @@ -61,6 +107,11 @@ export function stateFromHookEvent(event) { } } +/** + * Creates the canonical empty, versioned six-slot snapshot. + * + * @returns {SlotSnapshot} Fresh mutable snapshot with no shared nested state. + */ export function emptySnapshot() { return { version: 1, @@ -82,6 +133,13 @@ function cloneSnapshot(snapshot) { }; } +/** + * Sanitizes persisted data into the current snapshot shape and slot count. + * Invalid snapshots fail closed to {@link emptySnapshot}; extra slots vanish. + * + * @param {unknown} value Deserialized snapshot candidate. + * @returns {SlotSnapshot} Defensive copy safe for reducer operations. + */ export function normalizeSnapshot(value) { const base = emptySnapshot(); if (!value || typeof value !== "object" || !Array.isArray(value.slots)) return base; @@ -134,8 +192,14 @@ function rememberPending(snapshot, sessionId, state) { } /** - * Applies a hook event. A session missing from the roster gets no slot: its - * state is held pending, and gets promoted if the roster later confirms it. + * Applies a hook transition without mutating the input snapshot. A session + * missing from the roster gets no slot: its state remains pending until roster + * membership is confirmed. + * + * @param {SlotSnapshot} snapshot Current reducer state. + * @param {object} event Normalized hook event with a `sessionId` when addressable. + * @param {number} [now=0] Timestamp recorded on an applied transition. + * @returns {{snapshot: SlotSnapshot, changed: boolean}} Next state and render hint. */ export function applyHookEvent(snapshot, event, now = 0) { const next = cloneSnapshot(snapshot); @@ -163,11 +227,16 @@ export function applyHookEvent(snapshot, event, now = 0) { } /** - * Reconciles the official roster. Creates the missing entries, refreshes the - * metadata, and marks `ended` any session whose process has disappeared. + * Reconciles the authoritative roster without mutating the input snapshot. + * Missing sessions are assigned or promoted; disappeared processes become + * `ended`, while active and blocked sessions are never evicted. * - * @param roster array from `claude agents --json`, optionally enriched with - * `tty` and `terminalApp` fields by the caller. + * @param {SlotSnapshot} snapshot Current reducer state. + * @param {object[]} roster Rows from `claude agents --json`, optionally enriched + * with `tty` and `terminalApp` by the caller. + * @param {number} [now=0] Timestamp recorded on membership changes. + * @returns {{snapshot: SlotSnapshot, changed: boolean, notes: string[]}} + * Next state, render hint, and explicit overflow diagnostics. */ export function applyRoster(snapshot, roster, now = 0) { const next = cloneSnapshot(snapshot); @@ -221,6 +290,14 @@ export function applyRoster(snapshot, roster, now = 0) { return { snapshot: next, changed, notes }; } +/** + * Projects reducer state into the six rows consumed by CLI rendering and HID + * lighting, preserving the underlying slot entry for navigation. + * + * @param {SlotSnapshot} snapshot Current reducer state. + * @returns {Array<{slot: number, control: string, state: string, color: string|null, entry: SessionSlot|null}>} + * Rows in physical Agent-key order. + */ export function slotView(snapshot) { return snapshot.slots.map((entry, index) => ({ slot: index + 1, @@ -240,6 +317,9 @@ export function slotView(snapshot) { * AppleScript compared against each window's real `tty` and could never match. * The only navigable sessions were therefore all failing with "window not * found". + * + * @param {string|null|undefined} tty Terminal name from `ps -o tty=`. + * @returns {string|null} Absolute device path, or null for non-terminal hosts. */ export function ttyDevice(tty) { if (!tty || tty === "??" || tty === "-") return null; @@ -262,6 +342,9 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; * A session with no `tty` is hosted by Claude Desktop. `claude://resume` * addresses it by its `sessionId` — the roster's, not the `hostSessionId`, * which groups several sessions and addresses none of them. + * + * @param {SessionSlot|null|undefined} entry Slot entry to route. + * @returns {NavigationTarget} Evidence-backed route decision with no side effect. */ export function resolveNavigation(entry) { if (!entry) return { kind: "empty" }; diff --git a/shared/input-profile.mjs b/shared/input-profile.mjs index 2fa6fbf..588bec0 100644 --- a/shared/input-profile.mjs +++ b/shared/input-profile.mjs @@ -1,6 +1,7 @@ const DEVICE_TYPE = "codex_micro"; const TARGET_LAYER_NAME = "Claude"; +/** Supported shortcut modifier names mapped to Work Louder keycodes. */ const MODIFIER_KEYCODES = { Command: "KC_LGUI", Shift: "KC_LSFT", @@ -8,9 +9,10 @@ const MODIFIER_KEYCODES = { Control: "KC_LCTL", }; -// Final keys allowed in a shortcut. Return/Enter, Delete and Backspace are -// deliberately absent: an accidental press must never send, approve or destroy -// anything. +/** + * Allowlisted final shortcut keys mapped to Work Louder keycodes. Send, + * deletion, and approval keys are deliberately absent from this public API. + */ const FINAL_KEYCODES = { ...Object.fromEntries( Array.from({ length: 26 }, (_, index) => { @@ -50,10 +52,10 @@ const MODIFIER_KEY_BY_KEYCODE = Object.fromEntries( Object.entries(MODIFIER_KEYCODES).map(([key, keycode]) => [keycode, key]), ); +/** Final keys rejected even if a caller attempts to bypass the GUI catalogue. */ const FORBIDDEN_KEYS = Object.freeze(["Enter", "Return", "Delete", "Backspace"]); -// A printable key on its own would type text into the conversation: it is only -// accepted together with a modifier. +/** Printable final keys that require at least one modifier for safe assignment. */ const PRINTABLE_KEYS = new Set([ ...Array.from({ length: 26 }, (_, index) => String.fromCharCode(65 + index)), ...Array.from({ length: 10 }, (_, digit) => String(digit)), @@ -65,9 +67,10 @@ const PRINTABLE_KEYS = new Set([ "Minus", ]); -// The twelve programmable keycaps are spread over four rows. The first cell of -// the last row is the layer-change sensor: it is deliberately absent from this -// table and stays untouched. +/** + * Public control ids mapped to physical profile cells. The layer-change sensor + * is intentionally absent so transformations cannot overwrite it. + */ const KEY_CONTROL_LOCATIONS = Object.freeze({ "key-9": { row: 0, column: 0 }, "key-10": { row: 0, column: 1 }, @@ -82,11 +85,13 @@ const KEY_CONTROL_LOCATIONS = Object.freeze({ "key-11": { row: 3, column: 1 }, "key-12": { row: 3, column: 2 }, }); +/** Physical key controls in deterministic transformation and reporting order. */ const KEY_CONTROL_ORDER = Object.keys(KEY_CONTROL_LOCATIONS); -// The top-left rotary encoder exposes counterclockwise, clockwise, and press -// cells. Its press is the thirteenth configurable physical switch. + +/** Control id for the press cell of the top-left rotary encoder. */ const ENCODER_PRESS_CONTROL = "key-13"; +/** Safe Claude mapping applied when callers do not provide an override. */ const DEFAULT_MAPPING = { joystick: "navigation", // The wheel is in Effort mode by default: that is this board's distinctive @@ -109,6 +114,7 @@ const DEFAULT_MAPPING = { "key-13": "none", }; +/** Canonical catalogue actions understood by the profile transformer. */ const ACTION_DEFINITIONS = { newSession: { name: "Claude New", @@ -269,6 +275,10 @@ const EFFORT_PICKER_DELAY_MS = 80; // at the end of the macro and would change nothing on screen. const EFFORT_FEEDBACK_DELAY_MS = 10; +/** + * Supported wheel modes and their physical clockwise/counterclockwise outputs. + * Experimental modes are marked in their value rather than silently enabled. + */ const WHEEL_MODES = { scroll: { counterClockwise: "KC_PGUP", clockwise: "KC_PGDN" }, effort: { @@ -545,19 +555,10 @@ function addActionsToGroup(profile, actionIds) { group.actionIds = [...new Set([...group.actionIds, ...actionIds])]; } -// On top of the two `navigation` and `none` presets, the joystick accepts a -// per-direction assignment: -// -// { directions: 4, sectors: ["newSession", "voice", "diff", "stop"] } -// -// Each sector takes the same value as a key — catalogue id, custom shortcut, or -// `none`. Input's serialisation does convert `KA_` references inside sectors, so -// a full macro is possible there and not only a bare keycode. -// -// 45° stay reserved for the `KI_X` close zone at the top, exactly like Input's -// default template. The remaining 315° are shared out, so 78.75° at four -// directions and 39.4° at eight. Beyond eight, aiming with a thumb gets -// unreliable: the bound is ergonomic, the format imposes none. +/** + * Ergonomically supported custom joystick sector counts. Each sector accepts + * the same catalogue/custom value as a key; 45° remain reserved for `KI_X`. + */ const JOYSTICK_DIRECTION_COUNTS = Object.freeze([4, 8]); const JOYSTICK_CLOSE_ANGLE = 45 / 360; const JOYSTICK_START_ANGLE = (90 - 45 / 2) / 360; @@ -579,6 +580,15 @@ function validateCustomJoystick(joystick) { ); } +/** + * Computes the normalized radial geometry shared by profile serialization and + * GUI rendering, including the fixed 45° `KI_X` close zone. + * + * @param {number} directionCount Positive number of assignable sectors. + * @returns {{close: {a1: number, a2: number}, sectors: Array<{index: number, a1: number, a2: number}>}} + * Normalized turn fractions in clockwise order. + * @throws {Error} With `JOYSTICK_SECTOR_COUNT` for a non-positive count. + */ function radialSectorGeometry(directionCount) { assert( Number.isInteger(directionCount) && directionCount > 0, @@ -610,6 +620,17 @@ function radialSectors(keycodes) { ]; } +/** + * Validates and inventories an official Codex Micro `*-profile.json` export. + * It requires exactly one non-native Claude layer and never mutates the source. + * + * @param {object} source Parsed Work Louder Input profile export. + * @param {{requireAppSense?: boolean}} [options] Whether the Claude layer must + * already reference a local AppSense entry. + * @returns {{device: string, language: string, profileName: string, layerName: string, layerIndex: number, appSenseLinked: boolean, configurableSwitches: number, inputSchema: string}} + * Stable inventory consumed by the CLI and GUI. + * @throws {Error} With a stable `code` when device, layer, layout, or AppSense invariants fail. + */ export function inspectInputProfile(source, { requireAppSense = true } = {}) { assert(source && typeof source === "object", "Le fichier JSON est vide.", "EMPTY_FILE"); assert( @@ -713,10 +734,16 @@ function hasClaudeLayout(layer) { ); } -// Creates the "Claude" layer from an export that has none, by cloning the -// structure of an existing layer. The AppSense link (linkedAppId) references -// Input's local registry and cannot be invented here: the created layer has to -// be linked through "Auto detect" after import. +/** + * Synthesizes a neutral Claude layer by cloning a compatible layer structure. + * Assignable controls are cleared, the native sensor is preserved, and no + * device-local AppSense reference is invented. + * + * @param {object} source Official profile export that has no Claude layer. + * @returns {{source: object, templateName: string, layerIndex: number}} A cloned + * profile plus the chosen template and new layer index. + * @throws {Error} With a stable `code` when creation would be ambiguous or unsafe. + */ export function addClaudeLayer(source) { assert(source && typeof source === "object", "Le fichier JSON est vide.", "EMPTY_FILE"); assert( @@ -788,6 +815,15 @@ export function addClaudeLayer(source) { }; } +/** + * Decodes the existing Claude layer back into the configurator's canonical + * mapping, including custom actions, wheel mode, and joystick sectors. + * + * @param {object} source Official profile export containing one Claude layer. + * @returns {{mapping: object, assigned: number}} Derived mapping and count of + * non-`none` assignments. + * @throws {Error} When the source profile violates inspection invariants. + */ export function deriveMappingFromProfile(source) { const inspection = inspectInputProfile(source, { requireAppSense: false }); const layer = source.profile.layers[inspection.layerIndex]; @@ -886,6 +922,26 @@ function validateAppSenseId(value, label) { ); } +/** + * Produces a new Claude profile while protecting the native layer, unrelated + * layers, source object, and local AppSense boundaries. + * + * Forced AppSense ids are references only: the corresponding entries must + * already exist in the device-local `linkedApps` registry. When a base-layer id + * is supplied, only that layer's `linkedAppId` may change; its keymap remains + * byte-for-byte equivalent. + * + * @param {object} source Official Work Louder Input profile export. + * @param {object} [requestedMapping] Partial configurator mapping overlaid on + * {@link DEFAULT_MAPPING}. + * @param {object} [options] AppSense and validation controls. + * @param {boolean} [options.requireAppSense=true] Require an inherited Claude link. + * @param {number} [options.appSenseId] Existing local entry for the Claude layer. + * @param {number} [options.baseLayerAppSenseId] Existing local entry used to + * return automatically to the protected native layer. + * @returns {{profile: object, report: object}} New profile and preservation report. + * @throws {Error} With a stable `code` when assignments or preservation rules fail. + */ export function buildInputProfile( source, requestedMapping = DEFAULT_MAPPING, diff --git a/shared/thread-status-palette.mjs b/shared/thread-status-palette.mjs index fdc45bd..23a02f4 100644 --- a/shared/thread-status-palette.mjs +++ b/shared/thread-status-palette.mjs @@ -7,6 +7,7 @@ // importers keep working, and the GUI imports it to draw its legend — so the two // cannot diverge. +/** Canonical session states shared by the reducer, lighting bridge, and GUI. */ export const STATES = Object.freeze({ free: "free", idle: "idle", @@ -16,9 +17,10 @@ export const STATES = Object.freeze({ ended: "ended", }); -// Hues taken from the repository palette. `blocked` is the only one added: no -// existing colour meant "a decision is waiting". -// `free` is `null`: a free slot is unlit, not coloured. +/** + * Repository palette by session state. `free` is null because an unused slot + * is unlit; `blocked` is reserved exclusively for a decision awaiting a person. + */ export const STATE_COLORS = Object.freeze({ free: null, idle: "#6D5A7D", @@ -28,8 +30,7 @@ export const STATE_COLORS = Object.freeze({ ended: "#2F2927", }); -// Reading order for a legend: from the most urgent to the most inert. This is -// not the order of `STATES`, which follows a session's life cycle. +/** Legend order from the most urgent state to the most inert. */ export const LEGEND_ORDER = Object.freeze([ STATES.blocked, STATES.running, diff --git a/tests/api-docs.test.mjs b/tests/api-docs.test.mjs new file mode 100644 index 0000000..a88e2bf --- /dev/null +++ b/tests/api-docs.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const RUNTIME_BOUNDARY_MODULES = [ + "scripts/lib/hid-frame.mjs", + "scripts/lib/hid-device.mjs", + "scripts/lib/hid-lighting.mjs", + "scripts/lib/thread-slots.mjs", + "shared/input-profile.mjs", + "shared/thread-status-palette.mjs", +]; + +// Importing the prototype workflow would load the whole browser application and +// distort the root coverage report. Keep its public surface explicit and inspect +// the source instead; the equality check below catches newly exported functions. +const SOURCE_ONLY_BOUNDARY_EXPORTS = new Map([ + [ + "prototype/src/profile-workflow.js", + [ + "createProfileReview", + "describeProfileError", + "prepareImportedProfile", + "sha256Hex", + ], + ], +]); + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function callableDeclaration(source, name) { + const escaped = escapeRegExp(name); + const match = source.match( + new RegExp( + `(\/\\*\\*[\\s\\S]*?\\*\/)[\\t ]*(?:\\r?\\n[\\t ]*)*` + + `(?:export\\s+)?(?:async\\s+)?(function|class)\\s+${escaped}\\b`, + ), + ); + if (!match) return null; + return { jsdoc: match[1], kind: match[2] }; +} + +function functionParameterSource(source, name) { + const escaped = escapeRegExp(name); + return source.match( + new RegExp(`(?:export\\s+)?(?:async\\s+)?function\\s+${escaped}\\s*\\(([^)]*)\\)`), + )?.[1] ?? null; +} + +function exportedFunctionNames(source) { + return [...source.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)] + .map((match) => match[1]) + .sort(); +} + +function constantDeclaration(source, name) { + const escaped = escapeRegExp(name); + return source.match( + new RegExp( + `(\/\\*\\*[\\s\\S]*?\\*\/)[\\t ]*(?:\\r?\\n[\\t ]*)*` + + `(?:export\\s+)?const\\s+${escaped}\\b`, + ), + )?.[1] ?? null; +} + +function isReexported(source, name) { + return [...source.matchAll(/export\s*\{([^}]+)\}\s*from/g)].some((match) => + match[1] + .split(",") + .map((specifier) => specifier.trim().split(/\s+as\s+/).at(-1)) + .includes(name), + ); +} + +function assertDocumentedMember(source, file, className, member, endMarker = null) { + const classStart = source.indexOf(`export class ${className}`); + assert.notEqual(classStart, -1, `${file}: missing class ${className}`); + const classEnd = endMarker ? source.indexOf(endMarker, classStart) : source.length; + const classSource = source.slice(classStart, classEnd === -1 ? source.length : classEnd); + const escaped = escapeRegExp(member); + assert.match( + classSource, + new RegExp( + `\/\\*\\*[\\s\\S]*?\\*\/[\\t ]*(?:\\r?\\n[\\t ]*)*` + + `(?:static\\s+)?(?:async\\s+)?${escaped}\\s*\\(`, + ), + `${file}: ${className}.${member} must have an adjacent JSDoc contract`, + ); +} + +function assertCallableContract(source, file, name) { + const declaration = callableDeclaration(source, name); + assert.ok(declaration, `${file}: ${name} must have an adjacent JSDoc contract`); + if (declaration.kind === "class") return; + + assert.match( + declaration.jsdoc, + /@returns?\s+\{/, + `${file}: ${name} must document its return contract`, + ); + const parameters = functionParameterSource(source, name); + if (parameters?.trim()) { + assert.match( + declaration.jsdoc, + /@param\s+\{/, + `${file}: ${name} must document its parameters`, + ); + } +} + +test("complex boundary exports keep adjacent formal JSDoc contracts", async (context) => { + for (const relativePath of RUNTIME_BOUNDARY_MODULES) { + await context.test(relativePath, async () => { + const absolutePath = path.join(REPOSITORY_ROOT, relativePath); + const [source, exports] = await Promise.all([ + readFile(absolutePath, "utf8"), + import(pathToFileURL(absolutePath).href), + ]); + + const exportedEntries = Object.entries(exports); + const callableNames = exportedEntries + .filter(([, value]) => typeof value === "function") + .map(([name]) => name) + .sort(); + + for (const name of callableNames) assertCallableContract(source, relativePath, name); + + const constantNames = exportedEntries + .filter(([, value]) => typeof value !== "function") + .map(([name]) => name) + .sort(); + for (const name of constantNames) { + if (isReexported(source, name)) continue; + assert.ok( + constantDeclaration(source, name), + `${relativePath}: ${name} must have an adjacent JSDoc contract`, + ); + } + }); + } +}); + +test("prototype boundary exports keep adjacent formal JSDoc contracts", async (context) => { + for (const [relativePath, expectedNames] of SOURCE_ONLY_BOUNDARY_EXPORTS) { + await context.test(relativePath, async () => { + const source = await readFile(path.join(REPOSITORY_ROOT, relativePath), "utf8"); + assert.deepEqual( + exportedFunctionNames(source), + [...expectedNames].sort(), + `${relativePath}: update the documented public API list when exports change`, + ); + for (const name of expectedNames) assertCallableContract(source, relativePath, name); + }); + } +}); + +test("public HID session methods keep adjacent formal JSDoc contracts", async () => { + const file = "scripts/lib/hid-device.mjs"; + const source = await readFile(path.join(REPOSITORY_ROOT, file), "utf8"); + + assertDocumentedMember(source, file, "DeviceError", "constructor", "export async function loadHid"); + for (const member of ["constructor", "open", "onNotification", "call", "close"]) { + assertDocumentedMember(source, file, "DeviceSession", member); + } +});