From 2873d12f55396d63bd0f084cc1a5c37ed9eeb70d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 21:24:27 +0900 Subject: [PATCH 1/9] Add --reverse output ordering to lookup Implement the new --reverse option for fedify lookup and apply it across default multi-input lookup, recurse chains, and traversal output while preserving existing error and partial-output behavior. Traversal now keeps streaming semantics when --reverse is disabled, and reverse mode preserves already-fetched items even if traversal fails later. Recurse mode also preserves root-object emission when a later recursive fetch fails. Update CLI docs and lookup config documentation, add changelog entries, and expand lookup tests for option parsing, ordering, and partial-collection behavior. Fixes https://github.com/fedify-dev/fedify/issues/607 Co-Authored-By: OpenAI Codex --- CHANGES.md | 7 + docs/cli.md | 17 +++ packages/cli/src/config.ts | 1 + packages/cli/src/lookup.test.ts | 82 ++++++++++++ packages/cli/src/lookup.ts | 220 ++++++++++++++++++++++---------- 5 files changed, 261 insertions(+), 66 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6f8e0d573..a72ca2356 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -21,6 +21,11 @@ To be released. ### @fedify/cli + - Added `--reverse` option to `fedify lookup` to reverse presentation order + of emitted results. It now works across default multi-input lookup, + `--traverse` collection traversal output, and `--recurse` object chains, + while preserving existing fetch/error semantics. [[#607], [#609]] + - Fixed `fedify lookup` printing separators with extra quotes between adjacent objects/items in some output paths (e.g., recurse/traverse flows). Separators are now printed as plain text consistently. @@ -42,7 +47,9 @@ To be released. [[#608]] [#606]: https://github.com/fedify-dev/fedify/issues/606 +[#607]: https://github.com/fedify-dev/fedify/issues/607 [#608]: https://github.com/fedify-dev/fedify/pull/608 +[#609]: https://github.com/fedify-dev/fedify/pull/609 ### @fedify/vocab diff --git a/docs/cli.md b/docs/cli.md index 68c775134..363b0bfbc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -176,6 +176,7 @@ firstKnock = "draft-cavage-http-signatures-12" # or "rfc9421" allowPrivateAddress = false traverse = false suppressErrors = false +reverse = false defaultFormat = "default" # "default", "raw", "compact", or "expand" separator = "----" timeout = 30 # seconds @@ -1021,6 +1022,22 @@ It does not affect the output when looking up a single object. > The separator is also used when looking up a collection object with the > [`-t`/`--traverse`](#t-traverse-traverse-the-collection) option. +### `--reverse`: Reverse output order + +*This option is available since Fedify 2.1.0.* + +The `--reverse` option reverses presentation order of fetched results. +It affects output order only, and does not change lookup semantics. + +~~~~ sh +fedify lookup @fedify@hollo.social @hongminhee@fosstodon.org --reverse +fedify lookup --traverse https://fosstodon.org/users/hongminhee/outbox --reverse +fedify lookup --recurse=replyTarget https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf --reverse +~~~~ + +When using `--reverse`, `fedify lookup` buffers results before printing. +This may increase memory usage for large traversals or long recursion chains. + ### `-o`/`--output`: Output file path *This option is available since Fedify 1.8.0.* diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 450197c59..8411b6def 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -50,6 +50,7 @@ const lookupSchema = pipe( ), recurseDepth: optional(pipe(number(), integer(), minValue(1))), suppressErrors: optional(boolean()), + reverse: optional(boolean()), defaultFormat: optional(picklist(["default", "raw", "compact", "expand"])), separator: optional(string()), timeout: optional(number()), diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 06ecca7c1..e59bd3bc5 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -15,6 +15,7 @@ import { getContextLoader } from "./docloader.ts"; import { authorizedFetchOption, clearTimeoutSignal, + collectAsyncItems, collectRecursiveObjects, createTimeoutSignal, getLookupFailureHint, @@ -24,6 +25,7 @@ import { shouldPrintLookupFailureHint, shouldSuggestSuppressErrorsForLookupFailure, TimeoutError, + toPresentationOrder, writeObjectToStream, writeSeparator, } from "./lookup.ts"; @@ -445,6 +447,28 @@ test("lookupCommand - reads allowPrivateAddress from config", async () => { assert.strictEqual(result.allowPrivateAddress, true); }); +test("lookupCommand - parses --reverse", () => { + setActiveConfig(configContext.id, {}); + const result = parse(lookupCommand, [ + "lookup", + "--reverse", + "https://example.com/notes/1", + ]); + clearActiveConfig(configContext.id); + assert.ok(result.success); + if (result.success) { + assert.strictEqual(result.value.reverse, true); + } +}); + +test("lookupCommand - reads reverse from config", async () => { + const result = await runWithConfig(lookupCommand, configContext, { + load: () => ({ lookup: { reverse: true } }), + args: ["lookup", "https://example.com/notes/1"], + }); + assert.strictEqual(result.reverse, true); +}); + test("lookupCommand - parses recurse option", () => { setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ @@ -821,3 +845,61 @@ test( assert.equal(visited.has("https://example.com/notes/1"), false); }, ); + +test( + "toPresentationOrder - keeps order when reverse is false (default mode)", + () => { + assert.deepEqual( + toPresentationOrder( + ["https://example.com/1", "https://example.com/2"], + false, + ), + ["https://example.com/1", "https://example.com/2"], + ); + }, +); + +test("toPresentationOrder - reverses order when reverse is true (default mode)", () => { + assert.deepEqual( + toPresentationOrder( + ["https://example.com/1", "https://example.com/2"], + true, + ), + ["https://example.com/2", "https://example.com/1"], + ); +}); + +test("toPresentationOrder - reverses recursive chain order when reverse is true", () => { + assert.deepEqual( + toPresentationOrder(["self", "parent", "root"], true), + ["root", "parent", "self"], + ); +}); + +test("toPresentationOrder - reverses traversed item order when reverse is true", () => { + assert.deepEqual( + toPresentationOrder(["item-1", "item-2", "item-3"], true), + ["item-3", "item-2", "item-1"], + ); +}); + +test("collectAsyncItems - collects items without error", async () => { + async function* source() { + yield 1; + yield 2; + } + const result = await collectAsyncItems(source()); + assert.deepEqual(result.items, [1, 2]); + assert.equal(result.error, undefined); +}); + +test("collectAsyncItems - keeps partial items when iteration fails", async () => { + async function* source() { + yield "first"; + throw new Error("boom"); + } + const result = await collectAsyncItems(source()); + assert.deepEqual(result.items, ["first"]); + assert.ok(result.error instanceof Error); + assert.equal((result.error as Error).message, "boom"); +}); diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 78fadb878..5a23059ad 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -234,6 +234,17 @@ export const lookupCommand = command( ), }), object("Output options", { + reverse: bindConfig( + flag("--reverse", { + description: + message`Reverse the output order of fetched objects or items.`, + }), + { + context: configContext, + key: (config) => config.lookup?.reverse ?? false, + default: false, + }, + ), format: bindConfig( optional( or( @@ -438,6 +449,27 @@ export async function writeSeparator( await writeToStream(stream ?? process.stdout, `${separator}\n`); } +export function toPresentationOrder( + items: readonly T[], + reverse: boolean, +): T[] { + return reverse ? [...items].reverse() : [...items]; +} + +export async function collectAsyncItems( + iterable: AsyncIterable, +): Promise<{ items: T[]; error?: unknown }> { + const items: T[] = []; + try { + for await (const item of iterable) { + items.push(item); + } + return { items }; + } catch (error) { + return { items, error }; + } +} + const signalTimers = new WeakMap(); export function createTimeoutSignal( @@ -879,24 +911,6 @@ export async function runLookup( return; } - try { - if (totalObjects > 0) { - await writeSeparator(command.separator, getOutputStream()); - } - await writeObjectToStream( - current, - command.output, - command.format, - contextLoader, - getOutputStream(), - ); - } catch (error) { - logger.error("Failed to write lookup output: {error}", { error }); - spinner.fail("Failed to write output."); - await finalizeAndExit(1); - return; - } - totalObjects++; visited.add(url); if (current.id != null) { visited.add(current.id.href); @@ -917,6 +931,26 @@ export async function runLookup( { suppressErrors: command.suppressErrors, visited }, ); } catch (error) { + try { + if (totalObjects > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + current, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + totalObjects++; + } catch (writeError) { + logger.error("Failed to write lookup output: {error}", { + error: writeError, + }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; + } logger.error( "Failed to recursively fetch an object in chain: {error}", { @@ -949,14 +983,27 @@ export async function runLookup( return; } - for (const next of chain) { + const chainEntries = toPresentationOrder( + [ + { object: current, objectContextLoader: contextLoader }, + ...chain.map((next) => ({ + object: next, + objectContextLoader: recursiveContextLoader, + })), + ], + command.reverse, + ); + for (let chainIndex = 0; chainIndex < chainEntries.length; chainIndex++) { + const entry = chainEntries[chainIndex]; try { - await writeSeparator(command.separator, getOutputStream()); + if (totalObjects > 0 || chainIndex > 0) { + await writeSeparator(command.separator, getOutputStream()); + } await writeObjectToStream( - next, + entry.object, command.output, command.format, - recursiveContextLoader, + entry.objectContextLoader, getOutputStream(), ); totalObjects++; @@ -1024,36 +1071,73 @@ export async function runLookup( spinner.succeed(`Fetched collection: ${colors.green(url)}.`); try { - let collectionItems = 0; - for await ( - const item of traverseCollection(collection, { - documentLoader: authLoader ?? documentLoader, - contextLoader, - suppressError: command.suppressErrors, - }) - ) { - try { - if (totalItems > 0 || collectionItems > 0) { - await writeSeparator(command.separator, getOutputStream()); + if (command.reverse) { + const { + items: traversedItems, + error: traversalError, + } = await collectAsyncItems( + traverseCollection(collection, { + documentLoader: authLoader ?? documentLoader, + contextLoader, + suppressError: command.suppressErrors, + }), + ); + for (const item of toPresentationOrder(traversedItems, true)) { + try { + if (totalItems > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + item, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + } catch (error) { + logger.error("Failed to write output for {url}: {error}", { + url, + error, + }); + spinner.fail(`Failed to write output for: ${colors.red(url)}.`); + await finalizeAndExit(1); + return; } - await writeObjectToStream( - item, - command.output, - command.format, + totalItems++; + } + if (traversalError != null) { + throw traversalError; + } + } else { + for await ( + const item of traverseCollection(collection, { + documentLoader: authLoader ?? documentLoader, contextLoader, - getOutputStream(), - ); - } catch (error) { - logger.error("Failed to write output for {url}: {error}", { - url, - error, - }); - spinner.fail(`Failed to write output for: ${colors.red(url)}.`); - await finalizeAndExit(1); - return; + suppressError: command.suppressErrors, + }) + ) { + try { + if (totalItems > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + item, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + } catch (error) { + logger.error("Failed to write output for {url}: {error}", { + url, + error, + }); + spinner.fail(`Failed to write output for: ${colors.red(url)}.`); + await finalizeAndExit(1); + return; + } + totalItems++; } - collectionItems++; - totalItems++; } } catch (error) { logger.error("Failed to complete the traversal for {url}: {error}", { @@ -1113,6 +1197,7 @@ export async function runLookup( spinner.stop(); let success = true; let printedCount = 0; + const successfulObjects: APObject[] = []; for (const [i, obj] of objects.entries()) { const url = command.urls[i]; if (obj == null) { @@ -1125,25 +1210,28 @@ export async function runLookup( success = false; } else { spinner.succeed(`Fetched object: ${colors.green(url)}`); - try { - if (printedCount > 0) { - await writeSeparator(command.separator, getOutputStream()); - } - await writeObjectToStream( - obj, - command.output, - command.format, - contextLoader, - getOutputStream(), - ); - } catch (error) { - logger.error("Failed to write lookup output: {error}", { error }); - spinner.fail("Failed to write output."); - await finalizeAndExit(1); - return; + successfulObjects.push(obj); + } + } + for (const obj of toPresentationOrder(successfulObjects, command.reverse)) { + try { + if (printedCount > 0) { + await writeSeparator(command.separator, getOutputStream()); } - printedCount++; + await writeObjectToStream( + obj, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + } catch (error) { + logger.error("Failed to write lookup output: {error}", { error }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; } + printedCount++; } if (success) { spinner.succeed( From 13562952c1565c113cb9feba9264ef6d4a3d19ab Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 21:42:24 +0900 Subject: [PATCH 2/9] Test runLookup reverse ordering end-to-end Add runLookup-level tests that verify emitted output order for multi-input default lookup, recurse mode, and traverse mode when --reverse is enabled. To keep these tests deterministic and network-free, runLookup now accepts injectable lookup/traverse dependencies with defaults bound to the existing implementations. Production behavior is unchanged. The new tests execute the real runLookup control flow, capture output, and assert final ordering in each mode. https://github.com/fedify-dev/fedify/pull/609#discussion_r2901771389 Co-Authored-By: OpenAI Codex --- packages/cli/src/lookup.test.ts | 224 +++++++++++++++++++++++++++++++- packages/cli/src/lookup.ts | 19 ++- 2 files changed, 236 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index e59bd3bc5..488fe2ed4 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1,4 +1,4 @@ -import { Activity, Note } from "@fedify/vocab"; +import { Activity, Collection, Note } from "@fedify/vocab"; import { clearActiveConfig, setActiveConfig } from "@optique/config"; import { runWithConfig } from "@optique/config/run"; import { parse } from "@optique/core/parser"; @@ -22,6 +22,7 @@ import { getRecursiveTargetId, lookupCommand, RecursiveLookupError, + runLookup, shouldPrintLookupFailureHint, shouldSuggestSuppressErrorsForLookupFailure, TimeoutError, @@ -903,3 +904,224 @@ test("collectAsyncItems - keeps partial items when iteration fails", async () => assert.ok(result.error instanceof Error); assert.equal((result.error as Error).message, "boom"); }); + +class ExitSignal extends Error { + code: number; + constructor(code: number) { + super(`Exited with code ${code}`); + this.code = code; + } +} + +function createLookupRunCommand( + overrides: Partial[0]>, +): Parameters[0] { + return { + command: "lookup", + urls: [], + traverse: false, + recurse: undefined, + recurseDepth: undefined, + suppressErrors: false, + authorizedFetch: false, + firstKnock: undefined, + tunnelService: undefined, + userAgent: "FedifyTest/1.0", + allowPrivateAddress: true, + timeout: undefined, + reverse: false, + format: "raw", + separator: "----", + output: undefined, + debug: false, + ignoreConfig: false, + ...overrides, + } as Parameters[0]; +} + +async function runLookupAndCaptureExitCode( + command: Parameters[0], + deps?: Parameters[1], +): Promise { + const originalExit = process.exit; + process.exit = ((code?: number) => { + throw new ExitSignal(code ?? 0); + }) as typeof process.exit; + try { + await runLookup(command, deps); + return null; + } catch (error) { + if (error instanceof ExitSignal) return error.code; + throw error; + } finally { + process.exit = originalExit; + } +} + +function extractIdsFromRawOutput(content: string): string[] { + return [...content.matchAll(/"id"\s*:\s*"([^"]+)"/g)].map((match) => + match[1] + ); +} + +test("runLookup - reverses output order in default multi-input mode", async () => { + const testDir = "./test_output_runlookup_default_reverse"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const objects = new Map([ + [ + "u1", + new Note({ + id: new URL("https://example.com/notes/1"), + content: "one", + }), + ], + [ + "u2", + new Note({ + id: new URL("https://example.com/notes/2"), + content: "two", + }), + ], + [ + "u3", + new Note({ + id: new URL("https://example.com/notes/3"), + content: "three", + }), + ], + ]); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["u1", "u2", "u3"], + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => + Promise.resolve( + objects.get(typeof url === "string" ? url : url.href) ?? null, + ), + traverseCollection: () => { + throw new Error("not used"); + }, + }, + ); + assert.equal(exitCode, null); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/notes/3", + "https://example.com/notes/2", + "https://example.com/notes/1", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test("runLookup - reverses output order in recurse mode", async () => { + const testDir = "./test_output_runlookup_recurse_reverse"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const u1 = "https://lookup.test/u1"; + const u2 = "https://lookup.test/u2"; + const u3 = "https://lookup.test/u3"; + const objects = new Map([ + [ + u1, + new Note({ + id: new URL("https://example.com/notes/1"), + content: "one", + }), + ], + [ + u2, + new Note({ + id: new URL("https://example.com/notes/2"), + replyTarget: new URL(u1), + content: "two", + }), + ], + [ + u3, + new Note({ + id: new URL("https://example.com/notes/3"), + replyTarget: new URL(u2), + content: "three", + }), + ], + ]); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [u3], + recurse: "replyTarget", + recurseDepth: 20, + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => + Promise.resolve( + objects.get(typeof url === "string" ? url : url.href) ?? null, + ), + traverseCollection: () => { + throw new Error("not used"); + }, + }, + ); + assert.equal(exitCode, 0); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/notes/1", + "https://example.com/notes/2", + "https://example.com/notes/3", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test("runLookup - reverses output order in traverse mode", async () => { + const testDir = "./test_output_runlookup_traverse_reverse"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const collection = new Collection({ + id: new URL("https://example.com/collection"), + }); + const items = [ + new Note({ id: new URL("https://example.com/items/1"), content: "one" }), + new Note({ id: new URL("https://example.com/items/2"), content: "two" }), + new Note({ + id: new URL("https://example.com/items/3"), + content: "three", + }), + ]; + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["collection-url"], + traverse: true, + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => + Promise.resolve(url === "collection-url" ? collection : null), + async *traverseCollection() { + for (const item of items) yield item; + }, + }, + ); + assert.equal(exitCode, 0); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/items/3", + "https://example.com/items/2", + "https://example.com/items/1", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 5a23059ad..9017109a5 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -674,6 +674,13 @@ export async function collectRecursiveObjects( export async function runLookup( command: InferValue & GlobalOptions, + deps: { + lookupObject: typeof lookupObject; + traverseCollection: typeof traverseCollection; + } = { + lookupObject, + traverseCollection, + }, ) { if (command.urls.length < 1) { printError(message`At least one URL or actor handle must be provided.`); @@ -885,7 +892,7 @@ export async function runLookup( } let current: APObject | null = null; try { - current = await lookupObject(url, { + current = await deps.lookupObject(url, { documentLoader: initialLookupDocumentLoader, contextLoader, userAgent: command.userAgent, @@ -923,7 +930,7 @@ export async function runLookup( command.recurse, recurseDepth, (target) => - lookupObject(target, { + deps.lookupObject(target, { documentLoader: recursiveLookupDocumentLoader, contextLoader: recursiveContextLoader, userAgent: command.userAgent, @@ -1035,7 +1042,7 @@ export async function runLookup( let collection: APObject | null = null; try { - collection = await lookupObject(url, { + collection = await deps.lookupObject(url, { documentLoader: authLoader ?? documentLoader, contextLoader, userAgent: command.userAgent, @@ -1076,7 +1083,7 @@ export async function runLookup( items: traversedItems, error: traversalError, } = await collectAsyncItems( - traverseCollection(collection, { + deps.traverseCollection(collection, { documentLoader: authLoader ?? documentLoader, contextLoader, suppressError: command.suppressErrors, @@ -1110,7 +1117,7 @@ export async function runLookup( } } else { for await ( - const item of traverseCollection(collection, { + const item of deps.traverseCollection(collection, { documentLoader: authLoader ?? documentLoader, contextLoader, suppressError: command.suppressErrors, @@ -1173,7 +1180,7 @@ export async function runLookup( for (const url of command.urls) { promises.push( - lookupObject(url, { + deps.lookupObject(url, { documentLoader: authLoader ?? documentLoader, contextLoader, userAgent: command.userAgent, From 62c3a394865fcf0d75aa62329ffdfe916d850680 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 21:57:43 +0900 Subject: [PATCH 3/9] Preserve recurse streaming and inject lookup exit Restore recurse-mode output timing so the root object is emitted immediately when --reverse is not used, preserving prior streaming/visibility semantics. Also make runLookup accept an injected exit dependency and update the runLookup ordering tests to use that injection instead of global process.exit monkeypatching. https://github.com/fedify-dev/fedify/pull/609#discussion_r2901791894 https://github.com/fedify-dev/fedify/pull/609#discussion_r2901791898 Co-Authored-By: OpenAI Codex --- packages/cli/src/lookup.test.ts | 13 ++-- packages/cli/src/lookup.ts | 115 +++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 488fe2ed4..1931eaf66 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -943,18 +943,17 @@ async function runLookupAndCaptureExitCode( command: Parameters[0], deps?: Parameters[1], ): Promise { - const originalExit = process.exit; - process.exit = ((code?: number) => { - throw new ExitSignal(code ?? 0); - }) as typeof process.exit; try { - await runLookup(command, deps); + await runLookup(command, { + ...deps, + exit: (code: number) => { + throw new ExitSignal(code); + }, + }); return null; } catch (error) { if (error instanceof ExitSignal) return error.code; throw error; - } finally { - process.exit = originalExit; } } diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 9017109a5..5ecb49074 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -674,17 +674,26 @@ export async function collectRecursiveObjects( export async function runLookup( command: InferValue & GlobalOptions, - deps: { + deps: Partial<{ lookupObject: typeof lookupObject; traverseCollection: typeof traverseCollection; + exit: (code: number) => never; + }> = {}, +) { + const effectiveDeps: { + lookupObject: typeof lookupObject; + traverseCollection: typeof traverseCollection; + exit: (code: number) => never; } = { lookupObject, traverseCollection, - }, -) { + exit: (code: number) => process.exit(code), + ...deps, + }; + if (command.urls.length < 1) { printError(message`At least one URL or actor handle must be provided.`); - process.exit(1); + effectiveDeps.exit(1); } // Enable Debug mode if requested @@ -763,7 +772,7 @@ export async function runLookup( }, ); } - process.exit(cleanupFailed && code === 0 ? 1 : code); + effectiveDeps.exit(cleanupFailed && code === 0 ? 1 : code); }; if (command.authorizedFetch) { @@ -892,7 +901,7 @@ export async function runLookup( } let current: APObject | null = null; try { - current = await deps.lookupObject(url, { + current = await effectiveDeps.lookupObject(url, { documentLoader: initialLookupDocumentLoader, contextLoader, userAgent: command.userAgent, @@ -923,21 +932,7 @@ export async function runLookup( visited.add(current.id.href); } - let chain: APObject[] = []; - try { - chain = await collectRecursiveObjects( - current, - command.recurse, - recurseDepth, - (target) => - deps.lookupObject(target, { - documentLoader: recursiveLookupDocumentLoader, - contextLoader: recursiveContextLoader, - userAgent: command.userAgent, - }), - { suppressErrors: command.suppressErrors, visited }, - ); - } catch (error) { + if (!command.reverse) { try { if (totalObjects > 0) { await writeSeparator(command.separator, getOutputStream()); @@ -950,14 +945,51 @@ export async function runLookup( getOutputStream(), ); totalObjects++; - } catch (writeError) { - logger.error("Failed to write lookup output: {error}", { - error: writeError, - }); + } catch (error) { + logger.error("Failed to write lookup output: {error}", { error }); spinner.fail("Failed to write output."); await finalizeAndExit(1); return; } + } + + let chain: APObject[] = []; + try { + chain = await collectRecursiveObjects( + current, + command.recurse, + recurseDepth, + (target) => + effectiveDeps.lookupObject(target, { + documentLoader: recursiveLookupDocumentLoader, + contextLoader: recursiveContextLoader, + userAgent: command.userAgent, + }), + { suppressErrors: command.suppressErrors, visited }, + ); + } catch (error) { + if (command.reverse) { + try { + if (totalObjects > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + current, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + totalObjects++; + } catch (writeError) { + logger.error("Failed to write lookup output: {error}", { + error: writeError, + }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; + } + } logger.error( "Failed to recursively fetch an object in chain: {error}", { @@ -990,16 +1022,21 @@ export async function runLookup( return; } - const chainEntries = toPresentationOrder( - [ - { object: current, objectContextLoader: contextLoader }, - ...chain.map((next) => ({ - object: next, - objectContextLoader: recursiveContextLoader, - })), - ], - command.reverse, - ); + const chainEntries = command.reverse + ? toPresentationOrder( + [ + { object: current, objectContextLoader: contextLoader }, + ...chain.map((next) => ({ + object: next, + objectContextLoader: recursiveContextLoader, + })), + ], + true, + ) + : chain.map((next) => ({ + object: next, + objectContextLoader: recursiveContextLoader, + })); for (let chainIndex = 0; chainIndex < chainEntries.length; chainIndex++) { const entry = chainEntries[chainIndex]; try { @@ -1042,7 +1079,7 @@ export async function runLookup( let collection: APObject | null = null; try { - collection = await deps.lookupObject(url, { + collection = await effectiveDeps.lookupObject(url, { documentLoader: authLoader ?? documentLoader, contextLoader, userAgent: command.userAgent, @@ -1083,7 +1120,7 @@ export async function runLookup( items: traversedItems, error: traversalError, } = await collectAsyncItems( - deps.traverseCollection(collection, { + effectiveDeps.traverseCollection(collection, { documentLoader: authLoader ?? documentLoader, contextLoader, suppressError: command.suppressErrors, @@ -1117,7 +1154,7 @@ export async function runLookup( } } else { for await ( - const item of deps.traverseCollection(collection, { + const item of effectiveDeps.traverseCollection(collection, { documentLoader: authLoader ?? documentLoader, contextLoader, suppressError: command.suppressErrors, @@ -1180,7 +1217,7 @@ export async function runLookup( for (const url of command.urls) { promises.push( - deps.lookupObject(url, { + effectiveDeps.lookupObject(url, { documentLoader: authLoader ?? documentLoader, contextLoader, userAgent: command.userAgent, From 1647ff33241852965e4674bc68bf6bece37b4986 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 22:11:18 +0900 Subject: [PATCH 4/9] Polish reverse-order helper and docs wording Avoid an unnecessary array clone in toPresentationOrder() for non-reverse paths when input is already an array, and tighten the --reverse docs wording for clarity. --- docs/cli.md | 2 +- packages/cli/src/lookup.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 363b0bfbc..8195b3000 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1026,7 +1026,7 @@ It does not affect the output when looking up a single object. *This option is available since Fedify 2.1.0.* -The `--reverse` option reverses presentation order of fetched results. +The `--reverse` option reverses the output order of fetched results. It affects output order only, and does not change lookup semantics. ~~~~ sh diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 5ecb49074..945f172f6 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -453,7 +453,8 @@ export function toPresentationOrder( items: readonly T[], reverse: boolean, ): T[] { - return reverse ? [...items].reverse() : [...items]; + if (reverse) return [...items].reverse(); + return Array.isArray(items) ? items : [...items]; } export async function collectAsyncItems( From 50c1c6a4dadbcfcdd516618bfc2f479312c0b06b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 22:32:04 +0900 Subject: [PATCH 5/9] Tighten toPresentationOrder readonly semantics Return readonly arrays from toPresentationOrder and keep the non-reverse path as a direct readonly passthrough to avoid unsafe mutable narrowing. --- packages/cli/src/lookup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 945f172f6..7c4f02c2b 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -452,9 +452,9 @@ export async function writeSeparator( export function toPresentationOrder( items: readonly T[], reverse: boolean, -): T[] { +): readonly T[] { if (reverse) return [...items].reverse(); - return Array.isArray(items) ? items : [...items]; + return items; } export async function collectAsyncItems( From 14788666cecd9802d08bc0aff55406519baaa62b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 22:43:48 +0900 Subject: [PATCH 6/9] Cover reverse-mode failure-path output semantics Add runLookup integration tests for reverse-mode failure paths: traverse+reverse now verifies reversed partial item emission with non-zero exit, and recurse+reverse verifies root object emission on recursive failure. --- packages/cli/src/lookup.test.ts | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 1931eaf66..92255e43c 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1124,3 +1124,91 @@ test("runLookup - reverses output order in traverse mode", async () => { await rm(testDir, { recursive: true }); } }); + +test("runLookup - emits reversed partial items on traverse reverse failure", async () => { + const testDir = "./test_output_runlookup_traverse_reverse_partial_failure"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const collection = new Collection({ + id: new URL("https://example.com/collection"), + }); + const item1 = new Note({ + id: new URL("https://example.com/items/1"), + content: "one", + }); + const item2 = new Note({ + id: new URL("https://example.com/items/2"), + content: "two", + }); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["collection-url"], + traverse: true, + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => + Promise.resolve( + (typeof url === "string" ? url : url.href) === "collection-url" + ? collection + : null, + ), + async *traverseCollection() { + yield item1; + yield item2; + throw new Error("traversal failed"); + }, + }, + ); + assert.equal(exitCode, 1); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/items/2", + "https://example.com/items/1", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test("runLookup - emits root object on recurse reverse failure", async () => { + const testDir = "./test_output_runlookup_recurse_reverse_partial_failure"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const u3 = "https://lookup.test/u3"; + const root = new Note({ + id: new URL("https://example.com/notes/3"), + replyTarget: new URL("https://lookup.test/u2"), + content: "three", + }); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [u3], + recurse: "replyTarget", + recurseDepth: 20, + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => { + const key = typeof url === "string" ? url : url.href; + if (key === u3) return Promise.resolve(root); + throw new Error("recursive lookup failed"); + }, + traverseCollection: () => { + throw new Error("not used"); + }, + }, + ); + assert.equal(exitCode, 1); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/notes/3", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); From 577f92ab3af3dd34b0ed9d98c06ebf1a43d1ad72 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 22:54:03 +0900 Subject: [PATCH 7/9] Add regression tests for traverse separators Add runLookup regression coverage to verify separators are emitted between adjacent traversed items in both normal and reverse traversal modes. This locks in the current behavior and prevents false-positive regressions around same-collection item boundaries. https://github.com/fedify-dev/fedify/pull/609#discussion_r2901863342 Co-Authored-By: Codex --- packages/cli/src/lookup.test.ts | 126 ++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 92255e43c..1b0f60fee 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1173,6 +1173,132 @@ test("runLookup - emits reversed partial items on traverse reverse failure", asy } }); +test("runLookup - writes separators between adjacent traversed items", async () => { + const testDir = "./test_output_runlookup_traverse_separator"; + const testFile = `${testDir}/out.jsonl`; + const separator = ""; + await mkdir(testDir, { recursive: true }); + try { + const collectionA = new Collection({ + id: new URL("https://example.com/collections/a"), + }); + const collectionB = new Collection({ + id: new URL("https://example.com/collections/b"), + }); + const a1 = new Note({ + id: new URL("https://example.com/items/a1"), + content: "a1", + }); + const a2 = new Note({ + id: new URL("https://example.com/items/a2"), + content: "a2", + }); + const b1 = new Note({ + id: new URL("https://example.com/items/b1"), + content: "b1", + }); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["collection-a", "collection-b"], + traverse: true, + separator, + output: testFile, + }), + { + lookupObject: (url) => { + const key = typeof url === "string" ? url : url.href; + if (key === "collection-a") return Promise.resolve(collectionA); + if (key === "collection-b") return Promise.resolve(collectionB); + return Promise.resolve(null); + }, + async *traverseCollection(collection) { + if (collection === collectionA) { + yield a1; + yield a2; + } else if (collection === collectionB) { + yield b1; + } + }, + }, + ); + assert.equal(exitCode, 0); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/items/a1", + "https://example.com/items/a2", + "https://example.com/items/b1", + ]); + assert.equal(content.split(`${separator}\n`).length - 1, 2); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test( + "runLookup - writes separators between adjacent traversed items in reverse mode", + async () => { + const testDir = "./test_output_runlookup_traverse_separator_reverse"; + const testFile = `${testDir}/out.jsonl`; + const separator = ""; + await mkdir(testDir, { recursive: true }); + try { + const collectionA = new Collection({ + id: new URL("https://example.com/collections/a"), + }); + const collectionB = new Collection({ + id: new URL("https://example.com/collections/b"), + }); + const a1 = new Note({ + id: new URL("https://example.com/items/a1"), + content: "a1", + }); + const a2 = new Note({ + id: new URL("https://example.com/items/a2"), + content: "a2", + }); + const b1 = new Note({ + id: new URL("https://example.com/items/b1"), + content: "b1", + }); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["collection-a", "collection-b"], + traverse: true, + reverse: true, + separator, + output: testFile, + }), + { + lookupObject: (url) => { + const key = typeof url === "string" ? url : url.href; + if (key === "collection-a") return Promise.resolve(collectionA); + if (key === "collection-b") return Promise.resolve(collectionB); + return Promise.resolve(null); + }, + async *traverseCollection(collection) { + if (collection === collectionA) { + yield a1; + yield a2; + } else if (collection === collectionB) { + yield b1; + } + }, + }, + ); + assert.equal(exitCode, 0); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/items/a2", + "https://example.com/items/a1", + "https://example.com/items/b1", + ]); + assert.equal(content.split(`${separator}\n`).length - 1, 2); + } finally { + await rm(testDir, { recursive: true }); + } + }, +); + test("runLookup - emits root object on recurse reverse failure", async () => { const testDir = "./test_output_runlookup_recurse_reverse_partial_failure"; const testFile = `${testDir}/out.jsonl`; From ac8d2c5e56e02478475b22eb1a0507d835ab0cd1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 23:13:18 +0900 Subject: [PATCH 8/9] Reduce reverse-path allocations in lookup output Avoid extra array cloning in reverse output paths by iterating buffered entries in reverse order for recurse and traverse modes. Also tighten test helper typing by replacing the direct command-object type assertion with a `satisfies`-checked base command shape. https://github.com/fedify-dev/fedify/pull/609#discussion_r2901878956 https://github.com/fedify-dev/fedify/pull/609#discussion_r2901878962 https://github.com/fedify-dev/fedify/pull/609#discussion_r2901878969 Co-Authored-By: Codex --- packages/cli/src/lookup.test.ts | 13 ++--- packages/cli/src/lookup.ts | 91 ++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 1b0f60fee..a13bee7f6 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -915,8 +915,8 @@ class ExitSignal extends Error { function createLookupRunCommand( overrides: Partial[0]>, -): Parameters[0] { - return { +) { + const baseCommand = { command: "lookup", urls: [], traverse: false, @@ -935,16 +935,17 @@ function createLookupRunCommand( output: undefined, debug: false, ignoreConfig: false, - ...overrides, - } as Parameters[0]; + configPath: undefined, + } satisfies Parameters[0]; + return { ...baseCommand, ...overrides }; } async function runLookupAndCaptureExitCode( - command: Parameters[0], + command: ReturnType, deps?: Parameters[1], ): Promise { try { - await runLookup(command, { + await runLookup(command as Parameters[0], { ...deps, exit: (code: number) => { throw new ExitSignal(code); diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 7c4f02c2b..6389bd322 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -1023,40 +1023,68 @@ export async function runLookup( return; } - const chainEntries = command.reverse - ? toPresentationOrder( - [ - { object: current, objectContextLoader: contextLoader }, - ...chain.map((next) => ({ - object: next, - objectContextLoader: recursiveContextLoader, - })), - ], - true, - ) - : chain.map((next) => ({ + if (command.reverse) { + const chainEntries = [ + { object: current, objectContextLoader: contextLoader }, + ...chain.map((next) => ({ + object: next, + objectContextLoader: recursiveContextLoader, + })), + ]; + for ( + let chainIndex = chainEntries.length - 1; + chainIndex >= 0; + chainIndex-- + ) { + const entry = chainEntries[chainIndex]; + try { + if (totalObjects > 0 || chainIndex < chainEntries.length - 1) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + entry.object, + command.output, + command.format, + entry.objectContextLoader, + getOutputStream(), + ); + totalObjects++; + } catch (error) { + logger.error("Failed to write lookup output: {error}", { error }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; + } + } + } else { + const chainEntries = chain.map((next) => ({ object: next, objectContextLoader: recursiveContextLoader, })); - for (let chainIndex = 0; chainIndex < chainEntries.length; chainIndex++) { - const entry = chainEntries[chainIndex]; - try { - if (totalObjects > 0 || chainIndex > 0) { - await writeSeparator(command.separator, getOutputStream()); + for ( + let chainIndex = 0; + chainIndex < chainEntries.length; + chainIndex++ + ) { + const entry = chainEntries[chainIndex]; + try { + if (totalObjects > 0 || chainIndex > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + entry.object, + command.output, + command.format, + entry.objectContextLoader, + getOutputStream(), + ); + totalObjects++; + } catch (error) { + logger.error("Failed to write lookup output: {error}", { error }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; } - await writeObjectToStream( - entry.object, - command.output, - command.format, - entry.objectContextLoader, - getOutputStream(), - ); - totalObjects++; - } catch (error) { - logger.error("Failed to write lookup output: {error}", { error }); - spinner.fail("Failed to write output."); - await finalizeAndExit(1); - return; } } } @@ -1127,7 +1155,8 @@ export async function runLookup( suppressError: command.suppressErrors, }), ); - for (const item of toPresentationOrder(traversedItems, true)) { + for (let index = traversedItems.length - 1; index >= 0; index--) { + const item = traversedItems[index]; try { if (totalItems > 0) { await writeSeparator(command.separator, getOutputStream()); From 624f75b12d66f7d8d60933caf54788758fed33be Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 8 Mar 2026 23:25:26 +0900 Subject: [PATCH 9/9] Remove runLookup command cast in test helper Keep runLookupAndCaptureExitCode() fully typed by accepting the exact runLookup command parameter type, and move the conversion responsibility into createLookupRunCommand(). https://github.com/fedify-dev/fedify/pull/609#discussion_r2901898291 Co-Authored-By: Codex --- packages/cli/src/lookup.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index a13bee7f6..859d32cd6 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -915,7 +915,7 @@ class ExitSignal extends Error { function createLookupRunCommand( overrides: Partial[0]>, -) { +): Parameters[0] { const baseCommand = { command: "lookup", urls: [], @@ -937,15 +937,15 @@ function createLookupRunCommand( ignoreConfig: false, configPath: undefined, } satisfies Parameters[0]; - return { ...baseCommand, ...overrides }; + return { ...baseCommand, ...overrides } as Parameters[0]; } async function runLookupAndCaptureExitCode( - command: ReturnType, + command: Parameters[0], deps?: Parameters[1], ): Promise { try { - await runLookup(command as Parameters[0], { + await runLookup(command, { ...deps, exit: (code: number) => { throw new ExitSignal(code);