From a6347d4e65bdce1e10156fc09e48625ec86aa500 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 16 Oct 2026 16:12:05 -0500 Subject: [PATCH 1/2] feat(adf): adf vendor rename-legacy and legacy agents.md detection (closes #302) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + README.md | 1 + docs/cli-reference.md | 18 +- packages/cli/README.md | 4 +- .../__tests__/adf-vendor-doctor-score.test.ts | 2 +- .../src/__tests__/adf-vendor-legacy.test.ts | 649 ++++++++++++++++++ packages/cli/src/commands/adf-compile.ts | 22 +- .../cli/src/commands/adf-manifest-register.ts | 26 + packages/cli/src/commands/adf-vendor.ts | 406 ++++++++++- packages/cli/src/commands/adf.ts | 2 +- packages/cli/src/commands/doctor.ts | 48 +- 11 files changed, 1164 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/__tests__/adf-vendor-legacy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab4693..12a6608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,12 @@ The format is based on Keep a Changelog and follows Semantic Versioning. - **`charter score` counts a thin pointer as substantive when `.ai/` is** (`#318`, part 3) — score numbers in JSON change, and only upward. A pointer `CLAUDE.md`, `.cursorrules` or `AGENTS.md` used to score as "thin" (−5, and "Expand `CLAUDE.md` beyond a thin pointer") however much its `.ai/` held. It is now substantive when its text is, or when the file `adf compile` would render for it is (compiled in memory; nothing is written). Hand-authored and compiled files are scored on their text as before, so no score goes down. `signals.agentConfig` adds `mode`, `source` and `substantiveVia` per file. Recommendations no longer tell you to edit a pointer or compiled `CLAUDE.md`; a pointer with little to route to gets "Add repo commands and guardrails to `.ai/core.adf` (or run: charter adf populate)". - **`charter adf migrate` without `--source` skips a file recorded `unmanaged`** (`#318`, part 3) — naming the file with `--source` still migrates it, and now changes its entry to `pointer` before the pointer is written (back to `unmanaged` if the migration fails), so doctor reports no drift afterwards. - **`adf compile --check` names `charter adf vendor restore ` instead of `git checkout -- `** (`#318`, part 3) — for a missing recorded pointer, and for a recorded pointer that now holds another mode's content. +- **A legacy-keyed `AGENTS.md` gets a specific refusal from `adf compile` and `adf vendor set`** (`#302`, `#318` part 6) — when `AGENTS.md` is recorded under the legacy name `agents.md`, `adf compile --target agents|all --write --mode compiled` used to report the generic `refused-record`. It now reports `refused-legacy-spelling` (new `targets[].status` value, exit 1), renders nothing and leaves the record alone. Without `--mode`, the skip or refusal remedy becomes `charter adf vendor rename-legacy && charter adf compile --target agents --write --mode compiled`. `adf vendor set AGENTS.md compiled` exits 1 with a message naming the rename instead of "no compile target writes it". Doctor's `legacy-spelling` row now names `charter adf vendor rename-legacy`, and sets `remedy` to it. ### Added +- **`charter adf vendor rename-legacy`, and doctor rows for a tracked lowercase `agents.md`** (`#302`, `#318` part 6) — repos bootstrapped before `#297` track `agents.md`, which nothing renamed. `rename-legacy` moves the `VENDORS` key first (`agents.md: ` becomes `AGENTS.md: `, or a same-mode duplicate `agents.md` line is removed through a new `removeManifestBullet` and the same re-parse guard), then renames in two steps through `agents.md.charter-rename`, which works on case-insensitive filesystems. **Index effect:** when git tracks `agents.md`, both steps are `git mv`, so the command leaves a staged rename (`R agents.md -> AGENTS.md`) and stages nothing else. `manifest.adf` is left unstaged, and the output names `git add .ai/manifest.adf && git commit`. An untracked file, or a directory outside git, is renamed on disk only. **Content effect:** the only edit is a first line that is exactly `# agents.md`, rewritten to `# AGENTS.md` and left unstaged. Mentions of `agents.md` elsewhere are listed, never edited. It refuses (exit 1, nothing changed) when both spellings are tracked or are two different files (it never merges or deletes), when `agents.md` has uncommitted changes, when the temp name exists, or when the two keys are recorded with different modes. If a rename step fails, the completed step is undone and the record put back. `--dry-run` writes nothing, and `--format json` prints one result. Nothing calls it automatically: bootstrap, tidy, compile and the hook never rename. `charter doctor`'s `adf vendor mode` check adds three INFO rows read from the git index only: `legacy-tracked` (names `rename-legacy`), `legacy-both-distinct` (merge into `AGENTS.md`, then `git rm agents.md`) and `legacy-both-one-file` (`git rm --cached agents.md`). They never fail `--ci`. + - **`charter adf tidy --source` is repeatable, and `--print-paths` lists the files tidy writes** (`#320`) — each `--source` is processed in argument order, and two names for one file (`AGENTS.md` and `agents.md` on a case-insensitive filesystem, or a hard link) are tidied once, under the first name. A `--source` with no value is now a usage error (exit 2); it used to tidy every vendor file silently. `--print-paths` prints one `vendor ` or `module ` line per file tidy writes (or would write, under `--dry-run`) and nothing else on stdout; warnings go to stderr. It cannot be combined with `--format json` (exit 2). JSON output adds `paths` (`vendor` and `modules`); `modulesModified` is unchanged. A single `--source` gives the same output as before. - **`charter adf vendor restore `** (`#318`, part 3) — rewrites one vendor file as its thin pointer template (`CLAUDE.md` gets the hybrid template with its module index, as bootstrap writes it), carrying `## Environment` and other retained sections over verbatim. It runs only for a file in `pointer` mode, or an absent file with no entry, and refuses (exit 1, nothing written) whenever content would be lost: hand-authored content, content `adf tidy` would extract, any line of a pointer that is not from a Charter template and that the new pointer would not carry (an edited title, an extra note, edited `## Session Start` text), your own sections in a compiled file, or a compiled file that differs from what `.ai/` compiles to now (checked in memory). It is the fix doctor names for a recorded pointer that now holds compiled output. A file with no entry gets `pointer` recorded first; a failed file write puts the manifest back and is reported as `write-failed` with `writeError`. `--dry-run` and `--format json` are supported. - **`charter adf migrate` registers the modules it routes content into** (`#239`, completes the migrate half of `#241`) — migrate could create `infra.adf`, `frontend.adf` or `core.adf` without adding them to `.ai/manifest.adf` or the `CLAUDE.md` module index, so no agent knew to load them and `charter doctor` failed until someone wired them by hand. Each target module that is not already in the manifest is now registered after the module merge and before the pointer is written, so the hybrid `CLAUDE.md` index includes it. A source other than `CLAUDE.md` that adds a module refreshes that index afterwards. `core.adf` and `state.adf` go to `DEFAULT_LOAD`, through the same classification `doctor` checks. Other modules go to `ON_DEMAND` with up to six triggers inferred from the headings of the items routed there. The heading word the router matched counts double, a named module's curated triggers come first, words that already trigger another module are dropped, and the lowercase module stem is the fallback. To supply that heading word, `@stackbilt/adf`'s `RoutingTrace` gains an optional `headingKeyword`. `classifyElement` now always sets `routingTrace`, even without a trigger map, in which case `candidateScores` is `{}`. That is an additive change, but code that read a defined `routingTrace` as "content routing ran" should check `candidateScores` instead. Registration is append-only. New bullets are inserted as text after the section's last item, in that item's indentation and bullet style, and a missing section is appended at the end of the file. Comments, HTML comments, tables, section order, blank lines and CRLF or LF line endings are left byte-for-byte. Before writing, the edited manifest is re-parsed and must equal the old one plus exactly the new entries; otherwise nothing is written and a warning is printed. An existing entry is never rewritten, moved or given new triggers, and a re-run writes nothing. An existing entry is reported as it stands, in its real section with its triggers as spelled. A section header with no items counts as an empty list and is filled. `core.adf` under `ON_DEMAND` is left in place with a warning naming the move. A missing `manifest.adf` warns to run `charter adf init`, an unparseable one warns to fix it or run `charter doctor`, and in both cases registration is skipped. The plan prints a `REGISTER` line per module, and `--format json` adds `sources[].registrations[]` (`module`, `loadPolicy`, `triggers`, `triggerSources`, `reason`, `status`, `skipReason`). `--dry-run` writes nothing and reports each planned module once across sources. As a known gap, it does not simulate an earlier source's new triggers when routing a later one. Bootstrap's two orphan writers now use the same shared writer, which also removes a line-splice ON_DEMAND writer that could register a module twice. That brings three behavior changes. Orphan triggers are now the lowercase stem (`Observability.adf` gets `observability`). A `DEFAULT_LOAD` that is not a list is now skipped with a warning, where it used to have a raw `DEFAULT_LOAD:` block appended. And with `--format json`, writer warnings now appear in the phase 3 step's `warnings`. A bootstrap `--yes` run registers migrate's modules in phase 4, so a re-run finds no orphans. `charter adf create` keeps its own upsert writer, and the stale `charter adf register` hint in bootstrap is tracked separately in `#347`. diff --git a/README.md b/README.md index dbba0ed..aa0e6fc 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,7 @@ charter adf compile --target all --write # Re-render compiled-mode CLAUDE.md, AG charter adf compile --target all --write --mode compiled # Switch pointer files to compiled output charter adf vendor status # Show each vendor file's mode (pointer, compiled, unmanaged) charter adf vendor restore CLAUDE.md # Rewrite one pointer from its template (refuses if content would be lost) +charter adf vendor rename-legacy # Rename a pre-#297 agents.md to AGENTS.md (stages a git mv; moves its VENDORS entry) charter adf patch --ops # Apply structured patch operations to an ADF file charter adf sync --check # Verify files match lock charter adf fmt .ai/core.adf --write # Reformat to canonical form diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 73b60df..798e05a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -185,9 +185,14 @@ The `adf vendor mode` check compares each vendor file's `VENDORS` entry (see [`c | `recorded-absent` | recorded `pointer` or `compiled`, no file. Recreate with `adf vendor restore ` or `adf compile --target --write` | WARN | | `entry-problem` | a malformed, unknown-file or not-allowed entry (WARN); a duplicate entry, which names the ignored line to delete because a later entry wins (INFO) | WARN / INFO | | `alias-recorded` | both `AGENTS.md` and `agents.md` recorded for one file | INFO | -| `legacy-spelling` | the file `AGENTS.md` recorded under the legacy name `agents.md` | INFO | +| `legacy-spelling` | the file `AGENTS.md` recorded under the legacy name `agents.md`. Names `charter adf vendor rename-legacy` | INFO | +| `legacy-tracked` | git tracks `agents.md` under its pre-#297 lowercase name, and not `AGENTS.md`. Names `charter adf vendor rename-legacy` | INFO | +| `legacy-both-distinct` | git tracks both `agents.md` and `AGENTS.md`, and they are two different files. Move what you need into `AGENTS.md`, then `git rm agents.md` | INFO | +| `legacy-both-one-file` | git has both index entries for one file (a case-insensitive checkout). Drop the stale entry: `git rm --cached agents.md` | INFO | | `section-not-list` | `VENDORS` does not read as a list | WARN | +The three `legacy-*` index rows read only the git index (`git ls-files -- ':(icase)agents.md'`, then an exact-case filter), so they are the same on case-sensitive and case-insensitive checkouts. An untracked `agents.md`, or a directory outside git, gives no row. They are INFO because Charter's own pre-#297 bootstrap created this state, so they never fail `doctor --ci`. + A repo without a `VENDORS` section gets only the `inferred` INFO line, so upgrading never fails `doctor --ci`. A WARN appears only where an explicit record is contradicted or ignored. Doctor never writes the record or a vendor file. The `adf agent config`, `adf vendor bloat` and `adf cold start` checks read the record too. Each physical file is checked once (`AGENTS.md` and `agents.md` on a case-insensitive filesystem are one file). A hand-authored file recorded `unmanaged` no longer warns that it "contains stack rules", and the PASS line counts thin pointers and compiled files separately. Bloat and cold start look only at pointers: a file in pointer mode that looks like a pointer on disk. @@ -542,6 +547,8 @@ Default with no `--write` or `--check` prints to stdout, single target only. | pointer | skipped (its `[skip]` line names the command), exit 0 | refused, exit 1 | record set to compiled, then written as `Written CLAUDE.md (mode: pointer -> compiled)` | | unmanaged | skipped, exit 0 | refused, exit 1 | as pointer, but an existing hand-authored file also needs `--force` | +**Legacy `agents.md` key.** When `AGENTS.md` is recorded under the legacy name `agents.md` (one physical file on a case-insensitive filesystem), `--target agents|all --write --mode compiled` refuses that target as `refused-legacy-spelling` (exit 1): nothing is rendered and the record is not touched, because the git path is still lowercase. Its remedy is `charter adf vendor rename-legacy && charter adf compile --target agents --write --mode compiled`. Without `--mode`, the target is skipped or refused as before, and its remedy is that same rename-first command. + When every target is skipped, compile says that nothing was written and how to switch. JSON output keeps `written`, `refused` and `nextActions`, and adds `skipped` and `targets` (one row per target: `state`, `source`, `status`, `recordedMode`, `previousMode`, `remedy`, `writeError`, `retainedSections`, `retainedSource`, `retainedSkipped`, `retainedAmbiguous`). `previousMode` (`pointer` or `unmanaged`) is how a JSON consumer tells a conversion from a re-compile. **Retained sections.** Compiled output keeps your own `## Environment` section and the other sections `adf tidy` retains (`## Session ...` other than `## Session Start`, and headings containing Protocol, Receipt, Provenance or Registration). They live in a block at the very end of the file, after the module index: @@ -684,6 +691,7 @@ npx charter adf vendor set CLAUDE.md compiled # record one file's mode npx charter adf vendor set CLAUDE.md pointer --dry-run npx charter adf vendor init # record inferred modes for unrecorded files npx charter adf vendor restore CLAUDE.md # rewrite one pointer from its template +npx charter adf vendor rename-legacy --dry-run # plan renaming a pre-#297 agents.md to AGENTS.md ``` ``` @@ -698,7 +706,13 @@ npx charter adf vendor restore CLAUDE.md # rewrite one pointer from i - `restore `: rewrites one file as its thin pointer template (`CLAUDE.md` gets the hybrid template with the module index filled in, as bootstrap writes it). It runs only when the file's mode is `pointer` (recorded or inferred), or the file is absent with no entry, and it refuses (exit 1, file untouched) whenever content would be lost: `mode-not-pointer` (record the intent first with `vendor set pointer`), `hand-authored` (run `adf migrate --source `), `pointer-bloated` (run `adf tidy --source `), and `compiled-differs` (a compiled file that differs from what `.ai/` compiles to now, checked in memory; keep it with `vendor set compiled`). A compiled file's retained block, and retained sections outside one, are not a difference: they are carried into the pointer as plain sections and listed in `keptSections`. The `retained-divergent` refusal is retired. A block holding anything a pointer cannot keep as a retained section is refused as `would-drop`, naming it, and the file is left as it is. That covers a non-retained heading such as `## My Notes`, and text before the first heading (`adf tidy` would move it out of the pointer). The first remedy is `vendor set compiled`, which keeps the file compiled. Otherwise, move that content under `## Environment` or into a `.ai/` module first. For a pointer, every non-blank line must survive: a line that is not from a Charter pointer template (current or earlier) or the generated module index, and that the new pointer would not carry, is refused as `hand-authored` with the first few lines named. Retained sections of a pointer are carried over verbatim and listed. It is the fix for a recorded pointer that now holds compiled output. A file with no entry gets `pointer` recorded before the file is written; if that cannot be recorded, nothing is written (`record-write`), and if the file write fails, the manifest is put back byte for byte (`write-failed`, with `writeError`). A result identical to the file on disk is `unchanged` and writes nothing. `--dry-run` reports `planned`. An unknown file name exits 2. JSON output: `file`, `status` (`restored`, `planned`, `unchanged`, `refused`), `refusal`, `keptSections`, `remedy`, `writeError`; text lines go to stderr under `--format json`. One physical file has one entry. When two vendor names are the same file (`AGENTS.md` and `agents.md` on a case-insensitive filesystem, or a hard link), every writer records it under the spelling already recorded, or `AGENTS.md` when neither is. `status` shows it once. `set` names the spelling it used. -- `--dry-run`: with `set`, `init` or `restore`, report the change without writing +- `rename-legacy`: renames a lowercase `agents.md` (written by bootstrap before #297) to `AGENTS.md`, and moves its `VENDORS` key with it. The record moves first, through the same text-level writer and re-parse guard as `set`: `agents.md: ` becomes `AGENTS.md: `, or, when `AGENTS.md` is already recorded with the same mode, the `agents.md` line is removed. The rename is two steps through `agents.md.charter-rename`, because a one-step case-only rename is a no-op or an error on a case-insensitive filesystem. + - **Index effect.** When git tracks `agents.md`, both steps are `git mv`, so the rename is **staged** (`git diff --cached` shows `R agents.md -> AGENTS.md`) and no other path is. `manifest.adf` is left unstaged, and the output names the command: `git add .ai/manifest.adf && git commit`. An untracked `agents.md`, or a directory outside git, is renamed on disk only (`method: fs`). When the file is already `AGENTS.md`, only the key moves (`record-only`). + - **Content effect.** The only content edit is a first line that is exactly `# agents.md` (the old template title), rewritten to `# AGENTS.md` and left unstaged with the manifest. Mentions of `agents.md` in `.ai/*.adf` and root `*.md` files are listed, never edited. + - **Refusals** (exit 1, nothing changed): `both-distinct` (git tracks both spellings, or the disk has two different files: Charter never merges or deletes them), `dirty` (`agents.md` has staged or unstaged changes, so commit or stash first), `tmp-exists`, `record-conflict` (both keys recorded with different modes), `record-write` (the record cannot be read or edited safely). If either rename step fails, the completed step is undone and the record put back (`rename-failed`). + - Nothing renames `agents.md` automatically: bootstrap, tidy, compile and the pre-commit hook never call this. `--format json` prints one result: `status`, `method`, `from`, `to`, `staged`, `record`, `titleUpdated`, `references`, `refusal`, `remedy`, `error`. +- `set AGENTS.md compiled` on a file recorded as `agents.md` exits 1 and names `rename-legacy` first. `pointer` and `unmanaged` still write through the recorded `agents.md` spelling. +- `--dry-run`: with `set`, `init`, `restore` or `rename-legacy`, report the change without writing - `--all`: with `status`, also list vendor names that are absent and unrecorded - `--ai-dir `: custom `.ai/` directory (default: `.ai`) diff --git a/packages/cli/README.md b/packages/cli/README.md index efc95ce..6f40896 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -81,6 +81,7 @@ charter adf compile --target all --write # re-render compiled-mode vendor files charter adf compile --target all --write --mode compiled # switch pointer files to compiled output charter adf vendor status # show each vendor file's mode (pointer, compiled, unmanaged) charter adf vendor restore CLAUDE.md # rewrite one pointer from its template (refuses if content would be lost) +charter adf vendor rename-legacy # rename a pre-#297 agents.md to AGENTS.md (stages a git mv; moves its VENDORS entry) charter # quick value/risk snapshot + next action charter why # why teams adopt Charter and expected payoff charter setup # bootstrap .charter/ directory + policy baseline @@ -324,6 +325,7 @@ charter adf vendor status [--all] [--ai-dir ] charter adf vendor set [--dry-run] [--ai-dir ] charter adf vendor init [--dry-run] [--ai-dir ] charter adf vendor restore [--dry-run] [--ai-dir ] +charter adf vendor rename-legacy [--dry-run] [--ai-dir ] ``` - `init`: Scaffold `.ai/` with `manifest.adf`, `core.adf`, `state.adf`, and starter `frontend.adf`/`backend.adf` on-demand module stubs. Core module includes a 500-line LOC guardrail metric by default. @@ -337,7 +339,7 @@ charter adf vendor restore [--dry-run] [--ai-dir ] - `evidence`: Validate all metric ceilings in the merged document and produce a structured pass/fail evidence report. `--auto-measure` counts lines in files referenced by the manifest METRICS section. `--context` or `--context-file` inject external metric overrides that take precedence over auto-measured and document values. In `--ci` mode, exits 1 on constraint failures (warnings don't fail). Also reports stale-baseline warnings (baseline vs current delta + recommended ceiling) when baseline values drift significantly. The governance workflow template runs this automatically on PRs when `.ai/manifest.adf` is present. - `metrics recalibrate`: Re-measure current LOC from manifest metric sources, propose and apply new ceilings using configurable headroom, and append rationale records to `BUDGET_RATIONALES`. Requires explicit rationale (`--reason`) unless `--auto-rationale` is used. - `migrate`: Scan existing agent config files (CLAUDE.md, .cursorrules, agents.md, GEMINI.md, copilot-instructions.md), classify content using the ADX-002 decision tree, and migrate into ADF modules. `--dry-run` previews the migration plan without writing files. `--source ` targets a single file. `--no-backup` skips `.pre-adf-migrate.bak` creation. `--merge-strategy` controls deduplication: `dedupe` (default, skip items already in ADF), `append` (always add), or `replace`. Environment-specific rules (WSL, PATH, credential helpers) are retained in the thin pointer. Each module that receives content is registered in `manifest.adf`: `core.adf` and `state.adf` under `DEFAULT_LOAD`, the rest under `ON_DEMAND` with triggers inferred from the migrated headings. Registration is append-only, so existing entries are never rewritten. The `CLAUDE.md` module index is refreshed to match, and the plan shows a `REGISTER` line per module. -- `vendor`: Show or record whether each vendor file (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `GEMINI.md`, `agents.md`, `copilot-instructions.md`) is a thin `pointer`, `compiled` output, or `unmanaged`. The record is a `VENDORS` list in `manifest.adf` (` - CLAUDE.md: pointer`). `status` is read-only and shows each file's recorded mode, or the mode inferred from its markers when none is recorded. `set` records one file's mode; only the four compile targets may be `compiled`. `init` records the inferred mode of every vendor file on disk that has no entry yet, and never changes an existing one. `bootstrap`, `adf init --emit-pointers`, `adf migrate` and `adf compile --write` also record the mode of each file they write, without changing an existing entry (an explicit `adf migrate --source ` changes `unmanaged` to `pointer`). `restore ` rewrites one pointer from its template, keeping retained sections such as `## Environment`, and refuses whenever content would be lost (hand-authored content, bloat, or a compiled file that differs from what `.ai/` compiles to); a compiled file's retained sections are carried into the pointer. `adf compile --write` keeps your `## Environment` and other retained sections in a `charter:retained` block at the end of each compiled file, converts a pointer with notes without refusing, and refuses only over content it would not carry (`refused-bloated`, `refused-would-drop`) (`#303`). `adf compile`, `adf tidy`, `doctor`, `score`, `bootstrap` and `adf migrate` act on the record: for example, `bootstrap --force` never replaces a compiled or hand-authored vendor file, and bare `adf migrate` skips a file recorded `unmanaged` (`#318`). +- `vendor`: Show or record whether each vendor file (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `GEMINI.md`, `agents.md`, `copilot-instructions.md`) is a thin `pointer`, `compiled` output, or `unmanaged`. The record is a `VENDORS` list in `manifest.adf` (` - CLAUDE.md: pointer`). `status` is read-only and shows each file's recorded mode, or the mode inferred from its markers when none is recorded. `set` records one file's mode; only the four compile targets may be `compiled`. `init` records the inferred mode of every vendor file on disk that has no entry yet, and never changes an existing one. `bootstrap`, `adf init --emit-pointers`, `adf migrate` and `adf compile --write` also record the mode of each file they write, without changing an existing entry (an explicit `adf migrate --source ` changes `unmanaged` to `pointer`). `restore ` rewrites one pointer from its template, keeping retained sections such as `## Environment`, and refuses whenever content would be lost (hand-authored content, bloat, or a compiled file that differs from what `.ai/` compiles to); a compiled file's retained sections are carried into the pointer. `rename-legacy` renames a pre-#297 lowercase `agents.md` to `AGENTS.md`: it moves the `VENDORS` key first, then renames in two steps; when git tracks the file both steps are `git mv`, so the rename is staged and `manifest.adf` is left for you to `git add`. Its only content edit is an exact `# agents.md` title line. It refuses (exit 1) when both spellings are tracked or are two files, when `agents.md` has uncommitted changes, or when the two keys disagree, and undoes itself if a step fails. `doctor` reports a tracked lowercase `agents.md` as INFO (`#302`). `adf compile --write` keeps your `## Environment` and other retained sections in a `charter:retained` block at the end of each compiled file, converts a pointer with notes without refusing, and refuses only over content it would not carry (`refused-bloated`, `refused-would-drop`) (`#303`). `adf compile`, `adf tidy`, `doctor`, `score`, `bootstrap` and `adf migrate` act on the record: for example, `bootstrap --force` never replaces a compiled or hand-authored vendor file, and bare `adf migrate` skips a file recorded `unmanaged` (`#318`). ### `charter blast` diff --git a/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts b/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts index 14d5bcc..989a514 100644 --- a/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts +++ b/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts @@ -291,7 +291,7 @@ describe('doctor: adf vendor mode rows', () => { fs.writeFileSync('.ai/manifest.adf', vendors('agents.md: pointer')); c = vendorCheck((await doctor(true)).json); expect(c.rows?.map(x => [x.kind, x.file, x.status])).toEqual([['recorded-ok', 'agents.md', 'PASS'], ['legacy-spelling', 'agents.md', 'INFO']]); - expect(c.details).toBe("VENDORS records the legacy name agents.md for the file AGENTS.md; `adf compile --target agents --mode compiled` cannot switch it. Change the line to ' - AGENTS.md: pointer' (an automatic rename comes with #302)."); + expect(c.details).toBe('VENDORS records the legacy name agents.md for the file AGENTS.md, so `adf compile --target agents --mode compiled` cannot switch it. Run: charter adf vendor rename-legacy'); }); it('section-not-list WARNs once', async () => { diff --git a/packages/cli/src/__tests__/adf-vendor-legacy.test.ts b/packages/cli/src/__tests__/adf-vendor-legacy.test.ts new file mode 100644 index 0000000..c107b9c --- /dev/null +++ b/packages/cli/src/__tests__/adf-vendor-legacy.test.ts @@ -0,0 +1,649 @@ +/** + * Legacy lowercase agents.md (#302 gap 1; #318 PR 6 of 6). + * + * Pins doctor's index-only detection (legacy-tracked / legacy-both-distinct / + * legacy-both-one-file, all INFO), `charter adf vendor rename-legacy` (record + * first, two-step rename, undo), and the compile / vendor set messages for a + * legacy-keyed AGENTS.md (PR2-review note 8). INV-P6-1..9 in the PR6 contract. + * + * Every git repo here is a fresh mkdtemp directory; each test asserts that git's + * top level IS that directory before any git write. + */ + +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { parseAdf, parseManifest } from '@stackbilt/adf'; +import type { CLIOptions } from '../index'; +import { adfCommand, POINTER_AGENTS_MD } from '../commands/adf'; +import { + legacyIndexEntries, + legacyIndexKind, + legacyKeyFor, + POINTER_ONLY_VENDOR_FILES, + RENAME_TMP, + renameLegacyAgents, +} from '../commands/adf-vendor'; +import { removeManifestBullet } from '../commands/adf-manifest-register'; +import { AGENT_CONFIG_FILES, discoverAgentConfigFiles } from '../commands/adf-migrate'; +import { doctorCommand } from '../commands/doctor'; + +const TEXT: CLIOptions = { configPath: '.charter', format: 'text', ciMode: false, yes: false }; +const JSON_OPTS: CLIOptions = { ...TEXT, format: 'json' }; + +const BASE_MANIFEST = [ + 'ADF: 0.1', + '', + '\u{1F3AF} ROLE: Test project', + '', + '\u{1F4E6} DEFAULT_LOAD:', + ' - core.adf', + ' - state.adf', + '', +].join('\n'); + +/** The pre-#297 template: the AGENTS.md pointer under its old lowercase title. */ +const LEGACY_POINTER = POINTER_AGENTS_MD.replace(/^# AGENTS\.md/, '# agents.md'); + +const vendors = (...lines: string[]): string => `${BASE_MANIFEST}\n\u{1F3F7} VENDORS:\n${lines.map(l => ` - ${l}`).join('\n')}\n`; + +const originalCwd = process.cwd(); +let tmp = ''; +let out: string[] = []; +let err: string[] = []; + +// A test run from inside a git hook inherits GIT_DIR / GIT_INDEX_FILE, which would +// point every git call here at the outer repository. Drop them for this file. +const GIT_ENV_KEYS = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_WORK_TREE', 'GIT_PREFIX', 'GIT_OBJECT_DIRECTORY', 'GIT_COMMON_DIR']; +const savedEnv: Record = {}; +beforeAll(() => { + for (const k of GIT_ENV_KEYS) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } +}); +afterAll(() => { + for (const k of GIT_ENV_KEYS) if (savedEnv[k] !== undefined) process.env[k] = savedEnv[k]; +}); + +beforeEach(() => { + tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'charter-legacy-agents-'))); + process.chdir(tmp); + out = []; + err = []; + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.map(String).join(' ')); }); + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { err.push(a.map(String).join(' ')); }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.chdir(originalCwd); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +function git(args: string[]): string { + return execFileSync('git', args, { cwd: tmp, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); +} + +/** git init in the temp dir, then prove git's top level is that dir before any write. */ +function initRepo(): void { + git(['init', '-q']); + const top = fs.realpathSync.native(path.resolve(git(['rev-parse', '--show-toplevel']).trim())); + if (top !== tmp) throw new Error(`refusing to run: git top level ${top} is not the temp repo ${tmp}`); + git(['config', 'user.email', 'test@example.com']); + git(['config', 'user.name', 'Test']); + git(['config', 'commit.gpgsign', 'false']); + git(['config', 'core.autocrlf', 'false']); +} + +function commitAll(): void { + git(['add', '-A']); + git(['commit', '-q', '-m', 'fixture']); +} + +function scaffold(manifest = BASE_MANIFEST): void { + fs.mkdirSync('.ai', { recursive: true }); + fs.writeFileSync('.ai/manifest.adf', manifest); + fs.writeFileSync('.ai/core.adf', 'ADF: 0.1\n\n\u{1F4CB} CONTEXT:\n - TypeScript monorepo\n'); + fs.writeFileSync('.ai/state.adf', 'ADF: 0.1\n\n\u{1F9E0} STATE:\n CURRENT: Active development\n'); +} + +const manifestText = (): string => fs.readFileSync('.ai/manifest.adf', 'utf-8'); +const rootNames = (): string[] => fs.readdirSync('.').filter(n => n !== '.git').sort(); +const cached = (): string => git(['diff', '--cached', '--name-status', '-M']).trim(); +const porcelain = (): string => git(['status', '--porcelain=v1', '--untracked-files=all']); + +/** Is this temp dir on a case-insensitive filesystem? */ +function caseInsensitive(): boolean { + fs.writeFileSync('.case-probe', ''); + const ci = fs.existsSync('.CASE-PROBE'); + fs.rmSync('.case-probe'); + return ci; +} + +/** A blob for `git update-index --cacheinfo`. */ +function blob(content: string): string { + return execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: tmp, input: content, encoding: 'utf-8' }).trim(); +} + +async function run(args: string[], options: CLIOptions = TEXT): Promise<{ code: number; out: string; err: string }> { + out = []; + err = []; + const code = await adfCommand(options, args); + return { code, out: out.join('\n'), err: err.join('\n') }; +} + +async function runJson>(args: string[]): Promise<{ code: number; json: T; err: string }> { + const r = await run([...args, '--format', 'json'], JSON_OPTS); + return { code: r.code, json: JSON.parse(r.out) as T, err: r.err }; +} + +interface Row { kind: string; status: string; file: string | null; remedy: string | null } +interface DoctorJson { checks: Array<{ name: string; status: string; details: string; rows?: Row[] }> } + +async function doctor(ci = false): Promise<{ code: number; check: DoctorJson['checks'][number] | undefined }> { + out = []; + const code = await doctorCommand({ ...JSON_OPTS, ciMode: ci }, ['--adf-only']); + const json = JSON.parse(out.join('\n')) as DoctorJson; + return { code, check: json.checks.find(c => c.name === 'adf vendor mode') }; +} + +const legacyRows = (rows: Row[] | undefined): Array<[string, string, string | null]> => + (rows ?? []).filter(r => r.kind.startsWith('legacy-')).map(r => [r.kind, r.status, r.remedy]); + +/** A committed repo tracking the lowercase pre-#297 agents.md, recorded as agents.md: pointer. */ +function legacyRepo(manifest = vendors('agents.md: pointer'), content = LEGACY_POINTER): void { + initRepo(); + scaffold(manifest); + fs.writeFileSync('agents.md', content); + commitAll(); +} + +const RESULT_KEYS = ['status', 'method', 'from', 'to', 'staged', 'record', 'titleUpdated', 'references', 'refusal', 'remedy', 'error']; + +// ============================================================================ +// removeManifestBullet (the shared writer's remove helper) +// ============================================================================ + +describe('removeManifestBullet', () => { + it('removes exactly one item line, keeping every other byte (LF and CRLF)', () => { + const lf = vendors('AGENTS.md: pointer', 'agents.md: pointer', 'CLAUDE.md: compiled'); + expect(removeManifestBullet(lf, 'VENDORS', 1)).toBe(vendors('AGENTS.md: pointer', 'CLAUDE.md: compiled')); + const crlf = lf.replace(/\n/g, '\r\n'); + expect(removeManifestBullet(crlf, 'VENDORS', 1)).toBe(vendors('AGENTS.md: pointer', 'CLAUDE.md: compiled').replace(/\n/g, '\r\n')); + }); + + it('keeps the no-trailing-newline state when the last line goes', () => { + const text = `${BASE_MANIFEST}\n\u{1F3F7} VENDORS:\r\n - AGENTS.md: pointer\r\n - agents.md: pointer`; + expect(removeManifestBullet(text, 'VENDORS', 1)).toBe(`${BASE_MANIFEST}\n\u{1F3F7} VENDORS:\r\n - AGENTS.md: pointer`); + }); + + it('returns null for a missing section or item', () => { + expect(removeManifestBullet(BASE_MANIFEST, 'VENDORS', 0)).toBeNull(); + expect(removeManifestBullet(vendors('AGENTS.md: pointer'), 'VENDORS', 3)).toBeNull(); + }); +}); + +// ============================================================================ +// Detection (D0-D4; INV-P6-1, INV-P6-2) +// ============================================================================ + +describe('legacy agents.md detection (index only)', () => { + it('queries the index with an icase pathspec and filters by exact case (INV-P6-1, mocked git)', () => { + const calls: string[][] = []; + const entries = legacyIndexEntries(args => { + calls.push(args); + return 'Agents.md\0agents.md\0'; + }); + expect(calls).toEqual([['ls-files', '-z', '--', ':(icase)agents.md']]); + expect(entries).toEqual({ lower: true, upper: false }); + expect(legacyIndexEntries(() => 'AGENTS.md\0agents.md\0')).toEqual({ lower: true, upper: true }); + expect(legacyIndexEntries(() => { throw new Error('not a git repository'); })).toBeNull(); + }); + + it('classifies by index entries, using file identity only to split both-spellings (mocked)', () => { + expect(legacyIndexKind(null)).toBeNull(); + expect(legacyIndexKind({ lower: false, upper: true })).toBeNull(); + expect(legacyIndexKind({ lower: true, upper: false }, () => { throw new Error('identity must not be read'); })).toBe('legacy-tracked'); + expect(legacyIndexKind({ lower: true, upper: true }, () => false)).toBe('legacy-both-distinct'); + expect(legacyIndexKind({ lower: true, upper: true }, () => true)).toBe('legacy-both-one-file'); + }); + + it('reports a tracked lowercase agents.md as legacy-tracked INFO naming rename-legacy', async () => { + legacyRepo(vendors('AGENTS.md: pointer')); + const { check } = await doctor(); + expect(legacyRows(check?.rows)).toEqual([['legacy-tracked', 'INFO', 'charter adf vendor rename-legacy']]); + expect(check?.details).toContain('git tracks agents.md under its pre-#297 lowercase name. Rename it (case-safe, keeps its VENDORS entry): charter adf vendor rename-legacy'); + }); + + it('reports two index entries for one file as legacy-both-one-file (update-index --cacheinfo)', async () => { + legacyRepo(vendors('AGENTS.md: pointer')); + // One physical file under both names: a case-insensitive checkout, or a hard link. + if (!fs.existsSync('AGENTS.md')) fs.linkSync('agents.md', 'AGENTS.md'); + git(['update-index', '--add', '--cacheinfo', `100644,${blob(LEGACY_POINTER)},AGENTS.md`]); + expect(git(['ls-files']).split('\n')).toEqual(expect.arrayContaining(['agents.md', 'AGENTS.md'])); + const { check } = await doctor(); + expect(legacyRows(check?.rows)).toEqual([['legacy-both-one-file', 'INFO', 'git rm --cached agents.md']]); + }); + + it('reports two distinct tracked files as legacy-both-distinct (case-sensitive filesystems only)', async () => { + initRepo(); + if (caseInsensitive()) return; // covered by the mocked classification test above + scaffold(vendors('AGENTS.md: pointer')); + fs.writeFileSync('agents.md', LEGACY_POINTER); + fs.writeFileSync('AGENTS.md', POINTER_AGENTS_MD); + commitAll(); + const { check } = await doctor(); + expect(legacyRows(check?.rows)).toEqual([['legacy-both-distinct', 'INFO', 'git rm agents.md']]); + }); + + it('gives no row for an untracked agents.md, a tracked AGENTS.md, or a non-git directory (D3)', async () => { + scaffold(vendors('AGENTS.md: pointer')); + fs.writeFileSync('agents.md', LEGACY_POINTER); + expect(legacyRows((await doctor()).check?.rows)).toEqual([]); // not a git repo + initRepo(); + expect(legacyRows((await doctor()).check?.rows)).toEqual([]); // untracked + fs.rmSync('agents.md'); + fs.writeFileSync('AGENTS.md', POINTER_AGENTS_MD); + commitAll(); + expect(legacyRows((await doctor()).check?.rows)).toEqual([]); // tracked as AGENTS.md + }); + + it('never raises the check above INFO or changes doctor --ci exit (INV-P6-2)', async () => { + legacyRepo(vendors('agents.md: pointer')); + const before = await doctor(true); + expect(before.check?.status).toBe('INFO'); + expect(before.check?.rows?.filter(r => r.kind.startsWith('legacy-')).every(r => r.status === 'INFO')).toBe(true); + expect(before.code).toBe(0); + }); +}); + +// ============================================================================ +// rename-legacy: git method (CP-1..CP-5; INV-P6-3..6) +// ============================================================================ + +describe('adf vendor rename-legacy (git mv)', () => { + it('--dry-run plans the rename and writes nothing to fs, git or the manifest (INV-P6-6)', async () => { + legacyRepo(); + const names = rootNames(); + const manifest = manifestText(); + const status = porcelain(); + const r = await runJson>(['vendor', 'rename-legacy', '--dry-run']); + expect(r.code).toBe(0); + expect(r.json).toMatchObject({ status: 'planned', method: 'git-mv', staged: [], record: { action: 'rekeyed', mode: 'pointer' }, titleUpdated: true }); + expect(rootNames()).toEqual(names); + expect(manifestText()).toBe(manifest); + expect(porcelain()).toBe(status); + expect(git(['ls-files', 'agents.md']).trim()).toBe('agents.md'); + }); + + it('stages a pure rename, re-keys the record first, leaves the manifest unstaged with a hint (CP-1, CP-3, INV-P6-5)', async () => { + legacyRepo(); + // Unrelated work in progress must not be staged or touched. + fs.writeFileSync('.ai/core.adf', 'ADF: 0.1\n\n\u{1F4CB} CONTEXT:\n - edited, not staged\n'); + fs.writeFileSync('notes.txt', 'untracked\n'); + const r = await run(['vendor', 'rename-legacy']); + expect(r.code).toBe(0); + expect(cached()).toMatch(/^R100\tagents\.md\tAGENTS\.md$/); + expect(git(['ls-files', '-z', '--', ':(icase)agents.md']).split('\0').filter(Boolean)).toEqual(['AGENTS.md']); + expect(porcelain().split('\n').filter(Boolean).sort()).toEqual([' M .ai/core.adf', ' M .ai/manifest.adf', '?? notes.txt', 'RM agents.md -> AGENTS.md'].sort()); + expect(parseManifest(parseAdf(manifestText())).vendors.map(v => v.raw)).toEqual(['AGENTS.md: pointer']); + expect(manifestText()).toBe(vendors('AGENTS.md: pointer')); + expect(r.out).toContain('[ok] Renamed agents.md -> AGENTS.md (git-mv); VENDORS entry moved; title line updated'); + expect(r.out).toContain('The rename is staged. Commit it together with .ai/manifest.adf and AGENTS.md: git add .ai/manifest.adf AGENTS.md && git commit'); + }); + + it('rewrites only an exact `# agents.md` first line (CP-4, INV-P6-3)', async () => { + legacyRepo(); + await run(['vendor', 'rename-legacy']); + expect(fs.readFileSync('AGENTS.md', 'utf-8')).toBe(POINTER_AGENTS_MD); + expect(rootNames()).toContain('AGENTS.md'); + expect(rootNames()).not.toContain('agents.md'); + }); + + it('leaves any other first line, and every other byte, untouched (INV-P6-3)', async () => { + const content = '# agents.md notes\r\n\r\nMy own text mentions # agents.md here.\r\n'; + legacyRepo(vendors('agents.md: unmanaged'), content); + const r = await runJson<{ titleUpdated: boolean; record: unknown }>(['vendor', 'rename-legacy']); + expect(r.json.titleUpdated).toBe(false); + expect(r.json.record).toEqual({ action: 'rekeyed', mode: 'unmanaged' }); + expect(fs.readFileSync('AGENTS.md', 'utf-8')).toBe(content); + }); + + it('updates a CRLF legacy title line and keeps its line ending', async () => { + legacyRepo(vendors('agents.md: pointer'), LEGACY_POINTER.replace(/\n/g, '\r\n')); + await run(['vendor', 'rename-legacy']); + expect(fs.readFileSync('AGENTS.md', 'utf-8')).toBe(POINTER_AGENTS_MD.replace(/\n/g, '\r\n')); + }); + + it('is idempotent: a second run after the commit reports unchanged', async () => { + legacyRepo(); + await run(['vendor', 'rename-legacy']); + git(['add', '.ai/manifest.adf', 'AGENTS.md']); + git(['commit', '-q', '-m', 'rename']); + const r = await runJson<{ status: string; method: string }>(['vendor', 'rename-legacy']); + expect(r.code).toBe(0); + expect(r.json).toMatchObject({ status: 'unchanged', method: 'none' }); + expect(porcelain()).toBe(''); + expect(legacyRows((await doctor()).check?.rows)).toEqual([]); + }); + + it('removes a same-mode duplicate agents.md entry through the re-parse guard (CP-5)', async () => { + legacyRepo(vendors('AGENTS.md: pointer', 'CLAUDE.md: pointer', 'agents.md: pointer')); + const r = await runJson<{ status: string; record: unknown }>(['vendor', 'rename-legacy']); + expect(r.json).toMatchObject({ status: 'renamed', record: { action: 'removed-duplicate', mode: 'pointer' } }); + expect(manifestText()).toBe(vendors('AGENTS.md: pointer', 'CLAUDE.md: pointer')); + }); + + it('refuses record-conflict when the two keys disagree, changing nothing (CP-5)', async () => { + legacyRepo(vendors('AGENTS.md: unmanaged', 'agents.md: pointer')); + const manifest = manifestText(); + const r = await run(['vendor', 'rename-legacy']); + expect(r.code).toBe(1); + expect(r.err).toContain('VENDORS records agents.md as pointer and AGENTS.md as unmanaged. Keep one line in .ai/manifest.adf, then re-run.'); + expect(manifestText()).toBe(manifest); + expect(porcelain()).toBe(''); + }); + + it('refuses when agents.md has unstaged or staged changes (CP-2)', async () => { + legacyRepo(); + fs.appendFileSync('agents.md', 'local edit\n'); + const unstaged = await runJson<{ status: string; refusal: string }>(['vendor', 'rename-legacy']); + expect(unstaged.code).toBe(1); + expect(unstaged.json).toMatchObject({ status: 'refused', refusal: 'dirty' }); + expect(unstaged.err).toContain('agents.md has uncommitted changes. Commit or stash them first'); + git(['add', 'agents.md']); + const staged = await runJson<{ refusal: string }>(['vendor', 'rename-legacy']); + expect(staged.json.refusal).toBe('dirty'); + expect(manifestText()).toBe(vendors('agents.md: pointer')); + expect(cached()).toBe('M\tagents.md'); + }); + + it('refuses when the temp name exists, changing nothing', async () => { + legacyRepo(); + fs.writeFileSync(RENAME_TMP, 'in the way\n'); + const status = porcelain(); + const r = await runJson<{ refusal: string; remedy: string }>(['vendor', 'rename-legacy']); + expect(r.code).toBe(1); + expect(r.json).toMatchObject({ refusal: 'tmp-exists', remedy: `rm ${RENAME_TMP}` }); + expect(porcelain()).toBe(status); + expect(manifestText()).toBe(vendors('agents.md: pointer')); + }); + + it('refuses two index entries on one file with the index-only remedy (P4)', async () => { + legacyRepo(); + if (!fs.existsSync('AGENTS.md')) fs.linkSync('agents.md', 'AGENTS.md'); + git(['update-index', '--add', '--cacheinfo', `100644,${blob(LEGACY_POINTER)},AGENTS.md`]); + const r = await runJson<{ refusal: string; remedy: string }>(['vendor', 'rename-legacy']); + expect(r.code).toBe(1); + expect(r.json).toMatchObject({ refusal: 'both-distinct', remedy: 'git rm --cached agents.md' }); + expect(manifestText()).toBe(vendors('agents.md: pointer')); + }); + + it('refuses two distinct files with the merge remedy, never deleting either (P2, simulated case-sensitive index)', async () => { + legacyRepo(); + // Two distinct index entries, as a case-sensitive checkout has them. + git(['update-index', '--add', '--cacheinfo', `100644,${blob('# my AGENTS\n')},AGENTS.md`]); + const sensitive = !caseInsensitive(); + if (sensitive) fs.writeFileSync('AGENTS.md', '# my AGENTS\n'); + const r = await runJson<{ refusal: string; remedy: string }>(['vendor', 'rename-legacy']); + expect(r.code).toBe(1); + expect(r.json.refusal).toBe('both-distinct'); + // On a case-insensitive disk the two entries share one file: the index-only remedy. + expect(r.json.remedy).toBe(sensitive ? 'git rm agents.md' : 'git rm --cached agents.md'); + expect(fs.readFileSync('agents.md', 'utf-8')).toBe(sensitive ? LEGACY_POINTER : fs.readFileSync('AGENTS.md', 'utf-8')); + expect(manifestText()).toBe(vendors('agents.md: pointer')); + }); + + it('splits both index entries by file identity, deterministically on any host (mocked index and identity)', () => { + fs.writeFileSync('agents.md', 'a\n'); + const git = (args: string[]): string => (args[0] === 'ls-files' ? 'agents.md\0AGENTS.md\0' : ''); + const distinct = renameLegacyAgents('.ai', { git, sameFile: () => false }); + expect(distinct).toMatchObject({ refusal: 'both-distinct', remedy: 'git rm agents.md' }); + const oneFile = renameLegacyAgents('.ai', { git, sameFile: () => true }); + expect(oneFile).toMatchObject({ refusal: 'both-distinct', remedy: 'git rm --cached agents.md' }); + expect(rootNames()).toEqual(['agents.md']); + }); + + it('JSON stdout is exactly one RenameResult document', async () => { + legacyRepo(); + const r = await runJson>(['vendor', 'rename-legacy']); + expect(Object.keys(r.json)).toEqual(RESULT_KEYS); + expect(r.json).toMatchObject({ status: 'renamed', method: 'git-mv', from: 'agents.md', to: 'AGENTS.md', staged: ['agents.md', 'AGENTS.md'] }); + }); + + it('reports references to agents.md without editing them (S6)', async () => { + legacyRepo(); + fs.writeFileSync('README.md', '# Project\n\nSee agents.md for agent rules.\n'); + commitAll(); + const r = await runJson<{ references: string[] }>(['vendor', 'rename-legacy']); + expect(r.json.references).toEqual(['README.md:3']); + expect(fs.readFileSync('README.md', 'utf-8')).toContain('See agents.md'); + expect(r.err).toContain('agents.md is still mentioned in: README.md:3'); + }); +}); + +// ============================================================================ +// rename-legacy: other methods and preconditions +// ============================================================================ + +describe('adf vendor rename-legacy (fs and record-only)', () => { + it('renames an untracked agents.md with fs (nothing staged)', async () => { + initRepo(); + scaffold(vendors('agents.md: pointer')); + commitAll(); + fs.writeFileSync('agents.md', LEGACY_POINTER); + const r = await runJson<{ method: string; staged: string[] }>(['vendor', 'rename-legacy']); + expect(r.json).toMatchObject({ method: 'fs', staged: [] }); + expect(cached()).toBe(''); + expect(rootNames()).toContain('AGENTS.md'); + expect(rootNames()).not.toContain('agents.md'); + expect(manifestText()).toBe(vendors('AGENTS.md: pointer')); + }); + + it('renames in a non-git directory, and without a manifest (P5)', async () => { + fs.writeFileSync('agents.md', LEGACY_POINTER); + const r = await runJson<{ status: string; method: string; record: unknown }>(['vendor', 'rename-legacy']); + expect(r.json).toMatchObject({ status: 'renamed', method: 'fs', record: { action: 'none', mode: null } }); + expect(fs.readFileSync('AGENTS.md', 'utf-8')).toBe(POINTER_AGENTS_MD); + }); + + it('moves only the record when the file is already AGENTS.md (record-only)', async () => { + scaffold(vendors('agents.md: pointer')); + fs.writeFileSync('AGENTS.md', POINTER_AGENTS_MD); + const r = await run(['vendor', 'rename-legacy']); + expect(r.code).toBe(0); + expect(r.out).toContain('The file is already AGENTS.md; moved its VENDORS entry from agents.md to AGENTS.md'); + expect(manifestText()).toBe(vendors('AGENTS.md: pointer')); + expect(rootNames()).toEqual(['.ai', 'AGENTS.md']); + }); + + it('reports unchanged with nothing to do (P1)', async () => { + scaffold(vendors('AGENTS.md: pointer')); + fs.writeFileSync('AGENTS.md', POINTER_AGENTS_MD); + const r = await run(['vendor', 'rename-legacy']); + expect(r.code).toBe(0); + expect(r.out).toContain('Nothing to rename: no lowercase agents.md in git, on disk, or in VENDORS.'); + }); + + it('refuses record-write and renames nothing when the record cannot be read (guard skip)', async () => { + scaffold(`${BASE_MANIFEST}\n\u{1F3F7} VENDORS: agents.md is pointer\n`); + fs.writeFileSync('agents.md', LEGACY_POINTER); + const manifest = manifestText(); + const r = await runJson<{ refusal: string }>(['vendor', 'rename-legacy']); + expect(r.code).toBe(1); + expect(r.json.refusal).toBe('record-write'); + expect(rootNames()).toContain('agents.md'); + expect(manifestText()).toBe(manifest); + }); + + it('rejects positional arguments with a usage error (exit 2)', async () => { + await expect(run(['vendor', 'rename-legacy', 'agents.md'])).rejects.toMatchObject({ + message: 'Usage: charter adf vendor rename-legacy [--dry-run] [--ai-dir ] [--format json]', + exitCode: 2, + }); + }); +}); + +// ============================================================================ +// Undo (S4; INV-P6-4) +// ============================================================================ + +describe('adf vendor rename-legacy undo', () => { + it('git: a failed second git mv puts the file, index and record back (real git, injected failure)', () => { + legacyRepo(); + const manifest = manifestText(); + let mvCalls = 0; + const injected = (args: string[]): string => { + if (args[0] === 'mv' && ++mvCalls === 2) throw new Error('simulated: destination exists'); + return git(args); + }; + const lines: string[] = []; + const r = renameLegacyAgents('.ai', { git: injected, print: l => lines.push(l) }); + expect(r).toMatchObject({ status: 'refused', refusal: 'rename-failed', record: { action: 'reverted', mode: 'pointer' }, error: 'simulated: destination exists' }); + expect(lines.join('\n')).toContain('Rename failed: simulated: destination exists. agents.md and the VENDORS entry are back as they were.'); + expect(rootNames()).toContain('agents.md'); + expect(rootNames()).not.toContain(RENAME_TMP); + expect(manifestText()).toBe(manifest); + expect(porcelain()).toBe(''); + }); + + it('fs: a failed second rename puts the file and record back (mocked rename)', () => { + scaffold(vendors('agents.md: pointer')); + fs.writeFileSync('agents.md', LEGACY_POINTER); + const manifest = manifestText(); + let calls = 0; + const rename = (from: string, to: string): void => { + if (++calls === 2) throw new Error('EPERM'); + fs.renameSync(from, to); + }; + const r = renameLegacyAgents('.ai', { rename, git: () => { throw new Error('not a git repository'); } }); + expect(r).toMatchObject({ refusal: 'rename-failed', record: { action: 'reverted' } }); + expect(rootNames()).toEqual(['.ai', 'agents.md']); + expect(fs.readFileSync('agents.md', 'utf-8')).toBe(LEGACY_POINTER); + expect(manifestText()).toBe(manifest); + }); + + it('names the manual recovery when the undo itself fails', () => { + scaffold(vendors('agents.md: pointer')); + fs.writeFileSync('agents.md', LEGACY_POINTER); + let calls = 0; + const rename = (from: string, to: string): void => { + if (++calls >= 2) throw new Error('EPERM'); + fs.renameSync(from, to); + }; + const lines: string[] = []; + const r = renameLegacyAgents('.ai', { rename, git: () => { throw new Error('no git'); }, print: l => lines.push(l) }); + expect(r.refusal).toBe('rename-failed'); + expect(lines.join('\n')).toContain(`Recover by hand: mv ${RENAME_TMP} agents.md`); + expect(r.record.action).toBe('reverted'); + }); +}); + +// ============================================================================ +// Note 8: compile and vendor set with a legacy key (INV-P6-7) +// ============================================================================ + +/** agents.md and AGENTS.md as one physical file (a hard link on a case-sensitive host). */ +function aliasedAgents(content: string): void { + fs.writeFileSync('agents.md', content); + if (!fs.existsSync('AGENTS.md')) fs.linkSync('agents.md', 'AGENTS.md'); +} + +describe('compile and vendor set with a legacy agents.md key (note 8)', () => { + it('legacyKeyFor is set only for AGENTS.md keyed as agents.md on one physical file', () => { + scaffold(vendors('agents.md: pointer')); + expect(legacyKeyFor('.ai/manifest.adf', 'AGENTS.md')).toBeNull(); // no file + aliasedAgents(POINTER_AGENTS_MD); + expect(legacyKeyFor('.ai/manifest.adf', 'AGENTS.md')).toBe('agents.md'); + expect(legacyKeyFor('.ai/manifest.adf', 'agents.md')).toBeNull(); + fs.writeFileSync('.ai/manifest.adf', vendors('AGENTS.md: pointer')); + expect(legacyKeyFor('.ai/manifest.adf', 'AGENTS.md')).toBeNull(); + }); + + it('--mode compiled gives refused-legacy-spelling, exit 1, and writes no record or file', async () => { + scaffold(vendors('agents.md: pointer')); + aliasedAgents(POINTER_AGENTS_MD); + const manifest = manifestText(); + const text = await run(['compile', '--target', 'agents', '--write', '--mode', 'compiled']); + expect(text.code).toBe(1); + expect(text.err).toContain('Refused to compile AGENTS.md: its VENDORS entry and git path still use the legacy name agents.md'); + expect(text.err).toContain('Run: charter adf vendor rename-legacy, then re-run this command.'); + expect(text.err).not.toContain('could not be recorded'); + const json = await runJson<{ targets: Array<{ status: string; remedy: string }>; nextActions: string[] }>( + ['compile', '--target', 'agents', '--write', '--mode', 'compiled']); + expect(json.json.targets[0]).toMatchObject({ status: 'refused-legacy-spelling', remedy: 'charter adf vendor rename-legacy && charter adf compile --target agents --write --mode compiled' }); + expect(json.err).not.toContain("cannot be 'compiled'"); + expect(manifestText()).toBe(manifest); + expect(fs.readFileSync('AGENTS.md', 'utf-8')).toBe(POINTER_AGENTS_MD); + }); + + it('without --mode, the skip / refusal remedy is prefixed with the rename', async () => { + scaffold(vendors('agents.md: pointer')); + aliasedAgents(POINTER_AGENTS_MD); + const one = await runJson<{ targets: Array<{ status: string; remedy: string }> }>(['compile', '--target', 'agents', '--write']); + expect(one.json.targets[0]).toMatchObject({ status: 'refused-mode-change', remedy: 'charter adf vendor rename-legacy && charter adf compile --target agents --write --mode compiled' }); + const all = await run(['compile', '--target', 'all', '--write']); + expect(all.out).toContain('AGENTS.md is still recorded as agents.md; rename it first: charter adf vendor rename-legacy'); + }); + + it('after rename-legacy, --mode compiled succeeds', async () => { + scaffold(vendors('agents.md: pointer')); + aliasedAgents(POINTER_AGENTS_MD); + expect((await run(['vendor', 'rename-legacy'])).code).toBe(0); + const r = await runJson<{ targets: Array<{ status: string }> }>(['compile', '--target', 'agents', '--write', '--mode', 'compiled']); + expect(r.code).toBe(0); + expect(r.json.targets[0].status).toBe('written'); + expect(parseManifest(parseAdf(manifestText())).vendors.map(v => v.raw)).toEqual(['AGENTS.md: compiled']); + }); + + it('vendor set AGENTS.md compiled names the rename, not the generic modeNotAllowed', async () => { + scaffold(vendors('agents.md: pointer')); + aliasedAgents(POINTER_AGENTS_MD); + const manifest = manifestText(); + const r = await run(['vendor', 'set', 'AGENTS.md', 'compiled']); + expect(r.code).toBe(1); + expect(r.err).toContain("AGENTS.md is recorded under the legacy name agents.md, which cannot be 'compiled'. Run: charter adf vendor rename-legacy, then: charter adf vendor set AGENTS.md compiled"); + expect(r.err).not.toContain('no compile target writes it'); + const json = await runJson<{ remedy: string }>(['vendor', 'set', 'AGENTS.md', 'compiled']); + expect(json.json.remedy).toBe('charter adf vendor rename-legacy'); + // pointer|unmanaged still write through the recorded agents.md spelling (N4). + expect((await run(['vendor', 'set', 'AGENTS.md', 'unmanaged'])).code).toBe(0); + expect(manifestText()).toBe(manifest.replace('agents.md: pointer', 'agents.md: unmanaged')); + }); + + it('doctor legacy-spelling now names rename-legacy (D4)', async () => { + scaffold(vendors('agents.md: pointer')); + aliasedAgents(POINTER_AGENTS_MD); + const { check } = await doctor(); + const row = check?.rows?.find(r => r.kind === 'legacy-spelling'); + expect(row).toMatchObject({ status: 'INFO', remedy: 'charter adf vendor rename-legacy' }); + }); +}); + +// ============================================================================ +// INV-P6-7 (no auto-rename) and INV-P6-9 (discovery keeps agents.md) +// ============================================================================ + +describe('no automatic rename; discovery keeps the legacy name', () => { + it('doctor, compile --check and vendor status never rename a tracked agents.md (CP-7)', async () => { + legacyRepo(); + await doctor(); + await run(['compile', '--target', 'all', '--check']); + await run(['vendor', 'status']); + await run(['tidy', '--dry-run']); + expect(git(['ls-files', '-z', '--', ':(icase)agents.md']).split('\0').filter(Boolean)).toEqual(['agents.md']); + expect(porcelain()).toBe(''); + }); + + it('agents.md stays a vendor and discovery name (INV-P6-9)', () => { + expect(POINTER_ONLY_VENDOR_FILES).toContain('agents.md'); + expect(AGENT_CONFIG_FILES).toContain('agents.md'); + fs.writeFileSync('agents.md', LEGACY_POINTER); + // Discovered once, under whichever spelling the host's filesystem matches first. + expect(discoverAgentConfigFiles().filter(n => n.toLowerCase() === 'agents.md')).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/commands/adf-compile.ts b/packages/cli/src/commands/adf-compile.ts index 578b155..2628b98 100644 --- a/packages/cli/src/commands/adf-compile.ts +++ b/packages/cli/src/commands/adf-compile.ts @@ -44,7 +44,7 @@ import { POINTER_GEMINI_MD, } from './adf'; import { countExtractable, readRetainedSections } from './adf-tidy'; -import { inferVendorMode, readVendorRecord, recordedSpelling, recordVendorModes, resolveVendorMode } from './adf-vendor'; +import { inferVendorMode, legacyKeyFor, MSG as VENDOR_MSG, readVendorRecord, recordedSpelling, recordVendorModes, resolveVendorMode } from './adf-vendor'; import type { ModeSource, VendorFile, VendorMode } from './adf-vendor'; import { composeCompiled, linesConvertWouldDrop, readAiModuleLines, RETAINED_END, RETAINED_MSG, RETAINED_START } from './adf-retained'; import type { RetainedSource } from './adf-retained'; @@ -322,7 +322,8 @@ type WriteStatus = | 'refused-bloated' | 'refused-would-drop' | 'refused-ambiguous' - | 'refused-record'; + | 'refused-record' + | 'refused-legacy-spelling'; // 'refused-retained' is retired (#303): retained sections are carried, not refused over. interface WriteTargetOutcome { @@ -371,6 +372,16 @@ function runWriteMode( }; rows.push(row); + // AGENTS.md still recorded (and tracked) as the legacy agents.md cannot become + // compiled output: name the rename, never the generic refused-record (#302). + const legacyKey = legacyKeyFor(manifestPath, filename as VendorFile); + if (legacyKey && flags.modeCompiled) { + row.status = 'refused-legacy-spelling'; + row.remedy = VENDOR_MSG.legacyRemedy; + if (!json) console.error(VENDOR_MSG.refusedLegacySpelling); + continue; + } + // A pointer or unmanaged file changes mode only under --mode compiled (#318). // --force alone never converts one: it means "overwrite hand-authored content". if ((state === 'pointer' || state === 'unmanaged') && !flags.modeCompiled) { @@ -378,14 +389,15 @@ function runWriteMode( const handAuthored = state === 'unmanaged' && fs.existsSync(filename) && !CHARTER_OWNED_MARKERS.some(marker => fs.readFileSync(filename, 'utf-8').includes(marker)); const force = handAuthored ? ' --force' : ''; - row.remedy = `charter adf compile --target ${target} --write --mode compiled${force}`; + row.remedy = legacyKey ? VENDOR_MSG.legacyRemedy : `charter adf compile --target ${target} --write --mode compiled${force}`; const values = { file: filename, mode: state, source: source ?? 'inferred', target, force }; + const legacyLine = legacyKey ? `\n${VENDOR_MSG.legacyFirst}` : ''; if (flags.all) { row.status = 'skipped-mode'; - if (!json) console.log(fill(MSG.skippedMode, values)); + if (!json) console.log(fill(MSG.skippedMode, values) + legacyLine); } else { row.status = 'refused-mode-change'; - if (!json) console.error(fill(MSG.refusedModeChange, values)); + if (!json) console.error(fill(MSG.refusedModeChange, values) + legacyLine); } continue; } diff --git a/packages/cli/src/commands/adf-manifest-register.ts b/packages/cli/src/commands/adf-manifest-register.ts index 2a4d51c..7a4b40a 100644 --- a/packages/cli/src/commands/adf-manifest-register.ts +++ b/packages/cli/src/commands/adf-manifest-register.ts @@ -324,6 +324,32 @@ export function replaceManifestBullet(text: string, key: string, itemIndex: numb return null; } +/** + * Remove the line of list item `itemIndex` (0-based, as parseAdf counts the + * items) from the last `key` section. Returns null when the section or item is + * not found. Line endings and every other line are kept; when the removed line + * was the file's last, the trailing-newline state is kept too. Callers re-parse + * the result and compare it with the expected document (the same guard as + * insertManifestBullet and replaceManifestBullet). + */ +export function removeManifestBullet(text: string, key: string, itemIndex: number): string | null { + const lines = text.split('\n'); + const { headers, own } = sectionHeaders(lines, key); + if (own.length === 0) return null; + const header = own[own.length - 1]; + const next = headers.find(i => i > header) ?? lines.length; + let seen = 0; + for (let i = header + 1; i < next; i++) { + if (!LIST_ITEM_PREFIX_RE.test(cleanLine(lines[i]))) continue; + if (seen++ !== itemIndex) continue; + lines.splice(i, 1); + // The removed line had no newline of its own: the one before it now ends the file. + if (i === lines.length && i > 0) lines[i - 1] = lines[i - 1].replace(/\r$/, ''); + return lines.join('\n'); + } + return null; +} + function skipAll(manifestPath: string, entries: ModuleRegistration[], skipReason: SkipReason): RegisterManifestModulesResult { return { manifestPath, written: false, registrations: entries.map(e => ({ ...e, status: 'skipped', skipReason })) }; } diff --git a/packages/cli/src/commands/adf-vendor.ts b/packages/cli/src/commands/adf-vendor.ts index 18bf974..ea64911 100644 --- a/packages/cli/src/commands/adf-vendor.ts +++ b/packages/cli/src/commands/adf-vendor.ts @@ -23,6 +23,7 @@ import type { AdfDocument, CompileTarget, ManifestVendorEntry } from '@stackbilt import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; +import { getGitErrorMessage, runGit } from '../git-helpers'; import { MODULE_INDEX_END, MODULE_INDEX_START, @@ -38,7 +39,7 @@ import { fileIdentity, generateModuleIndex } from './adf-migrate'; import { divergentRetainedHeadings } from './adf-compile'; import { composeCompiled, compiledMatchesFresh, readAiModuleLines } from './adf-retained'; import { countExtractable, readRetainedSections } from './adf-tidy'; -import { insertManifestBullet, lastSectionIndex, replaceManifestBullet, sectionHeaders } from './adf-manifest-register'; +import { insertManifestBullet, lastSectionIndex, removeManifestBullet, replaceManifestBullet, sectionHeaders } from './adf-manifest-register'; // ============================================================================ // Entities @@ -159,6 +160,30 @@ export const MSG = { restoreWriteFailed: ' [warn] Could not write {file}: {error}.{note}', restoreReverted: ' Its new mode record was removed again.', restoreNotReverted: ' Its new mode record (pointer) was kept; delete that VENDORS line by hand if you do not want it.', + // Legacy lowercase agents.md (#302). + usageRenameLegacy: 'Usage: charter adf vendor rename-legacy [--dry-run] [--ai-dir ] [--format json]', + renameOk: ' [ok] Renamed agents.md -> AGENTS.md ({method}){recordNote}{titleNote}', + renamePlanned: ' [dry-run] Would rename agents.md -> AGENTS.md ({method}){recordNote}{titleNote}', + renameStagedHint: ' The rename is staged. Commit it together with {paths}: git add {addArgs} && git commit', + renameStagedOnly: ' The rename is staged. Commit it: git commit', + renameRecordOnly: ' [ok] The file is already AGENTS.md; moved its VENDORS entry from agents.md to AGENTS.md', + renameRecordOnlyDuplicate: ' [ok] The file is already AGENTS.md; removed the duplicate agents.md VENDORS entry', + renameRecordOnlyPlanned: ' [dry-run] The file is already AGENTS.md; would {action} its agents.md VENDORS entry', + renameUnchanged: ' [ok] Nothing to rename: no lowercase agents.md in git, on disk, or in VENDORS.', + renameReferences: ' agents.md is still mentioned in: {refs}. Charter does not edit these; update them by hand if they mean the file.', + renameBothDistinct: ' [warn] agents.md and AGENTS.md are two different files. Move anything you need from agents.md into AGENTS.md, then: {rm} agents.md', + renameBothOneFile: ' [warn] git has two index entries (agents.md and AGENTS.md) for one file on this case-insensitive checkout. Drop the stale entry: git rm --cached agents.md', + renameDirty: ' [warn] agents.md has uncommitted changes. Commit or stash them first, so the rename lands as a pure rename.', + renameTmpExists: ' [warn] {tmp} already exists; remove it and re-run.', + renameRecordConflict: ' [warn] VENDORS records agents.md as {legacyMode} and AGENTS.md as {mode}. Keep one line in {manifestPath}, then re-run.', + renameRecordWrite: ' [warn] agents.md was not renamed: its VENDORS entry could not be moved in {manifestPath}. Fix the manifest or run: charter adf vendor status', + renameFailed: ' [warn] Rename failed: {error}. {undoNote}', + renameUndone: 'agents.md and the VENDORS entry are back as they were.', + renameRecover: 'Recover by hand: {recovery}', + legacyRemedy: 'charter adf vendor rename-legacy && charter adf compile --target agents --write --mode compiled', + refusedLegacySpelling: ' [warn] Refused to compile AGENTS.md: its VENDORS entry and git path still use the legacy name agents.md, which cannot be compiled output.\n Run: charter adf vendor rename-legacy, then re-run this command.', + legacyFirst: ' AGENTS.md is still recorded as agents.md; rename it first: charter adf vendor rename-legacy', + setLegacy: " [warn] AGENTS.md is recorded under the legacy name agents.md, which cannot be 'compiled'. Run: charter adf vendor rename-legacy, then: charter adf vendor set AGENTS.md compiled", } as const; function fill(template: string, values: Record): string { @@ -613,9 +638,11 @@ export function adfVendorCommand(options: CLIOptions, args: string[]): number { return vendorInit(options, manifestPath, dryRun); case 'restore': return vendorRestore(options, aiDir, rest, dryRun); + case 'rename-legacy': + return vendorRenameLegacy(options, aiDir, rest, dryRun); default: throw new CLIError( - `${sub ? `Unknown adf vendor subcommand: ${sub}. ` : ''}Usage: charter adf vendor [--ai-dir ] [--format json]`, + `${sub ? `Unknown adf vendor subcommand: ${sub}. ` : ''}Usage: charter adf vendor [--ai-dir ] [--format json]`, ); } } @@ -677,6 +704,15 @@ function vendorSet(options: CLIOptions, manifestPath: string, rest: string[], dr if (!isVendorMode(mode)) throw new CLIError(fill(MSG.unknownMode, { mode })); const json = options.format === 'json'; + // A legacy-keyed AGENTS.md names the rename, not the generic modeNotAllowed (#302, N4). + if (file === 'AGENTS.md' && mode === 'compiled' && legacyKeyFor(manifestPath, file)) { + if (json) { + const result = { status: 'skipped', file: 'agents.md', mode, skipReason: 'mode-not-allowed' }; + console.log(JSON.stringify({ manifestPath, written: false, result, remedy: 'charter adf vendor rename-legacy' }, null, 2)); + } + console.error(MSG.setLegacy); + return EXIT_CODE.POLICY_VIOLATION; + } const warnings: string[] = []; const outcome = recordVendorModes(manifestPath, [{ file, mode }], 'upsert', { dryRun, onWarning: w => warnings.push(w) }); const result = outcome.results[0]; @@ -1030,3 +1066,369 @@ function vendorRestore(options: CLIOptions, aiDir: string, rest: string[], dryRu if (json) console.log(JSON.stringify(result, null, 2)); return result.status === 'refused' ? EXIT_CODE.POLICY_VIOLATION : EXIT_CODE.SUCCESS; } + +// ============================================================================ +// Legacy lowercase agents.md (#302) +// ============================================================================ + +/** Temp name for the case-safe two-step rename. It must not exist beforehand. */ +export const RENAME_TMP = 'agents.md.charter-rename'; + +export type GitRunner = (args: string[]) => string; + +/** Exact-case agents.md / AGENTS.md entries in the git index. */ +export interface LegacyIndexEntries { + lower: boolean; + upper: boolean; +} + +/** + * D0: the git index's agents.md / AGENTS.md entries, or null outside git (or + * when ls-files fails). The `:(icase)` pathspec finds both spellings whatever + * core.ignorecase says; the exact-case filter makes the answer the same on + * case-sensitive and case-insensitive checkouts. Never looks at the disk. + */ +export function legacyIndexEntries(git: GitRunner = runGit): LegacyIndexEntries | null { + let out: string; + try { + out = git(['ls-files', '-z', '--', ':(icase)agents.md']); + } catch { + return null; + } + const paths = out.split('\0'); + return { lower: paths.includes('agents.md'), upper: paths.includes('AGENTS.md') }; +} + +/** Both names exist and are one physical file (case-insensitive checkout, or a hard link). */ +export function sameFileOnDisk(a: string, b: string): boolean { + const fa = path.resolve(a); + const fb = path.resolve(b); + return fs.existsSync(fa) && fs.existsSync(fb) && fileIdentity(fa) === fileIdentity(fb); +} + +export type LegacyIndexKind = 'legacy-tracked' | 'legacy-both-distinct' | 'legacy-both-one-file'; + +/** D1/D2: classify the index entries (null: nothing to report). */ +export function legacyIndexKind( + entries: LegacyIndexEntries | null, + sameFile: () => boolean = () => sameFileOnDisk('agents.md', 'AGENTS.md'), +): LegacyIndexKind | null { + if (!entries?.lower) return null; + if (!entries.upper) return 'legacy-tracked'; + return sameFile() ? 'legacy-both-one-file' : 'legacy-both-distinct'; +} + +/** + * N1: 'agents.md' iff `file` is AGENTS.md, both names are one physical file, + * and the VENDORS entry for that file is the legacy agents.md key. Else null. + */ +export function legacyKeyFor(manifestPath: string, file: VendorFile): 'agents.md' | null { + if (file !== 'AGENTS.md') return null; + const groups = vendorFileGroups(); + if (!groups.some(g => g.includes('AGENTS.md') && g.includes('agents.md'))) return null; + const { recorded } = readVendorRecord(manifestPath); + return recordedSpelling('AGENTS.md', recorded, groups) === 'agents.md' ? 'agents.md' : null; +} + +export type RenameMethod = 'git-mv' | 'fs' | 'record-only' | 'none'; +export type RenameRefusal = 'both-distinct' | 'dirty' | 'tmp-exists' | 'record-conflict' | 'record-write' | 'rename-failed'; + +export interface RenameResult { + status: 'renamed' | 'planned' | 'unchanged' | 'refused'; + method: RenameMethod; + from: 'agents.md'; + to: 'AGENTS.md'; + /** Paths this command left staged (git-mv only), as one rename. */ + staged: string[]; + record: { action: 'rekeyed' | 'removed-duplicate' | 'none' | 'reverted'; mode: 'pointer' | 'unmanaged' | null }; + /** True iff line 1 was exactly `# agents.md` and was (or, under --dry-run, would be) rewritten. */ + titleUpdated: boolean; + /** ":" mentions of agents.md, report-only. */ + references: string[]; + refusal: RenameRefusal | null; + remedy: string | null; + error: string | null; +} + +export interface RenameLegacyOptions { + dryRun?: boolean; + /** Receives each result line; isWarning marks a refusal or failure. */ + print?: (line: string, isWarning: boolean) => void; + /** Injected for tests: the git runner and the fs rename. */ + git?: GitRunner; + rename?: (from: string, to: string) => void; + /** Injected for tests: are agents.md and AGENTS.md one physical file? */ + sameFile?: () => boolean; +} + +const LEGACY_TITLE_RE = /^# agents\.md(?=\r?\n|$)/; +const LEGACY_REF_RE = /\bagents\.md\b/; + +function posix(p: string): string { + return p.split(path.sep).join('/'); +} + +function shellQuote(p: string): string { + return /^[\w./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`; +} + +function listDir(dir: string): string[] { + try { + return fs.readdirSync(dir).sort(); + } catch { + return []; + } +} + +/** + * S6: ":" for each line mentioning agents.md (case-sensitive, word + * bounded) in /*.adf and the repo-root *.md files. `planned` stands in + * for a file's content (by display path) when a dry run has not written it. + */ +function legacyReferences(aiDir: string, planned: ReadonlyMap): string[] { + const files: Array<{ shown: string; full: string }> = []; + for (const f of listDir(aiDir)) if (f.endsWith('.adf')) files.push({ shown: posix(path.join(aiDir, f)), full: path.join(aiDir, f) }); + for (const f of listDir('.')) { + if (!f.endsWith('.md')) continue; + // The file being renamed is shown under its new name. + files.push({ shown: f.toLowerCase() === 'agents.md' ? 'AGENTS.md' : f, full: f }); + } + const refs: string[] = []; + for (const { shown, full } of files) { + const text = planned.get(shown) ?? (fs.statSync(full, { throwIfNoEntry: false })?.isFile() ? readText(full) : null); + if (text === null) continue; + text.split('\n').forEach((line, i) => { + if (LEGACY_REF_RE.test(line)) refs.push(`${shown}:${i + 1}`); + }); + } + return [...new Set(refs)]; +} + +/** The S2 edit: the manifest text with the legacy key moved, checked by re-parse. Null on a guard skip. */ +function legacyRecordEdit(text: string, index: number, removeDuplicate: boolean, mode: VendorMode): string | null { + let doc: AdfDocument; + try { + doc = parseAdf(text); + } catch { + return null; + } + const expected: AdfDocument = JSON.parse(JSON.stringify(doc)); + const section = expected.sections[lastSectionIndex(expected, VENDORS_SECTION_KEY)]; + if (!section || section.content.type !== 'list') return null; + const bullet = `AGENTS.md: ${mode}`; + const next = removeDuplicate + ? removeManifestBullet(text, VENDORS_SECTION_KEY, index) + : replaceManifestBullet(text, VENDORS_SECTION_KEY, index, bullet); + if (next === null) return null; + if (removeDuplicate) section.content.items.splice(index, 1); + else section.content.items[index] = bullet; + // Guard: the edit must change nothing but that one entry. + try { + return JSON.stringify(parseAdf(next)) === JSON.stringify(expected) ? next : null; + } catch { + return null; + } +} + +/** + * `charter adf vendor rename-legacy`: rename a pre-#297 lowercase agents.md to + * AGENTS.md, moving its VENDORS key with it. The record moves first; the rename + * is two steps through RENAME_TMP (a one-step case-only rename is a no-op or an + * error on a case-insensitive filesystem), with `git mv` when agents.md is + * tracked, so the rename is staged and nothing else is. On any rename failure + * the completed step is undone and the record put back. The only content edit + * is an exact `# agents.md` first line. Never deletes or merges a file. + */ +export function renameLegacyAgents(aiDir: string, options: RenameLegacyOptions = {}): RenameResult { + const git = options.git ?? runGit; + const renameFs = options.rename ?? ((from: string, to: string) => fs.renameSync(from, to)); + const print = options.print ?? (() => undefined); + const manifestPath = path.join(aiDir, 'manifest.adf'); + const shownManifest = posix(manifestPath); + let result: RenameResult = { + status: 'unchanged', method: 'none', from: 'agents.md', to: 'AGENTS.md', staged: [], + record: { action: 'none', mode: null }, titleUpdated: false, references: [], refusal: null, remedy: null, error: null, + }; + const refuse = (refusal: RenameRefusal, remedy: string | null, line: string): RenameResult => { + print(line, true); + return { ...result, status: 'refused', refusal, remedy }; + }; + + // S1: plan. Nothing below writes until the dry-run return. + const index = legacyIndexEntries(git); + const names = listDir('.'); + const diskLower = names.includes('agents.md'); + const diskUpper = names.includes('AGENTS.md'); + const oneFile = (options.sameFile ?? (() => sameFileOnDisk('agents.md', 'AGENTS.md')))(); + if (index?.lower && index.upper) { + return oneFile + ? refuse('both-distinct', 'git rm --cached agents.md', MSG.renameBothOneFile) + : refuse('both-distinct', 'git rm agents.md', fill(MSG.renameBothDistinct, { rm: 'git rm' })); + } + if (diskLower && diskUpper && !oneFile) { + const rm = index?.lower ? 'git rm' : 'rm'; + return refuse('both-distinct', `${rm} agents.md`, fill(MSG.renameBothDistinct, { rm })); + } + + const record = readVendorRecord(manifestPath); + const legacy = record.recorded.get('agents.md'); + const method: RenameMethod = index?.lower ? 'git-mv' : diskLower && !diskUpper ? 'fs' : legacy ? 'record-only' : 'none'; + result.method = method; + if (method === 'none') { + print(MSG.renameUnchanged, false); + return result; + } + if (record.readProblem) { + return refuse('record-write', 'charter adf vendor status', fill(MSG.renameRecordWrite, { manifestPath: shownManifest })); + } + const renames = method === 'git-mv' || method === 'fs'; + if (method === 'git-mv') { + let dirty = true; + try { + dirty = git(['status', '--porcelain=v1', '-z', '--untracked-files=no', '--', 'agents.md']) !== ''; + } catch { + dirty = true; + } + if (dirty) return refuse('dirty', 'git commit', MSG.renameDirty); + } + if (renames) { + let tmpTracked = false; + try { + tmpTracked = index !== null && git(['ls-files', '-z', '--', RENAME_TMP]) !== ''; + } catch { + tmpTracked = false; + } + if (fs.existsSync(RENAME_TMP) || tmpTracked) { + return refuse('tmp-exists', `rm ${RENAME_TMP}`, fill(MSG.renameTmpExists, { tmp: RENAME_TMP })); + } + } + + const upper = record.recorded.get('AGENTS.md'); + let edit: { next: string; before: string; action: 'rekeyed' | 'removed-duplicate'; mode: 'pointer' | 'unmanaged' } | null = null; + if (legacy) { + if (upper && upper.mode !== legacy.mode) { + return refuse('record-conflict', null, fill(MSG.renameRecordConflict, { + legacyMode: legacy.mode, mode: upper.mode, manifestPath: shownManifest, + })); + } + const before = readText(manifestPath); + const next = before === null ? null : legacyRecordEdit(before, legacy.index, upper !== undefined, legacy.mode); + if (before === null || next === null) { + return refuse('record-write', 'charter adf vendor status', fill(MSG.renameRecordWrite, { manifestPath: shownManifest })); + } + // agents.md may only be pointer or unmanaged (ALLOWED_MODES), both allowed for AGENTS.md. + edit = { next, before, action: upper ? 'removed-duplicate' : 'rekeyed', mode: legacy.mode as 'pointer' | 'unmanaged' }; + result.record = { action: edit.action, mode: edit.mode }; + } + const current = renames ? readText('agents.md') : null; + const retitled = current !== null && LEGACY_TITLE_RE.test(current) ? current.replace(LEGACY_TITLE_RE, '# AGENTS.md') : null; + const recordNote = edit ? (edit.action === 'rekeyed' ? '; VENDORS entry moved' : '; duplicate VENDORS entry removed') : ''; + + if (options.dryRun) { + const planned = new Map(); + if (edit) planned.set(shownManifest, edit.next); + if (retitled !== null) planned.set('AGENTS.md', retitled); + result = { ...result, status: 'planned', titleUpdated: retitled !== null, references: legacyReferences(aiDir, planned) }; + print(renames + ? fill(MSG.renamePlanned, { method, recordNote, titleNote: retitled !== null ? '; title line would be updated' : '' }) + : fill(MSG.renameRecordOnlyPlanned, { action: edit?.action === 'removed-duplicate' ? 'remove the duplicate' : 'move' }), false); + return result; + } + + // S2: record first. + if (edit) { + try { + fs.writeFileSync(manifestPath, edit.next); + } catch { + return refuse('record-write', 'charter adf vendor status', fill(MSG.renameRecordWrite, { manifestPath: shownManifest })); + } + } + if (!renames) { + result = { ...result, status: 'renamed', references: legacyReferences(aiDir, new Map()) }; + print(edit?.action === 'removed-duplicate' ? MSG.renameRecordOnlyDuplicate : MSG.renameRecordOnly, false); + return result; + } + + // S3: two-step rename; S4: undo on failure. + const move = (from: string, to: string): void => { + if (method === 'git-mv') git(['mv', '--', from, to]); + else renameFs(from, to); + }; + let done = 0; + try { + move('agents.md', RENAME_TMP); + done = 1; + move(RENAME_TMP, 'AGENTS.md'); + done = 2; + } catch (err: unknown) { + const error = method === 'git-mv' ? getGitErrorMessage(err) : err instanceof Error ? err.message : String(err); + const recovery: string[] = []; + let undone = true; + try { + if (done === 1) move(RENAME_TMP, 'agents.md'); + else if (fs.existsSync(RENAME_TMP) && !fs.existsSync('agents.md')) renameFs(RENAME_TMP, 'agents.md'); + } catch { + undone = false; + recovery.push(`${method === 'git-mv' ? 'git mv --' : 'mv'} ${RENAME_TMP} agents.md`); + } + if (edit) { + let reverted = false; + try { + if (readText(manifestPath) === edit.next) { + fs.writeFileSync(manifestPath, edit.before); + reverted = true; + } + } catch { + reverted = false; + } + if (reverted) result.record = { ...result.record, action: 'reverted' }; + else recovery.push(`put back the line ' - agents.md: ${edit.mode}' under VENDORS in ${shownManifest}`); + } + const undoNote = undone && recovery.length === 0 ? MSG.renameUndone : fill(MSG.renameRecover, { recovery: recovery.join('; ') }); + result.error = error; + // The full error is in JSON; the line shows its first line (git's hints run long). + const shown = error.split(/\r?\n/, 1)[0].replace(/\.$/, ''); + return refuse('rename-failed', 'charter adf vendor rename-legacy', fill(MSG.renameFailed, { error: shown, undoNote })); + } + result.status = 'renamed'; + if (method === 'git-mv') result.staged = ['agents.md', 'AGENTS.md']; + + // S5: the legacy title line only. + if (retitled !== null) { + try { + fs.writeFileSync('AGENTS.md', retitled); + result.titleUpdated = true; + } catch (err: unknown) { + print(` Warning: could not update the title line of AGENTS.md: ${err instanceof Error ? err.message : String(err)}`, true); + } + } + result.references = legacyReferences(aiDir, new Map()); + + print(fill(MSG.renameOk, { method, recordNote, titleNote: result.titleUpdated ? '; title line updated' : '' }), false); + if (method === 'git-mv') { + // CP-3: the manifest (and a title edit) are left unstaged, for the user to commit with the rename. + const paths = [...(edit ? [shownManifest] : []), ...(result.titleUpdated ? ['AGENTS.md'] : [])]; + print(paths.length > 0 + ? fill(MSG.renameStagedHint, { paths: paths.join(' and '), addArgs: paths.map(shellQuote).join(' ') }) + : MSG.renameStagedOnly, false); + } + return result; +} + +function vendorRenameLegacy(options: CLIOptions, aiDir: string, rest: string[], dryRun: boolean): number { + if (rest.length !== 0) throw new CLIError(MSG.usageRenameLegacy); + const json = options.format === 'json'; + const result = renameLegacyAgents(aiDir, { + dryRun, + // JSON stdout stays one document: text lines go to stderr there. + print: (line, isWarning) => (json || isWarning ? console.error(line) : console.log(line)), + }); + if (result.references.length > 0) { + const shown = result.references.slice(0, 5).join(', ') + (result.references.length > 5 ? `, and ${result.references.length - 5} more` : ''); + const line = fill(MSG.renameReferences, { refs: shown }); + if (json) console.error(line); + else console.log(line); + } + if (json) console.log(JSON.stringify(result, null, 2)); + return result.status === 'refused' ? EXIT_CODE.POLICY_VIOLATION : EXIT_CODE.SUCCESS; +} diff --git a/packages/cli/src/commands/adf.ts b/packages/cli/src/commands/adf.ts index 9b61ca0..a9450f5 100644 --- a/packages/cli/src/commands/adf.ts +++ b/packages/cli/src/commands/adf.ts @@ -972,7 +972,7 @@ function printHelp(): void { console.log(' --force: with --write, overwrite hand-authored content (never changes a file\'s mode)'); console.log(' Default (no --write/--check): print to stdout (single target only)'); console.log(''); - console.log(' charter adf vendor | init | restore > [--ai-dir ] [--dry-run]'); + console.log(' charter adf vendor | init | restore | rename-legacy> [--ai-dir ] [--dry-run]'); console.log(' Show or record whether each vendor file is a pointer, compiled output, or unmanaged'); console.log(' (VENDORS in manifest.adf). adf compile and adf tidy act on it. restore rewrites one pointer, refusing on any content loss.'); console.log(''); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index fd1e46c..82621d9 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -16,7 +16,7 @@ import { CHARTER_OWNED_MARKERS } from './adf'; import { TARGET_FILENAMES } from '@stackbilt/adf'; import type { CompileTarget } from '@stackbilt/adf'; import { countExtractable } from './adf-tidy'; -import { readVendorRecord, resolveVendorModes, vendorFileGroups } from './adf-vendor'; +import { legacyIndexEntries, legacyIndexKind, readVendorRecord, resolveVendorMode, resolveVendorModes, vendorFileGroups } from './adf-vendor'; import type { ModeSource, VendorEntryProblem, VendorFile, VendorMode } from './adf-vendor'; import { checkGateEnforcement, checkPreCommitScope } from './doctor-gate-enforcement'; import { findMissingDefaultLoad, formatMissingDefaultLoad, REQUIRED_DEFAULT_LOAD_MODULES } from './adf-wiring'; @@ -152,7 +152,11 @@ export const DOCTOR_MSG = { entryProblem: "VENDORS entry '{raw}' in {manifestPath} is ignored ({problem}). Fix or delete that line in {manifestPath}.", entryDuplicate: "VENDORS lists {file} more than once in {manifestPath}; '{raw}' is ignored because a later entry wins. Delete that line.", alias: 'VENDORS records both {file} and {other}, which are the same file on disk; {file} is used. Delete the {other} line.', - legacySpelling: "VENDORS records the legacy name agents.md for the file AGENTS.md; `adf compile --target agents --mode compiled` cannot switch it. Change the line to ' - AGENTS.md: {mode}' (an automatic rename comes with #302).", + legacySpelling: "VENDORS records the legacy name agents.md for the file AGENTS.md, so `adf compile --target agents --mode compiled` cannot switch it. Run: charter adf vendor rename-legacy", + // Legacy lowercase agents.md in the git index (#302). All INFO: Charter's own pre-#297 bootstrap created it. + legacyTracked: 'git tracks agents.md under its pre-#297 lowercase name. Rename it (case-safe, keeps its VENDORS entry): charter adf vendor rename-legacy', + legacyBothDistinct: 'git tracks both agents.md and AGENTS.md as separate files. Move anything you need from agents.md into AGENTS.md, then: git rm agents.md', + legacyBothOneFile: 'git has two index entries (agents.md and AGENTS.md) for one file on this case-insensitive checkout. Drop the stale entry: git rm --cached agents.md', sectionNotList: 'VENDORS in {manifestPath} does not read as a list, so every vendor mode is inferred. Every line under it must look like " - CLAUDE.md: pointer".', agentConfigPass: '{pointers} thin pointer(s) and {compiled} compiled file(s) route to .ai/{unmanagedNote}.', unmanagedNote: '; {n} file(s) recorded unmanaged are left alone', @@ -170,6 +174,9 @@ export type VendorModeRowKind = | 'entry-problem' | 'alias-recorded' | 'legacy-spelling' + | 'legacy-tracked' + | 'legacy-both-distinct' + | 'legacy-both-one-file' | 'section-not-list'; type CheckStatus = 'PASS' | 'WARN' | 'INFO'; @@ -183,6 +190,9 @@ export const ROW_STATUS: Record = { 'entry-problem': 'WARN', 'alias-recorded': 'INFO', 'legacy-spelling': 'INFO', + 'legacy-tracked': 'INFO', + 'legacy-both-distinct': 'INFO', + 'legacy-both-one-file': 'INFO', 'section-not-list': 'WARN', }; @@ -227,7 +237,10 @@ export function vendorModeRows(aiDir: string, manifestPath: string): Array<{ row // Key order follows the row schema: kind, status, file, recordedMode, inferredMode, source, raw, remedy. const base: VendorModeRow = { kind: 'recorded-ok', status: 'PASS', file: null, recordedMode: null, inferredMode: null, source: null, raw: null, remedy: null }; if (record.readProblem === 'section-not-list') { - return [{ row: { ...base, kind: 'section-not-list', status: 'WARN' }, detail: fill(DOCTOR_MSG.sectionNotList, { manifestPath }) }]; + return [ + { row: { ...base, kind: 'section-not-list', status: 'WARN' }, detail: fill(DOCTOR_MSG.sectionNotList, { manifestPath }) }, + ...legacyIndexRows(aiDir, base), + ]; } const out: Array<{ row: VendorModeRow; detail: string | null }> = []; for (const r of resolveVendorModes(aiDir)) { @@ -288,13 +301,38 @@ export function vendorModeRows(aiDir: string, manifestPath: string): Array<{ row } } else if (names[0] === 'agents.md' && group.includes('AGENTS.md')) { const mode = record.recorded.get('agents.md')?.mode ?? 'pointer'; - const row: VendorModeRow = { ...base, kind: 'legacy-spelling', status: 'INFO', file: 'agents.md', recordedMode: mode, source: 'recorded' }; - out.push({ row, detail: fill(DOCTOR_MSG.legacySpelling, { mode }) }); + const row: VendorModeRow = { + ...base, kind: 'legacy-spelling', status: 'INFO', file: 'agents.md', recordedMode: mode, source: 'recorded', remedy: 'charter adf vendor rename-legacy', + }; + out.push({ row, detail: DOCTOR_MSG.legacySpelling }); } } + out.push(...legacyIndexRows(aiDir, base)); return out; } +const LEGACY_INDEX_ROWS = { + 'legacy-tracked': { detail: DOCTOR_MSG.legacyTracked, remedy: 'charter adf vendor rename-legacy' }, + 'legacy-both-distinct': { detail: DOCTOR_MSG.legacyBothDistinct, remedy: 'git rm agents.md' }, + 'legacy-both-one-file': { detail: DOCTOR_MSG.legacyBothOneFile, remedy: 'git rm --cached agents.md' }, +} as const; + +/** + * A lowercase agents.md in the git index (#302). Reads the index only (plus the + * file identity to tell two files from one), so an untracked agents.md, or a + * directory outside git, gives no row. Always INFO (INV-P6-2). + */ +function legacyIndexRows(aiDir: string, base: VendorModeRow): Array<{ row: VendorModeRow; detail: string | null }> { + const kind = legacyIndexKind(legacyIndexEntries()); + if (!kind) return []; + const r = resolveVendorMode(aiDir, 'agents.md'); + const row: VendorModeRow = { + ...base, kind, status: ROW_STATUS[kind], file: 'agents.md', recordedMode: r.source === 'recorded' ? r.mode : null, + inferredMode: r.evidence.inferredMode, source: r.source, remedy: LEGACY_INDEX_ROWS[kind].remedy, + }; + return [{ row, detail: LEGACY_INDEX_ROWS[kind].detail }]; +} + /** The `adf vendor mode` check: worst row status, one detail line per non-PASS row. */ function checkVendorModes(aiDir: string, manifestPath: string): DoctorResult['checks'][number] | undefined { const entries = vendorModeRows(aiDir, manifestPath); From 96bd2fd2e2b1cc80840ea6695974f433fc271fae Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 16 Oct 2026 16:35:19 -0500 Subject: [PATCH 2/2] fix(adf): rename-legacy ignores pathspec env and never falls back blindly (refs #302) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/cli-reference.md | 6 +- packages/cli/README.md | 2 +- .../src/__tests__/adf-vendor-legacy.test.ts | 155 +++++++++++++++++- packages/cli/src/commands/adf-vendor.ts | 117 +++++++++++-- 5 files changed, 253 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a6608..d6361aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Added -- **`charter adf vendor rename-legacy`, and doctor rows for a tracked lowercase `agents.md`** (`#302`, `#318` part 6) — repos bootstrapped before `#297` track `agents.md`, which nothing renamed. `rename-legacy` moves the `VENDORS` key first (`agents.md: ` becomes `AGENTS.md: `, or a same-mode duplicate `agents.md` line is removed through a new `removeManifestBullet` and the same re-parse guard), then renames in two steps through `agents.md.charter-rename`, which works on case-insensitive filesystems. **Index effect:** when git tracks `agents.md`, both steps are `git mv`, so the command leaves a staged rename (`R agents.md -> AGENTS.md`) and stages nothing else. `manifest.adf` is left unstaged, and the output names `git add .ai/manifest.adf && git commit`. An untracked file, or a directory outside git, is renamed on disk only. **Content effect:** the only edit is a first line that is exactly `# agents.md`, rewritten to `# AGENTS.md` and left unstaged. Mentions of `agents.md` elsewhere are listed, never edited. It refuses (exit 1, nothing changed) when both spellings are tracked or are two different files (it never merges or deletes), when `agents.md` has uncommitted changes, when the temp name exists, or when the two keys are recorded with different modes. If a rename step fails, the completed step is undone and the record put back. `--dry-run` writes nothing, and `--format json` prints one result. Nothing calls it automatically: bootstrap, tidy, compile and the hook never rename. `charter doctor`'s `adf vendor mode` check adds three INFO rows read from the git index only: `legacy-tracked` (names `rename-legacy`), `legacy-both-distinct` (merge into `AGENTS.md`, then `git rm agents.md`) and `legacy-both-one-file` (`git rm --cached agents.md`). They never fail `--ci`. +- **`charter adf vendor rename-legacy`, and doctor rows for a tracked lowercase `agents.md`** (`#302`, `#318` part 6) — repos bootstrapped before `#297` track `agents.md`, which nothing renamed. `rename-legacy` moves the `VENDORS` key first (`agents.md: ` becomes `AGENTS.md: `, or a same-mode duplicate `agents.md` line is removed through a new `removeManifestBullet` and the same re-parse guard), then renames in two steps through `agents.md.charter-rename`, which works on case-insensitive filesystems. **Index effect:** when git tracks `agents.md`, both steps are `git mv`, so the command leaves a staged rename (`R agents.md -> AGENTS.md`) and stages nothing else. `manifest.adf` is left unstaged, and the output names `git add .ai/manifest.adf && git commit` (with `AGENTS.md` in the `git add` when its title line was rewritten), plus a note that `git commit` also records anything already staged. An untracked file, or a directory outside git, is renamed on disk only. **Content effect:** the only edit is a first line that is exactly `# agents.md`, rewritten to `# AGENTS.md` and left unstaged. Mentions of `agents.md` elsewhere are listed, never edited. It refuses (exit 1, nothing changed) when both spellings are tracked or are two different files (it never merges or deletes), when `agents.md` has uncommitted changes, when the temp name exists, when the two keys are recorded with different modes or `agents.md` is listed twice, when the git index cannot be read inside a work tree, or when it is run below the repository root. The index is read with plain exact paths, so exported `GIT_*_PATHSPECS` variables cannot hide a tracked `agents.md`. If a rename step fails, the completed step is undone and the record put back. `--dry-run` writes nothing, and `--format json` prints one result. Nothing calls it automatically: bootstrap, tidy, compile and the hook never rename. `charter doctor`'s `adf vendor mode` check adds three INFO rows read from the git index only: `legacy-tracked` (names `rename-legacy`), `legacy-both-distinct` (merge into `AGENTS.md`, then `git rm agents.md`) and `legacy-both-one-file` (`git rm --cached agents.md`). They never fail `--ci`. - **`charter adf tidy --source` is repeatable, and `--print-paths` lists the files tidy writes** (`#320`) — each `--source` is processed in argument order, and two names for one file (`AGENTS.md` and `agents.md` on a case-insensitive filesystem, or a hard link) are tidied once, under the first name. A `--source` with no value is now a usage error (exit 2); it used to tidy every vendor file silently. `--print-paths` prints one `vendor ` or `module ` line per file tidy writes (or would write, under `--dry-run`) and nothing else on stdout; warnings go to stderr. It cannot be combined with `--format json` (exit 2). JSON output adds `paths` (`vendor` and `modules`); `modulesModified` is unchanged. A single `--source` gives the same output as before. - **`charter adf vendor restore `** (`#318`, part 3) — rewrites one vendor file as its thin pointer template (`CLAUDE.md` gets the hybrid template with its module index, as bootstrap writes it), carrying `## Environment` and other retained sections over verbatim. It runs only for a file in `pointer` mode, or an absent file with no entry, and refuses (exit 1, nothing written) whenever content would be lost: hand-authored content, content `adf tidy` would extract, any line of a pointer that is not from a Charter template and that the new pointer would not carry (an edited title, an extra note, edited `## Session Start` text), your own sections in a compiled file, or a compiled file that differs from what `.ai/` compiles to now (checked in memory). It is the fix doctor names for a recorded pointer that now holds compiled output. A file with no entry gets `pointer` recorded first; a failed file write puts the manifest back and is reported as `write-failed` with `writeError`. `--dry-run` and `--format json` are supported. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 798e05a..8300f24 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -191,7 +191,7 @@ The `adf vendor mode` check compares each vendor file's `VENDORS` entry (see [`c | `legacy-both-one-file` | git has both index entries for one file (a case-insensitive checkout). Drop the stale entry: `git rm --cached agents.md` | INFO | | `section-not-list` | `VENDORS` does not read as a list | WARN | -The three `legacy-*` index rows read only the git index (`git ls-files -- ':(icase)agents.md'`, then an exact-case filter), so they are the same on case-sensitive and case-insensitive checkouts. An untracked `agents.md`, or a directory outside git, gives no row. They are INFO because Charter's own pre-#297 bootstrap created this state, so they never fail `doctor --ci`. +The three `legacy-*` index rows read only the git index (`git ls-files -- agents.md AGENTS.md`, then an exact-case filter, with any `GIT_*_PATHSPECS` variables removed from git's environment), so they are the same on case-sensitive and case-insensitive checkouts. An untracked `agents.md`, or a directory outside git, gives no row. They are INFO because Charter's own pre-#297 bootstrap created this state, so they never fail `doctor --ci`. A repo without a `VENDORS` section gets only the `inferred` INFO line, so upgrading never fails `doctor --ci`. A WARN appears only where an explicit record is contradicted or ignored. Doctor never writes the record or a vendor file. @@ -707,9 +707,9 @@ npx charter adf vendor rename-legacy --dry-run # plan renaming a pre-#297 a One physical file has one entry. When two vendor names are the same file (`AGENTS.md` and `agents.md` on a case-insensitive filesystem, or a hard link), every writer records it under the spelling already recorded, or `AGENTS.md` when neither is. `status` shows it once. `set` names the spelling it used. - `rename-legacy`: renames a lowercase `agents.md` (written by bootstrap before #297) to `AGENTS.md`, and moves its `VENDORS` key with it. The record moves first, through the same text-level writer and re-parse guard as `set`: `agents.md: ` becomes `AGENTS.md: `, or, when `AGENTS.md` is already recorded with the same mode, the `agents.md` line is removed. The rename is two steps through `agents.md.charter-rename`, because a one-step case-only rename is a no-op or an error on a case-insensitive filesystem. - - **Index effect.** When git tracks `agents.md`, both steps are `git mv`, so the rename is **staged** (`git diff --cached` shows `R agents.md -> AGENTS.md`) and no other path is. `manifest.adf` is left unstaged, and the output names the command: `git add .ai/manifest.adf && git commit`. An untracked `agents.md`, or a directory outside git, is renamed on disk only (`method: fs`). When the file is already `AGENTS.md`, only the key moves (`record-only`). + - **Index effect.** When git tracks `agents.md`, both steps are `git mv`, so the rename is **staged** (`git diff --cached` shows `R agents.md -> AGENTS.md`) and no other path is. `manifest.adf` is left unstaged, and the output names the command: `git add .ai/manifest.adf && git commit`, with `AGENTS.md` added to the `git add` when its title line was rewritten. A plain `git commit` also records anything you had already staged, and the output says so. An untracked `agents.md`, or a directory outside git, is renamed on disk only (`method: fs`). When the file is already `AGENTS.md`, only the key moves (`record-only`). - **Content effect.** The only content edit is a first line that is exactly `# agents.md` (the old template title), rewritten to `# AGENTS.md` and left unstaged with the manifest. Mentions of `agents.md` in `.ai/*.adf` and root `*.md` files are listed, never edited. - - **Refusals** (exit 1, nothing changed): `both-distinct` (git tracks both spellings, or the disk has two different files: Charter never merges or deletes them), `dirty` (`agents.md` has staged or unstaged changes, so commit or stash first), `tmp-exists`, `record-conflict` (both keys recorded with different modes), `record-write` (the record cannot be read or edited safely). If either rename step fails, the completed step is undone and the record put back (`rename-failed`). + - **Refusals** (exit 1, nothing changed): `both-distinct` (git tracks both spellings, or the disk has two different files: Charter never merges or deletes them), `dirty` (`agents.md` has staged or unstaged changes, so commit or stash first), `tmp-exists`, `record-conflict` (both keys recorded with different modes, or more than one `agents.md` line), `index-unreadable` (inside a git work tree but `git ls-files` failed: it never falls back to a plain rename of a file git may track), `not-repo-root` (run below the repository root, or outside git with an `--ai-dir` above the current directory: run it from the root, so a nested `docs/agents.md` is never renamed), `record-write` (the record cannot be read or edited safely). If either rename step fails, the completed step is undone and the record put back (`rename-failed`). - Nothing renames `agents.md` automatically: bootstrap, tidy, compile and the pre-commit hook never call this. `--format json` prints one result: `status`, `method`, `from`, `to`, `staged`, `record`, `titleUpdated`, `references`, `refusal`, `remedy`, `error`. - `set AGENTS.md compiled` on a file recorded as `agents.md` exits 1 and names `rename-legacy` first. `pointer` and `unmanaged` still write through the recorded `agents.md` spelling. - `--dry-run`: with `set`, `init`, `restore` or `rename-legacy`, report the change without writing diff --git a/packages/cli/README.md b/packages/cli/README.md index 6f40896..689fb24 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -339,7 +339,7 @@ charter adf vendor rename-legacy [--dry-run] [--ai-dir ] - `evidence`: Validate all metric ceilings in the merged document and produce a structured pass/fail evidence report. `--auto-measure` counts lines in files referenced by the manifest METRICS section. `--context` or `--context-file` inject external metric overrides that take precedence over auto-measured and document values. In `--ci` mode, exits 1 on constraint failures (warnings don't fail). Also reports stale-baseline warnings (baseline vs current delta + recommended ceiling) when baseline values drift significantly. The governance workflow template runs this automatically on PRs when `.ai/manifest.adf` is present. - `metrics recalibrate`: Re-measure current LOC from manifest metric sources, propose and apply new ceilings using configurable headroom, and append rationale records to `BUDGET_RATIONALES`. Requires explicit rationale (`--reason`) unless `--auto-rationale` is used. - `migrate`: Scan existing agent config files (CLAUDE.md, .cursorrules, agents.md, GEMINI.md, copilot-instructions.md), classify content using the ADX-002 decision tree, and migrate into ADF modules. `--dry-run` previews the migration plan without writing files. `--source ` targets a single file. `--no-backup` skips `.pre-adf-migrate.bak` creation. `--merge-strategy` controls deduplication: `dedupe` (default, skip items already in ADF), `append` (always add), or `replace`. Environment-specific rules (WSL, PATH, credential helpers) are retained in the thin pointer. Each module that receives content is registered in `manifest.adf`: `core.adf` and `state.adf` under `DEFAULT_LOAD`, the rest under `ON_DEMAND` with triggers inferred from the migrated headings. Registration is append-only, so existing entries are never rewritten. The `CLAUDE.md` module index is refreshed to match, and the plan shows a `REGISTER` line per module. -- `vendor`: Show or record whether each vendor file (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `GEMINI.md`, `agents.md`, `copilot-instructions.md`) is a thin `pointer`, `compiled` output, or `unmanaged`. The record is a `VENDORS` list in `manifest.adf` (` - CLAUDE.md: pointer`). `status` is read-only and shows each file's recorded mode, or the mode inferred from its markers when none is recorded. `set` records one file's mode; only the four compile targets may be `compiled`. `init` records the inferred mode of every vendor file on disk that has no entry yet, and never changes an existing one. `bootstrap`, `adf init --emit-pointers`, `adf migrate` and `adf compile --write` also record the mode of each file they write, without changing an existing entry (an explicit `adf migrate --source ` changes `unmanaged` to `pointer`). `restore ` rewrites one pointer from its template, keeping retained sections such as `## Environment`, and refuses whenever content would be lost (hand-authored content, bloat, or a compiled file that differs from what `.ai/` compiles to); a compiled file's retained sections are carried into the pointer. `rename-legacy` renames a pre-#297 lowercase `agents.md` to `AGENTS.md`: it moves the `VENDORS` key first, then renames in two steps; when git tracks the file both steps are `git mv`, so the rename is staged and `manifest.adf` is left for you to `git add`. Its only content edit is an exact `# agents.md` title line. It refuses (exit 1) when both spellings are tracked or are two files, when `agents.md` has uncommitted changes, or when the two keys disagree, and undoes itself if a step fails. `doctor` reports a tracked lowercase `agents.md` as INFO (`#302`). `adf compile --write` keeps your `## Environment` and other retained sections in a `charter:retained` block at the end of each compiled file, converts a pointer with notes without refusing, and refuses only over content it would not carry (`refused-bloated`, `refused-would-drop`) (`#303`). `adf compile`, `adf tidy`, `doctor`, `score`, `bootstrap` and `adf migrate` act on the record: for example, `bootstrap --force` never replaces a compiled or hand-authored vendor file, and bare `adf migrate` skips a file recorded `unmanaged` (`#318`). +- `vendor`: Show or record whether each vendor file (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `GEMINI.md`, `agents.md`, `copilot-instructions.md`) is a thin `pointer`, `compiled` output, or `unmanaged`. The record is a `VENDORS` list in `manifest.adf` (` - CLAUDE.md: pointer`). `status` is read-only and shows each file's recorded mode, or the mode inferred from its markers when none is recorded. `set` records one file's mode; only the four compile targets may be `compiled`. `init` records the inferred mode of every vendor file on disk that has no entry yet, and never changes an existing one. `bootstrap`, `adf init --emit-pointers`, `adf migrate` and `adf compile --write` also record the mode of each file they write, without changing an existing entry (an explicit `adf migrate --source ` changes `unmanaged` to `pointer`). `restore ` rewrites one pointer from its template, keeping retained sections such as `## Environment`, and refuses whenever content would be lost (hand-authored content, bloat, or a compiled file that differs from what `.ai/` compiles to); a compiled file's retained sections are carried into the pointer. `rename-legacy` renames a pre-#297 lowercase `agents.md` to `AGENTS.md`: it moves the `VENDORS` key first, then renames in two steps; when git tracks the file both steps are `git mv`, so the rename is staged and `manifest.adf` (and `AGENTS.md`, when its title line was rewritten) is left for you to `git add`. Its only content edit is an exact `# agents.md` title line. It refuses (exit 1) when both spellings are tracked or are two files, when `agents.md` has uncommitted changes, when the two keys disagree, when the git index cannot be read, or when run below the repository root, and undoes itself if a step fails. `doctor` reports a tracked lowercase `agents.md` as INFO (`#302`). `adf compile --write` keeps your `## Environment` and other retained sections in a `charter:retained` block at the end of each compiled file, converts a pointer with notes without refusing, and refuses only over content it would not carry (`refused-bloated`, `refused-would-drop`) (`#303`). `adf compile`, `adf tidy`, `doctor`, `score`, `bootstrap` and `adf migrate` act on the record: for example, `bootstrap --force` never replaces a compiled or hand-authored vendor file, and bare `adf migrate` skips a file recorded `unmanaged` (`#318`). ### `charter blast` diff --git a/packages/cli/src/__tests__/adf-vendor-legacy.test.ts b/packages/cli/src/__tests__/adf-vendor-legacy.test.ts index c107b9c..df8c798 100644 --- a/packages/cli/src/__tests__/adf-vendor-legacy.test.ts +++ b/packages/cli/src/__tests__/adf-vendor-legacy.test.ts @@ -22,6 +22,7 @@ import { legacyIndexEntries, legacyIndexKind, legacyKeyFor, + readLegacyIndex, POINTER_ONLY_VENDOR_FILES, RENAME_TMP, renameLegacyAgents, @@ -115,6 +116,8 @@ const manifestText = (): string => fs.readFileSync('.ai/manifest.adf', 'utf-8'); const rootNames = (): string[] => fs.readdirSync('.').filter(n => n !== '.git').sort(); const cached = (): string => git(['diff', '--cached', '--name-status', '-M']).trim(); const porcelain = (): string => git(['status', '--porcelain=v1', '--untracked-files=all']); +/** Index entries spelled agents.md in any case (read without pathspecs). */ +const indexAgents = (): string[] => git(['ls-files', '-z']).split('\0').filter(n => n.toLowerCase() === 'agents.md'); /** Is this temp dir on a case-insensitive filesystem? */ function caseInsensitive(): boolean { @@ -192,18 +195,27 @@ describe('removeManifestBullet', () => { // ============================================================================ describe('legacy agents.md detection (index only)', () => { - it('queries the index with an icase pathspec and filters by exact case (INV-P6-1, mocked git)', () => { + it('queries the index with plain exact paths and filters by exact case (INV-P6-1, mocked git)', () => { const calls: string[][] = []; const entries = legacyIndexEntries(args => { calls.push(args); - return 'Agents.md\0agents.md\0'; + return args[0] === 'rev-parse' ? tmp : 'Agents.md\0agents.md\0'; }); - expect(calls).toEqual([['ls-files', '-z', '--', ':(icase)agents.md']]); + expect(calls).toEqual([['rev-parse', '--show-toplevel'], ['ls-files', '-z', '--', 'agents.md', 'AGENTS.md']]); + expect(calls.flat().some(a => a.startsWith(':('))).toBe(false); // no pathspec magic expect(entries).toEqual({ lower: true, upper: false }); - expect(legacyIndexEntries(() => 'AGENTS.md\0agents.md\0')).toEqual({ lower: true, upper: true }); + expect(legacyIndexEntries(args => (args[0] === 'rev-parse' ? tmp : 'AGENTS.md\0agents.md\0'))).toEqual({ lower: true, upper: true }); expect(legacyIndexEntries(() => { throw new Error('not a git repository'); })).toBeNull(); }); + it('tells "not a git repo" from "index unreadable" (mocked git)', () => { + expect(readLegacyIndex(() => { throw new Error('not a git repository'); })).toBe('not-git'); + expect(readLegacyIndex(args => { + if (args[0] === 'ls-files') throw new Error('index file corrupt'); + return tmp; + })).toBe('unreadable'); + }); + it('classifies by index entries, using file identity only to split both-spellings (mocked)', () => { expect(legacyIndexKind(null)).toBeNull(); expect(legacyIndexKind({ lower: false, upper: true })).toBeNull(); @@ -288,12 +300,13 @@ describe('adf vendor rename-legacy (git mv)', () => { const r = await run(['vendor', 'rename-legacy']); expect(r.code).toBe(0); expect(cached()).toMatch(/^R100\tagents\.md\tAGENTS\.md$/); - expect(git(['ls-files', '-z', '--', ':(icase)agents.md']).split('\0').filter(Boolean)).toEqual(['AGENTS.md']); + expect(indexAgents()).toEqual(['AGENTS.md']); expect(porcelain().split('\n').filter(Boolean).sort()).toEqual([' M .ai/core.adf', ' M .ai/manifest.adf', '?? notes.txt', 'RM agents.md -> AGENTS.md'].sort()); expect(parseManifest(parseAdf(manifestText())).vendors.map(v => v.raw)).toEqual(['AGENTS.md: pointer']); expect(manifestText()).toBe(vendors('AGENTS.md: pointer')); expect(r.out).toContain('[ok] Renamed agents.md -> AGENTS.md (git-mv); VENDORS entry moved; title line updated'); expect(r.out).toContain('The rename is staged. Commit it together with .ai/manifest.adf and AGENTS.md: git add .ai/manifest.adf AGENTS.md && git commit'); + expect(r.out).toContain('(git commit also records anything else you had already staged; check git status first.)'); }); it('rewrites only an exact `# agents.md` first line (CP-4, INV-P6-3)', async () => { @@ -400,7 +413,7 @@ describe('adf vendor rename-legacy (git mv)', () => { it('splits both index entries by file identity, deterministically on any host (mocked index and identity)', () => { fs.writeFileSync('agents.md', 'a\n'); - const git = (args: string[]): string => (args[0] === 'ls-files' ? 'agents.md\0AGENTS.md\0' : ''); + const git = (args: string[]): string => (args[0] === 'rev-parse' ? tmp : args[0] === 'ls-files' ? 'agents.md\0AGENTS.md\0' : ''); const distinct = renameLegacyAgents('.ai', { git, sameFile: () => false }); expect(distinct).toMatchObject({ refusal: 'both-distinct', remedy: 'git rm agents.md' }); const oneFile = renameLegacyAgents('.ai', { git, sameFile: () => true }); @@ -635,7 +648,7 @@ describe('no automatic rename; discovery keeps the legacy name', () => { await run(['compile', '--target', 'all', '--check']); await run(['vendor', 'status']); await run(['tidy', '--dry-run']); - expect(git(['ls-files', '-z', '--', ':(icase)agents.md']).split('\0').filter(Boolean)).toEqual(['agents.md']); + expect(indexAgents()).toEqual(['agents.md']); expect(porcelain()).toBe(''); }); @@ -647,3 +660,131 @@ describe('no automatic rename; discovery keeps the legacy name', () => { expect(discoverAgentConfigFiles().filter(n => n.toLowerCase() === 'agents.md')).toHaveLength(1); }); }); + +// ============================================================================ +// Review round 1: pathspec env, index read failure, duplicates, root, ordering +// ============================================================================ + +describe('adf vendor rename-legacy hardening', () => { + const PATHSPEC_ENV = ['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS']; + + for (const name of PATHSPEC_ENV) { + it(`detects and renames correctly with ${name}=1 exported (INV-P6-1)`, async () => { + legacyRepo(vendors('AGENTS.md: pointer')); + const saved = process.env[name]; + process.env[name] = '1'; + try { + expect(legacyRows((await doctor()).check?.rows)).toEqual([['legacy-tracked', 'INFO', 'charter adf vendor rename-legacy']]); + const r = await runJson<{ status: string; method: string }>(['vendor', 'rename-legacy']); + expect(r.json).toMatchObject({ status: 'renamed', method: 'git-mv' }); + } finally { + if (saved === undefined) delete process.env[name]; + else process.env[name] = saved; + } + expect(cached()).toMatch(/^R100\tagents\.md\tAGENTS\.md$/); + expect(indexAgents()).toEqual(['AGENTS.md']); + }); + } + + it('refuses index-unreadable inside a work tree and never falls back to an fs rename', () => { + legacyRepo(); + const manifest = manifestText(); + const status = porcelain(); + const injected = (args: string[]): string => { + if (args[0] === 'ls-files') throw new Error('fatal: index file corrupt'); + return git(args); + }; + const renames: string[] = []; + const r = renameLegacyAgents('.ai', { git: injected, rename: (a, b) => { renames.push(`${a}->${b}`); } }); + expect(r).toMatchObject({ status: 'refused', refusal: 'index-unreadable', method: 'none' }); + expect(renames).toEqual([]); + expect(rootNames()).toContain('agents.md'); + expect(manifestText()).toBe(manifest); + expect(porcelain()).toBe(status); + }); + + it('refuses a duplicate legacy agents.md entry up front, naming both lines (no partial move)', async () => { + legacyRepo(vendors('agents.md: unmanaged', 'agents.md: pointer')); + const manifest = manifestText(); + const r = await runJson<{ refusal: string }>(['vendor', 'rename-legacy']); + expect(r.code).toBe(1); + expect(r.json.refusal).toBe('record-conflict'); + expect(r.err).toContain("VENDORS lists agents.md more than once in .ai/manifest.adf ('agents.md: unmanaged', 'agents.md: pointer'). Keep one agents.md line, then re-run."); + expect(manifestText()).toBe(manifest); + expect(porcelain()).toBe(''); + }); + + it('refuses from a subdirectory of the repo and leaves a nested docs/agents.md alone', async () => { + legacyRepo(); + fs.mkdirSync('docs'); + fs.writeFileSync('docs/agents.md', '# agents.md\n\nnested\n'); + commitAll(); + process.chdir(path.join(tmp, 'docs')); + const r = await runJson<{ refusal: string; remedy: string }>(['vendor', 'rename-legacy', '--ai-dir', '../.ai']); + expect(r.code).toBe(1); + expect(r.json.refusal).toBe('not-repo-root'); + expect(r.json.remedy).toContain('&& charter adf vendor rename-legacy'); + expect(r.err).toContain('Run rename-legacy from the repository root:'); + process.chdir(tmp); + expect(fs.readdirSync('docs')).toEqual(['agents.md']); + expect(indexAgents()).toEqual(['agents.md']); + expect(git(['ls-files', '--', 'docs']).trim()).toBe('docs/agents.md'); + expect(porcelain()).toBe(''); + }); + + it('refuses outside git when --ai-dir points above the current directory', async () => { + scaffold(vendors('agents.md: pointer')); + fs.mkdirSync('docs'); + fs.writeFileSync('docs/agents.md', '# agents.md\n'); + process.chdir(path.join(tmp, 'docs')); + const r = await runJson<{ refusal: string }>(['vendor', 'rename-legacy', '--ai-dir', '../.ai']); + expect(r.json.refusal).toBe('not-repo-root'); + process.chdir(tmp); + expect(fs.readdirSync('docs')).toEqual(['agents.md']); + expect(manifestText()).toBe(vendors('agents.md: pointer')); + }); + + it('prints a title-write failure after the [ok] line, and keeps the staged rename', () => { + legacyRepo(); + fs.chmodSync('agents.md', 0o444); + const probe = (() => { + try { + fs.appendFileSync('agents.md', ''); + return true; + } catch { + return false; + } + })(); + if (probe) { // privileged user: read-only does not block writes here + fs.chmodSync('agents.md', 0o644); + return; + } + const lines: string[] = []; + const r = renameLegacyAgents('.ai', { print: l => lines.push(l) }); + fs.chmodSync('AGENTS.md', 0o644); + expect(r).toMatchObject({ status: 'renamed', titleUpdated: false }); + const ok = lines.findIndex(l => l.includes('[ok] Renamed agents.md -> AGENTS.md')); + const warn = lines.findIndex(l => l.includes("title line of AGENTS.md was left as '# agents.md'")); + expect(ok).toBeGreaterThanOrEqual(0); + expect(warn).toBeGreaterThan(ok); + expect(cached()).toMatch(/^R100\tagents\.md\tAGENTS\.md$/); + }); + + it('prints the whole git error when a rename step fails', () => { + legacyRepo(); + let mv = 0; + const injected = (args: string[]): string => { + if (args[0] === 'mv' && ++mv === 1) { + const e = new Error('git mv failed') as Error & { stderr: string }; + e.stderr = 'The following paths and/or pathspecs matched paths that exist\noutside of your sparse-checkout definition, so will not be\nupdated in the index:\nagents.md'; + throw e; + } + return git(args); + }; + const lines: string[] = []; + const r = renameLegacyAgents('.ai', { git: injected, print: l => lines.push(l) }); + expect(r.refusal).toBe('rename-failed'); + expect(lines.join('\n')).toContain('outside of your sparse-checkout definition'); + expect(porcelain()).toBe(''); + }); +}); diff --git a/packages/cli/src/commands/adf-vendor.ts b/packages/cli/src/commands/adf-vendor.ts index ea64911..389634d 100644 --- a/packages/cli/src/commands/adf-vendor.ts +++ b/packages/cli/src/commands/adf-vendor.ts @@ -16,6 +16,7 @@ * record the mode of a vendor file they just wrote). */ +import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { compileAdf, parseAdf, parseManifest, COMPILE_BANNER_MARKER, TARGET_FILENAMES } from '@stackbilt/adf'; @@ -23,7 +24,7 @@ import type { AdfDocument, CompileTarget, ManifestVendorEntry } from '@stackbilt import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; -import { getGitErrorMessage, runGit } from '../git-helpers'; +import { getGitErrorMessage } from '../git-helpers'; import { MODULE_INDEX_END, MODULE_INDEX_START, @@ -166,6 +167,11 @@ export const MSG = { renamePlanned: ' [dry-run] Would rename agents.md -> AGENTS.md ({method}){recordNote}{titleNote}', renameStagedHint: ' The rename is staged. Commit it together with {paths}: git add {addArgs} && git commit', renameStagedOnly: ' The rename is staged. Commit it: git commit', + renameStagedNote: ' (git commit also records anything else you had already staged; check git status first.)', + renameIndexUnreadable: ' [warn] Could not read the git index (git ls-files failed), so agents.md may be tracked. Nothing was renamed. Check: git status', + renameNotRoot: ' [warn] Run rename-legacy from the repository root: {root}', + renameNotRootAiDir: ' [warn] --ai-dir {aiDir} is outside this directory. Run rename-legacy from the project root (the directory that holds the vendor files).', + renameRecordDuplicate: ' [warn] VENDORS lists agents.md more than once in {manifestPath} ({lines}). Keep one agents.md line, then re-run.', renameRecordOnly: ' [ok] The file is already AGENTS.md; moved its VENDORS entry from agents.md to AGENTS.md', renameRecordOnlyDuplicate: ' [ok] The file is already AGENTS.md; removed the duplicate agents.md VENDORS entry', renameRecordOnlyPlanned: ' [dry-run] The file is already AGENTS.md; would {action} its agents.md VENDORS entry', @@ -1082,23 +1088,50 @@ export interface LegacyIndexEntries { upper: boolean; } +/** Environment variables that change how git reads a pathspec. */ +const PATHSPEC_ENV = ['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS']; + /** - * D0: the git index's agents.md / AGENTS.md entries, or null outside git (or - * when ls-files fails). The `:(icase)` pathspec finds both spellings whatever - * core.ignorecase says; the exact-case filter makes the answer the same on - * case-sensitive and case-insensitive checkouts. Never looks at the disk. + * runGit with the pathspec-mode variables removed from the child environment, + * so `agents.md` always means that exact path, whatever the caller exported. */ -export function legacyIndexEntries(git: GitRunner = runGit): LegacyIndexEntries | null { +export function legacyGit(args: string[]): string { + const env = { ...process.env }; + for (const k of PATHSPEC_ENV) delete env[k]; + return execFileSync('git', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, env }); +} + +/** The index read: its entries, 'not-git' outside a work tree, or 'unreadable' when ls-files fails inside one. */ +export type LegacyIndexRead = LegacyIndexEntries | 'not-git' | 'unreadable'; + +/** + * D0: the git index's exact-case agents.md / AGENTS.md entries. Plain pathspecs + * are exact-case even with core.ignorecase=true, and the exact filter covers + * GIT_ICASE_PATHSPECS, so the answer is the same on case-sensitive and + * case-insensitive checkouts. Needs no pathspec magic. Never looks at the disk. + */ +export function readLegacyIndex(git: GitRunner = legacyGit): LegacyIndexRead { + try { + git(['rev-parse', '--show-toplevel']); + } catch { + return 'not-git'; + } let out: string; try { - out = git(['ls-files', '-z', '--', ':(icase)agents.md']); + out = git(['ls-files', '-z', '--', 'agents.md', 'AGENTS.md']); } catch { - return null; + return 'unreadable'; } const paths = out.split('\0'); return { lower: paths.includes('agents.md'), upper: paths.includes('AGENTS.md') }; } +/** D0 for doctor: the entries, or null when there is nothing to report from (D3). */ +export function legacyIndexEntries(git: GitRunner = legacyGit): LegacyIndexEntries | null { + const read = readLegacyIndex(git); + return typeof read === 'string' ? null : read; +} + /** Both names exist and are one physical file (case-insensitive checkout, or a hard link). */ export function sameFileOnDisk(a: string, b: string): boolean { const fa = path.resolve(a); @@ -1131,7 +1164,10 @@ export function legacyKeyFor(manifestPath: string, file: VendorFile): 'agents.md } export type RenameMethod = 'git-mv' | 'fs' | 'record-only' | 'none'; -export type RenameRefusal = 'both-distinct' | 'dirty' | 'tmp-exists' | 'record-conflict' | 'record-write' | 'rename-failed'; +export type RenameRefusal = + | 'both-distinct' | 'dirty' | 'tmp-exists' | 'record-conflict' | 'record-write' | 'rename-failed' + // Added in review: an unreadable index inside a work tree, and a run from below the root. + | 'index-unreadable' | 'not-repo-root'; export interface RenameResult { status: 'renamed' | 'planned' | 'unchanged' | 'refused'; @@ -1154,10 +1190,11 @@ export interface RenameLegacyOptions { dryRun?: boolean; /** Receives each result line; isWarning marks a refusal or failure. */ print?: (line: string, isWarning: boolean) => void; - /** Injected for tests: the git runner and the fs rename. */ + /** @internal Injected for tests: the git runner (default: legacyGit). */ git?: GitRunner; + /** @internal Injected for tests: the fs rename. */ rename?: (from: string, to: string) => void; - /** Injected for tests: are agents.md and AGENTS.md one physical file? */ + /** @internal Injected for tests: are agents.md and AGENTS.md one physical file? */ sameFile?: () => boolean; } @@ -1168,6 +1205,19 @@ function posix(p: string): string { return p.split(path.sep).join('/'); } +/** One directory, compared by real path (case-insensitively on Windows). */ +function samePath(a: string, b: string): boolean { + const real = (p: string): string => { + try { + return fs.realpathSync.native(path.resolve(p)); + } catch { + return path.resolve(p); + } + }; + const [ra, rb] = [real(a), real(b)]; + return process.platform === 'win32' ? ra.toLowerCase() === rb.toLowerCase() : ra === rb; +} + function shellQuote(p: string): string { return /^[\w./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`; } @@ -1240,7 +1290,7 @@ function legacyRecordEdit(text: string, index: number, removeDuplicate: boolean, * is an exact `# agents.md` first line. Never deletes or merges a file. */ export function renameLegacyAgents(aiDir: string, options: RenameLegacyOptions = {}): RenameResult { - const git = options.git ?? runGit; + const git = options.git ?? legacyGit; const renameFs = options.rename ?? ((from: string, to: string) => fs.renameSync(from, to)); const print = options.print ?? (() => undefined); const manifestPath = path.join(aiDir, 'manifest.adf'); @@ -1255,7 +1305,32 @@ export function renameLegacyAgents(aiDir: string, options: RenameLegacyOptions = }; // S1: plan. Nothing below writes until the dry-run return. - const index = legacyIndexEntries(git); + // Inside a work tree the index must be read: never fall back to an fs rename + // of a file git may track. + const read = readLegacyIndex(git); + if (read === 'unreadable') return refuse('index-unreadable', 'git status', MSG.renameIndexUnreadable); + const index = read === 'not-git' ? null : read; + // Vendor names are cwd-relative, so run below the root this would rename a + // nested docs/agents.md. Refuse rather than resolve to the root: every other + // path here (--ai-dir, the references scan) is cwd-relative too. + if (index) { + let top = ''; + try { + top = git(['rev-parse', '--show-toplevel']).trim(); + } catch { + top = ''; + } + if (!top || !samePath(top, process.cwd())) { + const remedy = top ? `cd ${shellQuote(top)} && charter adf vendor rename-legacy` : null; + return refuse('not-repo-root', remedy, fill(MSG.renameNotRoot, { root: top || '(unknown)' })); + } + } else { + // Outside git, an --ai-dir above cwd is the sign of a run from a subdirectory. + const rel = path.relative(process.cwd(), path.resolve(aiDir)); + if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { + return refuse('not-repo-root', null, fill(MSG.renameNotRootAiDir, { aiDir })); + } + } const names = listDir('.'); const diskLower = names.includes('agents.md'); const diskUpper = names.includes('AGENTS.md'); @@ -1281,6 +1356,12 @@ export function renameLegacyAgents(aiDir: string, options: RenameLegacyOptions = if (record.readProblem) { return refuse('record-write', 'charter adf vendor status', fill(MSG.renameRecordWrite, { manifestPath: shownManifest })); } + // Two agents.md lines: moving only the winner would make the other one live. Refuse up front. + const legacyDuplicates = record.problems.filter(p => p.problem === 'duplicate' && p.file === 'agents.md').map(p => p.raw); + if (legacy && legacyDuplicates.length > 0) { + const lines = [...legacyDuplicates, `agents.md: ${legacy.mode}`].map(l => `'${l}'`).join(', '); + return refuse('record-conflict', null, fill(MSG.renameRecordDuplicate, { manifestPath: shownManifest, lines })); + } const renames = method === 'git-mv' || method === 'fs'; if (method === 'git-mv') { let dirty = true; @@ -1386,31 +1467,33 @@ export function renameLegacyAgents(aiDir: string, options: RenameLegacyOptions = } const undoNote = undone && recovery.length === 0 ? MSG.renameUndone : fill(MSG.renameRecover, { recovery: recovery.join('; ') }); result.error = error; - // The full error is in JSON; the line shows its first line (git's hints run long). - const shown = error.split(/\r?\n/, 1)[0].replace(/\.$/, ''); - return refuse('rename-failed', 'charter adf vendor rename-legacy', fill(MSG.renameFailed, { error: shown, undoNote })); + // The whole error: git's second line often holds the reason (sparse checkout). + return refuse('rename-failed', 'charter adf vendor rename-legacy', fill(MSG.renameFailed, { error: error.trimEnd().replace(/\.$/, ''), undoNote })); } result.status = 'renamed'; if (method === 'git-mv') result.staged = ['agents.md', 'AGENTS.md']; // S5: the legacy title line only. + let titleWarning: string | null = null; if (retitled !== null) { try { fs.writeFileSync('AGENTS.md', retitled); result.titleUpdated = true; } catch (err: unknown) { - print(` Warning: could not update the title line of AGENTS.md: ${err instanceof Error ? err.message : String(err)}`, true); + titleWarning = ` Warning: the title line of AGENTS.md was left as '# agents.md': ${err instanceof Error ? err.message : String(err)}`; } } result.references = legacyReferences(aiDir, new Map()); print(fill(MSG.renameOk, { method, recordNote, titleNote: result.titleUpdated ? '; title line updated' : '' }), false); + if (titleWarning) print(titleWarning, true); if (method === 'git-mv') { // CP-3: the manifest (and a title edit) are left unstaged, for the user to commit with the rename. const paths = [...(edit ? [shownManifest] : []), ...(result.titleUpdated ? ['AGENTS.md'] : [])]; print(paths.length > 0 ? fill(MSG.renameStagedHint, { paths: paths.join(' and '), addArgs: paths.map(shellQuote).join(' ') }) : MSG.renameStagedOnly, false); + print(MSG.renameStagedNote, false); } return result; }