From e92943d9573dc6670d25907b4cfc266098ee647f Mon Sep 17 00:00:00 2001 From: saitota Date: Sat, 20 Jun 2026 00:31:12 +0900 Subject: [PATCH 1/3] test(gitignore): cover ignore feature in getSettablePaths coupling guard The getSettablePaths coverage test guarded commands/skills/subagents and mcp/hooks/permissions, but not the ignore feature, so the implicit coupling between each tool's ignore getSettablePaths() (the real output path, e.g. .augmentignore) and the hand-written GITIGNORE_ENTRY_REGISTRY went unverified. Add toolIgnoreFactories to the fileFeatures list. ToolIgnoreFactory has no meta, so guard the supportsProject lookup with an 'in' check to keep the union type-safe. --- src/cli/commands/gitignore-entries.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index bbd938343..e605e17cb 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { toolCommandFactories } from "../../features/commands/commands-processor.js"; import { toolHooksFactories } from "../../features/hooks/hooks-processor.js"; +import { toolIgnoreFactories } from "../../features/ignore/ignore-processor.js"; import { toolMcpFactories } from "../../features/mcp/mcp-processor.js"; import { toolPermissionsFactories } from "../../features/permissions/permissions-processor.js"; import { toolSkillFactories } from "../../features/skills/skills-processor.js"; @@ -137,12 +138,17 @@ describe("getSettablePaths coverage", () => { ["mcp", toolMcpFactories], ["hooks", toolHooksFactories], ["permissions", toolPermissionsFactories], + // `ignore` outputs a single file per tool (e.g. `.augmentignore`), fitting the + // file-feature shape. + ["ignore", toolIgnoreFactories], ] as const; for (const [feature, factories] of fileFeatures) { for (const [target, factory] of factories) { if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; - const meta = factory.meta as { supportsProject?: boolean } | undefined; + // `ToolIgnoreFactory` has no `meta`; guard the lookup so the union stays type-safe. + const meta = + "meta" in factory ? (factory.meta as { supportsProject?: boolean } | undefined) : undefined; if (meta && meta.supportsProject === false) continue; it(`covers ${feature} output for ${target}`, () => { // No try/catch: project-supporting tools must resolve without throwing From ce23a861306320177d9ab44d7590c2f95e759d71 Mon Sep 17 00:00:00 2001 From: saitota Date: Sat, 20 Jun 2026 01:02:41 +0900 Subject: [PATCH 2/3] test(gitignore): close coverage gaps and fix 3 drifted entries Extend the getSettablePaths coverage guard to catch the drifts it previously missed, and fix the drifts it surfaced. Coverage gaps closed: - rules feature is now checked (root/alternativeRoots as files, nonRoot as dir); it was previously unguarded. - Reverse check added: every non-general registry entry must map to a real emitted output, catching ghost entries left behind after a rename. - Directory matching is now exact instead of prefix-based, so renaming e.g. .augment/commands/ to .augment/cmds/ fails instead of matching a parent. Deliberate subtree-coverage roots (.cursor, .agents, .goose, .rovodev/.rulesync) are listed explicitly and asserted to exist. Drifts fixed (were shipping un-gitignored / stale): - junie root rule moved to .junie/AGENTS.md (added; .junie/guidelines.md kept as legacy import fallback). - junie non-root rules under .junie/memories/ (added). - kilo MCP output moved to root kilo.json; removed the stale **/.kilo/mcp.json ghost entry. Regenerated .gitignore accordingly. --- .gitignore | 3 +- src/cli/commands/gitignore-entries.test.ts | 157 ++++++++++++++++++++- src/cli/commands/gitignore-entries.ts | 15 +- 3 files changed, 163 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index a95c3d8d3..4ef782ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -303,7 +303,9 @@ rulesync.local.jsonc **/.copilot/mcp-config.json **/.copilot/agents/ **/.copilot/hooks/ +**/.junie/AGENTS.md **/.junie/guidelines.md +**/.junie/memories/ **/.junie/commands/ **/.junie/mcp/mcp.json **/.junie/skills/ @@ -313,7 +315,6 @@ rulesync.local.jsonc **/.kilo/skills/ **/.kilo/commands/ **/.kilo/agents/ -**/.kilo/mcp.json **/.kilo/plugins/ **/.kilocodeignore **/.kiro/steering/ diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index e605e17cb..1f3743c50 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -5,6 +5,7 @@ import { toolHooksFactories } from "../../features/hooks/hooks-processor.js"; import { toolIgnoreFactories } from "../../features/ignore/ignore-processor.js"; import { toolMcpFactories } from "../../features/mcp/mcp-processor.js"; import { toolPermissionsFactories } from "../../features/permissions/permissions-processor.js"; +import { toolRuleFactories } from "../../features/rules/rules-processor.js"; import { toolSkillFactories } from "../../features/skills/skills-processor.js"; import { toolSubagentFactories } from "../../features/subagents/subagents-processor.js"; import { createMockLogger } from "../../test-utils/mock-logger.js"; @@ -93,11 +94,29 @@ const fileToGlob = (relativeDirPath: string | undefined, relativeFilePath: strin return `**/${toPosixPath(hasDir ? `${relativeDirPath}/${relativeFilePath}` : relativeFilePath)}`; }; -const isCoveredByRegistry = (glob: string): boolean => { - const normalized = glob.replace(/\/$/, ""); +const stripTrailingSlash = (glob: string): string => glob.replace(/\/$/, ""); + +// Registry entries that deliberately gitignore a whole tool subtree, so a deeper +// output dir is covered without its own entry. Directories outside these must +// match EXACTLY, so a rename like `.augment/commands/` → `.augment/cmds/` fails +// instead of silently matching a broader parent. +const SUBTREE_COVERAGE_DIRS = ["**/.cursor", "**/.agents", "**/.goose", "**/.rovodev/.rulesync"]; + +// A directory output must match a registry entry exactly, unless under a subtree root. +const isCoveredDir = (dirGlob: string): boolean => { + const normalized = stripTrailingSlash(dirGlob); + if (SUBTREE_COVERAGE_DIRS.some((root) => normalized.startsWith(`${root}/`))) return true; + return GITIGNORE_ENTRY_REGISTRY.some((tag) => stripTrailingSlash(tag.entry) === normalized); +}; + +// A file output may match an entry exactly, or fall under a registered directory +// entry (trailing slash) that gitignores its whole subtree. +const isCoveredFile = (fileGlob: string): boolean => { + const normalized = stripTrailingSlash(fileGlob); return GITIGNORE_ENTRY_REGISTRY.some((tag) => { - const entry = tag.entry.replace(/\/$/, ""); - return entry === normalized || normalized.startsWith(`${entry}/`); + if (tag.entry === normalized) return true; + if (!tag.entry.endsWith("/")) return false; + return normalized.startsWith(stripTrailingSlash(tag.entry) + "/"); }); }; @@ -129,7 +148,7 @@ describe("getSettablePaths coverage", () => { if (!dir || dir === ".") return; const glob = dirToGlob(dir); if (DERIVED_PATHS_NOT_GITIGNORED.has(glob.replace(/\/$/, ""))) return; - expect(isCoveredByRegistry(glob)).toBe(true); + expect(isCoveredDir(glob)).toBe(true); }); } } @@ -159,10 +178,136 @@ describe("getSettablePaths coverage", () => { if (!paths.relativeFilePath) return; const glob = fileToGlob(paths.relativeDirPath, paths.relativeFilePath); if (DERIVED_PATHS_NOT_GITIGNORED.has(glob)) return; - expect(isCoveredByRegistry(glob)).toBe(true); + expect(isCoveredFile(glob)).toBe(true); }); } } + + // `rules` has a composite shape ({ root, alternativeRoots, nonRoot }); roots are + // files, nonRoot is a directory. + for (const [target, factory] of toolRuleFactories) { + if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; + it(`covers rules output for ${target}`, () => { + const paths = factory.class.getSettablePaths({ global: false }); + const rootFiles = [paths.root, ...(paths.alternativeRoots ?? [])].filter( + (entry): entry is { relativeDirPath: string; relativeFilePath: string } => + entry !== undefined, + ); + for (const root of rootFiles) { + const glob = fileToGlob(root.relativeDirPath, root.relativeFilePath); + if (DERIVED_PATHS_NOT_GITIGNORED.has(glob)) continue; + expect(isCoveredFile(glob), `root ${glob}`).toBe(true); + } + const nonRootDir = paths.nonRoot?.relativeDirPath; + if (nonRootDir && nonRootDir !== ".") { + const glob = dirToGlob(nonRootDir); + if (!DERIVED_PATHS_NOT_GITIGNORED.has(glob.replace(/\/$/, ""))) { + expect(isCoveredDir(glob), `nonRoot ${glob}`).toBe(true); + } + } + }); + } +}); + +describe("registry reverse coverage", () => { + // Every project-scope output glob some tool actually emits — the inverse of the + // coverage check, used to detect ghost entries no tool writes anymore. + const collectEmittedGlobs = (): Set => { + const globs = new Set(); + const dirFactories = [toolCommandFactories, toolSkillFactories, toolSubagentFactories]; + const fileFactories = [ + toolMcpFactories, + toolHooksFactories, + toolPermissionsFactories, + toolIgnoreFactories, + ]; + for (const factories of dirFactories) { + for (const [target, factory] of factories) { + if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; + const meta = + "meta" in factory + ? (factory.meta as { supportsProject?: boolean } | undefined) + : undefined; + if (meta && meta.supportsProject === false) continue; + const dir = factory.class.getSettablePaths({ global: false }).relativeDirPath; + if (dir && dir !== ".") globs.add(stripTrailingSlash(dirToGlob(dir))); + } + } + for (const factories of fileFactories) { + for (const [target, factory] of factories) { + if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; + const meta = + "meta" in factory + ? (factory.meta as { supportsProject?: boolean } | undefined) + : undefined; + if (meta && meta.supportsProject === false) continue; + const paths: { relativeDirPath?: string; relativeFilePath?: string } = + factory.class.getSettablePaths({ global: false }); + if (paths.relativeFilePath) + globs.add(fileToGlob(paths.relativeDirPath, paths.relativeFilePath)); + } + } + for (const [target, factory] of toolRuleFactories) { + if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; + const paths = factory.class.getSettablePaths({ global: false }); + for (const root of [paths.root, ...(paths.alternativeRoots ?? [])]) { + if (root) globs.add(fileToGlob(root.relativeDirPath, root.relativeFilePath)); + } + const nonRootDir = paths.nonRoot?.relativeDirPath; + if (nonRootDir && nonRootDir !== ".") globs.add(stripTrailingSlash(dirToGlob(nonRootDir))); + } + return globs; + }; + + // Real entries the reverse check can't match to a `{ global: false }` output. + const REVERSE_COVERAGE_EXCEPTIONS = new Set([ + // Aggregate subtree roots and shared trees re-tagged per target. + ...SUBTREE_COVERAGE_DIRS, + "**/AGENTS.md", + "**/.agents/skills", + // Global-scope-only outputs (emitted under the home dir). + "**/.copilot/agents", + "**/.copilot/hooks", + "**/.codeium/windsurf/skills", + "**/.deepagents/hooks.json", + // Outputs not produced via getSettablePaths (single-file or local/legacy rules). + "**/.roomodes", + "**/.codexignore", + "**/.augment-guidelines", + "**/CLAUDE.local.md", + "**/.claude/CLAUDE.local.md", + ]); + + it("has no ghost entries — every non-general entry maps to an emitted output", () => { + const emitted = collectEmittedGlobs(); + const ghosts: string[] = []; + for (const tag of GITIGNORE_ENTRY_REGISTRY) { + if (tag.feature === "general") continue; + const targets = Array.isArray(tag.target) ? tag.target : [tag.target]; + if (targets.includes("common")) continue; + const normalized = stripTrailingSlash(tag.entry); + if (REVERSE_COVERAGE_EXCEPTIONS.has(normalized)) continue; + // Real if some tool emits it exactly, or emits a file under it. + const matched = [...emitted].some( + (glob) => glob === normalized || glob.startsWith(`${normalized}/`), + ); + if (!matched) ghosts.push(`${tag.entry} (${targets.join(",")}/${tag.feature})`); + } + expect(ghosts).toEqual([]); + }); + + it("subtree-coverage roots exist as directory entries in the registry", () => { + // Guard that each prefix-coverage root is itself a registered directory entry, + // so removing the root surfaces here instead of silently widening coverage. + const dirEntries = new Set( + GITIGNORE_ENTRY_REGISTRY.filter((tag) => tag.entry.endsWith("/")).map((tag) => + stripTrailingSlash(tag.entry), + ), + ); + for (const root of SUBTREE_COVERAGE_DIRS) { + expect(dirEntries, `missing subtree root ${root}`).toContain(root); + } + }); }); describe("ALL_GITIGNORE_ENTRIES", () => { diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index 7a71cde86..a741df4fa 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -293,7 +293,12 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "copilotcli", feature: "hooks", entry: "**/.copilot/hooks/" }, // Junie + // Current root rule is `.junie/AGENTS.md`; `.junie/guidelines.md` is the legacy + // file still accepted on import, so both are gitignored. + { target: "junie", feature: "rules", entry: "**/.junie/AGENTS.md" }, { target: "junie", feature: "rules", entry: "**/.junie/guidelines.md" }, + // Non-root rules are emitted as memory files under `.junie/memories/`. + { target: "junie", feature: "rules", entry: "**/.junie/memories/" }, { target: "junie", feature: "commands", entry: "**/.junie/commands/" }, { target: "junie", feature: "mcp", entry: "**/.junie/mcp/mcp.json" }, { target: "junie", feature: "skills", entry: "**/.junie/skills/" }, @@ -305,13 +310,13 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "kilo", feature: "skills", entry: "**/.kilo/skills/" }, { target: "kilo", feature: "commands", entry: "**/.kilo/commands/" }, { target: "kilo", feature: "subagents", entry: "**/.kilo/agents/" }, - { target: "kilo", feature: "mcp", entry: "**/.kilo/mcp.json" }, { target: "kilo", feature: "hooks", entry: "**/.kilo/plugins/" }, { target: "kilo", feature: "ignore", entry: "**/.kilocodeignore" }, - // No `**/kilo.jsonc` entry: structurally identical to `opencode.jsonc` (no - // entry). The Kilo translator preserves non-permissions Kilo settings on - // round-trip, so the file is intended to be checked in by the user — adding - // `**/kilo.jsonc` would be too aggressive. + // No `**/kilo.json` (MCP) or `**/kilo.jsonc` entry: structurally identical to + // `opencode.jsonc` (no entry). The Kilo translator preserves non-permissions + // Kilo settings on round-trip, so the file is intended to be checked in by the + // user — a gitignore entry would be too aggressive. (The MCP output moved to the + // root `kilo.json`; the old `**/.kilo/mcp.json` entry was a stale ghost.) // Kiro { target: "kiro", feature: "rules", entry: "**/.kiro/steering/" }, From cd1812a9d38bb4e181ee0b56aa712b7d62b832e1 Mon Sep 17 00:00:00 2001 From: saitota Date: Sat, 20 Jun 2026 01:05:12 +0900 Subject: [PATCH 3/3] test(gitignore): clarify reverse-coverage exception reasons (review) Address claude-review doc-accuracy notes (no behavior change): - deepagents hooks is excepted because its factory is supportsProject:false (the project-scope collector skips it), not because its path is home-dir-specific; move it out of the home-dir group with the correct reason. - Note in the rules loop that rules are universally project-scoped, so it intentionally omits the supportsProject guard the other loops use. --- src/cli/commands/gitignore-entries.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index 1f3743c50..95a57009d 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -102,7 +102,6 @@ const stripTrailingSlash = (glob: string): string => glob.replace(/\/$/, ""); // instead of silently matching a broader parent. const SUBTREE_COVERAGE_DIRS = ["**/.cursor", "**/.agents", "**/.goose", "**/.rovodev/.rulesync"]; -// A directory output must match a registry entry exactly, unless under a subtree root. const isCoveredDir = (dirGlob: string): boolean => { const normalized = stripTrailingSlash(dirGlob); if (SUBTREE_COVERAGE_DIRS.some((root) => normalized.startsWith(`${root}/`))) return true; @@ -184,7 +183,8 @@ describe("getSettablePaths coverage", () => { } // `rules` has a composite shape ({ root, alternativeRoots, nonRoot }); roots are - // files, nonRoot is a directory. + // files, nonRoot is a directory. No supportsProject guard: rules are universally + // project-scoped, so getSettablePaths({ global: false }) never throws here. for (const [target, factory] of toolRuleFactories) { if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; it(`covers rules output for ${target}`, () => { @@ -269,6 +269,7 @@ describe("registry reverse coverage", () => { "**/.copilot/agents", "**/.copilot/hooks", "**/.codeium/windsurf/skills", + // supportsProject:false, so the project-scope collector skips it. "**/.deepagents/hooks.json", // Outputs not produced via getSettablePaths (single-file or local/legacy rules). "**/.roomodes", @@ -287,7 +288,6 @@ describe("registry reverse coverage", () => { if (targets.includes("common")) continue; const normalized = stripTrailingSlash(tag.entry); if (REVERSE_COVERAGE_EXCEPTIONS.has(normalized)) continue; - // Real if some tool emits it exactly, or emits a file under it. const matched = [...emitted].some( (glob) => glob === normalized || glob.startsWith(`${normalized}/`), );