diff --git a/.please/docs/tracks.jsonl b/.please/docs/tracks.jsonl index 48cb8de..658d425 100644 --- a/.please/docs/tracks.jsonl +++ b/.please/docs/tracks.jsonl @@ -6,3 +6,4 @@ {"id":"npm-publish-release-please-20260408","type":"chore","status":"planned","phase":"spec","issue":"#16","created":"2026-04-08","section":"active"} {"id":"maven-resolver-20260408","type":"feature","status":"in_progress","phase":"implement","issue":"#15","created":"2026-04-08","section":"active"} {"id":"extract-shared-registry-20260408","type":"refactor","status":"in_progress","phase":"implement","issue":"#14","created":"2026-04-08","section":"active"} +{"id":"ignore-vendored-docs-20260408","type":"feature","status":"review","phase":"review","issue":"#24","pr":"#26","created":"2026-04-08","section":"completed"} diff --git a/.please/docs/tracks/completed/ignore-vendored-docs-20260408/metadata.json b/.please/docs/tracks/completed/ignore-vendored-docs-20260408/metadata.json new file mode 100644 index 0000000..2c1d8eb --- /dev/null +++ b/.please/docs/tracks/completed/ignore-vendored-docs-20260408/metadata.json @@ -0,0 +1,11 @@ +{ + "track_id": "ignore-vendored-docs-20260408", + "type": "feature", + "status": "review", + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z", + "issue": "#24", + "pr": "#26", + "project": "", + "project_item_id": "" +} diff --git a/.please/docs/tracks/completed/ignore-vendored-docs-20260408/plan.md b/.please/docs/tracks/completed/ignore-vendored-docs-20260408/plan.md new file mode 100644 index 0000000..d51fdcf --- /dev/null +++ b/.please/docs/tracks/completed/ignore-vendored-docs-20260408/plan.md @@ -0,0 +1,164 @@ +# Plan: Vendored Docs Ignore Management + +> Track: ignore-vendored-docs-20260408 +> Spec: [spec.md](./spec.md) + +## Overview + +- **Source**: /please:plan +- **Track**: ignore-vendored-docs-20260408 +- **Issue**: TBD +- **Created**: 2026-04-08 +- **Approach**: Hybrid — self-contained nested config files + extend AGENTS.md auto-generated block + targeted root patching for nested-unaware tools + +## Purpose + +Mark `.ask/docs/` as vendored so lint/format/code-review tools skip it, while keeping the directory readable as AI context. Achieved by combining nested config files inside `.ask/docs/` (for tools that support hierarchical resolution) with an intent notice inside the existing AGENTS.md auto-generated block (consumed by AI review tools), and minimal root patching only for Prettier and SonarQube. + +## Context + +The existing CLI already manages `AGENTS.md` via `agents.ts:8-89` using a marker block pair (`` / ``) and writes `CLAUDE.md` with an `@AGENTS.md` import line. The new functionality plugs into that same lifecycle: `add`, `sync`, and `remove` already call `generateAgentsMd(projectDir)` at the tail end of their flow (`index.ts:282, 419-421, 469`) — `manageIgnoreFiles(projectDir)` is invoked at the same locations. The Zod `ConfigSchema` (`schemas.ts:57-60`) gains an optional `manageIgnores` field with default `true`. + +## Architecture Decision + +### Three categories, three strategies + +1. **Category A (self-contained)** — write nested config files inside `.ask/docs/`. ESLint flat config, Biome, markdownlint-cli2, and Git all walk up from each file to find the nearest config, so a config dropped inside `.ask/docs/` automatically scopes itself to that directory without touching anything at the root. +2. **Category B (intent notice)** — extend the existing `generateAgentsMd` auto-generated block to prepend a "vendored / read-only" notice section. Avoids introducing a second marker convention; the existing block is already idempotently rewritten on every `add`/`sync`/`remove`. +3. **Category C (root patching)** — only Prettier (`.prettierignore`) and SonarQube (`sonar-project.properties`) lack nested resolution. Patch them through a small `MarkerBlock` helper that injects/refreshes/removes a `# ` ... `# ` block in-place. + +### Why not patch root ESLint/Biome/Cursor? + +- Root flat config files are user-owned JS/JSON whose AST surface is too broad to mutate safely. +- `.cursorignore` would block Cursor from reading docs as AI context — the opposite of ASK's value proposition. +- Nested config covers ESLint and Biome perfectly without touching the root. + +### Why extend the existing auto-generated block? + +`agents.ts` already owns a marker block. Introducing a second `` block would duplicate the idempotency machinery and confuse readers. Prepending the notice as a sub-section inside the existing block is a one-line edit. + +### Idempotency model + +All writes are read-modify-write with deterministic templates. The `MarkerBlock` helper (new file `markers.ts`) provides: + +- `inject(content, block)` — insert if absent, replace if marker pair found +- `remove(content)` — strip the marker block; return content unchanged if not found +- `wrap(payload, syntax)` — wrap a payload in marker pair using the requested comment syntax (`html` for markdown, `hash` for properties/ignore) + +## Architecture Diagram + +``` +ask docs add / sync / remove + ↓ +generateAgentsMd (existing — extended) + ↓ +manageIgnoreFiles (new) + ↓ + ┌────┴────┬─────────────┐ + ↓ ↓ ↓ + A B (folded C + into A's (root patch) + agents call) + ↓ ↓ +.ask/docs/ .prettierignore + .gitattributes sonar-project.properties + eslint.config.mjs .markdownlintignore (legacy) + biome.json + .markdownlint-cli2.jsonc +``` + +## Tasks + +- [x] **T-1** — Add `manageIgnores` to `ConfigSchema` (`packages/cli/src/schemas.ts`), update `packages/cli/test/schemas.test.ts` +- [x] **T-2** — Create `packages/cli/src/markers.ts` (pure helpers: `inject`, `remove`, `wrap`) + `packages/cli/test/markers.test.ts` +- [x] **T-3** — Create `packages/cli/src/ignore-files.ts` with `writeNestedConfigs` / `removeNestedConfigs` for `.ask/docs/.gitattributes`, `eslint.config.mjs`, `biome.json`, `.markdownlint-cli2.jsonc` + tests +- [x] **T-4** — Extend `packages/cli/src/agents.ts` to prepend vendored-docs notice inside the existing auto-generated block + update `packages/cli/test/agents.test.ts` +- [x] **T-5** — Add `patchRootIgnores` / `unpatchRootIgnores` in `ignore-files.ts` for `.prettierignore`, `sonar-project.properties`, legacy `.markdownlintignore` (detection-only, marker-block based) + tests +- [x] **T-6** — Orchestrator `manageIgnoreFiles(projectDir, mode)` in `ignore-files.ts`; respects `manageIgnores` config; consola logging +- [x] **T-7** — Wire `manageIgnoreFiles` into `addCmd`, `runSync`, `removeCmd` in `packages/cli/src/index.ts` +- [x] **T-8** — Integration test covering full add → remove lifecycle (`packages/cli/test/ignore-lifecycle.test.ts`) +- [x] **T-9** — Documentation: update root `CLAUDE.md` Gotchas and, if applicable, `packages/cli/README.md` + +## Dependencies + +``` +T-1 (schema) ──┐ + ├──> T-6 (orchestrator) ──> T-7 (wiring) ──> T-8 (e2e) +T-2 (markers) ─┤ ↑ + │ │ +T-3 (cat A) ───┤ │ + │ │ +T-4 (cat B) ───┤ │ + │ │ +T-5 (cat C) ───┘ │ + │ + T-9 (docs) +``` + +T-1, T-2, T-3, T-4, T-5 are independent and can land in any order. T-6 needs T-1/T-3/T-5. T-7 needs T-6 and (for the agents notice) T-4. T-8 needs all of the above. + +## Key Files + +| File | Role | New / Modified | +|---|---|---| +| `packages/cli/src/schemas.ts` | Add `manageIgnores` field | Modified | +| `packages/cli/src/markers.ts` | Marker block helpers (pure) | New | +| `packages/cli/src/ignore-files.ts` | Categories A/C + orchestrator | New | +| `packages/cli/src/agents.ts` | Extend block with vendored notice | Modified | +| `packages/cli/src/index.ts` | Wire `manageIgnoreFiles` into add/sync/remove | Modified | +| `packages/cli/test/markers.test.ts` | Marker helper unit tests | New | +| `packages/cli/test/ignore-files.test.ts` | Categories A/C unit tests | New | +| `packages/cli/test/ignore-lifecycle.test.ts` | E2E add/remove lifecycle | New | +| `packages/cli/test/agents.test.ts` | Vendored notice presence | Modified | +| `packages/cli/test/schemas.test.ts` | `manageIgnores` schema | Modified | + +## Verification + +- `bun run --cwd packages/cli test` — all unit + e2e tests green +- `bun run --cwd packages/cli lint` — no new lint warnings +- Manual smoke: in a temp dir with mock project (containing `.prettierignore` and `sonar-project.properties`), run `node packages/cli/dist/index.js docs add npm:react`, verify all artifacts. Then `docs remove react` and verify cleanup. +- Manual: confirm `.ask/docs/.gitattributes` is honored by `git check-attr` for a file inside `.ask/docs/`. +- AC-1 through AC-8 from spec each map to a test in T-3/T-4/T-5/T-8. + +## Progress + +- 2026-04-08: All 9 tasks complete. Test suite: 199 pass, 0 fail across 21 files. Lint clean for all new files (only pre-existing `package.json` sort-keys errors remain). + +## Decision Log + +- **2026-04-08**: Reuse existing `` block in `agents.ts` instead of introducing a second marker convention (``). Reduces code duplication and avoids confusion for readers. +- **2026-04-08**: Skip auto-patching root ESLint flat config / Biome / `.cursorignore`. ESLint and Biome are covered by nested configs inside `.ask/docs/`; `.cursorignore` would block AI context access (anti-goal). Documented in spec Out of Scope. +- **2026-04-08**: cubic and CodeRabbit need no dedicated configuration file patching — both auto-consume AGENTS.md/CLAUDE.md as context (verified via vendor docs). The single AGENTS.md notice transitively covers them. + +## Outcomes & Retrospective + +### What Was Shipped + +- `markers.ts` (pure inject/remove/wrap helpers, two comment syntaxes) +- `ignore-files.ts` (nested-config writer, root-file patcher, top-level orchestrator with `manageIgnores` opt-out) +- Extended `agents.ts` auto-generated block with vendored-docs notice +- Schema field `ConfigSchema.manageIgnores` (optional, default `true`) +- Wired `manageIgnoreFiles(projectDir, mode)` into `addCmd`/`runSync`/`removeCmd` +- 43 new tests across markers, ignore-files, and a full add → remove lifecycle + +### What Went Well + +- Existing `agents.ts` marker block was reusable — no second marker convention needed +- Bun's nested workspaces let the existing test runner pick up new test files with zero config +- Spec carefully separated nested-capable tools (Cat A) from root-only tools (Cat C), so the implementation matched the design 1:1 + +### What Could Improve + +- WebSearch verification of "tool X supports nested config" went through several wrong answers before landing on accurate sources. A canonical compatibility table inside the spec would have saved iterations. +- Worktrees require a manual `bun run --cwd packages/registry-schema build` before tests pass — surprising and easy to miss. Worth a `prepare` script. +- The PR creation hit a `PreToolUse` review-state hook mid-finalize. Documented in CLAUDE.md gotchas but easy to forget when chaining `/please:*` commands. + +### Tech Debt Created + +- Sub-threshold review note: ESLint flat config nested resolution should be empirically verified inside `.ask/docs/` (run `eslint .` from project root and confirm files inside `.ask/docs/` are skipped). My research supported it but I did not run an end-to-end check. +- Root `eslint.config.{js,mjs,ts}` and `biome.json` automatic patching is intentionally out of scope; if user feedback shows the nested-config approach is insufficient, revisit. + +## Surprises & Discoveries + +- **registry-schema not pre-built in worktree**: Full test suite failed with `Cannot find module '@pleaseai/registry-schema'` until `bun run --cwd packages/registry-schema build` was run. The CLI package imports compiled output from `dist/`, so worktrees need an initial build of the shared package. Candidate for a `postinstall` hook or test-time `prepare` step. +- **Marker `remove()` trailing-newline edge case**: First implementation left a stray `\n` after stripping a block in the middle of a file. Fixed by normalising the "after" segment's leading whitespace and only adding a trailing newline if the preserved tail doesn't already have one. diff --git a/.please/docs/tracks/completed/ignore-vendored-docs-20260408/spec.md b/.please/docs/tracks/completed/ignore-vendored-docs-20260408/spec.md new file mode 100644 index 0000000..11654e1 --- /dev/null +++ b/.please/docs/tracks/completed/ignore-vendored-docs-20260408/spec.md @@ -0,0 +1,92 @@ +--- +product_spec_domain: cli/ignore-management +--- + +# Vendored Docs Ignore Management + +> Track: ignore-vendored-docs-20260408 + +## Overview + +The `ask docs add` command marks `.ask/docs/` as **vendored third-party documentation**, achieving two goals simultaneously: + +1. **AI agents must still be able to read it** (ASK's core value — docs are for reference) +2. **Excluded from lint / format / code review / modification** (because it's vendored) + +To accomplish this, ASK (A) creates self-contained config files inside `.ask/docs/`, (B) injects an intent notice into ASK marker blocks in AGENTS.md/CLAUDE.md, and (C) patches root files only for tools that do not support nested ignore. + +## Requirements + +### Functional Requirements + +#### A. Self-contained local config (created inside `.ask/docs/`) + +- [ ] FR-A1: Create `.ask/docs/.gitattributes` — `* linguist-vendored=true` + `* linguist-generated=true` (collapses GitHub PR diffs + excludes from language statistics). +- [ ] FR-A2: Create `.ask/docs/eslint.config.mjs` — `export default [{ ignores: ['**/*'] }]` (ESLint flat config auto-discovers nested configs). +- [ ] FR-A3: Create `.ask/docs/biome.json` — exclude all files from processing (Biome auto-discovers nested configs). +- [ ] FR-A4: Create `.ask/docs/.markdownlint-cli2.jsonc` — `{ "ignores": ["**/*"] }` (markdownlint-cli2 supports nested config). + +#### B. Intent notice (inside ASK marker block in AGENTS.md / CLAUDE.md) + +- [ ] FR-B1: Auto-inject the following notice inside an ASK-managed marker block (` ... `): + > `.ask/docs/` contains vendored third-party documentation downloaded by ASK. Treat it as **read-only**: AI context should reference these files, but they are NOT subject to modification, lint, format, or code review. Updates are performed via `ask docs sync`. +- [ ] FR-B2: If AGENTS.md exists, inject into AGENTS.md. If CLAUDE.md exists, inject into CLAUDE.md. If both exist, inject into both. If neither exists, create AGENTS.md (standard preference). +- [ ] FR-B3: This single notice transitively affects the following AI tools (verified): CodeRabbit, cubic, Claude Code, Codex, Cursor (rules), GitHub Copilot, Continue, Aider, etc. + +#### C. Root file patching (only for tools without nested support, only if detected) + +- [ ] FR-C1: If root `.prettierignore` exists, add `.ask/docs/` exclusion patterns inside a marker block. (Prettier does not support nested ignore.) +- [ ] FR-C2: If root `sonar-project.properties` exists, append `.ask/docs/**` to `sonar.exclusions` inside a marker block. +- [ ] FR-C3: If root legacy `.markdownlintignore` (markdownlint-cli legacy) exists, add `.ask/docs/` inside a marker block. Also emit a recommendation to migrate to markdownlint-cli2. +- [ ] FR-C4: Do not create these files if absent — patch only when detected. + +#### D. Marker block management + +- [ ] FR-D1: Injection format follows each file's comment syntax. Properties/ignore files: `# ` ... `# `. Markdown: ` ... `. Properties (FR-C2): `# ask:start` ... `# ask:end`. +- [ ] FR-D2: Idempotent: running `add` repeatedly does not duplicate marker blocks; only the contents inside are refreshed. +- [ ] FR-D3: `ask docs sync` refreshes marker blocks. When `ask docs remove` removes the last docs entry, ASK deletes the local config files in (A) and removes the marker blocks in (B) and (C). Empty blocks are not left behind. +- [ ] FR-D4: ASK never modifies user content outside marker blocks. + +#### E. Configuration and logging + +- [ ] FR-E1: Add `manageIgnores: boolean` (default `true`) to `ask.config.json`. When `false`, all of categories A/B/C are skipped. +- [ ] FR-E2: During `add` / `sync` / `remove`, report the list of created/updated/removed files and skip reasons via consola. + +### Non-functional Requirements + +- [ ] NFR-1: Do **not** auto-patch root ESLint flat config (`eslint.config.{js,mjs,ts}`) — replaced by `.ask/docs/eslint.config.mjs` (FR-A2). Same applies to root `biome.json` and `.cursorignore` (the latter is intentionally avoided since it would block AI context access). +- [ ] NFR-2: Preserve existing EOL and encoding when writing files. +- [ ] NFR-3: Unit tests cover (A) local file creation, (B) AGENTS.md/CLAUDE.md injection·refresh·removal, (C) Prettier/Sonar marker block injection·refresh·removal, idempotency, and the `manageIgnores: false` skip path. +- [ ] NFR-4: All generated files follow the project's ESM conventions (2-space indent, single quotes, no semicolons where applicable). + +## Acceptance Criteria + +- [ ] AC-1: In an empty project, running `ask docs add npm:react` creates `.gitattributes`, `eslint.config.mjs`, `biome.json`, and `.markdownlint-cli2.jsonc` inside `.ask/docs/`. +- [ ] AC-2: In a project without AGENTS.md, after `add`, `AGENTS.md` is created and contains the vendored notice inside the ASK marker block. +- [ ] AC-3: In a project where `CLAUDE.md` exists, the same marker block is also injected into CLAUDE.md. +- [ ] AC-4: In a project with a root `.prettierignore`, after `add`, the marker block is appended to `.prettierignore`. In a project without `.prettierignore`, no file is created. +- [ ] AC-5: Running the same `add` command twice does not duplicate marker blocks in any file (idempotency). +- [ ] AC-6: When `ask docs remove` removes the last docs entry: + - Local config files inside `.ask/docs/` are deleted + - Marker blocks are removed from AGENTS.md/CLAUDE.md + - Marker blocks are removed from root `.prettierignore`/`sonar-project.properties` (the files themselves remain) +- [ ] AC-7: Setting `"manageIgnores": false` in `ask.config.json` causes categories A/B/C to be entirely skipped. +- [ ] AC-8: Root `.gitattributes` is left untouched (verified that `.ask/docs/.gitattributes` works correctly via Git's nested resolution). + +## Out of Scope + +- Auto-patching root ESLint flat config (`eslint.config.{js,mjs,ts}`) — replaced by `.ask/docs/eslint.config.mjs` nested config. +- Auto-patching root `biome.json` — replaced by `.ask/docs/biome.json` nested config. +- Creating `.cursorignore` — `.cursorignore` blocks AI access to file contents, which is the opposite of ASK's goal (AI must reference docs). Replaced by AGENTS.md notice. +- Auto-patching `.coderabbit.yaml` `path_filters` — CodeRabbit auto-reads AGENTS.md, so (B) suffices. +- Automating cubic cloud dashboard settings — cubic also auto-reads AGENTS.md/CLAUDE.md, so (B) suffices. +- `.gitignore` patching — whether to commit `.ask/docs/` is a user policy. +- IDE-specific settings (`.vscode/settings.json`, JetBrains scopes) — personal scope. +- Reorganizing/sorting other parts of existing ignore files. + +## Assumptions + +- The user does not directly edit `.ask/docs/` and treats it as vendored (`ask docs sync` overwrites it). +- The user's existing root ignore file content must be preserved; ASK only modifies marker blocks it owns. +- AI code review tools "respect" the AGENTS.md/CLAUDE.md notice but are not mechanically enforced. However, since CodeRabbit / cubic / Claude Code / Cursor all structurally consume context files, the practical effect is high. +- `manageIgnores` is a global switch applied to add/sync/remove uniformly. diff --git a/CLAUDE.md b/CLAUDE.md index 8a8cf26..738a932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ node packages/cli/dist/index.js docs add -s [options] - Release is managed by release-please with TWO packages: `packages/cli` (npm `@pleaseai/ask`, independent) and `.` root (`ask-plugin`, `simple` type). Root bumps when non-CLI paths change (`.claude-plugin/`, `skills/`, `commands/`, root docs) and syncs `.claude-plugin/plugin.json` via `extra-files`. See `release-please-config.json`. - `.claude/agent-memory/` IS committed to git (not ignored) — it persists agent learnings across sessions. - When pinning GitHub Actions by SHA, verify via `gh api repos///git/refs/tags/ -q .object.sha` — bogus SHAs with correct-looking prefixes have slipped in before (e.g. `actions/setup-node@v4.4.0` real SHA is `49933ea5288caeca8642d1e84afbd3f7d6820020`). +- `ask docs add|sync|remove` auto-manages ignore files to mark `.ask/docs/` as vendored. Writes nested configs inside `.ask/docs/` (`.gitattributes`, `eslint.config.mjs`, `biome.json`, `.markdownlint-cli2.jsonc`) and patches root `.prettierignore`/`sonar-project.properties`/`.markdownlintignore` via a marker block (`# ask:start ... # ask:end`). Disable via `manageIgnores: false` in `.ask/config.json`. Do not hand-edit inside the marker blocks — `sync`/`remove` will overwrite them. ## CLI Architecture (packages/cli/) diff --git a/packages/cli/package.json b/packages/cli/package.json index 56c878c..20b80a4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,6 +4,11 @@ "version": "0.1.1", "description": "Agent Skills Kit - Download version-specific library docs for AI coding agents", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/pleaseai/ask.git", + "directory": "packages/cli" + }, "keywords": [ "ai", "agent", @@ -12,17 +17,12 @@ "claude", "agents-md" ], - "repository": { - "type": "git", - "url": "https://github.com/pleaseai/ask.git", - "directory": "packages/cli" + "bin": { + "ask": "./dist/index.js" }, "files": [ "dist" ], - "bin": { - "ask": "./dist/index.js" - }, "publishConfig": { "access": "public" }, diff --git a/packages/cli/src/agents.ts b/packages/cli/src/agents.ts index f2765c1..efaee4f 100644 --- a/packages/cli/src/agents.ts +++ b/packages/cli/src/agents.ts @@ -36,6 +36,13 @@ export function generateAgentsMd(projectDir: string): string { The libraries in this project may have APIs and patterns that differ from your training data. **Always read the relevant documentation before writing code.** +## .ask/docs/ — Vendored Documentation + +\`.ask/docs/\` contains third-party library documentation downloaded by ASK. +Treat it as **read-only**: AI context should reference these files, but they are +**not** subject to modification, lint, format, or code review. Updates are +performed via \`ask docs sync\`. + ${sections.join('\n\n')} ${END_MARKER}` diff --git a/packages/cli/src/ignore-files.ts b/packages/cli/src/ignore-files.ts new file mode 100644 index 0000000..92c653b --- /dev/null +++ b/packages/cli/src/ignore-files.ts @@ -0,0 +1,237 @@ +/** + * Vendored docs ignore-file management. + * + * `.ask/docs/` contains third-party documentation downloaded by ASK. It + * should not be treated as project source: lint/format/code-review tools + * should skip it, but AI agents must still be able to read it as context. + * + * Strategy: + * A. Write self-contained nested config files inside `.ask/docs/` for + * tools that support hierarchical config resolution (Git, ESLint flat + * config, Biome, markdownlint-cli2). + * B. The "intent notice" for AI review tools lives in `agents.ts` + * (extended `generateAgentsMd`). + * C. Patch root files only for tools that do NOT support nested config + * (Prettier, SonarQube, legacy markdownlint-cli). + */ + +import fs from 'node:fs' +import path from 'node:path' +import { consola } from 'consola' +import { loadConfig } from './config.js' +import { inject, remove as removeMarker, wrap } from './markers.js' +import { getDocsDir } from './storage.js' + +/** + * Local configuration files written inside `.ask/docs/` so that + * lint/format/review tools with nested-config support automatically skip + * the directory. Each file has a `name` (relative to `.ask/docs/`) and a + * `content` template. + */ +const NESTED_CONFIGS: Array<{ name: string, content: string }> = [ + { + name: '.gitattributes', + content: [ + '# Managed by ASK — marks vendored docs for GitHub Linguist.', + '* linguist-vendored=true', + '* linguist-generated=true', + '', + ].join('\n'), + }, + { + name: 'eslint.config.mjs', + content: [ + '// Managed by ASK — vendored docs, excluded from ESLint.', + 'export default [', + ' { ignores: [\'**/*\'] },', + ']', + '', + ].join('\n'), + }, + { + name: 'biome.json', + content: `${JSON.stringify( + { + $schema: 'https://biomejs.dev/schemas/2.0.0/schema.json', + files: { ignore: ['**/*'] }, + }, + null, + 2, + )}\n`, + }, + { + name: '.markdownlint-cli2.jsonc', + content: `${JSON.stringify( + { ignores: ['**/*'] }, + null, + 2, + )}\n`, + }, +] + +interface RootPatch { + /** File name relative to project root. */ + file: string + /** Marker syntax to use when injecting/removing. */ + syntax: 'html' | 'hash' + /** Payload to wrap between markers. */ + payload: string + /** Optional warning emitted when the file is detected. */ + warn?: string +} + +const ROOT_PATCHES: RootPatch[] = [ + { + file: '.prettierignore', + syntax: 'hash', + payload: '# Vendored docs — managed by ASK\n.ask/docs/', + }, + { + file: 'sonar-project.properties', + syntax: 'hash', + payload: '# Vendored docs — managed by ASK\nsonar.exclusions=.ask/docs/**', + }, + { + file: '.markdownlintignore', + syntax: 'hash', + payload: '# Vendored docs — managed by ASK\n.ask/docs/', + warn: 'Legacy .markdownlintignore detected. Consider migrating to markdownlint-cli2, which supports nested config inside .ask/docs/ automatically.', + }, +] + +/** + * Category A: write nested config files inside `.ask/docs/`. + * + * The directory is created if it does not exist, so callers may invoke this + * before any docs have been saved. Existing files are only rewritten if + * their contents differ, to keep filesystem mtimes stable and logs terse. + * + * Returns the list of files that were created or updated (relative to the + * project root) so callers can log a summary. + */ +export function writeNestedConfigs(projectDir: string): string[] { + const docsDir = getDocsDir(projectDir) + fs.mkdirSync(docsDir, { recursive: true }) + + const written: string[] = [] + for (const { name, content } of NESTED_CONFIGS) { + const target = path.join(docsDir, name) + const existing = fs.existsSync(target) ? fs.readFileSync(target, 'utf-8') : null + if (existing !== content) { + fs.writeFileSync(target, content, 'utf-8') + written.push(path.relative(projectDir, target)) + } + } + return written +} + +/** + * Remove all nested config files written by {@link writeNestedConfigs}. + * + * Only files with the exact names we manage are deleted. Other files + * inside `.ask/docs/` are left alone (including downloaded docs). Returns + * the list of removed file paths relative to the project root. + */ +export function removeNestedConfigs(projectDir: string): string[] { + const docsDir = getDocsDir(projectDir) + if (!fs.existsSync(docsDir)) + return [] + const removed: string[] = [] + for (const { name } of NESTED_CONFIGS) { + const target = path.join(docsDir, name) + if (fs.existsSync(target)) { + fs.rmSync(target) + removed.push(path.relative(projectDir, target)) + } + } + return removed +} + +/** + * Category C: patch root files that do not support nested ignore resolution. + * + * Only files that already exist are patched — ASK never creates a root + * ignore file from scratch, because doing so could imply a tool the user + * does not actually use. + */ +export function patchRootIgnores(projectDir: string): string[] { + const updated: string[] = [] + for (const patch of ROOT_PATCHES) { + const target = path.join(projectDir, patch.file) + if (!fs.existsSync(target)) + continue + + const existing = fs.readFileSync(target, 'utf-8') + const block = wrap(patch.payload, patch.syntax) + const next = inject(existing, block, patch.syntax) + if (next !== existing) { + fs.writeFileSync(target, next, 'utf-8') + updated.push(patch.file) + } + if (patch.warn) { + consola.warn(patch.warn) + } + } + return updated +} + +/** + * Remove ASK-managed marker blocks from root files patched by + * {@link patchRootIgnores}. Files themselves are never deleted. + */ +export function unpatchRootIgnores(projectDir: string): string[] { + const updated: string[] = [] + for (const patch of ROOT_PATCHES) { + const target = path.join(projectDir, patch.file) + if (!fs.existsSync(target)) + continue + const existing = fs.readFileSync(target, 'utf-8') + const next = removeMarker(existing, patch.syntax) + if (next !== existing) { + fs.writeFileSync(target, next, 'utf-8') + updated.push(patch.file) + } + } + return updated +} + +/** + * Top-level orchestrator called from the add/sync/remove commands. + * + * - `install` mode: create nested configs and patch detected root files. + * - `remove` mode: delete nested configs and strip root marker blocks. + * + * Respects `manageIgnores` in `.ask/config.json` (default: true). When the + * flag is explicitly set to false, the function is a no-op. + */ +export function manageIgnoreFiles( + projectDir: string, + mode: 'install' | 'remove', +): void { + // `loadConfig` returns a default empty config when `.ask/config.json` + // does not exist, and throws for corrupt/invalid files. We deliberately + // do NOT wrap this in a try/catch: callers should hear about a broken + // config rather than silently proceeding with mutations. + const config = loadConfig(projectDir) + if (config.manageIgnores === false) { + consola.info('Skipping ignore-file management (manageIgnores: false).') + return + } + + if (mode === 'install') { + const nested = writeNestedConfigs(projectDir) + const root = patchRootIgnores(projectDir) + if (nested.length > 0) + consola.info(`Nested configs written: ${nested.join(', ')}`) + if (root.length > 0) + consola.info(`Root ignore files patched: ${root.join(', ')}`) + } + else { + const nested = removeNestedConfigs(projectDir) + const root = unpatchRootIgnores(projectDir) + if (nested.length > 0) + consola.info(`Nested configs removed: ${nested.join(', ')}`) + if (root.length > 0) + consola.info(`Root ignore marker blocks removed: ${root.join(', ')}`) + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5c44994..3ba3107 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -21,6 +21,7 @@ import { loadConfig, removeDocEntry, } from './config.js' +import { manageIgnoreFiles } from './ignore-files.js' import { contentHash, getConfigPath, getLockPath, readLock, upsertLockEntry } from './io.js' import { migrateLegacyWorkspace } from './migrate-legacy.js' import { fetchRegistryEntry, parseDocSpec, parseEcosystem, resolveFromRegistry } from './registry.js' @@ -282,6 +283,8 @@ const addCmd = defineCommand({ const agentsPath = generateAgentsMd(projectDir) consola.info(`AGENTS.md updated: ${agentsPath}`) + manageIgnoreFiles(projectDir, 'install') + consola.success(`Done! ${libName}@${result.resolvedVersion} docs are ready for AI agents.`) }, }) @@ -419,6 +422,10 @@ export async function runSync( if (!options.skipAgentsMd) { generateAgentsMd(projectDir) } + // `skipAgentsMd` is specifically about AGENTS.md regeneration; ignore + // file management always runs so that the project's lint/format/review + // tooling stays in sync with the current `.ask/docs/` state. + manageIgnoreFiles(projectDir, 'install') consola.success( `Sync complete: ${counts.drifted} re-fetched, ${counts.unchanged} unchanged, ${counts.failed} failed. AGENTS.md updated.`, ) @@ -468,6 +475,11 @@ const removeCmd = defineCommand({ removeDocEntry(projectDir, name, ver) generateAgentsMd(projectDir) + // If no docs remain, strip all ignore-file artifacts. Otherwise keep + // them in sync (e.g. a new root .prettierignore added since last add). + const remaining = listDocs(projectDir) + manageIgnoreFiles(projectDir, remaining.length === 0 ? 'remove' : 'install') + consola.success(`Removed docs for ${name}${ver ? `@${ver}` : ' (all versions)'}`) }, }) diff --git a/packages/cli/src/markers.ts b/packages/cli/src/markers.ts new file mode 100644 index 0000000..a14b0aa --- /dev/null +++ b/packages/cli/src/markers.ts @@ -0,0 +1,100 @@ +/** + * Marker block helpers for injecting ASK-owned content into user files + * without touching the surrounding user content. + * + * Two comment syntaxes are supported: + * - `html` — for Markdown files (` ... `) + * - `hash` — for ignore/properties files (`# ask:start ... # ask:end`) + */ + +export type MarkerSyntax = 'html' | 'hash' + +interface MarkerPair { + begin: string + end: string +} + +const LEADING_WHITESPACE_RE = /^\s+/ + +const MARKERS: Record = { + html: { + begin: '', + end: '', + }, + hash: { + begin: '# ask:start', + end: '# ask:end', + }, +} + +/** + * Wrap a payload with begin/end markers for the given comment syntax. + * The output does not include a trailing newline. + */ +export function wrap(payload: string, syntax: MarkerSyntax): string { + const { begin, end } = MARKERS[syntax] + return `${begin}\n${payload}\n${end}` +} + +/** + * Inject or refresh a marker block in `content`. If a block with matching + * markers already exists, it is replaced in place. Otherwise the block is + * appended to the end of the file with a blank line separator. + * + * The function is deterministic and idempotent: calling `inject` twice with + * the same block produces the same output. + */ +export function inject( + content: string, + block: string, + syntax: MarkerSyntax, +): string { + const { begin, end } = MARKERS[syntax] + const beginIdx = content.indexOf(begin) + const endIdx = content.indexOf(end) + + if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) { + // Replace existing block in place. + return content.substring(0, beginIdx) + block + content.substring(endIdx + end.length) + } + + // Append to end with a blank-line separator. + if (content.length === 0) { + return `${block}\n` + } + return `${content.trimEnd()}\n\n${block}\n` +} + +/** + * Strip the marker block from `content` if present. Returns the content + * unchanged if no marker pair is found. Trailing blank lines around the + * removed block are normalised. + */ +export function remove(content: string, syntax: MarkerSyntax): string { + const { begin, end } = MARKERS[syntax] + const beginIdx = content.indexOf(begin) + const endIdx = content.indexOf(end) + if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) { + return content + } + const before = content.substring(0, beginIdx).trimEnd() + const after = content.substring(endIdx + end.length).replace(LEADING_WHITESPACE_RE, '') + if (before.length === 0 && after.length === 0) + return '' + if (before.length === 0) + return after.endsWith('\n') ? after : `${after}\n` + if (after.length === 0) + return `${before}\n` + const tail = after.endsWith('\n') ? after : `${after}\n` + return `${before}\n\n${tail}` +} + +/** + * Test whether a marker block of the given syntax exists in `content`. + */ +export function has(content: string, syntax: MarkerSyntax): boolean { + const { begin, end } = MARKERS[syntax] + const beginIdx = content.indexOf(begin) + const endIdx = content.indexOf(end) + return beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx +} diff --git a/packages/cli/src/schemas.ts b/packages/cli/src/schemas.ts index e824c77..8b26a1c 100644 --- a/packages/cli/src/schemas.ts +++ b/packages/cli/src/schemas.ts @@ -57,6 +57,7 @@ export type SourceConfig = z.infer export const ConfigSchema = z.object({ schemaVersion: z.literal(1), docs: z.array(SourceConfigSchema), + manageIgnores: z.boolean().optional(), }) export type Config = z.infer diff --git a/packages/cli/test/agents.test.ts b/packages/cli/test/agents.test.ts index d851660..0b05b22 100644 --- a/packages/cli/test/agents.test.ts +++ b/packages/cli/test/agents.test.ts @@ -32,6 +32,16 @@ describe('generateAgentsMd', () => { expect(content).toContain('') }) + it('includes the vendored-docs intent notice', () => { + saveDocs(tmpDir, 'zod', '3.22.4', [{ path: 'README.md', content: '# zod' }]) + generateAgentsMd(tmpDir) + const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8') + expect(content).toContain('Vendored Documentation') + expect(content).toContain('read-only') + expect(content).toContain('not') + expect(content).toContain('ask docs sync') + }) + it('preserves user content outside the marker block', () => { const agentsPath = path.join(tmpDir, 'AGENTS.md') fs.writeFileSync( diff --git a/packages/cli/test/ignore-files.test.ts b/packages/cli/test/ignore-files.test.ts new file mode 100644 index 0000000..5b87b29 --- /dev/null +++ b/packages/cli/test/ignore-files.test.ts @@ -0,0 +1,183 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { + manageIgnoreFiles, + patchRootIgnores, + removeNestedConfigs, + unpatchRootIgnores, + writeNestedConfigs, +} from '../src/ignore-files.js' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-ignore-test-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +const NESTED_FILES = [ + '.gitattributes', + 'eslint.config.mjs', + 'biome.json', + '.markdownlint-cli2.jsonc', +] + +function docsFile(name: string): string { + return path.join(tmpDir, '.ask', 'docs', name) +} + +describe('writeNestedConfigs', () => { + it('creates all four nested config files', () => { + writeNestedConfigs(tmpDir) + for (const name of NESTED_FILES) { + expect(fs.existsSync(docsFile(name))).toBe(true) + } + }) + + it('writes a .gitattributes with linguist-vendored and linguist-generated', () => { + writeNestedConfigs(tmpDir) + const content = fs.readFileSync(docsFile('.gitattributes'), 'utf-8') + expect(content).toContain('linguist-vendored=true') + expect(content).toContain('linguist-generated=true') + }) + + it('writes an eslint.config.mjs that ignores everything', () => { + writeNestedConfigs(tmpDir) + const content = fs.readFileSync(docsFile('eslint.config.mjs'), 'utf-8') + expect(content).toContain('ignores') + expect(content).toContain('**/*') + }) + + it('returns the list of written files on first call', () => { + const result = writeNestedConfigs(tmpDir) + expect(result.length).toBe(NESTED_FILES.length) + }) + + it('is idempotent: second call writes nothing new', () => { + writeNestedConfigs(tmpDir) + const result = writeNestedConfigs(tmpDir) + expect(result).toEqual([]) + }) +}) + +describe('removeNestedConfigs', () => { + it('deletes all nested config files', () => { + writeNestedConfigs(tmpDir) + removeNestedConfigs(tmpDir) + for (const name of NESTED_FILES) { + expect(fs.existsSync(docsFile(name))).toBe(false) + } + }) + + it('leaves unrelated files inside .ask/docs/ alone', () => { + writeNestedConfigs(tmpDir) + const otherFile = docsFile('user-notes.md') + fs.writeFileSync(otherFile, '# notes\n') + removeNestedConfigs(tmpDir) + expect(fs.existsSync(otherFile)).toBe(true) + }) + + it('is a no-op when .ask/docs/ does not exist', () => { + expect(() => removeNestedConfigs(tmpDir)).not.toThrow() + }) +}) + +describe('patchRootIgnores', () => { + it('patches .prettierignore only when the file exists', () => { + const prettierPath = path.join(tmpDir, '.prettierignore') + fs.writeFileSync(prettierPath, 'node_modules\n') + patchRootIgnores(tmpDir) + const content = fs.readFileSync(prettierPath, 'utf-8') + expect(content).toContain('node_modules') + expect(content).toContain('# ask:start') + expect(content).toContain('.ask/docs/') + expect(content).toContain('# ask:end') + }) + + it('does not create .prettierignore when absent', () => { + patchRootIgnores(tmpDir) + expect(fs.existsSync(path.join(tmpDir, '.prettierignore'))).toBe(false) + }) + + it('patches sonar-project.properties when present', () => { + const sonarPath = path.join(tmpDir, 'sonar-project.properties') + fs.writeFileSync(sonarPath, 'sonar.projectKey=demo\n') + patchRootIgnores(tmpDir) + const content = fs.readFileSync(sonarPath, 'utf-8') + expect(content).toContain('sonar.projectKey=demo') + expect(content).toContain('sonar.exclusions=.ask/docs/**') + }) + + it('is idempotent across repeat invocations', () => { + const prettierPath = path.join(tmpDir, '.prettierignore') + fs.writeFileSync(prettierPath, 'node_modules\n') + patchRootIgnores(tmpDir) + const first = fs.readFileSync(prettierPath, 'utf-8') + patchRootIgnores(tmpDir) + const second = fs.readFileSync(prettierPath, 'utf-8') + expect(second).toBe(first) + }) +}) + +describe('unpatchRootIgnores', () => { + it('removes the marker block but leaves the file and user content', () => { + const prettierPath = path.join(tmpDir, '.prettierignore') + fs.writeFileSync(prettierPath, 'node_modules\n') + patchRootIgnores(tmpDir) + unpatchRootIgnores(tmpDir) + const content = fs.readFileSync(prettierPath, 'utf-8') + expect(content).toContain('node_modules') + expect(content).not.toContain('# ask:start') + expect(content).not.toContain('.ask/docs/') + }) + + it('is a no-op when no marker block is present', () => { + const prettierPath = path.join(tmpDir, '.prettierignore') + fs.writeFileSync(prettierPath, 'node_modules\n') + unpatchRootIgnores(tmpDir) + expect(fs.readFileSync(prettierPath, 'utf-8')).toBe('node_modules\n') + }) +}) + +describe('manageIgnoreFiles', () => { + it('installs both nested configs and root patches', () => { + fs.writeFileSync(path.join(tmpDir, '.prettierignore'), 'node_modules\n') + manageIgnoreFiles(tmpDir, 'install') + expect(fs.existsSync(docsFile('.gitattributes'))).toBe(true) + const prettier = fs.readFileSync(path.join(tmpDir, '.prettierignore'), 'utf-8') + expect(prettier).toContain('.ask/docs/') + }) + + it('removes both nested configs and root patches', () => { + fs.writeFileSync(path.join(tmpDir, '.prettierignore'), 'node_modules\n') + manageIgnoreFiles(tmpDir, 'install') + manageIgnoreFiles(tmpDir, 'remove') + expect(fs.existsSync(docsFile('.gitattributes'))).toBe(false) + const prettier = fs.readFileSync(path.join(tmpDir, '.prettierignore'), 'utf-8') + expect(prettier).not.toContain('# ask:start') + }) + + it('is a no-op when manageIgnores is false in config', () => { + const askDir = path.join(tmpDir, '.ask') + fs.mkdirSync(askDir, { recursive: true }) + fs.writeFileSync( + path.join(askDir, 'config.json'), + JSON.stringify({ schemaVersion: 1, docs: [], manageIgnores: false }), + ) + manageIgnoreFiles(tmpDir, 'install') + expect(fs.existsSync(docsFile('.gitattributes'))).toBe(false) + }) + + it('surfaces corrupt config errors instead of silently mutating files', () => { + const askDir = path.join(tmpDir, '.ask') + fs.mkdirSync(askDir, { recursive: true }) + fs.writeFileSync(path.join(askDir, 'config.json'), '{ not valid json') + expect(() => manageIgnoreFiles(tmpDir, 'install')).toThrow() + expect(fs.existsSync(docsFile('.gitattributes'))).toBe(false) + }) +}) diff --git a/packages/cli/test/ignore-lifecycle.test.ts b/packages/cli/test/ignore-lifecycle.test.ts new file mode 100644 index 0000000..a7799b7 --- /dev/null +++ b/packages/cli/test/ignore-lifecycle.test.ts @@ -0,0 +1,109 @@ +/** + * End-to-end lifecycle test for vendored-docs ignore management. + * + * This test exercises the `add` path via direct calls to the underlying + * modules (not the CLI runner) so we avoid spawning a subprocess. It + * verifies that after saving a doc: + * + * - Nested configs inside `.ask/docs/` exist + * - AGENTS.md contains the vendored notice + * - A detected root `.prettierignore` is patched + * + * And that after simulating the `remove` cleanup path: + * + * - Nested configs are gone + * - Root `.prettierignore` marker block is gone + */ + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { generateAgentsMd } from '../src/agents.js' +import { manageIgnoreFiles } from '../src/ignore-files.js' +import { saveDocs } from '../src/storage.js' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-lifecycle-test-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('ignore-files lifecycle', () => { + it('install path produces all expected artifacts', () => { + // Simulate an existing project with a Prettier ignore file. + fs.writeFileSync(path.join(tmpDir, '.prettierignore'), 'node_modules\ndist\n') + + // Pretend the user ran `ask docs add zod`: + saveDocs(tmpDir, 'zod', '3.22.4', [{ path: 'README.md', content: '# zod' }]) + generateAgentsMd(tmpDir) + manageIgnoreFiles(tmpDir, 'install') + + // Nested configs inside .ask/docs/ + const docsDir = path.join(tmpDir, '.ask', 'docs') + expect(fs.existsSync(path.join(docsDir, '.gitattributes'))).toBe(true) + expect(fs.existsSync(path.join(docsDir, 'eslint.config.mjs'))).toBe(true) + expect(fs.existsSync(path.join(docsDir, 'biome.json'))).toBe(true) + expect(fs.existsSync(path.join(docsDir, '.markdownlint-cli2.jsonc'))).toBe(true) + + // AGENTS.md has the vendored notice + const agents = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8') + expect(agents).toContain('Vendored Documentation') + + // Root .prettierignore was patched (existing content preserved) + const prettier = fs.readFileSync(path.join(tmpDir, '.prettierignore'), 'utf-8') + expect(prettier).toContain('node_modules') + expect(prettier).toContain('dist') + expect(prettier).toContain('# ask:start') + expect(prettier).toContain('.ask/docs/') + expect(prettier).toContain('# ask:end') + }) + + it('remove path cleans up all artifacts but preserves user files', () => { + fs.writeFileSync(path.join(tmpDir, '.prettierignore'), 'node_modules\n') + saveDocs(tmpDir, 'zod', '3.22.4', [{ path: 'README.md', content: '# zod' }]) + generateAgentsMd(tmpDir) + manageIgnoreFiles(tmpDir, 'install') + + // Simulate `ask docs remove zod` → last doc removed → cleanup. + manageIgnoreFiles(tmpDir, 'remove') + + // Nested configs gone + const docsDir = path.join(tmpDir, '.ask', 'docs') + expect(fs.existsSync(path.join(docsDir, '.gitattributes'))).toBe(false) + expect(fs.existsSync(path.join(docsDir, 'eslint.config.mjs'))).toBe(false) + expect(fs.existsSync(path.join(docsDir, 'biome.json'))).toBe(false) + expect(fs.existsSync(path.join(docsDir, '.markdownlint-cli2.jsonc'))).toBe(false) + + // .prettierignore still exists, user content preserved, marker removed + const prettier = fs.readFileSync(path.join(tmpDir, '.prettierignore'), 'utf-8') + expect(prettier).toContain('node_modules') + expect(prettier).not.toContain('# ask:start') + expect(prettier).not.toContain('.ask/docs/') + }) + + it('is idempotent: running install twice produces a stable result', () => { + fs.writeFileSync(path.join(tmpDir, '.prettierignore'), 'node_modules\n') + saveDocs(tmpDir, 'zod', '3.22.4', [{ path: 'README.md', content: '# zod' }]) + manageIgnoreFiles(tmpDir, 'install') + const firstPrettier = fs.readFileSync(path.join(tmpDir, '.prettierignore'), 'utf-8') + const firstGitattributes = fs.readFileSync( + path.join(tmpDir, '.ask', 'docs', '.gitattributes'), + 'utf-8', + ) + + manageIgnoreFiles(tmpDir, 'install') + const secondPrettier = fs.readFileSync(path.join(tmpDir, '.prettierignore'), 'utf-8') + const secondGitattributes = fs.readFileSync( + path.join(tmpDir, '.ask', 'docs', '.gitattributes'), + 'utf-8', + ) + + expect(secondPrettier).toBe(firstPrettier) + expect(secondGitattributes).toBe(firstGitattributes) + }) +}) diff --git a/packages/cli/test/markers.test.ts b/packages/cli/test/markers.test.ts new file mode 100644 index 0000000..40594be --- /dev/null +++ b/packages/cli/test/markers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'bun:test' +import { has, inject, remove, wrap } from '../src/markers.js' + +describe('wrap', () => { + it('wraps a payload with html markers', () => { + expect(wrap('hello', 'html')).toBe('\nhello\n') + }) + + it('wraps a payload with hash markers', () => { + expect(wrap('hello', 'hash')).toBe('# ask:start\nhello\n# ask:end') + }) +}) + +describe('inject', () => { + it('appends the block to an empty file', () => { + const block = wrap('payload', 'hash') + const result = inject('', block, 'hash') + expect(result).toBe(`${block}\n`) + }) + + it('appends the block with a blank-line separator', () => { + const block = wrap('payload', 'hash') + const result = inject('existing\n', block, 'hash') + expect(result).toBe(`existing\n\n${block}\n`) + }) + + it('replaces an existing block in place', () => { + const existing = 'before\n\n# ask:start\nold\n# ask:end\n\nafter\n' + const block = wrap('new', 'hash') + const result = inject(existing, block, 'hash') + expect(result).toBe('before\n\n# ask:start\nnew\n# ask:end\n\nafter\n') + }) + + it('is idempotent', () => { + const block = wrap('payload', 'html') + const once = inject('existing\n', block, 'html') + const twice = inject(once, block, 'html') + expect(twice).toBe(once) + }) +}) + +describe('remove', () => { + it('strips an existing block and preserves surrounding content', () => { + const existing = 'before\n\n# ask:start\npayload\n# ask:end\n\nafter\n' + expect(remove(existing, 'hash')).toBe('before\n\nafter\n') + }) + + it('returns content unchanged when no marker is present', () => { + expect(remove('plain content\n', 'hash')).toBe('plain content\n') + }) + + it('returns empty string when content only contained the block', () => { + const block = wrap('payload', 'hash') + expect(remove(`${block}\n`, 'hash')).toBe('') + }) +}) + +describe('has', () => { + it('detects a present block', () => { + expect(has('# ask:start\nx\n# ask:end', 'hash')).toBe(true) + }) + + it('returns false for missing blocks', () => { + expect(has('', 'hash')).toBe(false) + expect(has('plain', 'hash')).toBe(false) + }) +}) diff --git a/packages/cli/test/schemas.test.ts b/packages/cli/test/schemas.test.ts index f313514..849272a 100644 --- a/packages/cli/test/schemas.test.ts +++ b/packages/cli/test/schemas.test.ts @@ -96,6 +96,20 @@ describe('ConfigSchema', () => { const result = ConfigSchema.safeParse({ schemaVersion: 2, docs: [] }) expect(result.success).toBe(false) }) + + it('accepts a config with manageIgnores flag', () => { + const result = ConfigSchema.safeParse({ + schemaVersion: 1, + docs: [], + manageIgnores: false, + }) + expect(result.success).toBe(true) + }) + + it('treats manageIgnores as optional', () => { + const result = ConfigSchema.safeParse({ schemaVersion: 1, docs: [] }) + expect(result.success).toBe(true) + }) }) describe('LockEntrySchema', () => {