From 996977edf2bf2055b49c6cb047239ce189929e49 Mon Sep 17 00:00:00 2001 From: Yunzhou Li <154221037+YunzhouLi-hub@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:21:28 +0800 Subject: [PATCH] feat(cli): expose caller-selected reference-source path exclusions (#2630) * feat(cli): expose caller-selected reference-source path exclusions Let import-reference-source accept a JSON array of Git-ignore rules and pass them to the existing importer before the source tree is opened. Fixes #2443 * docs(cli): use an effective reference exclusion negation * test(cli): expect entry_kind on reference-source exclusions Main records entry_kind on every historical source exclusion. The caller exclusion assertions still compared objects without that field, so the pruning test failed after merging main. Co-authored-by: Yunzhou Li * test(cli): record caller *.tmp over project ignore Caller exclusions are mandatory and override project rules, so project.tmp matched by *.tmp is caller-excluded. Align the pruning assertion with that result from the merged importer. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: REA Maintenance Co-authored-by: Cursor Agent Co-authored-by: Yunzhou Li --- docs/cli.md | 1 + src/cli/utilityCommands.ts | 78 ++++- .../cli/referenceSourceExclusions.test.ts | 286 ++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index d7414f514e..efc0db8e68 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -834,6 +834,7 @@ Import an older source tree to compare with the current artifact: ```bash rea import-reference-source /absolute/path/to/source rea import-reference-source /absolute/path/to/source --detail summary --outline-depth 2 --json +rea import-reference-source /absolute/path/to/source --exclude-paths '["vendor/","*.generated","!keep.generated"]' ``` The default (`--detail full`) returns the complete `HistoricalSourceGraph`. diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 5dd39f7079..b94da62f08 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -12,7 +12,10 @@ import { projectReferenceSourceImportError } from "../application/ReferenceSourc import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import { parseConfig } from "../config/parseConfig.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { + AnalysisInputError, + type AnalysisInputIssue, +} from "../domain/analysisErrorCore.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { safeParseJson } from "../domain/safeJson.js"; import { nativeUiScenarioInputSchema } from "../domain/native/nativeUiObservation.js"; @@ -282,6 +285,66 @@ const registerNativeCommands = ( }); }; +const referenceSourceExcludePathsSchema = z.object({ + excludePaths: z.array(z.string().min(1)), +}); + +const EXCLUDE_PATHS_INPUT_MESSAGE = + "--exclude-paths must be a JSON array of nonempty Git-ignore rules. Each rule uses Git-ignore syntax, including wildcards, directory suffixes, and negations, rather than a literal path match."; + +const invalidReferenceSourceExcludePaths = ( + issues: readonly AnalysisInputIssue[], +) => ({ + error: "Import failed" as const, + ...projectAnalysisError( + new AnalysisInputError( + CLI_COMMANDS.importReferenceSource, + { userMessage: EXCLUDE_PATHS_INPUT_MESSAGE }, + issues, + ), + ), +}); + +/** Parse caller exclusions before the import opens the selected tree. */ +const parseReferenceSourceExcludePaths = ( + encoded: string | undefined, +): + | { + readonly ok: true; + readonly rules: readonly string[] | undefined; + } + | { + readonly ok: false; + readonly failure: ReturnType; + } => { + if (encoded === undefined) return { ok: true, rules: undefined }; + const decoded = safeParseJson(encoded); + if (!decoded.ok) + return { + ok: false, + failure: invalidReferenceSourceExcludePaths([ + { + path: ["excludePaths"], + reason: "invalid_format", + expected: "JSON", + }, + ]), + }; + const parsed = referenceSourceExcludePathsSchema.safeParse({ + excludePaths: decoded.value, + }); + if (!parsed.success) + return { + ok: false, + failure: invalidReferenceSourceExcludePaths( + projectInputIssues(parsed.error.issues, { + excludePaths: decoded.value, + }), + ), + }; + return { ok: true, rules: parsed.data.excludePaths }; +}; + const registerReferenceSourceCommand = ( cli: CliInstance, logger: Logger, @@ -295,6 +358,12 @@ const registerReferenceSourceCommand = ( .describe("Local source directory to import as historical reference"), }), options: z.object({ + excludePaths: z + .string() + .optional() + .describe( + "JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.", + ), detail: z .enum(["full", "summary"]) .default("full") @@ -312,6 +381,10 @@ const registerReferenceSourceCommand = ( logger, "import-reference-source", async () => { + const excludePaths = parseReferenceSourceExcludePaths( + options.excludePaths, + ); + if (!excludePaths.ok) return excludePaths.failure; if ( options.outlineDepth !== undefined && options.detail !== "summary" @@ -341,6 +414,7 @@ const registerReferenceSourceCommand = ( return runReferenceSourceImport( args.root, config.value.referenceSourcePolicy, + excludePaths.rules, options.detail === "summary" ? (options.outlineDepth ?? 2) : undefined, @@ -354,6 +428,7 @@ const registerReferenceSourceCommand = ( const runReferenceSourceImport = async ( root: string, policy: ReferenceSourcePolicy, + excludePaths: readonly string[] | undefined, outlineDepth: number | undefined, ): Promise => { const resources = new ArtifactResourceScope(); @@ -373,6 +448,7 @@ const runReferenceSourceImport = async ( policy, importer: PRODUCT_IDENTITY.packageName, importerVersion: PRODUCT_IDENTITY.packageVersion, + ...(excludePaths === undefined ? {} : { excludePaths }), }, resources, ), diff --git a/tests/boundary/cli/referenceSourceExclusions.test.ts b/tests/boundary/cli/referenceSourceExclusions.test.ts index 87b8541166..77495d25d4 100644 --- a/tests/boundary/cli/referenceSourceExclusions.test.ts +++ b/tests/boundary/cli/referenceSourceExclusions.test.ts @@ -12,6 +12,31 @@ import { describe, expect } from "vitest"; import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import type { TestCli } from "../../support/cli/cliFixture.js"; + +const isolatedEnvironment = (root: string) => ({ + HOME: root, + USERPROFILE: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + REA_REFERENCE_SECRET_PATTERNS_JSON: '["private.txt"]', +}); + +const importSource = ( + cli: TestCli, + root: string, + excludePaths: string | undefined, +) => + cli.run({ + arguments: [ + "import-reference-source", + root, + "--json", + ...(excludePaths === undefined ? [] : ["--exclude-paths", excludePaths]), + ], + cwd: root, + environment: isolatedEnvironment(root), + }); describe.skipIf(process.platform === "win32")( "compiled reference-source exclusion provenance", @@ -123,3 +148,264 @@ describe.skipIf(process.platform === "win32")( ); }, ); + +describe.skipIf(process.platform === "win32")( + "compiled reference-source caller exclusions", + () => { + cliTest( + "prunes caller-excluded directories and applies Git-ignore rules", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-reference-caller-exclusions-", + ); + const logs = join(root, "logs"); + const skipped = join(root, "skip-me", "nested"); + await mkdir(logs); + await mkdir(skipped, { recursive: true }); + await Promise.all([ + writeFile(join(root, ".gitignore"), "project.tmp\n"), + writeFile(join(root, "project.tmp"), "project policy\n"), + writeFile(join(logs, "application.log"), "default policy\n"), + writeFile(join(root, "keep.log"), "negated default rule\n"), + writeFile(join(root, "private.txt"), "sensitive local file\n"), + writeFile(join(root, "notes.tmp"), "caller wildcard\n"), + writeFile(join(skipped, "hidden.ts"), "must not be traversed\n"), + writeFile(join(root, "source.ts"), "export const value = 1;\n"), + ]); + const callerRules = '["skip-me/","*.tmp","!keep.log","private.txt"]'; + + const omitted = await importSource(cli, root, undefined); + const empty = await importSource(cli, root, "[]"); + const selected = await importSource(cli, root, callerRules); + + expect(omitted.exitCode).toBe(0); + expect(empty.exitCode).toBe(0); + const omittedGraph = historicalSourceGraphSchema.parse(omitted.json); + const emptyGraph = historicalSourceGraphSchema.parse(empty.json); + expect(emptyGraph.exclusions).toEqual(omittedGraph.exclusions); + expect(emptyGraph.entries.map((entry) => entry.path)).toEqual( + omittedGraph.entries.map((entry) => entry.path), + ); + expect(omittedGraph.exclusions).not.toContainEqual( + expect.objectContaining({ reason: "caller-excluded" }), + ); + expect(omittedGraph.entries).toContainEqual( + expect.objectContaining({ path: "notes.tmp", kind: "file" }), + ); + expect(omittedGraph.entries).toContainEqual( + expect.objectContaining({ + path: "skip-me/nested/hidden.ts", + kind: "file", + }), + ); + expect(omittedGraph.exclusions).toContainEqual({ + path: "keep.log", + entry_kind: "file", + reason: "default-ignored", + pattern: "*.log", + }); + + expect(selected.exitCode).toBe(0); + const selectedGraph = historicalSourceGraphSchema.parse(selected.json); + expect(selectedGraph.exclusions).toEqual([ + { + path: "logs/application.log", + entry_kind: "file", + reason: "default-ignored", + pattern: "*.log", + }, + { + path: "notes.tmp", + entry_kind: "file", + reason: "caller-excluded", + pattern: "*.tmp", + }, + { + path: "private.txt", + entry_kind: "file", + reason: "configured-secret", + pattern: "private.txt", + }, + { + path: "project.tmp", + entry_kind: "file", + reason: "caller-excluded", + pattern: "*.tmp", + }, + { + path: "skip-me", + entry_kind: "directory", + reason: "caller-excluded", + pattern: "skip-me/", + }, + ]); + expect(selectedGraph.entries).toContainEqual( + expect.objectContaining({ path: "source.ts", kind: "file" }), + ); + expect(selectedGraph.entries).toContainEqual( + expect.objectContaining({ path: "keep.log", kind: "file" }), + ); + for (const path of [ + "skip-me", + "skip-me/nested", + "skip-me/nested/hidden.ts", + "notes.tmp", + ]) + expect( + selectedGraph.entries.some((entry) => entry.path === path), + path, + ).toBe(false); + expect( + selectedGraph.exclusions.some((exclusion) => + exclusion.path.startsWith("skip-me/"), + ), + ).toBe(false); + }, + ); + }, +); + +describe("compiled reference-source caller exclusion input", () => { + for (const scenario of [ + { + label: "malformed JSON", + value: "[", + issue: { + path: ["excludePaths"], + reason: "invalid_format", + expected: "JSON", + }, + }, + { + label: "a non-array JSON value", + value: "{}", + issue: { + path: ["excludePaths"], + reason: "invalid_type", + expected: "array", + }, + }, + { + label: "a numeric rule", + value: "[1]", + issue: { + path: ["excludePaths", 0], + reason: "invalid_type", + expected: "string", + }, + }, + { + label: "a null rule", + value: "[null]", + issue: { + path: ["excludePaths", 0], + reason: "invalid_type", + expected: "string", + }, + }, + { + label: "an object rule", + value: "[{}]", + issue: { + path: ["excludePaths", 0], + reason: "invalid_type", + expected: "string", + }, + }, + { + label: "a boolean rule", + value: "[true]", + issue: { + path: ["excludePaths", 0], + reason: "invalid_type", + expected: "string", + }, + }, + { + label: "an empty-string rule", + value: '[""]', + issue: { + path: ["excludePaths", 0], + reason: "out_of_range", + minimum: 1, + }, + }, + ]) { + cliTest( + `rejects ${scenario.label} before an import starts`, + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-reference-exclude-input-", + ); + const root = join(directory, "missing-source"); + const result = await cli.run({ + arguments: [ + "import-reference-source", + root, + "--exclude-paths", + scenario.value, + "--json", + ], + cwd: directory, + environment: { + HOME: directory, + USERPROFILE: directory, + XDG_CONFIG_HOME: directory, + XDG_CACHE_HOME: directory, + }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + error: "Import failed", + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining("Git-ignore"), + details: { + operation: "import-reference-source", + issues: [scenario.issue], + }, + }); + expect(JSON.stringify(result.json)).not.toContain( + "could not be resolved", + ); + expect(JSON.stringify(result.json)).not.toContain(root); + }, + ); + } + + cliTest( + "starts an import after an empty exclusion array", + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-reference-exclude-empty-", + ); + const root = join(directory, "missing-source"); + const result = await cli.run({ + arguments: [ + "import-reference-source", + root, + "--exclude-paths", + "[]", + "--json", + ], + cwd: directory, + environment: { + HOME: directory, + USERPROFILE: directory, + XDG_CONFIG_HOME: directory, + XDG_CACHE_HOME: directory, + }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + error: "Import failed", + category: "invalid_input", + message: expect.stringContaining( + "Reference source root could not be resolved", + ), + }); + }, + ); +});