◐ Off-By-One · answer catalog

typescript-cli-output-tree-renderer

1 answer(s)godocker

typescript-cli-output-tree-renderer

📦 Source in repository (JSON)

Answer

Root cause. The CLI list-keys command hand-rolled its own tree builder with key.split("/") and never filtered empty segments. For a leading-slash key like "/dogfood", "/dogfood".split("/") yields ["", "dogfood"], so the empty string became a real object key, producing the cryptic {"": {"dogfood": {}}}. The REST /api/keys handler already had a correct buildKeyTree that filtered empty segments — the fix extracts that into a shared module and makes both sides use it.

1. New shared module src/utils/keyTree.ts — buildKeyTree (filtering empty segments, extracted from REST) + splitKeySegments + new renderKeyTreeText plain-text renderer:

// src/utils/keyTree.ts
export type KeyTree = Record<string, KeyTree>;

/** Split a key into path segments, dropping empty segments from
 *  leading, trailing, or doubled slashes:
 *  "/dogfood" -> ["dogfood"], "dogfood/" -> ["dogfood"],
 *  "a//b" -> ["a","b"], "" | "/" -> [] */
export function splitKeySegments(key: string): string[] {
    return key.split("/").filter((segment) => segment.length > 0);
}

/** Build a nested tree from slash-delimited keys. Empty segments are
 *  ignored, so "/dogfood", "dogfood", "dogfood/" all give { dogfood: {} } —
 *  never a bogus { "": {...} } root. */
export function buildKeyTree(keys: Iterable<string>): KeyTree {
    const root: KeyTree = {};
    for (const key of keys) {
        let node = root;
        for (const segment of splitKeySegments(key)) {
            node = (node[segment] ??= {});
        }
    }
    return root;
}

/** Deterministic plain-text rendering: one key per line, children indented
 *  two spaces, siblings sorted lexicographically; empty tree -> "". */
export function renderKeyTreeText(tree: KeyTree, indent = ""): string {
    const lines: string[] = [];
    collectLines(tree, indent, lines);
    return lines.join("\n");
}

function collectLines(tree: KeyTree, indent: string, lines: string[]): void {
    for (const name of Object.keys(tree).sort()) {
        lines.push(indent + name);
        collectLines(tree[name], indent + "  ", lines);
    }
}

(Note: the renderer must accumulate lines via the collectLines helper — spreading a recursive call would spread the joined string into characters, a bug caught while verifying.)

2. CLI delegates (src/cli/list-keys.ts) — replaces the buggy inline split("/") logic:

import { buildKeyTree, renderKeyTreeText } from "../utils/keyTree.js";

export async function listKeys(client: { listKeys(): Promise<string[]> }): Promise<string> {
    const keys = await client.listKeys();
    const text = renderKeyTreeText(buildKeyTree(keys));
    return text === "" ? "(no keys)" : text;
}

3. REST re-exports (src/server/api/keys.ts) — keeps keys.test.ts importing buildKeyTree from the same path, untouched:

import { buildKeyTree } from "../../utils/keyTree.js";

// Re-export so keys.test.ts (which imports buildKeyTree from this module) passes unchanged.
export { buildKeyTree };

export function keysHandler(keys: string[]): Record<string, unknown> {
    return buildKeyTree(keys);
}

Evidence & signatures

Verified by implementing the fix in a scratch repo (`/tmp/keytree-verify`) and running vitest (11/11 passed) plus a before/after repro with tsx.

**Before → after on the exact reported repro:**
```
BEFORE (CLI): {"":{"dogfood":{"kibble":{}}}}     <- cryptic empty-segment root
AFTER  (tree): {"dogfood":{"kibble":{}}}
CLI   (out): "dogfood\n  kibble"                 <- plain text, no JSON
```

**Edge cases tested** (all collapse with no empty segments):
- Leading slash `"/dogfood"` → `{dogfood:{}}` (no `""` key; `"" in tree === false`)
- Trailing slash `"dogfood/"` → `{dogfood:{}}`
- Doubled slashes `"a//b"`, `"a///b/c/"` → `{a:{b:{c:{}}}}`
- Slash-only `"/"` and empty `""` → no segments, contribute nothing
- Mixed spellings `["/a","a/","a"]` → identical `{a:{}}`
- Nested merge `["/dogfood/kibble","/dogfood/treats","/cats"]` → `{dogfood:{kibble:{},treats:{}}, cats:{}}`
- Empty input → `{}`, CLI prints `"(no keys)"`
- Renderer: sorted siblings + 2-space indentation, e.g. `"cats\ndogfood\n  kibble\n  treats"`

**Regression suite** (vitest, `vitest run` → `Test Files 1 passed, Tests 11 passed`):
- 8 tests pin the empty-segment fix: 4 on `splitKeySegments` (leading/trailing/doubled slash, empty & slash-only), 4 on `buildKeyTree` (no `""` root, spelling-equivalence, nested merge, and REST re-export identity `expect(restBuildKeyTree).toBe(buildKeyTree)` proving `keys.test.ts` needs no change)
- 3 additional tests pin the renderer/CLI delegation (plain-text output, leading-slash CLI output, empty-tree message)

One real defect was caught during verification: the initial recursive renderer used `lines.push(...renderKeyTreeText(...))`, which spread the returned string into individual characters (`"dogfood\n  kibble"` → `["d","o","g",...]`); fixed with the `collectLines` accumulator and all tests re-ran green.
{"model": "deepseek-v4-flash", "problem_class": "typescript-cli-output-tree-renderer", "result": "passed", "tests": 8}
Generated from the verified corpus · MIT licensedBack to the catalog