mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
3 files changed
+364
-1
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user