diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 5b434143a..e3ff605ae 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -6,6 +6,8 @@ Rulesync follows symbolic links when it discovers source files, whether you use The trust boundary is the directory you point Rulesync at. There is **no** `realpath`-based containment check on individual symlinks, so a link may resolve to a target outside the input root — enforcing containment would break the shared-file use case above. Only run Rulesync against trees you control. Directory symlink **cycles** are handled safely: results are deduplicated by real path, so a cycle does not produce duplicated output. Note that the remote-fetch path (`rulesync fetch` from a Git repository) is a separate, hardened code path that **skips** symlinks entirely, so untrusted remote content never has its symlinks followed. +One discovery pass is deliberately excluded from the follow-symlinks rule: the scan for nested `AGENTS.md` files (see the `agentsmd` note below). Unlike every other glob above, it walks the whole project rather than a rulesync-owned directory, so a symlink committed to a repository you cloned could otherwise pull a file from outside the project into version-controlled `.rulesync/`. That scan does not follow symlinks. + ## `rulesync/rules/*.md` Example: @@ -56,6 +58,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ... ``` +> **AGENTS.md standard note (`agentsmd`):** Nested `AGENTS.md` files are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them from `agentsmd.subprojectPath` and, on **import**, discovers them by scanning the project for `**/AGENTS.md`. Hidden directories (other tools' generated output, including rulesync's own) are skipped at any depth, as are `node_modules/` and `__pycache__/`. Build, vendoring and scratch directories (`vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`) are skipped **at the project root only**, because a top-level `build/` is a build directory while `packages/build/` is a real subproject. Beyond those names, the scan honors your `.gitignore`: a file git does not track is not your project's source, and copying a vendored dependency's rule file into version-controlled `.rulesync/rules/` would hand third-party instructions to every tool — including the ones that concatenate non-root rules into a single always-loaded file. (Ignore rules come from the `.gitignore` files at and below the output root — a parent repository's rules are not consulted, so running against a subdirectory only sees that subdirectory's own. The test is applied to the _directories_ above each file, not the file itself, so the `**/AGENTS.md` entry that `rulesync gitignore` writes for its own output does not disable the scan; the flip side is that ignoring one individual `AGENTS.md` no longer keeps it out of the import.) Symbolic links are not followed, so a link committed to a repository cannot pull a file from outside the project into version-controlled `.rulesync/`. The scan is import-only: a nested file rulesync did not write is never removed by `--delete`. That means deleting the rulesync rule stops the reference from being listed in the root `AGENTS.md`, but leaves the subproject file itself on disk — where agents still read it, since the nearest file wins. Remove it by hand. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. A subproject that would claim the reserved `overview.md` name gets an `-agents` suffix instead, so the root rule is never overwritten; any other pair of sources deriving the same name is reported at import time, since only the last one survives. Import always rewrites `.rulesync/rules/`, so a subproject whose derived name matches a rule file you wrote by hand replaces it — pick distinct names, or keep hand-written rules out of the derived namespace. See . + > **Kiro note:** Kiro reads steering files from `.kiro/steering/*.md` and uses an `inclusion` frontmatter block to decide when each is loaded (`always`, `fileMatch` with a `fileMatchPattern`, `manual`, or `auto` — which auto-includes the file when a request matches its companion `description`, keyed by `name`). Rulesync derives this for non-root steering files: an explicit `kiro.inclusion` block round-trips as-is (carrying `name`/`description` through for `auto`); otherwise specific (non-wildcard) `globs` map to `inclusion: fileMatch` (a single glob is written as a string and multiple as a YAML array, both of which Kiro accepts), so the rule applies only to matching files instead of always; otherwise the file stays always-on and is written without a frontmatter block (Kiro's no-frontmatter default). The root overview index is always written plain so Kiro always loads it. In **global** mode (`--global`), steering is written to `~/.kiro/steering/` with the root rule as `~/.kiro/steering/product.md` (Kiro does not read `~/AGENTS.md`, so the project-scope root `AGENTS.md` is not used at the home level), and global MCP is written to `~/.kiro/settings/mcp.json`. > **Kilo Code note:** Kilo writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.kilo/rules/*.md`. Because Kilo v7 does not auto-load files under `.kilo/rules/`, Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `kilo.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved, and the `instructions` list is deduped and sorted. @@ -674,6 +678,10 @@ Skills are directory-based and can include additional files alongside SKILL.md. When `claudecode.scheduled-task: true` is set, that skill is emitted only as a Claude Code scheduled task and is not emitted to other tools even if `targets` contains `"*"`. ``` +> **`.agents/skills/` ownership note:** `.agents/skills/` is not an AGENTS.md convention — the AGENTS.md standard defines only `AGENTS.md` itself. It is the [Agent Skills](https://agentskills.io/specification) project location, which the native `agentsskills` target writes. Several targets write there — `agentsskills`, `agentsmd`, `aiassistant`, `codexcli`, `amp`, `zed`, `replit` and both Antigravity targets — because they all implement the same convention. Each native target writes its own documented frontmatter, so enabling more than one and reordering `--targets` can change which optional keys end up in the file; that is inherent to several tools sharing one path and is not specific to any of them. + +> The **simulated** `agentsmd` writer is the exception that is fixed: it has no frontmatter model of its own (the AGENTS.md standard defines no skills at all), so it used to overwrite the native output with a bare `name`/`description` pair and silently drop `license`, `compatibility`, `metadata` and `allowed-tools`. It now emits exactly what `agentsskills` emits, so a simulated writer can never degrade the file a native target owns. + > **Note:** `claudecode.disallowed-tools` (a space/comma-separated string or a YAML list) removes the listed tools from the model while the skill is active. The same field is available on Claude Code slash commands. Both round-trip through the `claudecode` frontmatter section. > **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, Rulesync emits `.agents/skills//agents/openai.yaml` and reads it back on import. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md). diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 39a4af909..383410a8c 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -6,6 +6,8 @@ Rulesync follows symbolic links when it discovers source files, whether you use The trust boundary is the directory you point Rulesync at. There is **no** `realpath`-based containment check on individual symlinks, so a link may resolve to a target outside the input root — enforcing containment would break the shared-file use case above. Only run Rulesync against trees you control. Directory symlink **cycles** are handled safely: results are deduplicated by real path, so a cycle does not produce duplicated output. Note that the remote-fetch path (`rulesync fetch` from a Git repository) is a separate, hardened code path that **skips** symlinks entirely, so untrusted remote content never has its symlinks followed. +One discovery pass is deliberately excluded from the follow-symlinks rule: the scan for nested `AGENTS.md` files (see the `agentsmd` note below). Unlike every other glob above, it walks the whole project rather than a rulesync-owned directory, so a symlink committed to a repository you cloned could otherwise pull a file from outside the project into version-controlled `.rulesync/`. That scan does not follow symlinks. + ## `rulesync/rules/*.md` Example: @@ -56,6 +58,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ... ``` +> **AGENTS.md standard note (`agentsmd`):** Nested `AGENTS.md` files are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them from `agentsmd.subprojectPath` and, on **import**, discovers them by scanning the project for `**/AGENTS.md`. Hidden directories (other tools' generated output, including rulesync's own) are skipped at any depth, as are `node_modules/` and `__pycache__/`. Build, vendoring and scratch directories (`vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`) are skipped **at the project root only**, because a top-level `build/` is a build directory while `packages/build/` is a real subproject. Beyond those names, the scan honors your `.gitignore`: a file git does not track is not your project's source, and copying a vendored dependency's rule file into version-controlled `.rulesync/rules/` would hand third-party instructions to every tool — including the ones that concatenate non-root rules into a single always-loaded file. (Ignore rules come from the `.gitignore` files at and below the output root — a parent repository's rules are not consulted, so running against a subdirectory only sees that subdirectory's own. The test is applied to the _directories_ above each file, not the file itself, so the `**/AGENTS.md` entry that `rulesync gitignore` writes for its own output does not disable the scan; the flip side is that ignoring one individual `AGENTS.md` no longer keeps it out of the import.) Symbolic links are not followed, so a link committed to a repository cannot pull a file from outside the project into version-controlled `.rulesync/`. The scan is import-only: a nested file rulesync did not write is never removed by `--delete`. That means deleting the rulesync rule stops the reference from being listed in the root `AGENTS.md`, but leaves the subproject file itself on disk — where agents still read it, since the nearest file wins. Remove it by hand. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. A subproject that would claim the reserved `overview.md` name gets an `-agents` suffix instead, so the root rule is never overwritten; any other pair of sources deriving the same name is reported at import time, since only the last one survives. Import always rewrites `.rulesync/rules/`, so a subproject whose derived name matches a rule file you wrote by hand replaces it — pick distinct names, or keep hand-written rules out of the derived namespace. See . + > **Kiro note:** Kiro reads steering files from `.kiro/steering/*.md` and uses an `inclusion` frontmatter block to decide when each is loaded (`always`, `fileMatch` with a `fileMatchPattern`, `manual`, or `auto` — which auto-includes the file when a request matches its companion `description`, keyed by `name`). Rulesync derives this for non-root steering files: an explicit `kiro.inclusion` block round-trips as-is (carrying `name`/`description` through for `auto`); otherwise specific (non-wildcard) `globs` map to `inclusion: fileMatch` (a single glob is written as a string and multiple as a YAML array, both of which Kiro accepts), so the rule applies only to matching files instead of always; otherwise the file stays always-on and is written without a frontmatter block (Kiro's no-frontmatter default). The root overview index is always written plain so Kiro always loads it. In **global** mode (`--global`), steering is written to `~/.kiro/steering/` with the root rule as `~/.kiro/steering/product.md` (Kiro does not read `~/AGENTS.md`, so the project-scope root `AGENTS.md` is not used at the home level), and global MCP is written to `~/.kiro/settings/mcp.json`. > **Kilo Code note:** Kilo writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.kilo/rules/*.md`. Because Kilo v7 does not auto-load files under `.kilo/rules/`, Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `kilo.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved, and the `instructions` list is deduped and sorted. @@ -674,6 +678,10 @@ Skills are directory-based and can include additional files alongside SKILL.md. When `claudecode.scheduled-task: true` is set, that skill is emitted only as a Claude Code scheduled task and is not emitted to other tools even if `targets` contains `"*"`. ``` +> **`.agents/skills/` ownership note:** `.agents/skills/` is not an AGENTS.md convention — the AGENTS.md standard defines only `AGENTS.md` itself. It is the [Agent Skills](https://agentskills.io/specification) project location, which the native `agentsskills` target writes. Several targets write there — `agentsskills`, `agentsmd`, `aiassistant`, `codexcli`, `amp`, `zed`, `replit` and both Antigravity targets — because they all implement the same convention. Each native target writes its own documented frontmatter, so enabling more than one and reordering `--targets` can change which optional keys end up in the file; that is inherent to several tools sharing one path and is not specific to any of them. + +> The **simulated** `agentsmd` writer is the exception that is fixed: it has no frontmatter model of its own (the AGENTS.md standard defines no skills at all), so it used to overwrite the native output with a bare `name`/`description` pair and silently drop `license`, `compatibility`, `metadata` and `allowed-tools`. It now emits exactly what `agentsskills` emits, so a simulated writer can never degrade the file a native target owns. + > **Note:** `claudecode.disallowed-tools` (a space/comma-separated string or a YAML list) removes the listed tools from the model while the skill is active. The same field is available on Claude Code slash commands. Both round-trip through the `claudecode` frontmatter section. > **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, Rulesync emits `.agents/skills//agents/openai.yaml` and reads it back on import. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md). diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 78da01a07..6d05e3c94 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -774,6 +774,36 @@ This is a test project for E2E testing. const importedContent = await readFileContent(importedRulePath); expect(importedContent).toContain("Project Overview"); }); + + // Nested `AGENTS.md` files are the AGENTS.md standard's only scoping + // mechanism ("agents automatically read the nearest file in the directory + // tree"). https://agents.md/ + it("should import nested agentsmd rules and round-trip their subproject scope", async () => { + const testDir = getTestDir(); + + await writeFileContent(join(testDir, "AGENTS.md"), "# Project Overview\n"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API Instructions\n"); + // Vendored and generated trees must stay out of the scan. + await writeFileContent(join(testDir, "node_modules", "dep", "AGENTS.md"), "# Vendored\n"); + await writeFileContent(join(testDir, ".agents", "AGENTS.md"), "# Tool output\n"); + + await runImport({ target: "agentsmd", features: "rules" }); + + const importedNested = await readFileContent( + join(testDir, ".rulesync", "rules", "packages-api.md"), + ); + expect(importedNested).toContain("API Instructions"); + expect(importedNested).toContain("subprojectPath: packages/api"); + expect(await fileExists(join(testDir, ".rulesync", "rules", "node_modules-dep.md"))).toBe( + false, + ); + + await runGenerate({ target: "agentsmd", features: "rules" }); + + expect(await readFileContent(join(testDir, "packages", "api", "AGENTS.md"))).toContain( + "API Instructions", + ); + }); }); const rulesGlobalTargets = [ diff --git a/src/features/rules/agentsmd-rule.test.ts b/src/features/rules/agentsmd-rule.test.ts index 3d6b69254..7fe7f6308 100644 --- a/src/features/rules/agentsmd-rule.test.ts +++ b/src/features/rules/agentsmd-rule.test.ts @@ -1,10 +1,11 @@ -import { join } from "node:path"; +import { rm, symlink } from "node:fs/promises"; +import { basename, join, relative } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { ensureDir, findFilesByGlobs, toPosixPath, writeFileContent } from "../../utils/file.js"; import { AgentsMdRule } from "./agentsmd-rule.js"; import { RulesyncRule } from "./rulesync-rule.js"; @@ -305,6 +306,167 @@ describe("AgentsMdRule", () => { }); }); + describe("nested AGENTS.md files", () => { + it("should match only nested subproject files, against a real tree", async () => { + // Asserting on the returned patterns alone would not catch a pattern that + // silently matches nothing, so run them against actual files. + for (const relativePath of [ + "AGENTS.md", + join("packages", "api", "AGENTS.md"), + join("packages", "api", "src", "AGENTS.md"), + join("node_modules", "dep", "AGENTS.md"), + join("vendor", "lib", "AGENTS.md"), + join("dist", "AGENTS.md"), + join(".agents", "AGENTS.md"), + join(".agents", "memories", "AGENTS.md"), + ]) { + await writeFileContent(join(testDir, relativePath), "# rule"); + } + + const patterns = AgentsMdRule.getNestedFilePatterns({ outputRoot: testDir }); + const matched = await findFilesByGlobs(patterns.include, { + type: "file", + followSymbolicLinks: false, + ignore: patterns.ignore, + }); + + expect( + matched.map((filePath) => toPosixPath(relative(testDir, filePath))).toSorted(), + ).toEqual(["packages/api/AGENTS.md", "packages/api/src/AGENTS.md"]); + }); + + it("should not follow symlinks out of the project", async () => { + // A repository can commit a symlink, so following one would copy a file + // from outside the project into version-controlled `.rulesync/rules/`. + const outsideDir = join(testDir, "..", `outside-${basename(testDir)}`); + await ensureDir(outsideDir); + await writeFileContent(join(outsideDir, "secret.md"), "SECRET"); + await ensureDir(join(testDir, "docs")); + await symlink(join(outsideDir, "secret.md"), join(testDir, "docs", "AGENTS.md")); + await symlink(outsideDir, join(testDir, "linked")); + + const patterns = AgentsMdRule.getNestedFilePatterns({ outputRoot: testDir }); + const matched = await findFilesByGlobs(patterns.include, { + type: "file", + followSymbolicLinks: false, + ignore: patterns.ignore, + }); + + expect(matched).toEqual([]); + await rm(outsideDir, { recursive: true, force: true }); + }); + + it("should import a nested AGENTS.md as a non-root rule scoped to its directory", async () => { + const subprojectDir = join(testDir, "packages", "api"); + await ensureDir(subprojectDir); + await writeFileContent(join(subprojectDir, "AGENTS.md"), "# API\n\nAPI instructions."); + + const rule = await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: join("packages", "api"), + relativeFilePath: "AGENTS.md", + }); + + expect(rule.isRoot()).toBe(false); + expect(rule.getSubprojectPath()).toBe("packages/api"); + expect(rule.getFileContent()).toBe("# API\n\nAPI instructions."); + }); + + it("should round-trip the subproject scope through the rulesync rule", async () => { + const subprojectDir = join(testDir, "packages", "api"); + await ensureDir(subprojectDir); + await writeFileContent(join(subprojectDir, "AGENTS.md"), "# API\n\nAPI instructions."); + + const rulesyncRule = ( + await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: join("packages", "api"), + relativeFilePath: "AGENTS.md", + }) + ).toRulesyncRule(); + + // Every nested file is named AGENTS.md, so the rulesync file is named + // after the directory it scopes. + expect(rulesyncRule.getRelativeFilePath()).toBe("packages-api.md"); + expect(rulesyncRule.getFrontmatter()).toMatchObject({ + root: false, + globs: ["packages/api/**/*"], + agentsmd: { subprojectPath: "packages/api" }, + }); + + // Back out to the same place on the next generate. + const regenerated = AgentsMdRule.fromRulesyncRule({ outputRoot: testDir, rulesyncRule }); + expect(regenerated.getRelativeDirPath()).toBe(join("packages", "api")); + expect(regenerated.getRelativeFilePath()).toBe("AGENTS.md"); + }); + + it("should exclude build directories only at the project root", async () => { + // A top-level `build/` is a build directory; `packages/build/` is a package. + for (const relativePath of [ + join("build", "AGENTS.md"), + join("packages", "build", "AGENTS.md"), + join("packages", "app", "node_modules", "dep", "AGENTS.md"), + ]) { + await writeFileContent(join(testDir, relativePath), "# rule"); + } + + const patterns = AgentsMdRule.getNestedFilePatterns({ outputRoot: testDir }); + const matched = await findFilesByGlobs(patterns.include, { + type: "file", + followSymbolicLinks: false, + ignore: patterns.ignore, + }); + + expect(matched.map((filePath) => toPosixPath(relative(testDir, filePath)))).toEqual([ + "packages/build/AGENTS.md", + ]); + }); + + it("should not claim the reserved overview.md name for an `overview` subproject", async () => { + // Overwriting overview.md would drop the root rule, and the next + // `--delete` would then remove the root AGENTS.md too. + const subprojectDir = join(testDir, "overview"); + await ensureDir(subprojectDir); + await writeFileContent(join(subprojectDir, "AGENTS.md"), "# Overview subproject"); + + const rulesyncRule = ( + await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: "overview", + relativeFilePath: "AGENTS.md", + }) + ).toRulesyncRule(); + + expect(rulesyncRule.getRelativeFilePath()).toBe("overview-agents.md"); + expect(rulesyncRule.getFrontmatter()).toMatchObject({ + agentsmd: { subprojectPath: "overview" }, + }); + }); + + it("should not treat the project root file or a memories file as a subproject", async () => { + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + const memoriesDir = join(testDir, ".agents", "memories"); + await ensureDir(memoriesDir); + await writeFileContent(join(memoriesDir, "AGENTS.md"), "# Memory"); + + const rootRule = await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + }); + expect(rootRule.isRoot()).toBe(true); + expect(rootRule.getSubprojectPath()).toBeUndefined(); + + const memoryRule = await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: join(".agents", "memories"), + relativeFilePath: "AGENTS.md", + }); + expect(memoryRule.isRoot()).toBe(false); + expect(memoryRule.getSubprojectPath()).toBeUndefined(); + }); + }); + describe("validate", () => { it("should always return success for any content", () => { const rule = new AgentsMdRule({ diff --git a/src/features/rules/agentsmd-rule.ts b/src/features/rules/agentsmd-rule.ts index f6c29c284..0ced2eb84 100644 --- a/src/features/rules/agentsmd-rule.ts +++ b/src/features/rules/agentsmd-rule.ts @@ -5,14 +5,19 @@ import { AGENTSMD_MEMORIES_DIR_PATH, AGENTSMD_RULE_FILE_NAME, } from "../../constants/agentsmd-paths.js"; +import { + RULESYNC_OVERVIEW_FILE_NAME, + RULESYNC_RULES_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; -import { readFileContent } from "../../utils/file.js"; +import { readFileContent, toPosixPath } from "../../utils/file.js"; import { RulesyncRule } from "./rulesync-rule.js"; import { ToolRule, ToolRuleForDeletionParams, ToolRuleFromFileParams, ToolRuleFromRulesyncRuleParams, + ToolRuleNestedFilePatterns, ToolRuleSettablePaths, buildToolPath, } from "./tool-rule.js"; @@ -31,6 +36,33 @@ export type AgentsMdRuleSettablePaths = Omit & { }; }; +/** + * Dependency trees never scanned for nested `AGENTS.md` files, at any depth. An + * `AGENTS.md` there describes somebody else's project, and neither name is ever + * a package name. Hidden directories are excluded separately, because an + * `AGENTS.md` inside one is another tool's generated output (rulesync writes + * several itself). + */ +const NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH = ["node_modules", "__pycache__"]; + +/** + * Build, vendoring and scratch directories, excluded at the **project root + * only**. A top-level `build/` is a build directory; `packages/build/` is a + * package, and dropping it silently would lose a real subproject. + */ +const NESTED_SCAN_EXCLUDED_ROOT_DIRS = [ + "vendor", + "third_party", + "dist", + "build", + "out", + "target", + "coverage", + "tmp", + "temp", + "venv", +]; + export class AgentsMdRule extends ToolRule { constructor({ fileContent, root, ...rest }: AgentsMdRuleParams) { super({ @@ -57,23 +89,84 @@ export class AgentsMdRule extends ToolRule { }; } + /** + * Patterns for the nested `AGENTS.md` files that are the standard's only scoping + * mechanism — "Agents automatically read the nearest file in the directory + * tree, so the closest one takes precedence and every subproject can ship + * tailored instructions." The project root file is excluded because it is + * enumerated separately as the root rule. + * + * Import-only. The matches are hand-authored files anywhere in the tree rather + * than files under a rulesync-owned directory, so enumerating them for + * `--delete` would sweep away work rulesync never wrote. + * + * @see https://agents.md/ + */ + static getNestedFilePatterns({ outputRoot }: { outputRoot: string }): ToolRuleNestedFilePatterns { + const root = toPosixPath(outputRoot); + return { + include: [`${root}/**/${AGENTSMD_RULE_FILE_NAME}`], + ignore: [ + // Enumerated separately as the root rule. + `${root}/${AGENTSMD_RULE_FILE_NAME}`, + `${root}/**/.*/**`, + ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`), + ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`), + ], + }; + } + + /** + * The subproject directory this rule scopes, or `undefined` for the project + * root file and for the modular `.agents/memories/` files. + */ + getSubprojectPath(): string | undefined { + if (this.isRoot() || this.getRelativeFilePath() !== AGENTSMD_RULE_FILE_NAME) { + return undefined; + } + const relativeDirPath = toPosixPath(this.getRelativeDirPath()); + if (relativeDirPath === "." || relativeDirPath === "" || relativeDirPath.startsWith(".")) { + return undefined; + } + return relativeDirPath; + } + static async fromFile({ outputRoot = process.cwd(), + relativeDirPath, relativeFilePath, validate = true, }: ToolRuleFromFileParams): Promise { - // Determine if it's a root file based on path - const isRoot = relativeFilePath === AGENTSMD_RULE_FILE_NAME; - const relativePath = isRoot - ? AGENTSMD_RULE_FILE_NAME - : join(AGENTSMD_MEMORIES_DIR_PATH, relativeFilePath); + // A nested subproject file is an `AGENTS.md` somewhere other than the project + // root and outside the tool's own `.agents/` tree. + const normalizedDirPath = relativeDirPath === undefined ? "." : toPosixPath(relativeDirPath); + const isNested = + relativeFilePath === AGENTSMD_RULE_FILE_NAME && + normalizedDirPath !== "." && + normalizedDirPath !== "" && + !normalizedDirPath.startsWith("."); + // Only the file at the project root is the root rule. A modular file that + // happens to be named `AGENTS.md` under `.agents/memories/` is not. + const isRoot = + !isNested && + relativeFilePath === AGENTSMD_RULE_FILE_NAME && + (normalizedDirPath === "." || normalizedDirPath === ""); + const relativePath = isNested + ? join(normalizedDirPath, relativeFilePath) + : isRoot + ? AGENTSMD_RULE_FILE_NAME + : join(AGENTSMD_MEMORIES_DIR_PATH, relativeFilePath); const fileContent = await readFileContent(join(outputRoot, relativePath)); return new AgentsMdRule({ outputRoot, - relativeDirPath: isRoot - ? this.getSettablePaths().root.relativeDirPath - : this.getSettablePaths().nonRoot.relativeDirPath, + // `join` so the stored path uses native separators like every other + // construction path (`fromRulesyncRule` builds it the same way). + relativeDirPath: isNested + ? join(normalizedDirPath) + : isRoot + ? this.getSettablePaths().root.relativeDirPath + : this.getSettablePaths().nonRoot.relativeDirPath, relativeFilePath: isRoot ? AGENTSMD_RULE_FILE_NAME : relativeFilePath, fileContent, validate, @@ -115,7 +208,37 @@ export class AgentsMdRule extends ToolRule { } toRulesyncRule(): RulesyncRule { - return this.toRulesyncRuleDefault(); + const subprojectPath = this.getSubprojectPath(); + if (subprojectPath === undefined) { + return this.toRulesyncRuleDefault(); + } + + // Every nested file is named `AGENTS.md`, so the rulesync file is named after + // the directory it scopes; `subprojectPath` sends it back to the same place + // on the next generate. A subproject that would claim the reserved root-rule + // name gets a suffix instead: overwriting `overview.md` would drop the root + // rule entirely, and the next `--delete` would then remove the root + // `AGENTS.md` along with it. + // Compared case-insensitively: on a case-insensitive filesystem an + // `Overview/` subproject would otherwise still land on the root rule's file. + const slug = subprojectPath.replaceAll("/", "-"); + const derivedName = `${slug}.md`; + return new RulesyncRule({ + outputRoot: process.cwd(), + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: + derivedName.toLowerCase() === RULESYNC_OVERVIEW_FILE_NAME.toLowerCase() + ? `${slug}-agents.md` + : derivedName, + frontmatter: { + root: false, + targets: ["*"], + description: this.getDescription(), + globs: [`${subprojectPath}/**/*`], + agentsmd: { subprojectPath }, + }, + body: this.getFileContent(), + }); } validate(): ValidationResult { diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 5208591c4..1a837ecc2 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -686,6 +686,112 @@ describe("RulesProcessor", () => { expect(claudecodePaths).toContain(join("backend", "api-rule.md")); }); + it("should discover nested AGENTS.md files on import but never for deletion", async () => { + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + + const imported = await processor.loadToolFiles(); + expect( + imported.map((file) => join(file.getRelativeDirPath(), file.getRelativeFilePath())), + ).toContain(join("packages", "api", "AGENTS.md")); + + // A nested file rulesync did not write must never become a deletion + // candidate — it is the user's own file, anywhere in the tree. + const forDeletion = await processor.loadToolFiles({ forDeletion: true }); + expect( + forDeletion.map((file) => join(file.getRelativeDirPath(), file.getRelativeFilePath())), + ).not.toContain(join("packages", "api", "AGENTS.md")); + }); + + it("should skip nested AGENTS.md files the project gitignores", async () => { + // A vendored dependency's rule file is third-party content the user + // deliberately kept untracked; importing it would copy it into + // version-controlled `.rulesync/rules/`. + await writeFileContent(join(testDir, ".gitignore"), "services/api/vendor/\n"); + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + await writeFileContent( + join(testDir, "services", "api", "vendor", "dep", "AGENTS.md"), + "# Vendored", + ); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + const files = await processor.loadToolFiles(); + const paths = files.map((file) => + join(file.getRelativeDirPath(), file.getRelativeFilePath()), + ); + + expect(paths).toContain(join("packages", "api", "AGENTS.md")); + expect(paths).not.toContain(join("services", "api", "vendor", "dep", "AGENTS.md")); + }); + + it("should still find nested files when .gitignore excludes the generated file name", async () => { + // `rulesync gitignore` writes `**/AGENTS.md` for its own output, so a + // file-level ignore test would silently disable the whole scan. + await writeFileContent(join(testDir, ".gitignore"), "services/api/vendor/\n**/AGENTS.md\n"); + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + await writeFileContent( + join(testDir, "services", "api", "vendor", "dep", "AGENTS.md"), + "# Vendored", + ); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + const paths = (await processor.loadToolFiles()).map((file) => + join(file.getRelativeDirPath(), file.getRelativeFilePath()), + ); + + expect(paths).toContain(join("packages", "api", "AGENTS.md")); + expect(paths).not.toContain(join("services", "api", "vendor", "dep", "AGENTS.md")); + }); + + it("should warn when two rule files import to the same rulesync file name", async () => { + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + await writeFileContent(join(testDir, "packages-api", "AGENTS.md"), "# Also API"); + + logger.warn.mockClear(); + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + await processor.convertToolFilesToRulesyncFiles(await processor.loadToolFiles()); + + expect( + logger.warn.mock.calls.some(([message]) => + String(message).includes(join(RULESYNC_RULES_RELATIVE_DIR_PATH, "packages-api.md")), + ), + ).toBe(true); + }); + + it("should not warn when every rule file maps to a distinct rulesync name", async () => { + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + await writeFileContent(join(testDir, "packages", "web", "AGENTS.md"), "# Web"); + await writeFileContent(join(testDir, ".agents", "memories", "extra.md"), "# Extra"); + + logger.warn.mockClear(); + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + await processor.convertToolFilesToRulesyncFiles(await processor.loadToolFiles()); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("should keep an `Overview` subproject away from the reserved root-rule name", async () => { + // Case-insensitive filesystems would otherwise resolve `Overview.md` and + // the root rule's `overview.md` to the same file. + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "Overview", "AGENTS.md"), "# Overview subproject"); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + const rulesyncFiles = await processor.convertToolFilesToRulesyncFiles( + await processor.loadToolFiles(), + ); + + expect(rulesyncFiles.map((file) => file.getRelativeFilePath()).toSorted()).toEqual([ + "Overview-agents.md", + "overview.md", + ]); + }); + it("should load CLAUDE.md from .claude/ directory when only .claude/CLAUDE.md exists", async () => { await ensureDir(join(testDir, ".claude")); await writeFileContent(join(testDir, ".claude", "CLAUDE.md"), "# Project from .claude dir"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 3a0cfdab6..981dda3f7 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -15,7 +15,12 @@ import { ToolFile } from "../../types/tool-file.js"; import { rulesProcessorToolTargetTuple } from "../../types/tool-target-tuples.js"; import { ToolTarget } from "../../types/tool-targets.js"; import { formatError } from "../../utils/error.js"; -import { checkPathTraversal, findFilesByGlobs, toPosixPath } from "../../utils/file.js"; +import { + checkPathTraversal, + filterOutPathsInGitIgnoredDirectories, + findFilesByGlobs, + toPosixPath, +} from "../../utils/file.js"; import type { Logger } from "../../utils/logger.js"; import { AgentsmdCommand } from "../commands/agentsmd-command.js"; import { CommandsProcessor } from "../commands/commands-processor.js"; @@ -71,6 +76,7 @@ import { ToolRuleForDeletionParams, ToolRuleFromFileParams, ToolRuleFromRulesyncRuleParams, + ToolRuleNestedFilePatterns, ToolRuleSettablePaths, ToolRuleSettablePathsGlobal, } from "./tool-rule.js"; @@ -245,6 +251,15 @@ type ToolRuleFactory = { * are cleaned up when no rule targets them. See {@link PiRule.getExtraFixedFiles}. */ getExtraFixedFiles?(params: { global?: boolean }): ToolRuleExtraFixedFile[]; + /** + * Patterns for rule files this tool discovers by glob rather than at a fixed + * path, used when the tool's scoping mechanism is the same file name repeated + * in subdirectories (the AGENTS.md standard's nested files). Import-only: + * the matches are hand-authored files outside any rulesync-owned directory, + * so enumerating them for `--delete` would sweep away work rulesync never + * wrote. See {@link AgentsMdRule.getNestedFilePatterns}. + */ + getNestedFilePatterns?(params: { outputRoot: string }): ToolRuleNestedFilePatterns; }; meta: { /** File extension for the rule file */ @@ -1311,6 +1326,29 @@ As this project's AI coding tool, you must follow the additional conventions bel return toolRule.toRulesyncRule(); }); + // Several tool files can derive the same rulesync file name — most easily + // with the AGENTS.md standard's nested files, where every source is named + // `AGENTS.md` and the rulesync name comes from the directory. The writer + // overwrites, so without this the earlier rule disappears silently. + // Keyed case-insensitively, because on a case-insensitive filesystem + // `Docs.md` and `docs.md` are one file. + const claimedBy = new Map(); + for (const [index, rulesyncRule] of rulesyncRules.entries()) { + const target = rulesyncRule.getRelativeFilePath(); + const source = join( + toolRules[index]!.getRelativeDirPath(), + toolRules[index]!.getRelativeFilePath(), + ); + const previous = claimedBy.get(target.toLowerCase()); + if (previous === undefined) { + claimedBy.set(target.toLowerCase(), source); + continue; + } + this.logger.warn( + `Both ${previous} and ${source} import to ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, target)} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`, + ); + } + return rulesyncRules; } @@ -1626,6 +1664,57 @@ As this project's AI coding tool, you must follow the additional conventions bel })(); this.logger.debug(`Found ${extraFixedToolRules.length} extra fixed tool rule files`); + // Pattern-discovered rule files (the AGENTS.md standard's nested + // subproject files). Import only — see `getNestedFileGlobs`. + const nestedToolRules = await (async () => { + // Never in global mode: the output root is the home directory there, and + // walking all of it looking for subprojects is both wrong and expensive. + const patterns = this.global + ? undefined + : factory.class.getNestedFilePatterns?.({ outputRoot: this.outputRoot }); + if (forDeletion || !patterns || patterns.include.length === 0) { + return []; + } + + // Symlinks are not followed. Unlike the fixed-path scans, this one walks + // the whole project tree, so a symlink committed to a repository could + // otherwise pull a file from outside the project (a key, a dotfile) into + // version-controlled `.rulesync/rules/`. Not following them also keeps a + // pair of directory symlinks from exploding the traversal. + const matchedPaths = await findFilesByGlobs(patterns.include, { + type: "file", + followSymbolicLinks: false, + ignore: patterns.ignore, + }); + + // The project's own statement of what is not its source. Without it a + // vendored dependency's rule file — third-party content the user + // deliberately kept untracked — would be copied into version-controlled + // `.rulesync/rules/`, and targets that concatenate non-root rules into + // one file would then load it unconditionally. + const filePaths = filterOutPathsInGitIgnoredDirectories({ + rootDir: this.outputRoot, + filePaths: matchedPaths, + }); + + return await Promise.all( + filePaths.map((filePath) => { + const relativeDirPath = resolveRelativeDirPath(filePath); + checkPathTraversal({ + relativePath: relativeDirPath, + intendedRootDir: this.outputRoot, + }); + return factory.class.fromFile({ + outputRoot: this.outputRoot, + relativeDirPath, + relativeFilePath: basename(filePath), + global: this.global, + }); + }), + ); + })(); + this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`); + const nonRootToolRules = await (async () => { if (!settablePaths.nonRoot) { return []; @@ -1697,6 +1786,7 @@ As this project's AI coding tool, you must follow the additional conventions bel ...localRootToolRules, ...rootMirrorDeletionRules, ...extraFixedToolRules, + ...nestedToolRules, ...nonRootToolRules, ]; } catch (error) { diff --git a/src/features/rules/tool-rule.ts b/src/features/rules/tool-rule.ts index dff34388b..3f467d32b 100644 --- a/src/features/rules/tool-rule.ts +++ b/src/features/rules/tool-rule.ts @@ -44,6 +44,21 @@ export type ToolRuleExtraFixedFile = { relativeFilePath: string; }; +/** + * Glob patterns for rule files a tool discovers by pattern rather than at a + * fixed path (the AGENTS.md standard's nested subproject files). Returned by the + * optional static `getNestedFilePatterns` hook and consumed by the + * RulesProcessor on import. + * + * `ignore` is separate from `include` rather than expressed as `!` patterns + * because globby rewrites a negative pattern containing no glob metacharacter as + * cwd-relative, which makes an absolute one silently match nothing. + */ +export type ToolRuleNestedFilePatterns = { + include: string[]; + ignore: string[]; +}; + export type ToolRuleSettablePaths = { root?: { relativeDirPath: string; diff --git a/src/features/skills/agentsmd-skill.test.ts b/src/features/skills/agentsmd-skill.test.ts index 41427faf0..e5351ca21 100644 --- a/src/features/skills/agentsmd-skill.test.ts +++ b/src/features/skills/agentsmd-skill.test.ts @@ -4,9 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SKILL_FILE_NAME } from "../../constants/general.js"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { createMockLogger } from "../../test-utils/mock-logger.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, writeFileContent } from "../../utils/file.js"; import { AgentsmdSkill } from "./agentsmd-skill.js"; +import { AgentsSkillsSkill } from "./agentsskills-skill.js"; import { RulesyncSkill } from "./rulesync-skill.js"; describe("AgentsmdSkill", () => { @@ -125,6 +127,61 @@ This is the body of the agentsmd skill.`; description: "Test skill description", }); }); + + it("should report the same spec violations the native writer would", () => { + const logger = createMockLogger(); + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "My_Bad--Name", + frontmatter: { name: "My_Bad--Name", description: "Test skill description" }, + body: "Test body content", + validate: true, + }); + + AgentsmdSkill.fromRulesyncSkill({ rulesyncSkill, logger }); + + expect( + logger.warn.mock.calls.some(([message]) => + String(message).includes("lowercase letters, digits and single hyphens"), + ), + ).toBe(true); + }); + + it("should emit the same frontmatter as the native writer that owns .agents/skills/", () => { + // Both targets resolve to `.agents/skills//SKILL.md`, so whichever + // runs last must not change the file or drop the Agent Skills fields. + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "test-skill", + description: "Test skill description", + agentsskills: { + license: "Apache-2.0", + compatibility: "Requires Python 3.14+ and uv", + metadata: { version: 1 }, + "allowed-tools": ["Read", "Bash(git:*)"], + }, + }, + body: "Test body content", + validate: true, + }); + + const agentsmdSkill = AgentsmdSkill.fromRulesyncSkill({ rulesyncSkill }); + const agentsSkillsSkill = AgentsSkillsSkill.fromRulesyncSkill({ rulesyncSkill }); + + expect(agentsmdSkill.getFrontmatter()).toEqual(agentsSkillsSkill.getFrontmatter()); + expect(agentsmdSkill.getFrontmatter()).toEqual({ + name: "test-skill", + description: "Test skill description", + license: "Apache-2.0", + compatibility: "Requires Python 3.14+ and uv", + metadata: { version: "1" }, + "allowed-tools": "Read Bash(git:*)", + }); + }); }); describe("isTargetedByRulesyncSkill", () => { diff --git a/src/features/skills/agentsmd-skill.ts b/src/features/skills/agentsmd-skill.ts index 3a1a8dac5..5cbe69807 100644 --- a/src/features/skills/agentsmd-skill.ts +++ b/src/features/skills/agentsmd-skill.ts @@ -1,6 +1,7 @@ import { AGENTSMD_SKILLS_DIR_PATH } from "../../constants/agentsmd-paths.js"; +import { AgentsSkillsSkill, toSpecConformantAgentSkillFields } from "./agentsskills-skill.js"; import { RulesyncSkill } from "./rulesync-skill.js"; -import { SimulatedSkill, SimulatedSkillParams } from "./simulated-skill.js"; +import { SimulatedSkill } from "./simulated-skill.js"; import { ToolSkillForDeletionParams, ToolSkillFromDirParams, @@ -12,6 +13,16 @@ import { * Represents a simulated skill for AGENTS.md. * Since AGENTS.md doesn't have native skill support, this provides * a compatible skill directory format at .agents/skills/. + * + * `.agents/skills/` is not an AGENTS.md convention — the standard defines only + * `AGENTS.md` itself. It is the Agent Skills standard's project location, which + * the native `agentsskills` target writes to as well, so both targets resolve to + * the same file. To keep that harmless, this writer emits exactly the frontmatter + * `AgentsSkillsSkill` emits: whichever target runs last, the file on disk is the + * same, and the standard's optional fields are not dropped. + * + * @see https://agents.md/ + * @see https://agentskills.io/specification */ export class AgentsmdSkill extends SimulatedSkill { static getSettablePaths(options?: { global?: boolean }): ToolSkillSettablePaths { @@ -29,11 +40,27 @@ export class AgentsmdSkill extends SimulatedSkill { } static fromRulesyncSkill(params: ToolSkillFromRulesyncSkillParams): AgentsmdSkill { - const baseParams: SimulatedSkillParams = { - ...this.fromRulesyncSkillDefault(params), - relativeDirPath: this.getSettablePaths().relativeDirPath, + const defaults = this.fromRulesyncSkillDefault(params); + const relativeDirPath = this.getSettablePaths().relativeDirPath; + const frontmatter = { + ...defaults.frontmatter, + // Same shared block, same normalization as the native target that owns + // this path, so the two writers cannot disagree about the file. + ...toSpecConformantAgentSkillFields(params.rulesyncSkill.getFrontmatter().agentsskills), }; - return new AgentsmdSkill(baseParams); + + // Same file, same diagnostics: generating for this target alone must report + // the spec violations the native target would have reported. + AgentsSkillsSkill.reportSpecViolations({ + outputRoot: params.outputRoot ?? process.cwd(), + relativeDirPath, + dirName: params.rulesyncSkill.getDirName(), + frontmatter, + sourceAllowedTools: params.rulesyncSkill.getFrontmatter().agentsskills?.["allowed-tools"], + logger: params.logger, + }); + + return new AgentsmdSkill({ ...defaults, relativeDirPath, frontmatter }); } static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { diff --git a/src/features/skills/agentsskills-skill.ts b/src/features/skills/agentsskills-skill.ts index 3e57369c3..cd90135bd 100644 --- a/src/features/skills/agentsskills-skill.ts +++ b/src/features/skills/agentsskills-skill.ts @@ -444,7 +444,7 @@ export class AgentsSkillsSkill extends ToolSkill { * skill points at the file that actually gets written under the home * directory rather than a same-named project path. */ - protected static reportSpecViolations({ + static reportSpecViolations({ outputRoot, relativeDirPath, dirName, diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index 29691ebab..80a7b259a 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -17,6 +17,7 @@ import { createPathResolver, directoryExists, ensureDir, + filterOutPathsInGitIgnoredDirectories, fileExists, findFiles, findFilesByGlobs, @@ -382,6 +383,51 @@ describe("file utilities", () => { }); }); + describe("filterOutPathsInGitIgnoredDirectories", () => { + it("should drop files inside an ignored directory but keep the rest", async () => { + await writeFileContent(join(testDir, ".gitignore"), "vendored/\n"); + const kept = join(testDir, "packages", "api", "AGENTS.md"); + const dropped = join(testDir, "vendored", "dep", "AGENTS.md"); + await writeFileContent(kept, "keep"); + await writeFileContent(dropped, "drop"); + + expect( + filterOutPathsInGitIgnoredDirectories({ rootDir: testDir, filePaths: [kept, dropped] }), + ).toEqual([kept]); + }); + + it("should ignore a rule that matches the files themselves", async () => { + // `rulesync gitignore` writes `**/AGENTS.md` for its own output; testing + // the files rather than their directories would disable every scan. + await writeFileContent(join(testDir, ".gitignore"), "**/AGENTS.md\n"); + const filePath = join(testDir, "packages", "api", "AGENTS.md"); + await writeFileContent(filePath, "keep"); + + expect( + filterOutPathsInGitIgnoredDirectories({ rootDir: testDir, filePaths: [filePath] }), + ).toEqual([filePath]); + }); + + it("should not recurse forever for a path outside the root", () => { + // `dirname("/")` is `"/"`, so walking ancestors would not terminate. + expect( + filterOutPathsInGitIgnoredDirectories({ + rootDir: join(testDir, "nested"), + filePaths: ["/etc/hostname"], + }), + ).toEqual(["/etc/hostname"]); + }); + + it("should keep everything when the project has no ignore rules", async () => { + const filePath = join(testDir, "packages", "api", "AGENTS.md"); + await writeFileContent(filePath, "keep"); + + expect( + filterOutPathsInGitIgnoredDirectories({ rootDir: testDir, filePaths: [filePath] }), + ).toEqual([filePath]); + }); + }); + describe("fileExists", () => { it("should return true for existing file", async () => { const filePath = join(testDir, "exists.txt"); diff --git a/src/utils/file.ts b/src/utils/file.ts index 6afd1df9e..cbd97ba66 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -14,7 +14,7 @@ import os from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { kebabCase } from "es-toolkit"; -import { globbySync } from "globby"; +import { globbySync, isGitIgnoredSync } from "globby"; import { formatError } from "./error.js"; import { isEnvTest } from "./vitest.js"; @@ -143,6 +143,57 @@ export async function ensureDir(dirPath: string): Promise { } } +/** + * Drop paths that sit inside a directory the project's git ignore rules exclude. + * + * Deliberately tests the **directories** above each file rather than the file + * itself. A project that ran `rulesync gitignore` has patterns for rulesync's + * own outputs — `**\/AGENTS.md` among them — so a file-level test would exclude + * every match and quietly disable the scan. What this is for is skipping + * vendored and generated *trees*: content the project deliberately does not + * track, which must not be copied into version-controlled rulesync sources. + * + * Ignore rules come from the `.gitignore` files at and below `rootDir`; a parent + * repository's rules are not consulted, so running against a subdirectory of a + * repository only sees that subdirectory's own rules. + */ +export function filterOutPathsInGitIgnoredDirectories({ + rootDir, + filePaths, +}: { + rootDir: string; + filePaths: string[]; +}): string[] { + if (filePaths.length === 0) { + // Building the matcher scans the tree for `.gitignore` files, which is not + // worth doing when there is nothing to filter. + return filePaths; + } + + const isIgnored = isGitIgnoredSync({ cwd: rootDir }); + const resolvedRoot = resolve(rootDir); + const cache = new Map(); + + const isInIgnoredDirectory = (directory: string): boolean => { + const cached = cache.get(directory); + if (cached !== undefined) { + return cached; + } + const parent = dirname(directory); + // Stop at `rootDir`, and at the filesystem root for a path that never + // reaches it — `dirname("/")` is `"/"`, so walking up would not terminate. + const ignored = + directory !== resolvedRoot && + parent !== directory && + // The trailing slash is what makes a `vendored/` rule match the directory. + (isIgnored(`${toPosixPath(directory)}/`) || isInIgnoredDirectory(parent)); + cache.set(directory, ignored); + return ignored; + }; + + return filePaths.filter((filePath) => !isInIgnoredDirectory(dirname(resolve(filePath)))); +} + /** * Converts OS-native path separators to POSIX forward slashes. * Use this instead of `path.posix.join` when input segments may already @@ -320,9 +371,23 @@ export async function findFiles(dir: string, extension: string = ".md"): Promise export async function findFilesByGlobs( globs: string | string[], - options: { type?: "file" | "dir" | "all"; followSymbolicLinks?: boolean } = {}, + options: { + type?: "file" | "dir" | "all"; + followSymbolicLinks?: boolean; + /** + * Patterns to exclude, passed to globby's `ignore`. Prefer this over inline + * `!` patterns: globby rewrites a negative pattern that contains no glob + * metacharacter as cwd-relative, so an absolute `!/abs/path/file.md` silently + * matches nothing. + * + * Match the form of the include patterns: when those are absolute, a + * relative `ignore` such as `dist/**` silently excludes nothing. Either use + * absolute ignore patterns or anchor them with a leading `**\/`. + */ + ignore?: string[]; + } = {}, ): Promise { - const { type = "all", followSymbolicLinks = true } = options; + const { type = "all", followSymbolicLinks = true, ignore } = options; const globbyOptions = type === "file" ? { onlyFiles: true, onlyDirectories: false } @@ -340,6 +405,7 @@ export async function findFilesByGlobs( const results = globbySync(normalizedGlobs, { absolute: true, followSymbolicLinks, + ...(ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {}), ...globbyOptions, }); // Deduplicate by real path so that directory symlink cycles (which globby follows up to