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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: REA Maintenance <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Yunzhou Li <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-11-06 17:21:28 +08:00
1 parent f1bfa1cba2
commit 996977edf2
3 files changed
+364 -1

No files matched your search

+1
View File
@@ -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`.
+77 -1
View File
@@ -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<typeof invalidReferenceSourceExcludePaths>;
} => {
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<unknown> => {
const resources = new ArtifactResourceScope();
@@ -373,6 +448,7 @@ const runReferenceSourceImport = async (
policy,
importer: PRODUCT_IDENTITY.packageName,
importerVersion: PRODUCT_IDENTITY.packageVersion,
...(excludePaths === undefined ? {} : { excludePaths }),
},
resources,
),
@@ -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",
),
});
},
);
});