diff --git a/README.md b/README.md index a490f13..3c38530 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ Truthmark has one repo-local contract with two ways to use it. Maintainers and CI use the CLI: * `truthmark config` - create the initial configuration. -* `truthmark init` - install or refresh routing, truth-doc scaffolds, and AI-host instructions. +* `truthmark init` - install or refresh routing, truth-doc scaffolds, and AI-host instructions, reconciling safely recognized disabled-host surfaces. * `truthmark check` - validate the repository truth from the terminal. +* `truthmark uninstall --dry-run|--apply` - preview or remove generated host surfaces while preserving authored truth and configuration. ### Agents follow the contract while coding @@ -214,7 +215,7 @@ For command-by-command usage, surface comparisons, supported platform details, c The current release provides: -- local CLI commands for config, init, check, index, impact, and workflow status +- local CLI commands for config, init, uninstall, check, index, impact, workflow status, and validate - generated repo-local agent instructions for Codex, Claude Code, GitHub Copilot, OpenCode, Antigravity, and Cursor - route, authority, frontmatter, link, freshness, generated-surface, branch-scope, and coverage diagnostics - branch-scoped truth docs and derived repository-intelligence artifacts diff --git a/changes/2026-07-10-harden-init-routing-and-lifecycle.md b/changes/2026-07-10-harden-init-routing-and-lifecycle.md new file mode 100644 index 0000000..520038b --- /dev/null +++ b/changes/2026-07-10-harden-init-routing-and-lifecycle.md @@ -0,0 +1,12 @@ +# Harden Init routing and generated-surface lifecycle + +Date: 2026-07-10 +Previous version: 2.2.6 +New version: 2.2.7 +Version action: patch +SemVer rationale: This is a backward-compatible release that adds platform-derived instruction routing, repository-wide coverage discovery, generated-surface reconciliation, and explicit uninstall lifecycle controls while retaining legacy configuration parsing. + +- Instruction placement now follows configured platforms; legacy version-2 `instruction_targets` remains accepted but ignored. +- Check and RepoIndex share Git-visible, NUL-safe repository discovery, including functional code under arbitrary roots. +- Init reconciles exact safely recognized inactive generated surfaces while preserving diverged, Gemini, and unrelated files. +- `truthmark uninstall --dry-run|--apply [--json]` provides a deterministic removal plan and preserves authored truth, configuration, templates, Portal output, and global package installation. diff --git a/docs/ai/repo-rules.md b/docs/ai/repo-rules.md index 6f8fedf..4b2cb5e 100644 --- a/docs/ai/repo-rules.md +++ b/docs/ai/repo-rules.md @@ -31,7 +31,7 @@ Code is the implementation. On code/doc conflict, inspect code, decide whether c The canonical product boundary is [docs/architecture/product-boundary.md](../architecture/product-boundary.md). This is repo-local policy for developing Truthmark itself; do not treat it as a downstream scaffold, generated surface, or required artifact for repositories that install Truthmark. Read it before generating any new design, implementation plan, generated-workflow redesign, architecture proposal, runtime/dependency change, or command-surface change. New designs and plans must include a product-boundary check that explains how the proposal preserves Truthmark's North Star, in-scope surfaces, explicit non-goals, optional-helper rule, and fail-closed write boundaries. -Truthmark public CLI commands are `config`, `init`, `check`, `index`, `impact`, `context`, `workflow`, and `validate`. The `workflow` subcommands expose read-only agent-facing status/instructions contracts; they do not run installed workflows. The `validate` subcommands are optional CLI-owned workflow helper validators; they validate reports or write leases but do not run Truth Structure, Truth Document, Truth Sync, Truth Realize, or Truth Check. Those named workflows are installed workflow surfaces, not top-level CLI commands. Truth Preview is not an installed workflow surface; preview-like routing selection is internal advisory behavior. +Truthmark public CLI commands are `config`, `init`, `uninstall`, `check`, `index`, `impact`, `workflow status`, and `validate`. The `workflow status` command exposes a read-only agent-facing state contract; it does not run installed workflows. The `validate` subcommands are optional CLI-owned workflow helper validators; they validate reports or write leases but do not run Truth Structure, Truth Document, Truth Sync, Truth Realize, or Truth Check. Those named workflows are installed workflow surfaces, not top-level CLI commands. Truth Preview is not an installed workflow surface; preview-like routing selection is internal advisory behavior. Agents inspect the active checkout directly. There is no daemon, database, remote service, hidden memory layer, or product-centered MCP server. diff --git a/docs/readmes/README.ar.md b/docs/readmes/README.ar.md index 0f2ab60..74598a6 100644 --- a/docs/readmes/README.ar.md +++ b/docs/readmes/README.ar.md @@ -223,3 +223,7 @@ Truthmark صغير عمداً: محلي، ملتزم به، مرتبط بالف ## الترخيص MIT. راجع [LICENSE](../../LICENSE). + +## إزالة آمنة + +استخدم `truthmark uninstall --dry-run` لمراجعة أسطح الاستضافة المولّدة بدقة، ثم `truthmark uninstall --apply` لإزالتها. يتم الاحتفاظ بـ truth المؤلفة والتكوين والقوالب ومخرجات البوابة وملفات Gemini والملفات غير المتعلقة بالمستخدم؛ أزل التثبيت العالمي لـ npm بشكل منفصل عبر مدير الحزم. diff --git a/docs/readmes/README.de.md b/docs/readmes/README.de.md index df772a0..a640fd5 100644 --- a/docs/readmes/README.de.md +++ b/docs/readmes/README.de.md @@ -223,3 +223,7 @@ Es ist kein gehosteter Dienst, MCP-Server, keine Vektordatenbank, versteckte Mem ## Lizenz MIT. Siehe [LICENSE](../../LICENSE). + +## Sichere Entfernung + +Verwenden Sie `truthmark uninstall --dry-run`, um die exakt erzeugten Host-Surfaces zu prüfen, und anschließend `truthmark uninstall --apply`, um sie zu entfernen. Erstellte truth, Konfiguration, Templates, Portal-Ausgabe, Gemini-Dateien und nicht zusammenhängende Benutzerdateien bleiben erhalten; entfernen Sie eine globale npm-Installation separat mit Ihrem Paketmanager. diff --git a/docs/readmes/README.el.md b/docs/readmes/README.el.md index ea68da5..6f38e55 100644 --- a/docs/readmes/README.el.md +++ b/docs/readmes/README.el.md @@ -223,3 +223,7 @@ keep the result reviewable in Git ## Άδεια MIT. Δείτε [LICENSE](../../LICENSE). + +## Ασφαλής κατάργηση + +Χρησιμοποιήστε το `truthmark uninstall --dry-run` για να ελέγξετε τις ακριβώς παραγόμενες επιφάνειες host και στη συνέχεια το `truthmark uninstall --apply` για να τις αφαιρέσετε. Το δημιουργημένο truth, η διαμόρφωση, τα templates, η έξοδος του Portal, τα αρχεία Gemini και τα μη σχετιζόμενα αρχεία χρήστη διατηρούνται. Καταργήστε την παγκόσμια εγκατάσταση του npm ξεχωριστά με το εργαλείο διαχείρισης πακέτων σας. diff --git a/docs/readmes/README.es.md b/docs/readmes/README.es.md index 0b34e82..8039085 100644 --- a/docs/readmes/README.es.md +++ b/docs/readmes/README.es.md @@ -223,3 +223,7 @@ No es un servicio alojado, servidor MCP, base de datos vectorial, capa de memori ## Licencia MIT. Consulta [LICENSE](../../LICENSE). + +## Eliminación segura + +Use `truthmark uninstall --dry-run` para revisar las superficies de host generadas exactas, luego `truthmark uninstall --apply` para eliminarlas. Los truth creados, la configuración, las plantillas, la salida de Portal, los archivos de Gemini y los archivos de usuario no relacionados se conservan; quite una instalación global de npm por separado con su gestor de paquetes. diff --git a/docs/readmes/README.fr.md b/docs/readmes/README.fr.md index 8d82d0a..4505674 100644 --- a/docs/readmes/README.fr.md +++ b/docs/readmes/README.fr.md @@ -223,3 +223,7 @@ Ce n’est pas un service hébergé, un serveur MCP, une base de données vector ## Licence MIT. Voir [LICENSE](../../LICENSE). + +## Suppression sûre + +Utilisez `truthmark uninstall --dry-run` pour examiner précisément les surfaces hôtes générées, puis `truthmark uninstall --apply` pour les supprimer. Les truth rédigées, la configuration, les templates, la sortie Portal, les fichiers Gemini et les fichiers utilisateur non liés sont conservés ; supprimez séparément une installation npm globale avec votre gestionnaire de paquets. diff --git a/docs/readmes/README.id.md b/docs/readmes/README.id.md index 8239039..8398809 100644 --- a/docs/readmes/README.id.md +++ b/docs/readmes/README.id.md @@ -223,3 +223,7 @@ Ini bukan layanan ter-host, server MCP, basis data vektor, lapisan memori tersem ## Lisensi MIT. Lihat [LICENSE](../../LICENSE). + +## Penghapusan aman + +Gunakan `truthmark uninstall --dry-run` untuk meninjau permukaan host yang tepat dihasilkan, lalu `truthmark uninstall --apply` untuk menghapusnya. Truth hasil penulisan, konfigurasi, template, keluaran Portal, berkas Gemini, dan berkas pengguna yang tidak terkait akan tetap dipertahankan; hapus instalasi npm global secara terpisah melalui pengelola paket Anda. diff --git a/docs/readmes/README.it.md b/docs/readmes/README.it.md index 9218323..8e0661c 100644 --- a/docs/readmes/README.it.md +++ b/docs/readmes/README.it.md @@ -223,3 +223,7 @@ Non è un servizio ospitato, un server MCP, un database vettoriale, un livello d ## Licenza MIT. Vedi [LICENSE](../../LICENSE). + +## Rimozione sicura + +Usa `truthmark uninstall --dry-run` per rivedere le superfici host generate con precisione, quindi `truthmark uninstall --apply` per rimuoverle. I truth creati, la configurazione, i template, l’output di Portal, i file Gemini e i file utente non correlati vengono preservati; rimuovi separatamente un’installazione npm globale con il tuo gestore pacchetti. diff --git a/docs/readmes/README.ja.md b/docs/readmes/README.ja.md index 51fd7c3..0111578 100644 --- a/docs/readmes/README.ja.md +++ b/docs/readmes/README.ja.md @@ -223,3 +223,7 @@ Truthmark は意図的に小さく保たれています:ローカル、コミ ## ライセンス MIT。[LICENSE](../../LICENSE) を参照してください。 + +## 安全な削除 + +`truthmark uninstall --dry-run` を使用して正確に生成されたホストサーフェスを確認し、次に `truthmark uninstall --apply` でそれらを削除します。作成した truth、設定、テンプレート、Portal の出力、Gemini ファイル、関連のないユーザーファイルは保持されます。npm のグローバルインストールはパッケージマネージャーで別途削除してください。 diff --git a/docs/readmes/README.ko.md b/docs/readmes/README.ko.md index ace7e60..916e933 100644 --- a/docs/readmes/README.ko.md +++ b/docs/readmes/README.ko.md @@ -223,3 +223,7 @@ Truthmark는 호스팅 서비스, MCP 서버, 벡터 데이터베이스, 숨겨 ## 라이선스 MIT. [LICENSE](../../LICENSE)를 참조하세요. + +## 안전한 제거 + +`truthmark uninstall --dry-run`을(를) 사용하여 정확히 생성된 호스트 표면을 검토한 후 `truthmark uninstall --apply`를 사용해 제거하세요. 작성된 truth, 구성, 템플릿, Portal 출력, Gemini 파일 및 관련 없는 사용자 파일은 보존되며, npm 전역 설치는 패키지 관리자에서 별도로 제거하십시오. diff --git a/docs/readmes/README.pl.md b/docs/readmes/README.pl.md index 1af5f48..a10d3ce 100644 --- a/docs/readmes/README.pl.md +++ b/docs/readmes/README.pl.md @@ -223,3 +223,7 @@ Nie jest usługą hostowaną, serwerem MCP, wektorową bazą danych, ukrytą war ## Licencja MIT. Zobacz [LICENSE](../../LICENSE). + +## Bezpieczne usuwanie + +Użyj `truthmark uninstall --dry-run`, aby przejrzeć dokładnie wygenerowane powierzchnie hosta, a następnie `truthmark uninstall --apply`, aby je usunąć. Utworzone truth, konfiguracja, szablony, wyjście Portalu, pliki Gemini i niezwiązane pliki użytkownika są zachowane; globalną instalację npm usuń oddzielnie za pomocą swojego menedżera pakietów. diff --git a/docs/readmes/README.pt.md b/docs/readmes/README.pt.md index 602054d..f8b041d 100644 --- a/docs/readmes/README.pt.md +++ b/docs/readmes/README.pt.md @@ -223,3 +223,7 @@ Ele não é um serviço hospedado, servidor MCP, banco de dados vetorial, camada ## Licença MIT. Veja [LICENSE](../../LICENSE). + +## Remoção segura + +Use `truthmark uninstall --dry-run` para revisar as superfícies de host geradas com precisão, e depois `truthmark uninstall --apply` para removê-las. Truth criada, configuração, templates, saída do Portal, arquivos Gemini e arquivos de usuário não relacionados são preservados; remova uma instalação global de npm separadamente com seu gerenciador de pacotes. diff --git a/docs/readmes/README.ru.md b/docs/readmes/README.ru.md index b5082dc..704d06c 100644 --- a/docs/readmes/README.ru.md +++ b/docs/readmes/README.ru.md @@ -223,3 +223,7 @@ Truthmark намеренно мал: локальный, закоммиченн ## Лицензия MIT. См. [LICENSE](../../LICENSE). + +## Безопасное удаление + +Используйте `truthmark uninstall --dry-run` для проверки точно сгенерированных host-поверхностей, затем `truthmark uninstall --apply` для их удаления. Сформированные truth, конфигурация, шаблоны, вывод Portal, файлы Gemini и несвязанные пользовательские файлы сохраняются; глобальную установку npm удаляйте отдельно через менеджер пакетов. diff --git a/docs/readmes/README.tr.md b/docs/readmes/README.tr.md index f4661a8..079f5ef 100644 --- a/docs/readmes/README.tr.md +++ b/docs/readmes/README.tr.md @@ -223,3 +223,7 @@ Barındırılan bir hizmet, MCP sunucusu, vektör veritabanı, gizli bellek katm ## Lisans MIT. Bkz. [LICENSE](../../LICENSE). + +## Güvenli kaldırma + +`truthmark uninstall --dry-run` komutunu kullanarak tam olarak oluşturulan ana bilgisayar yüzeylerini inceleyin, ardından kaldırmak için `truthmark uninstall --apply` komutunu çalıştırın. Oluşturulan truth, yapılandırma, şablonlar, Portal çıktısı, Gemini dosyaları ve alakasız kullanıcı dosyaları korunur; global npm kurulumunu paket yöneticinizle ayrı olarak kaldırın. diff --git a/docs/readmes/README.vi.md b/docs/readmes/README.vi.md index fe81846..da4b2aa 100644 --- a/docs/readmes/README.vi.md +++ b/docs/readmes/README.vi.md @@ -223,3 +223,7 @@ Nó không phải dịch vụ lưu trữ, máy chủ MCP, cơ sở dữ liệu v ## Giấy phép MIT. Xem [LICENSE](../../LICENSE). + +## Gỡ cài đặt an toàn + +Sử dụng `truthmark uninstall --dry-run` để xem lại chính xác các bề mặt host đã tạo ra, rồi `truthmark uninstall --apply` để loại bỏ chúng. Các truth đã tạo, cấu hình, mẫu, đầu ra Portal, tệp Gemini và các tệp người dùng không liên quan sẽ được giữ nguyên; hãy gỡ bỏ cài đặt npm toàn cục riêng biệt bằng trình quản lý gói của bạn. diff --git a/docs/readmes/README.zh.md b/docs/readmes/README.zh.md index 33925c9..9eeef84 100644 --- a/docs/readmes/README.zh.md +++ b/docs/readmes/README.zh.md @@ -223,3 +223,7 @@ Truthmark 有意保持小而明确:本地、已提交、按分支生效、可 ## 许可证 MIT。见 [LICENSE](../../LICENSE)。 + +## 安全移除 + +使用 `truthmark uninstall --dry-run` 查看精确生成的主机表面(host surfaces),然后使用 `truthmark uninstall --apply` 将其移除。已创建的 truth、配置、模板、Portal 输出、Gemini 文件以及不相关的用户文件将被保留;请使用包管理器单独移除全局 npm 安装。 diff --git a/docs/truthmark/engineering/architecture/overview.md b/docs/truthmark/engineering/architecture/overview.md index 403f4ad..47cc35c 100644 --- a/docs/truthmark/engineering/architecture/overview.md +++ b/docs/truthmark/engineering/architecture/overview.md @@ -92,3 +92,7 @@ Update when module boundaries, generated-surface ownership, or command architect - `src/config/load.ts` - `src/routing/areas.ts` - `src/templates/generated-surfaces.ts` + +## Renderer-derived lifecycle (2026-07-10) + +Generated ownership is derived from renderer outputs rather than host-directory prefixes or a persistent manifest. Optional Init, Check, and Uninstall helpers operate on exact paths; repository files remain the complete host-native runtime. diff --git a/docs/truthmark/engineering/behaviors/check-diagnostics.md b/docs/truthmark/engineering/behaviors/check-diagnostics.md index dff9ce4..ade1b50 100644 --- a/docs/truthmark/engineering/behaviors/check-diagnostics.md +++ b/docs/truthmark/engineering/behaviors/check-diagnostics.md @@ -122,4 +122,8 @@ Update when check categories, severity rules, lane audit behavior, or product ki - src/output/diagnostic.ts - tests/checks/check.test.ts - tests/checks/frontmatter.test.ts -- tests/templates/generated-surfaces.test.ts +- tests/checks/check.test.ts + +## Inactive surfaces and unknown roots (2026-07-10) + +Check reports unmapped functional code under any Git-visible root and feeds those findings into routing-coverage and ownership-clarity scorecard dimensions. It also previews exact inactive generated surfaces without treating unrelated files in host directories as owned. diff --git a/docs/truthmark/engineering/behaviors/init-and-scaffold.md b/docs/truthmark/engineering/behaviors/init-and-scaffold.md index 2ebed5c..d675cb6 100644 --- a/docs/truthmark/engineering/behaviors/init-and-scaffold.md +++ b/docs/truthmark/engineering/behaviors/init-and-scaffold.md @@ -44,7 +44,7 @@ Generated truth-doc frontmatter includes `truth_kind`. Generated truth-doc frontmatter does not include `doc_type` or `truth_lane`. -`truthmark init` removes auto-removable retired generated-surface artifacts when those paths are no longer part of current generated output. +`truthmark init` removes auto-removable retired generated-surface artifacts only when their whole-file bytes are recognized and those paths are no longer part of current generated output. Auto-removable retired artifacts include: @@ -130,7 +130,10 @@ Capability docs own: - `truthmark init` creates or refreshes workspace scaffold files. - It renders current templates and generated host surfaces from source renderers. -- It removes retired non-Gemini generated-surface artifacts that are no longer part of current generated output. +- Before any scaffold or generated-surface write, it rejects aliased, non-regular, or hard-linked managed instruction destinations and preflights every planned lifecycle mutation. +- It revalidates every planned mutation before applying the first one, so a changed or unsafe later target prevents partial cleanup and scaffold writes. +- It removes retired non-Gemini generated-surface artifacts only when exact recognized whole-file bytes or one valid managed block establish ownership. +- It preserves user bytes outside a removed managed block, including surrounding whitespace and line-ending convention. - It leaves retired Gemini surfaces for manual cleanup. ## Contracts @@ -180,5 +183,9 @@ Update when init writes new files, changes default paths, changes template filen - ../../../../src/config/defaults.ts - ../../../../src/init/hierarchy.ts - ../../../../src/templates/init-files.ts -- ../../../../tests/init/init-instructions.test.ts +- ../../../../tests/lifecycle/uninstall.test.ts - ../../../../tests/init/truth-doc-templates.test.ts + +## Platform Reconciliation (2026-07-10) + +Init derives `AGENTS.md` for shared-contract hosts and `CLAUDE.md` for Claude Code, retains renderer-owned host instructions, and writes no generic instruction file when no platform is configured. Its deterministic lifecycle plan and diagnostics expose removals, preserved diverged files, manual-only Gemini paths, and preflight failures to both human and JSON callers. diff --git a/docs/truthmark/engineering/contracts/config-route-and-check-contracts.md b/docs/truthmark/engineering/contracts/config-route-and-check-contracts.md index 65b2449..14a5746 100644 --- a/docs/truthmark/engineering/contracts/config-route-and-check-contracts.md +++ b/docs/truthmark/engineering/contracts/config-route-and-check-contracts.md @@ -150,3 +150,7 @@ Update when config fields, route metadata, diagnostics, route/index output schem - `src/routing/areas.ts` - `src/repo-index/types.ts` - `src/output/diagnostic.ts` + +## Routing and lifecycle update (2026-07-10) + +Instruction destinations are derived from configured platforms. The version-2 `instruction_targets` field remains parseable but is ignored with a review diagnostic and is omitted from new configuration. `uninstall` requires exactly one of `--dry-run` or `--apply` and returns a deterministic `truthmark-lifecycle/v0` plan. diff --git a/docs/truthmark/engineering/contracts/generated-host-surfaces.md b/docs/truthmark/engineering/contracts/generated-host-surfaces.md index f5a853f..b579548 100644 --- a/docs/truthmark/engineering/contracts/generated-host-surfaces.md +++ b/docs/truthmark/engineering/contracts/generated-host-surfaces.md @@ -18,15 +18,18 @@ It covers configured platform output paths, generated workflow files, managed in Truthmark renders workflow surfaces only for configured platforms. Legacy package artifacts and retired Preview adapters are explicitly retired. -- `truthmark init` removes obsolete generated files that are no longer in `renderGeneratedSurfaces(...)`. +- `truthmark init` removes obsolete generated files that are no longer in `renderGeneratedSurfaces(...)` only when exact recognized bytes establish whole-file ownership. - Removed obsolete files include `truthmark-preview` package contents. - Removed obsolete files include retired non-Gemini Preview adapters. - Removed obsolete files include legacy `helper-manifest.yml` and `support/helper-policy.md` files under host skill roots. - `truthmark check` reports missing, stale, or obsolete generated surfaces when render outputs and committed files differ. - Stale Gemini surfaces are reported for manual cleanup rather than deleted by init. +- Check, Init reconciliation, and Uninstall consume the same renderer-derived current catalog and retired-surface inventory. +- Destructive lifecycle application snapshots and revalidates containment, parent and final link status, regular-file and hard-link status, managed-block structure, and recognized whole-file bytes for every mutation before applying the first mutation. +- Managed-block removal preserves all bytes outside the single valid marker range; a block-only file is removed. - When `platforms` is omitted, fresh config does not assume a host platform. -- `truthmark init` still maintains instruction targets, but host-specific skill/prompt/command surfaces are opt-in through explicit `platforms` entries. +- `platforms` is the complete platform-ownership declaration. An explicit platform refreshes or retains renderer-owned instruction surfaces. Omitting `platforms` means no active platform, so `truthmark init` reconciles recognized generated surfaces/blocks away to an empty rendered host set. - Host skill packages carry canonical workflow entrypoints plus support files for full procedures, report templates, and subagent/lease guidance when the workflow uses subagents. - Generated helper manifest and helper policy files are intentionally not emitted. - GitHub Copilot prompt files are lightweight workflow adapters for supported generated workflows. @@ -132,3 +135,7 @@ Update when platform paths, supported hosts, optional validation commands, or ma - `src/templates/generated-surfaces.ts` - `src/templates/workflow-surfaces.ts` - `src/templates/agents-block.ts` + +## Exact-path lifecycle ownership (2026-07-10) + +Renderers catalogue exact generated paths with aggregated platform and Portal claims plus retired exact paths and bounded retired package/helper patterns. Check previews inactive claims; Init and Uninstall reconcile only recognized whole files or valid managed blocks. Diverged files, malformed blocks, unsafe aliases or hard links, Gemini surfaces, and unrelated sibling files are preserved for review without recursive directory deletion. diff --git a/docs/truthmark/engineering/repository/repository-intelligence.md b/docs/truthmark/engineering/repository/repository-intelligence.md index 484a340..3a9278f 100644 --- a/docs/truthmark/engineering/repository/repository-intelligence.md +++ b/docs/truthmark/engineering/repository/repository-intelligence.md @@ -156,3 +156,7 @@ Update when index, route-map, impact, evidence, freshness, or workflow-state out - tests/impact/build.test.ts - tests/evidence/validate.test.ts - tests/workflow-state/build.test.ts + +## Shared file discovery (2026-07-10) + +Check and RepoIndex share NUL-delimited `git ls-files` discovery for tracked and visible untracked current regular files. Results apply default and configured ignores, exclude deleted or escaping paths, and are normalized, deduplicated, and sorted. A deterministic full-tree fallback is used when Git enumeration is unavailable; it conservatively cannot reproduce every Git ignore rule. diff --git a/docs/truthmark/product/capabilities/agent-native-workflow-injection.md b/docs/truthmark/product/capabilities/agent-native-workflow-injection.md index db6db8b..cb9cd44 100644 --- a/docs/truthmark/product/capabilities/agent-native-workflow-injection.md +++ b/docs/truthmark/product/capabilities/agent-native-workflow-injection.md @@ -122,3 +122,7 @@ This capability covers: - ../../../../src/agents/workflow-manifest.ts - ../../../../src/templates/workflow-surfaces.ts - ../../../../src/templates/generated-surfaces.ts + +## Host instruction ownership (2026-07-10) + +Configured platforms, not arbitrary configuration paths, select instruction files. Shared-contract hosts aggregate ownership of `AGENTS.md`; Claude Code owns `CLAUDE.md`; other renderer-specific canonical instruction surfaces remain host-owned. Disabling a host makes only its exact recognized outputs eligible for reconciliation. diff --git a/docs/truthmark/product/capabilities/lane-separated-truth.md b/docs/truthmark/product/capabilities/lane-separated-truth.md index d72eab3..185b53a 100644 --- a/docs/truthmark/product/capabilities/lane-separated-truth.md +++ b/docs/truthmark/product/capabilities/lane-separated-truth.md @@ -58,3 +58,7 @@ Maintainers can review product promises and implementation realization without e - ../../../../src/routing/areas.ts - ../../../../src/init/hierarchy.ts - ../../../../src/templates/init-files.ts + +## Repository-wide coverage (2026-07-10) + +Routing coverage considers current Git-visible functional code under arbitrary repository roots. Tests, ignored paths, documentation, assets, and generated surfaces do not create unmapped-code findings. diff --git a/docs/user-guide.md b/docs/user-guide.md index 7a9d957..8c24854 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -68,6 +68,11 @@ Unknown platform names are config errors. Removing a platform stops rendering that platform's host-specific surfaces on future refreshes. `truthmark init` also removes known retired managed artifacts, but review generated-surface diffs intentionally. +Instruction files are derived from platforms: Claude Code uses `CLAUDE.md`; shared-contract hosts use the deduplicated `AGENTS.md`; host-specific canonical instructions remain renderer-owned. Legacy version-2 `instruction_targets` values still parse but are ignored and never authorize writes. +If `platforms` is omitted, no platform is active. `truthmark init` reconciles recognized renderer-owned generated files and managed instruction blocks away; authored content outside valid managed markers is preserved, and only a block-only file can become absent. + +Before leaving Truthmark, run `truthmark uninstall --dry-run`, review the exact-path `truthmark-lifecycle/v0` plan, then run `truthmark uninstall --apply`. Uninstall preserves `.truthmark/config.yml`, routes, truth documents, editable templates, Portal presentation output, Gemini files, unrelated host-directory files, and content outside managed markers. Remove those manually only after review. A globally installed npm package is separate; remove it with your package manager if desired. + ## Workflow commands These workflows are installed into supported AI coding hosts. They are not top-level shell commands. @@ -168,6 +173,7 @@ Most maintainers start with three commands. | --- | --- | | `truthmark config` | Create `.truthmark/config.yml`. Writes only that file unless `--stdout` is used. | | `truthmark init` | Install or refresh configured workflow surfaces from the reviewed config. | +| `truthmark uninstall --dry-run\|--apply` | Preview or apply safe removal of recognized generated host surfaces. Exactly one mode is required. | | `truthmark check` | Validate configuration, authority, routing, decision-bearing docs, frontmatter, internal links, branch scope, generated surfaces, freshness, and coverage diagnostics. | Optional repository-intelligence helpers generate derived review material for the active checkout, such as RepoIndex, RouteMap, ImpactSet, and compact WorkflowState/action-context JSON. Validation helpers are exposed as optional workflow metadata and explicit `truthmark validate ... --json` commands; they are accelerators, not bundled repo-local helper manifest or policy files and not sources of truth. Standalone Copilot prompts, Antigravity rules, and Cursor Agent Skills use the same CLI validator contract when the installed runner is available, and otherwise report a visible skipped helper status with manual validation. @@ -244,7 +250,7 @@ Important config areas include: | Fixed truth lanes | Product truth lives under `product/` and engineering truth under `engineering/` inside `truthmark.workspace`. | | Fixed templates | Truth-doc templates live under `templates/` inside `truthmark.workspace`. | | `truthmark.generated.portal` | Optional manual presentation workflow enablement: `enabled`. | -| `instruction_targets` | Files that receive shared managed instruction blocks, such as `AGENTS.md`. | +| `instruction_targets` | Legacy parse-only compatibility field; still parsed for compatibility but ignored by the renderer and never a write authority. | | `frontmatter.required` | Metadata fields that produce error diagnostics when missing. | | `frontmatter.recommended` | Metadata fields that produce review diagnostics when missing. | | `ignore` | Glob patterns excluded from relevant checks and routing logic. | diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 392946c..0000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -schema: spec-driven - -# Project context (optional) -# This is shown to AI when creating artifacts. -# Add your tech stack, conventions, style guides, domain knowledge, etc. -# Example: -# context: | -# Tech stack: TypeScript, React, Node.js -# We use conventional commits -# Domain: e-commerce platform - -# Per-artifact rules (optional) -# Add custom rules for specific artifacts. -# Example: -# rules: -# proposal: -# - Keep proposals under 500 words -# - Always include a "Non-goals" section -# tasks: -# - Break tasks into chunks of max 2 hours diff --git a/package-lock.json b/package-lock.json index 8917095..e4804d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "truthmark", - "version": "2.2.6", + "version": "2.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "truthmark", - "version": "2.2.6", + "version": "2.2.7", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index 9872e6a..0422af2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "truthmark", - "version": "2.2.6", + "version": "2.2.7", "description": "Git-native, branch-scoped truth workflow installer for local AI coding agents.", "license": "MIT", "type": "module", diff --git a/src/checks/areas.ts b/src/checks/areas.ts index e3eb68b..5fdaca1 100644 --- a/src/checks/areas.ts +++ b/src/checks/areas.ts @@ -5,6 +5,7 @@ import micromatch from "micromatch"; import type { TruthmarkConfig } from "../config/schema.js"; import { assertRepoContainment, resolveRepoPath } from "../fs/paths.js"; +import { discoverRepositoryFilePaths } from "../git/files.js"; import { resolveAreaRouting } from "../routing/area-resolver.js"; import type { Diagnostic } from "../output/diagnostic.js"; import { @@ -12,7 +13,7 @@ import { mergeTruthDocumentEntryRelationships, type TruthDocumentEntry, } from "../routing/areas.js"; -import { classifyPath } from "../sync/classify.js"; +import { classifyPath, isTestPath } from "../sync/classify.js"; import { resolveEngineeringTruthRoot, resolveProductTruthRoot, @@ -46,34 +47,6 @@ const pathExists = async (absolutePath: string): Promise => { } }; -const COVERAGE_SCAN_PATTERNS = [ - "app/**/*", - "api/**/*", - "apps/**/*", - "bin/**/*", - "client/**/*", - "cmd/**/*", - "frontend/**/*", - "infra/**/*", - "infrastructure/**/*", - "internal/**/*", - "k8s/**/*", - "kubernetes/**/*", - "lib/**/*", - "packages/**/*", - "pkg/**/*", - "proto/**/*", - "schema/**/*", - "schemas/**/*", - "scripts/**/*", - "server/**/*", - "services/**/*", - "src/**/*", - "terraform/**/*", - "web/**/*", - ".github/workflows/**/*", -] as const; - const BROAD_CODE_SURFACES = new Set([ "app/**", "apps/**", @@ -237,15 +210,14 @@ export const checkAreas = async ( engineeringTruthRoot: resolveEngineeringTruthRoot(config), }); - const discoveredCodeFiles = await fg([...COVERAGE_SCAN_PATTERNS], { - cwd: rootDir, - onlyFiles: true, - ignore: config.ignore, - followSymbolicLinks: false, - dot: true, - }); + const discoveredCodeFiles = await discoverRepositoryFilePaths( + rootDir, + config.ignore, + ); const rawCodeFiles = discoveredCodeFiles.filter( - (filePath) => classifyPath(filePath, config.ignore) === "functional-code", + (filePath) => + classifyPath(filePath, config.ignore) === "functional-code" && + !isTestPath(filePath), ); const diagnostics: Diagnostic[] = [...routing.diagnostics]; const truthDocumentPaths: string[] = []; @@ -257,16 +229,7 @@ export const checkAreas = async ( valid: true, patterns: [] as string[], })); - const codeFiles: string[] = []; - - for (const codeFile of rawCodeFiles.sort()) { - try { - await assertRepoContainment(rootDir, resolveRepoPath(rootDir, codeFile)); - codeFiles.push(codeFile); - } catch { - continue; - } - } + const codeFiles = rawCodeFiles; const truthReferences = routing.truthDocumentReferences; diff --git a/src/checks/generated-surfaces.ts b/src/checks/generated-surfaces.ts index 75c6377..66e9dad 100644 --- a/src/checks/generated-surfaces.ts +++ b/src/checks/generated-surfaces.ts @@ -1,14 +1,11 @@ import fs from "node:fs/promises"; -import type { Dirent } from "node:fs"; import type { TruthmarkConfig } from "../config/schema.js"; import { resolveRepoPath } from "../fs/paths.js"; import type { Diagnostic } from "../output/diagnostic.js"; -import { - TRUTHMARK_BLOCK_END, - TRUTHMARK_BLOCK_START, -} from "../templates/agents-block.js"; import { renderGeneratedSurfaces } from "../templates/generated-surfaces.js"; +import { buildLifecyclePlan } from "../init/lifecycle.js"; +import { extractManagedBlock } from "../managed-block.js"; const readOptionalFile = async ( rootDir: string, @@ -25,17 +22,6 @@ const readOptionalFile = async ( } }; -const extractManagedBlock = (content: string): string | null => { - const startIndex = content.indexOf(TRUTHMARK_BLOCK_START); - const endIndex = content.indexOf(TRUTHMARK_BLOCK_END); - - if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { - return null; - } - - return content.slice(startIndex, endIndex + TRUTHMARK_BLOCK_END.length); -}; - const normalizeGeneratedSurfaceContent = ( content: string | null, ): string | null => { @@ -46,34 +32,6 @@ const normalizeGeneratedSurfaceContent = ( return content.replace(/\r\n/g, "\n").replace(/\n$/u, ""); }; -const GENERATED_HOST_SKILL_ROOTS = [ - ".agents/skills", - ".opencode/skills", - ".claude/skills", - ".github/skills", - ".cursor/skills", -] as const; - -const RETIRED_SKILL_HELPER_PATHS = [ - "helper-manifest.yml", - "support/helper-policy.md", -] as const; - -const RETIRED_PACKAGE_DIRECTORIES = ["truthmark-preview"] as const; - -const RETIRED_GENERATED_SURFACE_PATHS = [ - "GEMINI.md", - ".github/prompts/truthmark-preview.prompt.md", - ".cursor/rules/truthmark-structure.mdc", - ".cursor/rules/truthmark-document.mdc", - ".cursor/rules/truthmark-sync.mdc", - ".cursor/rules/truthmark-realize.mdc", - ".cursor/rules/truthmark-check.mdc", - ".cursor/rules/truthmark-portal.mdc", -] as const; - -const RETIRED_GENERATED_SURFACE_ROOTS = [".gemini"] as const; - const isRetiredGeminiSurfacePath = (filePath: string): boolean => filePath === "GEMINI.md" || filePath.startsWith(".gemini/"); @@ -85,154 +43,6 @@ const obsoleteGeneratedSurfaceMessage = (surfacePath: string): string => { return `Generated surface ${surfacePath} is obsolete; rerun truthmark init.`; }; -type RetiredSurfaceCollectionOptions = { - includeGeminiSurfaces?: boolean; -}; - -const pathExists = async (absolutePath: string): Promise => { - try { - await fs.access(absolutePath); - return true; - } catch (error: unknown) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return false; - } - - throw error; - } -}; - -const listDirectoryFiles = async ( - rootDir: string, - packageRoot: string, -): Promise => { - const stack = [packageRoot]; - const files: string[] = []; - - while (stack.length > 0) { - const current = stack.pop(); - - if (current === undefined) { - continue; - } - - const absoluteCurrent = resolveRepoPath(rootDir, current); - const entries = await fs.readdir(absoluteCurrent, { - withFileTypes: true, - }); - - for (const entry of entries) { - const next = `${current}/${entry.name}`; - - if (entry.isDirectory()) { - stack.push(next); - } else { - files.push(next); - } - } - } - - return files; -}; - -const collectRetiredGeneratedSurfaces = async ( - rootDir: string, - expectedSurfacePaths: Set, - options: RetiredSurfaceCollectionOptions = {}, -): Promise => { - const legacyCandidates = new Set(); - const includeGeminiSurfaces = options.includeGeminiSurfaces ?? true; - - for (const retiredPath of RETIRED_GENERATED_SURFACE_PATHS) { - if (!includeGeminiSurfaces && isRetiredGeminiSurfacePath(retiredPath)) { - continue; - } - - const absoluteRetiredPath = resolveRepoPath(rootDir, retiredPath); - - if ( - (await pathExists(absoluteRetiredPath)) && - !expectedSurfacePaths.has(retiredPath) - ) { - legacyCandidates.add(retiredPath); - } - } - - for (const retiredRoot of RETIRED_GENERATED_SURFACE_ROOTS) { - if (!includeGeminiSurfaces && isRetiredGeminiSurfacePath(`${retiredRoot}/`)) { - continue; - } - - const absoluteRetiredRoot = resolveRepoPath(rootDir, retiredRoot); - - if (!(await pathExists(absoluteRetiredRoot))) { - continue; - } - - const retiredFiles = await listDirectoryFiles(rootDir, retiredRoot); - - for (const filePath of retiredFiles) { - if (!expectedSurfacePaths.has(filePath)) { - legacyCandidates.add(filePath); - } - } - } - - for (const skillRoot of GENERATED_HOST_SKILL_ROOTS) { - const absoluteSkillRoot = resolveRepoPath(rootDir, skillRoot); - - let skillPackages: Dirent[] = []; - - try { - skillPackages = await fs.readdir(absoluteSkillRoot, { - withFileTypes: true, - }); - } catch (error: unknown) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - continue; - } - - throw error; - } - - const packageNames = skillPackages - .filter((entry) => entry.isDirectory() && entry.name.startsWith("truthmark-")) - .map((entry) => entry.name); - - for (const packageName of RETIRED_PACKAGE_DIRECTORIES) { - if (!packageNames.includes(packageName)) { - continue; - } - - const packageRoot = `${skillRoot}/${packageName}`; - const packageFiles = await listDirectoryFiles(rootDir, packageRoot); - - for (const filePath of packageFiles) { - if (!expectedSurfacePaths.has(filePath)) { - legacyCandidates.add(filePath); - } - } - } - - for (const packageName of packageNames) { - for (const retiredName of RETIRED_SKILL_HELPER_PATHS) { - const retiredPath = `${packageName}/${retiredName}`; - const relativeRetiredPath = `${skillRoot}/${retiredPath}`; - const absoluteRetiredPath = resolveRepoPath(rootDir, relativeRetiredPath); - - if ( - (await pathExists(absoluteRetiredPath)) && - !expectedSurfacePaths.has(relativeRetiredPath) - ) { - legacyCandidates.add(relativeRetiredPath); - } - } - } - } - - return Array.from(legacyCandidates); -}; - export const checkGeneratedSurfaces = async ( rootDir: string, config: TruthmarkConfig, @@ -268,35 +78,29 @@ export const checkGeneratedSurfaces = async ( } } - const staleSurfaces = await collectRetiredGeneratedSurfaces( + const lifecyclePlan = await buildLifecyclePlan( rootDir, - new Set(renderedSurfaces.map((surface) => surface.path)), + config, + "dry-run", + renderedSurfaces, ); - - for (const surfacePath of staleSurfaces) { + for (const entry of lifecyclePlan.entries) { diagnostics.push({ category: "generated-surface", - severity: "review", - message: obsoleteGeneratedSurfaceMessage(surfacePath), - file: surfacePath, + severity: entry.action === "manual-review" ? "error" : "review", + message: + entry.action === "preserve" && + (entry.path.includes("truthmark-preview") || + entry.path.endsWith("helper-manifest.yml") || + entry.path.endsWith("support/helper-policy.md") || + isRetiredGeminiSurfacePath(entry.path)) + ? obsoleteGeneratedSurfaceMessage(entry.path) + : entry.action === "preserve" + ? `Generated surface ${entry.path} is inactive but was preserved: ${entry.reason}` + : `Generated surface ${entry.path} is inactive; rerun truthmark init to reconcile it.`, + file: entry.path, }); } return diagnostics; }; - -export const findRetiredGeneratedSurfaces = async ( - rootDir: string, - expectedSurfacePaths: Set, -): Promise => { - return collectRetiredGeneratedSurfaces(rootDir, expectedSurfacePaths); -}; - -export const findAutoRemovableRetiredGeneratedSurfaces = async ( - rootDir: string, - expectedSurfacePaths: Set, -): Promise => { - return collectRetiredGeneratedSurfaces(rootDir, expectedSurfacePaths, { - includeGeminiSurfaces: false, - }); -}; diff --git a/src/cli/handlers.ts b/src/cli/handlers.ts index e4fd797..abada32 100644 --- a/src/cli/handlers.ts +++ b/src/cli/handlers.ts @@ -1,5 +1,9 @@ -import { runConfig as runRepositoryConfig, type ConfigCommandOptions } from "../config/command.js"; +import { + runConfig as runRepositoryConfig, + type ConfigCommandOptions, +} from "../config/command.js"; import { runInit as runRepositoryInit } from "../init/init.js"; +import { runUninstall as runRepositoryUninstall } from "../init/uninstall.js"; import { runCheck as runRepositoryCheck } from "../checks/check.js"; import type { CommandResult } from "../output/diagnostic.js"; import { buildImpactSet } from "../impact/build.js"; @@ -18,7 +22,9 @@ import { } from "../agents/workflow-helper-validation.js"; import { buildRepoIndex } from "../repo-index/build.js"; -export const runConfig = async (options: ConfigCommandOptions): Promise => { +export const runConfig = async ( + options: ConfigCommandOptions, +): Promise => { return runRepositoryConfig(process.cwd(), options); }; @@ -26,14 +32,26 @@ export const runInit = async (): Promise => { return runRepositoryInit(process.cwd()); }; -export const runCheck = async (options: { base?: string } = {}): Promise => { +export const runUninstall = async ( + mode: "dry-run" | "apply", +): Promise => { + return runRepositoryUninstall(process.cwd(), mode); +}; + +export const runCheck = async ( + options: { base?: string } = {}, +): Promise => { return runRepositoryCheck(process.cwd(), options); }; export const runIndex = async (): Promise => { const repoIndex = await buildRepoIndex(process.cwd()); - const errorCount = repoIndex.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length; - const reviewCount = repoIndex.diagnostics.filter((diagnostic) => diagnostic.severity === "review").length; + const errorCount = repoIndex.diagnostics.filter( + (diagnostic) => diagnostic.severity === "error", + ).length; + const reviewCount = repoIndex.diagnostics.filter( + (diagnostic) => diagnostic.severity === "review", + ).length; return { command: "index", @@ -46,7 +64,9 @@ export const runIndex = async (): Promise => { }; }; -export const runImpact = async (options: { base?: string }): Promise => { +export const runImpact = async (options: { + base?: string; +}): Promise => { if (!options.base) { return { command: "impact", @@ -62,8 +82,12 @@ export const runImpact = async (options: { base?: string }): Promise diagnostic.severity === "error").length; - const reviewCount = impactSet.diagnostics.filter((diagnostic) => diagnostic.severity === "review").length; + const errorCount = impactSet.diagnostics.filter( + (diagnostic) => diagnostic.severity === "error", + ).length; + const reviewCount = impactSet.diagnostics.filter( + (diagnostic) => diagnostic.severity === "review", + ).length; return { command: "impact", @@ -75,8 +99,13 @@ export const runImpact = async (options: { base?: string }): Promise { - return typeof value === "string" && TRUTHMARK_WORKFLOW_IDS.includes(value as TruthmarkWorkflowId); +const isTruthmarkWorkflowId = ( + value: unknown, +): value is TruthmarkWorkflowId => { + return ( + typeof value === "string" && + TRUTHMARK_WORKFLOW_IDS.includes(value as TruthmarkWorkflowId) + ); }; const invalidWorkflowResult = ( @@ -101,7 +130,10 @@ const invalidWorkflowResult = ( }, }); -const readHelperFile = async (filePath: string, helper: string): Promise => { +const readHelperFile = async ( + filePath: string, + helper: string, +): Promise => { try { return await fs.readFile(filePath, "utf8"); } catch (error: unknown) { @@ -121,7 +153,9 @@ export const runValidateDocumentReport = async ( reportFile: string, ): Promise => { const text = await readHelperFile(reportFile, "validate-document-report"); - return typeof text === "string" ? validateTruthDocumentReportText(text) : text; + return typeof text === "string" + ? validateTruthDocumentReportText(text) + : text; }; export const runValidateWriteLease = async ( @@ -133,7 +167,10 @@ export const runValidateWriteLease = async ( return leaseText; } - const changedText = await readHelperFile(changedFilesFile, "validate-write-lease"); + const changedText = await readHelperFile( + changedFilesFile, + "validate-write-lease", + ); if (typeof changedText !== "string") { return changedText; } diff --git a/src/cli/program.ts b/src/cli/program.ts index 4fca863..4c990fa 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -8,6 +8,7 @@ import { runImpact, runIndex, runInit, + runUninstall, runValidateDocumentReport, runValidateSyncReport, runValidateWriteLease, @@ -37,8 +38,12 @@ type WorkflowOptions = OutputOptions & { base?: string; }; +type UninstallOptions = OutputOptions & { dryRun?: boolean; apply?: boolean }; + const markFailedWhenErrorDiagnosticsExist = (result: CommandResult): void => { - if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error")) { + if ( + result.diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { process.exitCode = 1; } }; @@ -49,12 +54,20 @@ const writeResult = (result: CommandResult, options: OutputOptions): void => { markFailedWhenErrorDiagnosticsExist(result); }; -const renderValidationHuman = (result: WorkflowHelperValidationResult): string => { +const renderValidationHuman = ( + result: WorkflowHelperValidationResult, +): string => { if (result.ok === true) { - return [`${result.helper}: ok`, ...result.checks.map((check) => `- ${check}`)].join("\n"); + return [ + `${result.helper}: ok`, + ...result.checks.map((check) => `- ${check}`), + ].join("\n"); } - return [`${result.helper}: failed`, ...result.errors.map((error) => `- ${error}`)].join("\n"); + return [ + `${result.helper}: failed`, + ...result.errors.map((error) => `- ${error}`), + ].join("\n"); }; const toValidationCommandResult = ( @@ -93,14 +106,21 @@ export const buildProgram = (): Command => { program .name("truthmark") - .description("Git-native, branch-scoped truth workflow installer for local AI coding agents.") + .description( + "Git-native, branch-scoped truth workflow installer for local AI coding agents.", + ) .showHelpAfterError(); addJsonOption( program .command("config") - .description("Create or render the Truthmark repository config before initialization.") - .option("--stdout", "Render default config in the JSON data payload without writing") + .description( + "Create or render the Truthmark repository config before initialization.", + ) + .option( + "--stdout", + "Render default config in the JSON data payload without writing", + ) .option("--force", "Overwrite an existing .truthmark/config.yml"), ).action(async (options: ConfigOptions) => { writeResult(await runConfig(options), options); @@ -109,11 +129,34 @@ export const buildProgram = (): Command => { addJsonOption( program .command("init") - .description("Initialize Truthmark workflow files in the current repository."), + .description( + "Initialize Truthmark workflow files in the current repository.", + ), ).action(async (options: OutputOptions) => { writeResult(await runInit(), options); }); + addJsonOption( + program + .command("uninstall") + .description( + "Remove recognized generated host surfaces while preserving truth, config, Portal output, Gemini, and user files.", + ) + .option("--dry-run", "Plan removals without changing files") + .option("--apply", "Apply the planned safe removals"), + ).action(async (options: UninstallOptions) => { + if (Boolean(options.dryRun) === Boolean(options.apply)) { + program.error( + "truthmark uninstall requires exactly one of --dry-run or --apply", + ); + return; + } + writeResult( + await runUninstall(options.apply ? "apply" : "dry-run"), + options, + ); + }); + addJsonOption( program .command("check") @@ -126,7 +169,9 @@ export const buildProgram = (): Command => { addJsonOption( program .command("index") - .description("Inspect derived Truthmark workflow routing metadata for the current checkout."), + .description( + "Inspect derived Truthmark workflow routing metadata for the current checkout.", + ), ).action(async (options: OutputOptions) => { writeResult(await runIndex(), options); }); @@ -134,7 +179,9 @@ export const buildProgram = (): Command => { addJsonOption( program .command("impact") - .description("Map changed files to truth routes, docs, owners, and tests.") + .description( + "Map changed files to truth routes, docs, owners, and tests.", + ) .requiredOption("--base ", "Base Git ref to compare against"), ).action(async (options: ImpactOptions) => { writeResult(await runImpact({ base: options.base }), options); @@ -147,8 +194,13 @@ export const buildProgram = (): Command => { addJsonOption( workflow .command("status") - .description("Return schema-versioned workflow state for a canonical workflow ID.") - .option("--workflow ", "Canonical workflow ID, such as truthmark-sync") + .description( + "Return schema-versioned workflow state for a canonical workflow ID.", + ) + .option( + "--workflow ", + "Canonical workflow ID, such as truthmark-sync", + ) .option("--base ", "Base Git ref for impact-backed workflow state"), ).action(async (options: WorkflowOptions) => { writeResult( @@ -162,7 +214,9 @@ export const buildProgram = (): Command => { const validate = program .command("validate") - .description("Run optional Truthmark workflow helper validators from the installed CLI."); + .description( + "Run optional Truthmark workflow helper validators from the installed CLI.", + ); addJsonOption( validate @@ -170,7 +224,11 @@ export const buildProgram = (): Command => { .description("Validate a Truth Sync report file.") .argument("", "Truth Sync report file"), ).action(async (reportFile: string, options: OutputOptions) => { - writeValidationResult("validate sync-report", await runValidateSyncReport(reportFile), options); + writeValidationResult( + "validate sync-report", + await runValidateSyncReport(reportFile), + options, + ); }); addJsonOption( @@ -189,7 +247,9 @@ export const buildProgram = (): Command => { addJsonOption( validate .command("write-lease") - .description("Validate a workflow write lease or worker report against changed files.") + .description( + "Validate a workflow write lease or worker report against changed files.", + ) .argument("", "Lease or worker report file") .argument("", "Newline-separated changed file list"), ).action( diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 64f892b..e8e35b2 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -21,8 +21,6 @@ export const DERIVED_TRUTHMARK_PATHS = { portalTemplate: "templates/portal.html", } as const; -export const DEFAULT_INSTRUCTION_TARGETS = ["AGENTS.md"] as const; - export const createDefaultRawConfig = () => ({ version: 2 as const, truthmark: { @@ -31,7 +29,6 @@ export const createDefaultRawConfig = () => ({ portal: { ...DEFAULT_TRUTHMARK_WORKSPACE.generated.portal }, }, }, - instruction_targets: [...DEFAULT_INSTRUCTION_TARGETS], frontmatter: { required: [], recommended: ["status", "last_reviewed"], @@ -75,7 +72,6 @@ export const createDefaultConfig = (): TruthmarkConfig => ({ "docs/truthmark/templates/*.md", ], }, - instructionTargets: [...DEFAULT_INSTRUCTION_TARGETS], frontmatter: { required: [], recommended: ["status", "last_reviewed"], diff --git a/src/config/load.ts b/src/config/load.ts index 81f25d9..9dbbd3b 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -6,7 +6,7 @@ import { parse } from "yaml"; import type { Diagnostic } from "../output/diagnostic.js"; import { resolveRepoPath } from "../fs/paths.js"; -import { DEFAULT_INSTRUCTION_TARGETS, DERIVED_TRUTHMARK_PATHS } from "./defaults.js"; +import { DERIVED_TRUTHMARK_PATHS } from "./defaults.js"; import { DEFAULT_PLATFORMS, type RawTruthmarkConfig, @@ -84,8 +84,15 @@ const FORBIDDEN_WORKSPACE_OVERLAPS = [ "tests", ] as const; -const unsupportedShapeDiagnostics = (parsedConfig: unknown, configPath: string): Diagnostic[] => { - if (!parsedConfig || typeof parsedConfig !== "object" || Array.isArray(parsedConfig)) { +const unsupportedShapeDiagnostics = ( + parsedConfig: unknown, + configPath: string, +): Diagnostic[] => { + if ( + !parsedConfig || + typeof parsedConfig !== "object" || + Array.isArray(parsedConfig) + ) { return []; } @@ -122,7 +129,9 @@ const validateWorkspacePaths = ( if ( isUnsafeRepoRelativePath(rawConfig.truthmark.workspace) || - FORBIDDEN_WORKSPACE_OVERLAPS.some((forbidden) => pathsOverlap(workspace, forbidden)) + FORBIDDEN_WORKSPACE_OVERLAPS.some((forbidden) => + pathsOverlap(workspace, forbidden), + ) ) { diagnostics.push( toConfigDiagnostic( @@ -132,32 +141,36 @@ const validateWorkspacePaths = ( ); } - for (const target of rawConfig.instruction_targets ?? DEFAULT_INSTRUCTION_TARGETS) { - if (isUnsafeRepoRelativePath(target) || pathsOverlap(workspace, target)) { - diagnostics.push( - toConfigDiagnostic( - "instruction_targets must be repo-relative files outside truthmark.workspace.", - configPath, - ), - ); - } - } - return diagnostics; }; const normalizeConfig = (rawConfig: RawTruthmarkConfig): TruthmarkConfig => { const workspace = normalizeRepoRelativePath(rawConfig.truthmark.workspace); - const routesIndex = joinWorkspacePath(workspace, DERIVED_TRUTHMARK_PATHS.routesIndex); - const routeAreasRoot = joinWorkspacePath(workspace, DERIVED_TRUTHMARK_PATHS.routeAreasRoot); - const productTruthRoot = joinWorkspacePath(workspace, DERIVED_TRUTHMARK_PATHS.productTruthRoot); + const routesIndex = joinWorkspacePath( + workspace, + DERIVED_TRUTHMARK_PATHS.routesIndex, + ); + const routeAreasRoot = joinWorkspacePath( + workspace, + DERIVED_TRUTHMARK_PATHS.routeAreasRoot, + ); + const productTruthRoot = joinWorkspacePath( + workspace, + DERIVED_TRUTHMARK_PATHS.productTruthRoot, + ); const engineeringTruthRoot = joinWorkspacePath( workspace, DERIVED_TRUTHMARK_PATHS.engineeringTruthRoot, ); - const templatesRoot = joinWorkspacePath(workspace, DERIVED_TRUTHMARK_PATHS.templatesRoot); + const templatesRoot = joinWorkspacePath( + workspace, + DERIVED_TRUTHMARK_PATHS.templatesRoot, + ); const portalOutput = portalOutputFor(workspace); - const portalTemplate = joinWorkspacePath(workspace, DERIVED_TRUTHMARK_PATHS.portalTemplate); + const portalTemplate = joinWorkspacePath( + workspace, + DERIVED_TRUTHMARK_PATHS.portalTemplate, + ); return { version: rawConfig.version, @@ -199,7 +212,6 @@ const normalizeConfig = (rawConfig: RawTruthmarkConfig): TruthmarkConfig => { `${templatesRoot}/*.md`, ], }, - instructionTargets: rawConfig.instruction_targets ?? [...DEFAULT_INSTRUCTION_TARGETS], frontmatter: { required: rawConfig.frontmatter?.required ?? [], recommended: rawConfig.frontmatter?.recommended ?? [], @@ -208,7 +220,28 @@ const normalizeConfig = (rawConfig: RawTruthmarkConfig): TruthmarkConfig => { }; }; -export const loadConfig = async (rootDir: string): Promise => { +const compatibilityDiagnostics = ( + rawConfig: RawTruthmarkConfig, + configPath: string, +): Diagnostic[] => { + if (!("instruction_targets" in rawConfig)) { + return []; + } + + return [ + { + category: "config", + severity: "review", + message: + "instruction_targets is accepted for compatibility but ignored; select platforms to control managed instruction-file writes.", + file: configPath, + }, + ]; +}; + +export const loadConfig = async ( + rootDir: string, +): Promise => { const absolutePath = resolveRepoPath(rootDir, CONFIG_PATH); let source: string; @@ -220,7 +253,9 @@ export const loadConfig = async (rootDir: string): Promise => return { status: "missing", config: null, - diagnostics: [toConfigDiagnostic("Missing .truthmark/config.yml.", CONFIG_PATH)], + diagnostics: [ + toConfigDiagnostic("Missing .truthmark/config.yml.", CONFIG_PATH), + ], configPath: CONFIG_PATH, }; } @@ -246,7 +281,10 @@ export const loadConfig = async (rootDir: string): Promise => }; } - const unsupportedDiagnostics = unsupportedShapeDiagnostics(parsedConfig, CONFIG_PATH); + const unsupportedDiagnostics = unsupportedShapeDiagnostics( + parsedConfig, + CONFIG_PATH, + ); if (unsupportedDiagnostics.length > 0) { return { status: "invalid", @@ -260,25 +298,30 @@ export const loadConfig = async (rootDir: string): Promise => return { status: "invalid", config: null, - diagnostics: (validateTruthmarkConfig.errors ?? []).map((error: ErrorObject) => { - const propertyPath = error.instancePath || "/"; - const additionalProperty = - error.keyword === "additionalProperties" && - error.params && - "additionalProperty" in error.params - ? String(error.params.additionalProperty) - : null; - const message = additionalProperty - ? `${propertyPath} additional property ${additionalProperty} is not allowed` - : `${propertyPath} ${error.message ?? "is invalid"}`.trim(); + diagnostics: (validateTruthmarkConfig.errors ?? []).map( + (error: ErrorObject) => { + const propertyPath = error.instancePath || "/"; + const additionalProperty = + error.keyword === "additionalProperties" && + error.params && + "additionalProperty" in error.params + ? String(error.params.additionalProperty) + : null; + const message = additionalProperty + ? `${propertyPath} additional property ${additionalProperty} is not allowed` + : `${propertyPath} ${error.message ?? "is invalid"}`.trim(); - return toConfigDiagnostic(message, CONFIG_PATH); - }), + return toConfigDiagnostic(message, CONFIG_PATH); + }, + ), configPath: CONFIG_PATH, }; } - const pathDiagnostics = validateWorkspacePaths(parsedConfig as RawTruthmarkConfig, CONFIG_PATH); + const pathDiagnostics = validateWorkspacePaths( + parsedConfig as RawTruthmarkConfig, + CONFIG_PATH, + ); if (pathDiagnostics.length > 0) { return { @@ -292,7 +335,10 @@ export const loadConfig = async (rootDir: string): Promise => return { status: "loaded", config: normalizeConfig(parsedConfig as RawTruthmarkConfig), - diagnostics: [], + diagnostics: compatibilityDiagnostics( + parsedConfig as RawTruthmarkConfig, + CONFIG_PATH, + ), configPath: CONFIG_PATH, }; }; diff --git a/src/config/schema.ts b/src/config/schema.ts index 81bb185..8532dff 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,6 +1,6 @@ import type { JSONSchemaType } from "ajv"; -const SUPPORTED_PLATFORMS = [ +export const SUPPORTED_PLATFORMS = [ "codex", "opencode", "claude-code", @@ -11,7 +11,9 @@ const SUPPORTED_PLATFORMS = [ export type TruthmarkPlatform = (typeof SUPPORTED_PLATFORMS)[number]; -export const DEFAULT_PLATFORMS = [] as const satisfies readonly TruthmarkPlatform[]; +// Omitted platforms are deliberate host-neutral state: no active platform ownership is inferred; empty means deprovisioned. +export const DEFAULT_PLATFORMS = + [] as const satisfies readonly TruthmarkPlatform[]; type TruthmarkPortalConfig = { enabled: boolean; @@ -71,7 +73,6 @@ export type TruthmarkConfig = { version: 2; platforms: TruthmarkPlatform[]; truthmark: TruthmarkWorkspaceConfig; - instructionTargets: string[]; frontmatter: { required: string[]; recommended: string[]; diff --git a/src/fs/paths.ts b/src/fs/paths.ts index 81bd0a3..d31d736 100644 --- a/src/fs/paths.ts +++ b/src/fs/paths.ts @@ -17,6 +17,10 @@ const isNodeErrorWithCode = (error: unknown, code: string): boolean => { return error instanceof Error && "code" in error && error.code === code; }; +const pathSegments = (absolutePath: string): string[] => { + return absolutePath.split(path.sep); +}; + const joinMissingSegments = (resolvedPath: string, missingSegments: string[]): string => { return missingSegments.reduce((currentResolvedPath, segment) => { return path.join(currentResolvedPath, segment); @@ -74,6 +78,56 @@ export const resolveRepoPath = (rootDir: string, relativePath: string): string = return resolvedPath; }; +export const isSafeExactFile = async ( + rootDir: string, + relativePath: string, + allowMissing: boolean, +): Promise => { + try { + const absolutePath = resolveRepoPath(rootDir, relativePath); + await assertRepoContainment(rootDir, absolutePath); + const relative = path.relative(rootDir, absolutePath); + const segments = pathSegments(relative); + + if (segments.length === 0 || segments.every((segment) => segment.length === 0)) { + return false; + } + + let current = rootDir; + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + current = path.join(current, segment); + + try { + const stat = await fs.lstat(current); + + if (stat.isSymbolicLink()) { + return false; + } + + const isFinal = index === segments.length - 1; + if (isFinal) { + return stat.isFile() && stat.nlink === 1; + } + + if (!stat.isDirectory()) { + return false; + } + } catch (error: unknown) { + if (!isNodeErrorWithCode(error, "ENOENT")) { + throw error; + } + + return allowMissing; + } + } + + return false; + } catch { + return false; + } +}; + export const assertRepoContainment = async ( rootDir: string, targetPath: string, diff --git a/src/git/files.ts b/src/git/files.ts new file mode 100644 index 0000000..1231c1c --- /dev/null +++ b/src/git/files.ts @@ -0,0 +1,96 @@ +import fs from "node:fs/promises"; + +import { execa } from "execa"; +import fg from "fast-glob"; +import micromatch from "micromatch"; + +import { assertRepoContainment, resolveRepoPath } from "../fs/paths.js"; + +const defaultIgnore = [".git/**", "node_modules/**", "dist/**", "build/**"]; + +const normalizePath = (filePath: string): string => + filePath.replaceAll("\\", "/").replace(/^\.\/+/u, ""); + +const isIgnoredPath = (filePath: string, ignorePatterns: string[]): boolean => + micromatch.isMatch(filePath, [...defaultIgnore, ...ignorePatterns]); + +const gitDiscoverableFiles = async ( + rootDir: string, +): Promise => { + try { + const result = await execa( + "git", + [ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--deduplicate", + ], + { + cwd: rootDir, + reject: false, + stripFinalNewline: false, + }, + ); + + if ((result.exitCode ?? 1) !== 0) { + return null; + } + + return result.stdout + .split("\0") + .filter((filePath) => filePath.length > 0) + .map(normalizePath); + } catch { + return null; + } +}; + +const isCurrentContainedFile = async ( + rootDir: string, + relativePath: string, +): Promise => { + try { + const absolutePath = resolveRepoPath(rootDir, relativePath); + await assertRepoContainment(rootDir, absolutePath); + return (await fs.stat(absolutePath)).isFile(); + } catch { + return false; + } +}; + +/** + * Enumerates current Git-visible regular files, with a local full-tree fallback + * when Git enumeration is unavailable. Returned paths are normalized, + * repository-relative, sorted, and deduplicated. + */ +export const discoverRepositoryFilePaths = async ( + rootDir: string, + ignorePatterns: string[], +): Promise => { + const discoveredPaths = + (await gitDiscoverableFiles(rootDir)) ?? + (await fg(["**/*"], { + cwd: rootDir, + onlyFiles: true, + dot: true, + ignore: [...defaultIgnore, ...ignorePatterns], + followSymbolicLinks: false, + })); + const paths = [...new Set(discoveredPaths.map(normalizePath))] + .filter((filePath) => !isIgnoredPath(filePath, ignorePatterns)) + .sort(); + const currentPaths = await Promise.all( + paths.map(async (filePath) => { + return (await isCurrentContainedFile(rootDir, filePath)) + ? filePath + : null; + }), + ); + + return currentPaths.filter( + (filePath): filePath is string => filePath !== null, + ); +}; diff --git a/src/init/init.ts b/src/init/init.ts index aa1fbce..137383c 100644 --- a/src/init/init.ts +++ b/src/init/init.ts @@ -1,135 +1,25 @@ import fs from "node:fs/promises"; import { loadConfig } from "../config/load.js"; +import { upsertManagedBlock as upsertManagedInstructionBlock } from "../managed-block.js"; import type { TruthmarkConfig } from "../config/schema.js"; -import type { CommandResult, DiagnosticCategory } from "../output/diagnostic.js"; +import type { + CommandResult, + DiagnosticCategory, +} from "../output/diagnostic.js"; import { getGitRepository } from "../git/repository.js"; -import { resolveRepoPath, type FileWriteResult, writeRepoFile } from "../fs/paths.js"; +import { + resolveRepoPath, + type FileWriteResult, + writeRepoFile, +} from "../fs/paths.js"; import { scaffoldHierarchy } from "./hierarchy.js"; -import { findAutoRemovableRetiredGeneratedSurfaces } from "../checks/generated-surfaces.js"; -import { renderAgentsBlock, TRUTHMARK_BLOCK_END, TRUTHMARK_BLOCK_START } from "../templates/agents-block.js"; -import { renderGeneratedSurfaces, type GeneratedSurface } from "../templates/generated-surfaces.js"; - -const escapeRegExp = (value: string): string => { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -}; - -const MANAGED_WORKFLOW_HEADING = "## Truthmark Workflow"; -const CANONICAL_MANAGED_LINES = new Set( - renderAgentsBlock() - .split("\n") - .map((line) => line.trim()) - .filter( - (line) => - line.length > 0 && - line !== TRUTHMARK_BLOCK_START && - line !== TRUTHMARK_BLOCK_END, - ), -); - -const countCanonicalManagedLineMatches = (lines: string[]): number => { - return lines.reduce((matchCount, line) => { - return CANONICAL_MANAGED_LINES.has(line.trim()) ? matchCount + 1 : matchCount; - }, 0); -}; - -const isManagedChunk = (lines: string[], minimumMatches: number): boolean => { - return countCanonicalManagedLineMatches(lines) >= minimumMatches; -}; - -const removeTrailingManagedChunk = (preservedLines: string[]): void => { - let startIndex = -1; - - for (let index = preservedLines.length - 1; index >= 0; index -= 1) { - if (preservedLines[index].trim() === MANAGED_WORKFLOW_HEADING) { - startIndex = index; - break; - } - } - - if (startIndex === -1) { - return; - } - - const candidateChunk = preservedLines.slice(startIndex); - const looksManaged = isManagedChunk(candidateChunk, 4); - - if (looksManaged) { - preservedLines.splice(startIndex); - } -}; - -const upsertManagedBlock = (existingContent: string | null, block: string): string => { - if (!existingContent || existingContent.trim().length === 0) { - return block; - } - - const normalizedExistingContent = existingContent; - const startMarkerPattern = new RegExp(escapeRegExp(TRUTHMARK_BLOCK_START), "g"); - const endMarkerPattern = new RegExp(escapeRegExp(TRUTHMARK_BLOCK_END), "g"); - const managedBlockPattern = new RegExp( - `${escapeRegExp(TRUTHMARK_BLOCK_START)}[\\s\\S]*?${escapeRegExp(TRUTHMARK_BLOCK_END)}`, - "g", - ); - const completeBlocks = normalizedExistingContent.match(managedBlockPattern) ?? []; - const startCount = normalizedExistingContent.match(startMarkerPattern)?.length ?? 0; - const endCount = normalizedExistingContent.match(endMarkerPattern)?.length ?? 0; - - if (startCount === 1 && endCount === 1 && completeBlocks.length === 1) { - return normalizedExistingContent.replace(managedBlockPattern, block); - } - - const preservedLines: string[] = []; - let insideManagedBlock = false; - let managedLines: string[] = []; - - for (const line of normalizedExistingContent.split("\n")) { - const trimmedLine = line.trim(); - - if (trimmedLine === TRUTHMARK_BLOCK_START) { - if (insideManagedBlock && !isManagedChunk(managedLines, 2)) { - preservedLines.push(...managedLines); - } - - insideManagedBlock = true; - managedLines = []; - continue; - } - - if (trimmedLine === TRUTHMARK_BLOCK_END) { - if (insideManagedBlock) { - insideManagedBlock = false; - managedLines = []; - continue; - } - - if (!insideManagedBlock) { - removeTrailingManagedChunk(preservedLines); - } - - continue; - } - - if (insideManagedBlock) { - managedLines.push(line); - continue; - } - - preservedLines.push(line); - } - - if (insideManagedBlock && !isManagedChunk(managedLines, 2)) { - preservedLines.push(...managedLines); - } - - const preservedContent = preservedLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); - - if (preservedContent.length === 0) { - return block; - } - - return `${preservedContent}\n\n${block}`; -}; +import { renderAgentsBlock } from "../templates/agents-block.js"; +import { + renderGeneratedSurfaces, + type GeneratedSurface, +} from "../templates/generated-surfaces.js"; +import { applyLifecyclePlan, buildLifecyclePlan } from "./lifecycle.js"; const writeManagedAgentsFile = async ( rootDir: string, @@ -141,12 +31,20 @@ const writeManagedAgentsFile = async ( try { existingContent = await fs.readFile(resolveRepoPath(rootDir, path), "utf8"); } catch (error: unknown) { - if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { + if ( + !(error instanceof Error) || + !("code" in error) || + error.code !== "ENOENT" + ) { throw error; } } - return writeRepoFile(rootDir, path, upsertManagedBlock(existingContent, block)); + return writeRepoFile( + rootDir, + path, + upsertManagedInstructionBlock(existingContent, block), + ); }; const diagnosticCategoryForPath = ( @@ -192,7 +90,6 @@ const diagnosticCategoryForPath = ( return "truth-sync"; } - if (filePath === config.truthmark.paths.routesIndex) { return "area-index"; } @@ -258,39 +155,78 @@ export const runInit = async (cwd: string): Promise => { const results: FileWriteResult[] = []; const config = loadedConfig.config; - results.push(...(await scaffoldHierarchy(rootDir, config))); const block = renderAgentsBlock(config); const platformFiles = renderGeneratedSurfaces(config, block); - const expectedSurfacePaths = new Set(platformFiles.map((file) => file.path)); + const lifecyclePlan = await buildLifecyclePlan( + rootDir, + config, + "apply", + platformFiles, + ); + if (!lifecyclePlan.applicable) { + return { + command: "init", + summary: + "Truthmark init made no changes because generated-surface preflight failed.", + diagnostics: [...loadedConfig.diagnostics, ...lifecyclePlan.diagnostics], + data: { lifecyclePlan }, + }; + } + const appliedLifecyclePlan = await applyLifecyclePlan(rootDir, lifecyclePlan); + if (!appliedLifecyclePlan.applicable) { + return { + command: "init", + summary: + "Truthmark init made no changes because generated-surface preflight failed.", + diagnostics: [ + ...loadedConfig.diagnostics, + ...appliedLifecyclePlan.diagnostics, + ], + data: { lifecyclePlan: appliedLifecyclePlan }, + }; + } + results.push(...(await scaffoldHierarchy(rootDir, config))); for (const file of platformFiles) { results.push(await writePlatformFile(rootDir, file)); } - const obsoleteSurfacePaths = await findAutoRemovableRetiredGeneratedSurfaces( - rootDir, - expectedSurfacePaths, + const changedResults = results.filter( + (result) => result.status !== "unchanged", + ); + const lifecycleChanged = appliedLifecyclePlan.entries.some( + ({ action }) => + action === "remove-file" || action === "remove-managed-block", ); - - for (const obsoletePath of obsoleteSurfacePaths) { - await fs.rm(resolveRepoPath(rootDir, obsoletePath), { force: true }); - } - - const changedResults = results.filter((result) => result.status !== "unchanged"); return { command: "init", summary: - changedResults.length > 0 + changedResults.length > 0 || lifecycleChanged ? "Initialized or updated the Truthmark repository scaffold." : "Truthmark repository scaffold is already up to date.", - diagnostics: writeDiagnostics(results, config), + diagnostics: [ + ...loadedConfig.diagnostics, + ...appliedLifecyclePlan.diagnostics, + ...appliedLifecyclePlan.entries.map((entry) => ({ + category: "generated-surface" as const, + severity: + entry.action === "remove-file" || + entry.action === "remove-managed-block" + ? ("action" as const) + : ("review" as const), + message: `${entry.action}: ${entry.reason}`, + file: entry.path, + })), + ...writeDiagnostics(results, config), + ], data: { repositoryRoot: repository.repositoryRoot, worktreePath: repository.worktreePath, branchName: repository.branchName, isDetached: repository.isDetached, isUnborn: repository.isUnborn, + lifecyclePlan: appliedLifecyclePlan, }, }; }; diff --git a/src/init/lifecycle.ts b/src/init/lifecycle.ts new file mode 100644 index 0000000..0a6c16e --- /dev/null +++ b/src/init/lifecycle.ts @@ -0,0 +1,338 @@ +import fs from "node:fs/promises"; +import type { TruthmarkConfig } from "../config/schema.js"; +import { isSafeExactFile, resolveRepoPath } from "../fs/paths.js"; +import type { Diagnostic } from "../output/diagnostic.js"; +import { parseManagedBlock } from "../managed-block.js"; +import { + renderGeneratedSurfaceCatalog, + renderGeneratedSurfaces, + RETIRED_GENERATED_SURFACES, +} from "../templates/generated-surfaces.js"; + +export type LifecycleAction = + | "remove-file" + | "remove-managed-block" + | "preserve" + | "manual-review"; + +export type LifecyclePlanEntry = { + path: string; + action: LifecycleAction; + reason: string; +}; + +export type LifecyclePlan = { + schemaVersion: "truthmark-lifecycle/v0"; + mode: "dry-run" | "apply"; + entries: LifecyclePlanEntry[]; + diagnostics: Diagnostic[]; + applicable: boolean; + applied: boolean; +}; + +const plannedContents = new WeakMap>(); +const readFile = async ( + rootDir: string, + filePath: string, +): Promise => { + try { + return await fs.readFile(resolveRepoPath(rootDir, filePath), "utf8"); + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return null; + throw error; + } +}; + +const listFiles = async ( + rootDir: string, + directory: string, +): Promise => { + const files: string[] = []; + const stack = [directory]; + while (stack.length > 0) { + const current = stack.pop()!; + try { + for (const entry of await fs.readdir(resolveRepoPath(rootDir, current), { + withFileTypes: true, + })) { + const next = `${current}/${entry.name}`; + if (entry.isDirectory()) stack.push(next); + else if (entry.isFile() || entry.isSymbolicLink()) files.push(next); + } + } catch (error: unknown) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) + throw error; + } + } + return files; +}; + +const findRetiredSurfaces = async (rootDir: string): Promise => { + const paths = new Set(); + for (const filePath of RETIRED_GENERATED_SURFACES.exactPaths) { + try { + await fs.lstat(resolveRepoPath(rootDir, filePath)); + paths.add(filePath); + } catch (error: unknown) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) + throw error; + } + } + for (const root of RETIRED_GENERATED_SURFACES.recursiveRoots) + for (const filePath of await listFiles(rootDir, root)) paths.add(filePath); + for (const skillRoot of RETIRED_GENERATED_SURFACES.skillRoots) { + for (const packageName of RETIRED_GENERATED_SURFACES.retiredPackages) + for (const filePath of await listFiles( + rootDir, + `${skillRoot}/${packageName}`, + )) + paths.add(filePath); + let packages: string[] = []; + try { + packages = ( + await fs.readdir(resolveRepoPath(rootDir, skillRoot), { + withFileTypes: true, + }) + ) + .filter( + (entry) => entry.isDirectory() && entry.name.startsWith("truthmark-"), + ) + .map((entry) => entry.name); + } catch (error: unknown) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) + throw error; + } + for (const packageName of packages) + for (const retiredFile of RETIRED_GENERATED_SURFACES.retiredPackageFiles) { + const filePath = `${skillRoot}/${packageName}/${retiredFile}`; + try { + await fs.lstat(resolveRepoPath(rootDir, filePath)); + paths.add(filePath); + } catch (error: unknown) { + if ( + !( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) + ) + throw error; + } + } + } + return [...paths]; +}; + +export const buildLifecyclePlan = async ( + rootDir: string, + config: TruthmarkConfig, + mode: "dry-run" | "apply", + desired = renderGeneratedSurfaces(config), +): Promise => { + const desiredPaths = new Set(desired.map(({ path }) => path)); + const entries: LifecyclePlanEntry[] = []; + const diagnostics: Diagnostic[] = []; + let applicable = true; + + for (const surface of desired.filter(({ managedBlock }) => managedBlock)) { + if (!(await isSafeExactFile(rootDir, surface.path, true))) { + applicable = false; + entries.push({ + path: surface.path, + action: "manual-review", + reason: + "Managed instruction destination is aliased or not a regular single-link file.", + }); + } + } + + for (const surface of desired.filter(({ managedBlock }) => managedBlock)) { + const content = await readFile(rootDir, surface.path); + if (content !== null && parseManagedBlock(content).status === "malformed") { + applicable = false; + entries.push({ + path: surface.path, + action: "manual-review", + reason: "Truthmark managed-block markers are malformed.", + }); + } + } + + const catalog = renderGeneratedSurfaceCatalog(config); + for (const retiredPath of await findRetiredSurfaces(rootDir)) { + if (!catalog.some(({ path: catalogPath }) => catalogPath === retiredPath)) + catalog.push({ + path: retiredPath, + content: "", + owners: [ + { + kind: "retired", + manualCleanupOnly: + retiredPath === "GEMINI.md" || retiredPath.startsWith(".gemini/"), + }, + ], + recognizedContents: [], + }); + } + + for (const surface of catalog) { + if (desiredPaths.has(surface.path)) continue; + const content = await readFile(rootDir, surface.path); + if (content === null) continue; + if (!(await isSafeExactFile(rootDir, surface.path, false))) { + applicable = false; + entries.push({ + path: surface.path, + action: "manual-review", + reason: "Path is not safely contained in the worktree.", + }); + continue; + } + if (surface.path === "GEMINI.md" || surface.path.startsWith(".gemini/")) { + entries.push({ + path: surface.path, + action: "preserve", + reason: "Gemini surfaces require manual cleanup.", + }); + } else if (surface.managedBlock) { + const block = parseManagedBlock(content); + if (block.status === "malformed") { + applicable = false; + entries.push({ + path: surface.path, + action: "manual-review", + reason: "Truthmark managed-block markers are malformed.", + }); + } else if (block.status === "valid") { + entries.push({ + path: surface.path, + action: "remove-managed-block", + reason: "No configured platform owns this managed block.", + }); + } + } else if (surface.recognizedContents.includes(content)) { + entries.push({ + path: surface.path, + action: "remove-file", + reason: "Generated file has no active platform owner.", + }); + } else { + entries.push({ + path: surface.path, + action: "preserve", + reason: "Generated path has diverged content and requires review.", + }); + diagnostics.push({ + category: "generated-surface", + severity: "review", + message: `Generated surface ${surface.path} has diverged content; preserved for manual review.`, + file: surface.path, + }); + } + } + + entries.sort( + (a, b) => a.path.localeCompare(b.path) || a.action.localeCompare(b.action), + ); + if (!applicable) + diagnostics.push({ + category: "generated-surface", + severity: "error", + message: + "Lifecycle changes were not applied because preflight found unsafe or malformed generated surfaces.", + }); + const plan: LifecyclePlan = { + schemaVersion: "truthmark-lifecycle/v0", + mode, + entries, + diagnostics, + applicable, + applied: false, + }; + const contents = new Map(); + for (const entry of entries) { + if ( + entry.action === "remove-file" || + entry.action === "remove-managed-block" + ) { + const content = await readFile(rootDir, entry.path); + if (content !== null) contents.set(entry.path, content); + } + } + plannedContents.set(plan, contents); + return plan; +}; + +export const applyLifecyclePlan = async ( + rootDir: string, + plan: LifecyclePlan, +): Promise => { + if (!plan.applicable || plan.mode !== "apply") return plan; + const expected = plannedContents.get(plan); + const failure = async (entry: LifecyclePlanEntry): Promise => { + if ( + entry.action !== "remove-file" && + entry.action !== "remove-managed-block" + ) + return null; + if (!(await isSafeExactFile(rootDir, entry.path, false))) + return `Unsafe lifecycle target: ${entry.path}`; + const content = await readFile(rootDir, entry.path); + if (content === null || expected?.get(entry.path) !== content) + return `Lifecycle target changed after planning: ${entry.path}`; + if ( + entry.action === "remove-managed-block" && + parseManagedBlock(content).status !== "valid" + ) + return `Managed block changed during removal: ${entry.path}`; + return null; + }; + for (const entry of plan.entries) { + const message = await failure(entry); + if (message) + return { + ...plan, + applicable: false, + applied: false, + diagnostics: [ + ...plan.diagnostics, + { + category: "generated-surface", + severity: "error", + message, + file: entry.path, + }, + ], + }; + } + for (const entry of plan.entries) { + const absolutePath = resolveRepoPath(rootDir, entry.path); + if (entry.action === "remove-file") { + const stat = await fs.lstat(absolutePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) + throw new Error( + `Refusing unsafe generated file removal: ${entry.path}`, + ); + await fs.rm(absolutePath); + } else if (entry.action === "remove-managed-block") { + const stat = await fs.lstat(absolutePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) + throw new Error(`Refusing unsafe managed-block removal: ${entry.path}`); + const content = await fs.readFile(absolutePath, "utf8"); + const block = parseManagedBlock(content); + if (block.status !== "valid") + throw new Error(`Managed block changed during removal: ${entry.path}`); + const remaining = `${content.slice(0, block.start)}${content.slice(block.end)}`; + if (remaining.trim().length === 0) await fs.rm(absolutePath); + else await fs.writeFile(absolutePath, remaining, "utf8"); + } + } + return { ...plan, applied: true }; +}; diff --git a/src/init/uninstall.ts b/src/init/uninstall.ts new file mode 100644 index 0000000..d39a897 --- /dev/null +++ b/src/init/uninstall.ts @@ -0,0 +1,47 @@ +import { loadConfig } from "../config/load.js"; +import { getGitRepository } from "../git/repository.js"; +import type { CommandResult } from "../output/diagnostic.js"; +import { applyLifecyclePlan, buildLifecyclePlan } from "./lifecycle.js"; + +export const runUninstall = async ( + cwd: string, + mode: "dry-run" | "apply", +): Promise => { + const repository = await getGitRepository(cwd); + const loaded = await loadConfig(repository.worktreePath); + if (!loaded.config) { + const lifecyclePlan = { + schemaVersion: "truthmark-lifecycle/v0" as const, + mode, + entries: [], + diagnostics: loaded.diagnostics, + applicable: false, + applied: false, + }; + return { + command: "uninstall", + summary: + "Truthmark uninstall requires a valid .truthmark/config.yml; no files were changed.", + diagnostics: loaded.diagnostics, + data: { lifecyclePlan }, + }; + } + const planned = await buildLifecyclePlan( + repository.worktreePath, + loaded.config, + mode, + [], + ); + const plan = await applyLifecyclePlan(repository.worktreePath, planned); + return { + command: "uninstall", + summary: + mode === "dry-run" + ? "Truthmark uninstall dry run completed; no files were changed." + : plan.applied + ? "Truthmark generated host surfaces were uninstalled; authored truth and config were preserved." + : "Truthmark uninstall was not applied.", + diagnostics: [...loaded.diagnostics, ...plan.diagnostics], + data: { lifecyclePlan: plan }, + }; +}; diff --git a/src/managed-block.ts b/src/managed-block.ts new file mode 100644 index 0000000..408d6dd --- /dev/null +++ b/src/managed-block.ts @@ -0,0 +1,91 @@ +import { TRUTHMARK_BLOCK_END, TRUTHMARK_BLOCK_START } from "./templates/agents-block.js"; + +export type ParsedManagedBlock = + | { status: "absent" } + | { status: "valid"; start: number; end: number } + | { status: "malformed" }; + +const findMarkerIndexes = (content: string, marker: string): number[] => { + const indexes: number[] = []; + let cursor = 0; + + while (true) { + const index = content.indexOf(marker, cursor); + if (index === -1) { + return indexes; + } + + indexes.push(index); + cursor = index + marker.length; + } +}; + +export const parseManagedBlock = (content: string): ParsedManagedBlock => { + const starts = findMarkerIndexes(content, TRUTHMARK_BLOCK_START); + const ends = findMarkerIndexes(content, TRUTHMARK_BLOCK_END); + + if (starts.length === 0 && ends.length === 0) { + return { status: "absent" }; + } + + if (starts.length !== 1 || ends.length !== 1) { + return { status: "malformed" }; + } + + const start = starts[0]; + const endStart = ends[0]; + + if (start === -1 || endStart === -1 || endStart < start) { + return { status: "malformed" }; + } + + return { + status: "valid", + start, + end: endStart + TRUTHMARK_BLOCK_END.length, + }; +}; + +export const extractManagedBlock = (content: string): string | null => { + const block = parseManagedBlock(content); + + if (block.status !== "valid") { + return null; + } + + return content.slice(block.start, block.end); +}; + +const trimmedBoundary = (value: string): string => value.replace(/\n+$/u, ""); + +export const upsertManagedBlock = ( + existingContent: string | null, + block: string, +): string => { + if (existingContent === null || existingContent.trim().length === 0) { + return block; + } + + const marker = parseManagedBlock(existingContent); + + if (marker.status !== "valid") { + return `${trimmedBoundary(existingContent)}\n\n${block}`; + } + + const before = trimmedBoundary(existingContent.slice(0, marker.start)); + const after = existingContent.slice(marker.end).replace(/^\n+/u, ""); + + if (before.length === 0 && after.length === 0) { + return block; + } + + if (before.length === 0) { + return `${block}\n\n${after}`; + } + + if (after.length === 0) { + return `${before}\n\n${block}`; + } + + return `${before}\n\n${block}\n\n${after}`; +}; diff --git a/src/repo-index/file-tree.ts b/src/repo-index/file-tree.ts index 6fa2f63..8369bd9 100644 --- a/src/repo-index/file-tree.ts +++ b/src/repo-index/file-tree.ts @@ -1,10 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { execa } from "execa"; -import fg from "fast-glob"; -import micromatch from "micromatch"; - +import { discoverRepositoryFilePaths } from "../git/files.js"; import { parseFrontmatter } from "../markdown/frontmatter.js"; import { parseMarkdownDocument } from "../markdown/parse.js"; import { @@ -13,7 +10,7 @@ import { laneForTruthDocumentKind, type TruthDocumentKind, } from "../routing/areas.js"; -import { classifyPath } from "../sync/classify.js"; +import { classifyPath, isTestPath } from "../sync/classify.js"; import { parseSourceReferences } from "../truth/source-references.js"; import type { RepoDocEntry, @@ -36,14 +33,6 @@ const languageByExtension = new Map([ [".toml", "toml"], ]); -const isTestPath = (filePath: string): boolean => { - return ( - filePath.startsWith("tests/") || - filePath.includes("/__tests__/") || - /(?:^|[./-])(test|spec)\.[cm]?[jt]sx?$/u.test(path.posix.basename(filePath)) - ); -}; - const fileKind = (filePath: string, ignore: string[]): RepoFileKind => { const classification = classifyPath(filePath, ignore); if (classification === "derived") { @@ -92,11 +81,6 @@ const targetHintsForTest = (filePath: string): string[] => { return [...hints].sort(); }; -const defaultIgnore = [".git/**", "node_modules/**", "dist/**", "build/**"]; - -const normalizePath = (filePath: string): string => - filePath.replaceAll("\\", "/").replace(/^\.\/+/u, ""); - const isTruthDocumentKind = (value: unknown): value is TruthDocumentKind => { return ( typeof value === "string" && @@ -104,30 +88,6 @@ const isTruthDocumentKind = (value: unknown): value is TruthDocumentKind => { ); }; -const gitDiscoverableFiles = async ( - rootDir: string, -): Promise => { - const result = await execa( - "git", - ["ls-files", "--cached", "--others", "--exclude-standard", "--deduplicate"], - { - cwd: rootDir, - reject: false, - }, - ); - if ((result.exitCode ?? 1) !== 0) { - return null; - } - return result.stdout - .split("\n") - .map((line) => normalizePath(line.trim())) - .filter((line) => line.length > 0); -}; - -const isIgnoredPath = (filePath: string, ignore: string[]): boolean => { - return micromatch.isMatch(filePath, [...defaultIgnore, ...ignore]); -}; - export const discoverRepoFiles = async ( rootDir: string, ignore: string[], @@ -136,22 +96,12 @@ export const discoverRepoFiles = async ( docs: RepoDocEntry[]; tests: RepoTestEntry[]; }> => { - const discoveredFiles = - (await gitDiscoverableFiles(rootDir)) ?? - (await fg(["**/*"], { - cwd: rootDir, - onlyFiles: true, - dot: true, - ignore: [...defaultIgnore, ...ignore], - followSymbolicLinks: false, - })); + const discoveredFiles = await discoverRepositoryFilePaths(rootDir, ignore); const files: RepoFileEntry[] = []; const docs: RepoDocEntry[] = []; const tests: RepoTestEntry[] = []; - for (const filePath of discoveredFiles - .filter((entry) => !isIgnoredPath(entry, ignore)) - .sort()) { + for (const filePath of discoveredFiles) { let stat: Awaited>; try { stat = await fs.stat(path.join(rootDir, filePath)); diff --git a/src/sync/classify.ts b/src/sync/classify.ts index 74fd43f..c82c225 100644 --- a/src/sync/classify.ts +++ b/src/sync/classify.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + import micromatch from "micromatch"; export type PathClassification = @@ -112,6 +114,14 @@ const getExtension = (filePath: string): string => { return baseName.slice(extensionIndex).toLowerCase(); }; +export const isTestPath = (filePath: string): boolean => { + return ( + filePath.startsWith("tests/") || + filePath.includes("/__tests__/") || + /(?:^|[./-])(test|spec)\.[cm]?[jt]sx?$/u.test(path.posix.basename(filePath)) + ); +}; + const isConfigPath = (filePath: string): boolean => { const normalizedPath = normalizePath(filePath); const baseName = getBaseName(normalizedPath); @@ -180,7 +190,10 @@ export const classifyPath = ( return "derived"; } - if (ignorePatterns.length > 0 && micromatch.isMatch(normalizedPath, ignorePatterns)) { + if ( + ignorePatterns.length > 0 && + micromatch.isMatch(normalizedPath, ignorePatterns) + ) { return "ignored"; } diff --git a/src/templates/generated-surfaces.ts b/src/templates/generated-surfaces.ts index f7c08cd..63abf1a 100644 --- a/src/templates/generated-surfaces.ts +++ b/src/templates/generated-surfaces.ts @@ -1,4 +1,8 @@ -import type { TruthmarkConfig, TruthmarkPlatform } from "../config/schema.js"; +import { + SUPPORTED_PLATFORMS, + type TruthmarkConfig, + type TruthmarkPlatform, +} from "../config/schema.js"; import { renderAgentsBlock } from "./agents-block.js"; import { renderTruthmarkCopilotCheckPrompt, @@ -90,8 +94,50 @@ export type GeneratedSurface = { managedBlock?: boolean; }; -const codexFiles = (config: TruthmarkConfig): GeneratedSurface[] => { +export type GeneratedSurfaceOwner = + | { + kind: "platform" | "portal"; + platform: TruthmarkPlatform; + } + | { + kind: "retired"; + manualCleanupOnly: boolean; + }; + +export type GeneratedSurfaceCatalogEntry = GeneratedSurface & { + owners: GeneratedSurfaceOwner[]; + recognizedContents: string[]; +}; + +export const RETIRED_GENERATED_SURFACES = { + exactPaths: [ + "GEMINI.md", + ".github/prompts/truthmark-preview.prompt.md", + ".cursor/rules/truthmark-structure.mdc", + ".cursor/rules/truthmark-document.mdc", + ".cursor/rules/truthmark-sync.mdc", + ".cursor/rules/truthmark-realize.mdc", + ".cursor/rules/truthmark-check.mdc", + ".cursor/rules/truthmark-portal.mdc", + ], + recursiveRoots: [".gemini"], + skillRoots: [ + ".agents/skills", + ".opencode/skills", + ".claude/skills", + ".github/skills", + ".cursor/skills", + ], + retiredPackages: ["truthmark-preview"], + retiredPackageFiles: ["helper-manifest.yml", "support/helper-policy.md"], +} as const; + +const codexFiles = ( + config: TruthmarkConfig, + block: string, +): GeneratedSurface[] => { const files: GeneratedSurface[] = [ + ...instructionBlockFiles(["AGENTS.md"], block), ...renderTruthmarkSkillPackage({ skillPath: TRUTHMARK_STRUCTURE_SKILL_PATH, workflowId: "truthmark-structure", @@ -166,7 +212,7 @@ const codexFiles = (config: TruthmarkConfig): GeneratedSurface[] => { skillPath: TRUTHMARK_PORTAL_SKILL_PATH, workflowId: "truthmark-portal", host: "codex", - config, + config, }), { path: TRUTHMARK_PORTAL_SKILL_METADATA_PATH, @@ -178,8 +224,12 @@ const codexFiles = (config: TruthmarkConfig): GeneratedSurface[] => { return files; }; -const opencodeFiles = (config: TruthmarkConfig): GeneratedSurface[] => { +const opencodeFiles = ( + config: TruthmarkConfig, + block: string, +): GeneratedSurface[] => { const files: GeneratedSurface[] = [ + ...instructionBlockFiles(["AGENTS.md"], block), ...renderTruthmarkSkillPackage({ skillPath: ".opencode/skills/truthmark-structure/SKILL.md", workflowId: "truthmark-structure", @@ -234,7 +284,7 @@ const opencodeFiles = (config: TruthmarkConfig): GeneratedSurface[] => { skillPath: ".opencode/skills/truthmark-portal/SKILL.md", workflowId: "truthmark-portal", host: "opencode", - config, + config, }), ); } @@ -502,9 +552,9 @@ const filesForPlatform = ( ): GeneratedSurface[] => { switch (platform) { case "codex": - return codexFiles(config); + return codexFiles(config, block); case "opencode": - return opencodeFiles(config); + return opencodeFiles(config, block); case "claude-code": return claudeFiles(config, block); case "github-copilot": @@ -520,14 +570,80 @@ export const renderGeneratedSurfaces = ( config: TruthmarkConfig, block = renderAgentsBlock(config), ): GeneratedSurface[] => { - const files = [ - ...instructionBlockFiles(config.instructionTargets, block), - ...config.platforms.flatMap((platform) => - filesForPlatform(platform, config, block), - ), - ]; + const files = config.platforms.flatMap((platform) => + filesForPlatform(platform, config, block), + ); return Array.from( new Map(files.map((file) => [file.path, file])).values(), ).sort((left, right) => left.path.localeCompare(right.path)); }; + +export const renderGeneratedSurfaceCatalog = ( + config: TruthmarkConfig, +): GeneratedSurfaceCatalogEntry[] => { + const catalog = new Map(); + + for (const platform of SUPPORTED_PLATFORMS) { + const baseConfig = { + ...config, + platforms: [platform], + truthmark: { + ...config.truthmark, + generated: { portal: { enabled: false } }, + }, + }; + const basePaths = new Set( + renderGeneratedSurfaces(baseConfig).map(({ path }) => path), + ); + + for (const surface of renderGeneratedSurfaces({ + ...baseConfig, + truthmark: { + ...baseConfig.truthmark, + generated: { portal: { enabled: true } }, + }, + })) { + const owner: GeneratedSurfaceOwner = { + kind: basePaths.has(surface.path) ? "platform" : "portal", + platform, + }; + const existing = catalog.get(surface.path); + const recognizedContent = surface.content.endsWith("\n") + ? surface.content + : `${surface.content}\n`; + if (existing) { + existing.owners.push(owner); + if (!existing.recognizedContents.includes(recognizedContent)) { + existing.recognizedContents.push(recognizedContent); + } + } else { + catalog.set(surface.path, { + ...surface, + owners: [owner], + recognizedContents: [recognizedContent], + }); + } + } + } + + for (const retiredPath of RETIRED_GENERATED_SURFACES.exactPaths) { + if (!catalog.has(retiredPath)) + catalog.set(retiredPath, { + path: retiredPath, + content: "", + owners: [ + { + kind: "retired", + manualCleanupOnly: + retiredPath === "GEMINI.md" || retiredPath.startsWith(".gemini/"), + }, + ], + recognizedContents: [], + }); + } + + return [...catalog.values()].sort((left, right) => + left.path.localeCompare(right.path), + ); +}; diff --git a/tests/checks/check.test.ts b/tests/checks/check.test.ts index 0b8a713..80a1b0f 100644 --- a/tests/checks/check.test.ts +++ b/tests/checks/check.test.ts @@ -210,7 +210,7 @@ Local stale edit. ); await repo.writeFile( ".gemini/commands/truthmark/sync.toml", - "description = \"Legacy Gemini sync command\"\n", + 'description = "Legacy Gemini sync command"\n', ); await repo.writeFile( ".agents/skills/truthmark-sync/helper-manifest.yml", @@ -848,7 +848,9 @@ Update truth when: await initializeRepo(repo.rootDir); await repo.writeFile( ".agents/skills/truthmark-sync/SKILL.md", - `${(await repo.readFile(".agents/skills/truthmark-sync/SKILL.md")).replace( + `${( + await repo.readFile(".agents/skills/truthmark-sync/SKILL.md") + ).replace( "Truthmark-managed generated file.", "Locally edited generated file.", )}\n`, @@ -2140,6 +2142,156 @@ ignore: } }); + it("reports unmapped functional code under an unknown root in coverage and scorecard output", async () => { + const repo = await createTempRepo(); + + try { + await initializeRepo(repo.rootDir); + await repo.writeFile( + "backend/auth/session.ts", + "export const session = true;\n", + ); + + const result = await runCheck(repo.rootDir); + const coverageDiagnostics = result.diagnostics.filter( + (diagnostic) => diagnostic.category === "coverage", + ); + const scorecard = scorecardFrom(result); + + expect(coverageDiagnostics).toEqual([ + expect.objectContaining({ + category: "coverage", + severity: "review", + message: + "Code file backend/auth/session.ts is not covered by any Truthmark area mapping.", + file: "backend/auth/session.ts", + }), + ]); + expect( + (result.data?.truthVisibility as { unmappedSurfaceCount: number }) + .unmappedSurfaceCount, + ).toBe(1); + expect(scorecardDimension(scorecard, "routing-coverage").status).toBe( + "warn", + ); + expect(scorecardDimension(scorecard, "ownership-clarity").status).toBe( + "warn", + ); + } finally { + await repo.cleanup(); + } + }); + + it("recognizes a mapped functional source file under an unknown root", async () => { + const repo = await createTempRepo(); + + try { + await initializeRepo(repo.rootDir); + await repo.writeFile("engine/runtime/main.rs", "fn main() {}\n"); + await repo.writeFile( + "docs/truthmark/engineering/runtime.md", + "---\nstatus: active\n---\n\n# Runtime\n", + ); + await repo.writeFile( + "docs/truthmark/routes/areas.md", + `# Truthmark Areas + +## Runtime + +Truth documents: +- docs/truthmark/engineering/runtime.md + +Code surface: +- engine/runtime/** + +Update truth when: +- runtime behavior changes +`, + ); + + const result = await runCheck(repo.rootDir); + + expect(result.diagnostics).not.toContainEqual( + expect.objectContaining({ + category: "coverage", + file: "engine/runtime/main.rs", + }), + ); + } finally { + await repo.cleanup(); + } + }); + + it("limits coverage diagnostics to ordinary non-test functional code", async () => { + const repo = await createTempRepo(); + + try { + await initializeRepo(repo.rootDir); + await repo.writeFile( + "backend/service.ts", + "export const service = true;\n", + ); + await repo.writeFile("README.md", "# Read me\n"); + await repo.writeFile("assets/logo.png", "not a source file\n"); + await repo.writeFile("tests/service.test.ts", "void 0;\n"); + await repo.writeFile("backend/service.test.ts", "void 0;\n"); + await repo.writeFile("backend/service.spec.ts", "void 0;\n"); + + const result = await runCheck(repo.rootDir); + const coverageFiles = result.diagnostics + .filter((diagnostic) => diagnostic.category === "coverage") + .map((diagnostic) => diagnostic.file); + + expect(coverageFiles).toEqual(["backend/service.ts"]); + } finally { + await repo.cleanup(); + } + }); + + it("limits coverage diagnostics for mixed-surface repositories to ordinary functional source only", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir); + const configPath = path.join(repo.rootDir, ".truthmark/config.yml"); + const configFile = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + configFile.replace( + "version: 2\n", + [ + "version: 2", + "platforms:", + " - codex", + "", + ].join("\n"), + ), + ); + await runInit(repo.rootDir); + + await repo.writeFile("backend/service.ts", "export const service = true;\n"); + await repo.writeFile("README.md", "# Read me\n"); + await repo.writeFile("assets/logo.png", "pretend-png\n"); + await repo.writeFile("tests/service.test.ts", "void 0;\n"); + await repo.writeFile("backend/service.spec.ts", "void 0;\n"); + await repo.writeFile("backend/service.test.ts", "void 0;\n"); + await repo.writeFile("docs/truthmark/engineering/overview.md", "# Overview\n"); + await repo.writeFile( + "tests/nested/nested.unit.spec.ts", + "void 0;\n", + ); + + const result = await runCheck(repo.rootDir); + const coverageFiles = result.diagnostics + .filter((diagnostic) => diagnostic.category === "coverage") + .map((diagnostic) => diagnostic.file); + + expect(coverageFiles).toEqual(["backend/service.ts"]); + } finally { + await repo.cleanup(); + } + }); + it("does not treat symlinked source directories outside the repo as live code coverage", async () => { const repo = await createTempRepo(); diff --git a/tests/cli/build-artifact.test.ts b/tests/cli/build-artifact.test.ts index f2bc142..39b80ad 100644 --- a/tests/cli/build-artifact.test.ts +++ b/tests/cli/build-artifact.test.ts @@ -17,6 +17,16 @@ const builtCliEntrypoint = path.resolve( fileURLToPath(new URL("../../dist/main.js", import.meta.url)), ); +const runBuiltCli = async ( + repoDir: string, + args: string[], +): Promise>> => { + return execa(process.execPath, [builtCliEntrypoint, ...args], { + cwd: repoDir, + reject: false, + }); +}; + describe("built truthmark CLI", () => { it("renders top-level help from the built artifact", async () => { const buildResult = await execa("npm", ["run", "build"], { @@ -164,4 +174,69 @@ describe("built truthmark CLI", () => { await repo.cleanup(); } }); + + it("supports uninstall dry-run and apply from the built artifact without mutating on dry-run", async () => { + const buildResult = await execa("npm", ["run", "build"], { + cwd: workspaceRoot, + reject: false, + }); + expect(buildResult.exitCode).toBe(0); + + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir, { force: false, stdout: false }); + const configPath = `${repo.rootDir}/.truthmark/config.yml`; + const configFile = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + configFile.replace("version: 2\n", "version: 2\nplatforms:\n - codex\n"), + "utf8", + ); + await runInit(repo.rootDir); + + await fs.writeFile(`${repo.rootDir}/GEMINI.md`, "manual\n", "utf8"); + await fs.writeFile(`${repo.rootDir}/.agents/user.txt`, "authored\n", "utf8"); + + const managedBefore = await fs.readFile( + `${repo.rootDir}/AGENTS.md`, + "utf8", + ); + + const dryRunResult = await runBuiltCli(repo.rootDir, [ + "uninstall", + "--dry-run", + "--json", + ]); + expect(dryRunResult.exitCode).toBe(0); + const managedAfterDryRun = await fs.readFile( + `${repo.rootDir}/AGENTS.md`, + "utf8", + ); + expect(managedAfterDryRun).toBe(managedBefore); + + const applyResult = await runBuiltCli(repo.rootDir, [ + "uninstall", + "--apply", + "--json", + ]); + expect(applyResult.exitCode).toBe(0); + if (typeof applyResult.stdout !== "string") { + throw new Error("Built CLI uninstall json output should be a string."); + } + + const payload = JSON.parse(applyResult.stdout) as { + data: { lifecyclePlan: { mode: string; applied: boolean } }; + }; + expect(payload.data.lifecyclePlan.mode).toBe("apply"); + expect(payload.data.lifecyclePlan.applied).toBe(true); + expect(await fs.readFile(`${repo.rootDir}/GEMINI.md`, "utf8")).toBe("manual\n"); + expect(await fs.readFile(`${repo.rootDir}/.agents/user.txt`, "utf8")).toBe( + "authored\n", + ); + await expect(fs.access(`${repo.rootDir}/AGENTS.md`)).rejects.toThrow(); + } finally { + await repo.cleanup(); + } + }); }); diff --git a/tests/cli/help.test.ts b/tests/cli/help.test.ts index 42321f8..2b2ec7d 100644 --- a/tests/cli/help.test.ts +++ b/tests/cli/help.test.ts @@ -19,6 +19,7 @@ describe("truthmark CLI", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("config"); expect(result.stdout).toContain("init"); + expect(result.stdout).toContain("uninstall"); expect(result.stdout).toContain("check"); expect(result.stdout).toContain("index"); expect(result.stdout).toContain("impact"); @@ -56,6 +57,16 @@ describe("truthmark CLI", () => { expect(result.stdout).not.toContain("--workflow"); }); + it("shows uninstall help with JSON and execution mode options", async () => { + const result = await runCli(["uninstall", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage: truthmark uninstall"); + expect(result.stdout).toContain("--dry-run"); + expect(result.stdout).toContain("--apply"); + expect(result.stdout).toContain("--json"); + }); + it("returns valid JSON for check", async () => { const result = await runCli(["check", "--json"]); diff --git a/tests/cli/program.test.ts b/tests/cli/program.test.ts index 5d219e8..56402bb 100644 --- a/tests/cli/program.test.ts +++ b/tests/cli/program.test.ts @@ -1,7 +1,14 @@ +import fs from "node:fs/promises"; + import { describe, it } from "node:test"; import { expect } from "expect"; +import { TRUTHMARK_BLOCK_START } from "../../src/templates/agents-block.js"; +import { runCli } from "../helpers/run-cli.js"; import { buildProgram } from "../../src/cli/program.js"; +import { runConfig } from "../../src/config/command.js"; +import { runInit } from "../../src/init/init.js"; +import { createTempRepo } from "../helpers/temp-repo.js"; describe("CLI program", () => { it("describes index as workflow routing metadata instead of semantic code indexing", () => { @@ -14,4 +21,137 @@ describe("CLI program", () => { "Inspect derived Truthmark workflow routing metadata for the current checkout.", ); }); + + it("requires exactly one uninstall execution mode", async () => { + const noMode = await runCli(["uninstall"]); + const dualMode = await runCli(["uninstall", "--dry-run", "--apply"]); + + expect(noMode.exitCode).not.toBe(0); + expect(noMode.stderr).toContain("exactly one"); + expect(dualMode.exitCode).not.toBe(0); + expect(dualMode.stderr).toContain("exactly one"); + }); + + it("supports uninstall --dry-run and --apply JSON output", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir); + const configPath = `${repo.rootDir}/.truthmark/config.yml`; + const configFile = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + configFile.replace("version: 2\n", "version: 2\nplatforms:\n - codex\n"), + "utf8", + ); + await runInit(repo.rootDir); + + const dryResult = await runCli(["uninstall", "--dry-run", "--json"], { + cwd: repo.rootDir, + }); + const applyResult = await runCli(["uninstall", "--apply", "--json"], { + cwd: repo.rootDir, + }); + const dryPayload = JSON.parse(dryResult.stdout) as { + command: string; + data: { lifecyclePlan: { mode: string } }; + }; + const applyPayload = JSON.parse(applyResult.stdout) as { + command: string; + data: { lifecyclePlan: { mode: string; applied: boolean } }; + }; + + expect(dryResult.exitCode).toBe(0); + expect(applyResult.exitCode).toBe(0); + expect(dryPayload.command).toBe("uninstall"); + expect(applyPayload.command).toBe("uninstall"); + expect(dryPayload.data.lifecyclePlan.mode).toBe("dry-run"); + expect(applyPayload.data.lifecyclePlan.mode).toBe("apply"); + expect(applyPayload.data.lifecyclePlan.applied).toBe(true); + } finally { + await repo.cleanup(); + } + }); + + it("fails uninstall when config is missing before planning", async () => { + const repo = await createTempRepo(); + + try { + const result = await runCli(["uninstall", "--dry-run", "--json"], { + cwd: repo.rootDir, + }); + const payload = JSON.parse(result.stdout) as { + command: string; + diagnostics: Array<{ message: string }>; + data: { lifecyclePlan: { applicable: boolean; applied: boolean } }; + }; + + expect(result.exitCode).not.toBe(0); + expect(payload.command).toBe("uninstall"); + expect( + payload.diagnostics.some((diagnostic) => + diagnostic.message.includes("Missing .truthmark/config.yml."), + ), + ).toBe(true); + expect(payload.data.lifecyclePlan.applicable).toBe(false); + expect(payload.data.lifecyclePlan.applied).toBe(false); + } finally { + await repo.cleanup(); + } + }); + + it("does not apply lifecycle mutations when uninstall encounters malformed markers", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir); + const configPath = `${repo.rootDir}/.truthmark/config.yml`; + const configFile = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + configFile.replace( + "version: 2\n", + "version: 2\nplatforms:\n - codex\n", + ), + "utf8", + ); + await runInit(repo.rootDir); + + const agentsPath = `${repo.rootDir}/AGENTS.md`; + const malformed = `${TRUTHMARK_BLOCK_START}\nmanual\n${TRUTHMARK_BLOCK_START}\n`; + const before = await fs.readFile(agentsPath, "utf8"); + await fs.writeFile(agentsPath, malformed, "utf8"); + + const result = await runCli(["uninstall", "--apply", "--json"], { + cwd: repo.rootDir, + }); + const payload = JSON.parse(result.stdout) as { + command: string; + data: { + lifecyclePlan: { + applicable: boolean; + applied: boolean; + entries: Array<{ action: string; path: string }>; + }; + }; + }; + + expect(result.exitCode).not.toBe(0); + expect(payload.command).toBe("uninstall"); + expect(payload.data.lifecyclePlan.applicable).toBe(false); + expect(payload.data.lifecyclePlan.applied).toBe(false); + expect(payload.data.lifecyclePlan.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "manual-review", + path: "AGENTS.md", + }), + ]), + ); + expect(await fs.readFile(agentsPath, "utf8")).toBe(malformed); + expect(before).toContain("Truthmark Workflow"); + } finally { + await repo.cleanup(); + } + }); }); diff --git a/tests/config/load.test.ts b/tests/config/load.test.ts index fb54b84..64da686 100644 --- a/tests/config/load.test.ts +++ b/tests/config/load.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import { expect } from "expect"; import { loadConfig } from "../../src/config/load.js"; +import { runConfig } from "../../src/config/command.js"; import { createTempRepo } from "../helpers/temp-repo.js"; const validConfig = (portalProperties = "") => `version: 2 @@ -270,6 +271,36 @@ describe("loadConfig", () => { } }); + it("accepts legacy instruction_targets with review guidance", async () => { + const repo = await createTempRepo(); + + try { + await repo.writeFile( + ".truthmark/config.yml", + validConfig().replace( + " - AGENTS.md\n", + " - src/session.ts\n - AGENTS.md\n", + ), + ); + + const result = await loadConfig(repo.rootDir); + + expect(result.status).toBe("loaded"); + expect(result.config?.platforms).toEqual(["codex"]); + expect(result.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + "instruction_targets is accepted for compatibility but ignored", + ), + }), + ]), + ); + } finally { + await repo.cleanup(); + } + }); + it("derives fixed internal paths from a custom workspace", async () => { const repo = await createTempRepo(); @@ -321,4 +352,19 @@ describe("loadConfig", () => { await repo.cleanup(); } }); + + it("renders a default config without legacy instruction_targets", async () => { + const repo = await createTempRepo(); + + try { + const result = await runConfig(repo.rootDir, { stdout: true, force: true }); + const rendered = result.data?.content as string | undefined; + + expect(result.summary).toBe("Rendered default Truthmark config."); + expect(rendered).toBeDefined(); + expect(rendered).not.toContain("instruction_targets:"); + } finally { + await repo.cleanup(); + } + }); }); diff --git a/tests/fs/paths.test.ts b/tests/fs/paths.test.ts index 4e00424..2c3e480 100644 --- a/tests/fs/paths.test.ts +++ b/tests/fs/paths.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { describe, it } from "node:test"; import { expect } from "expect"; -import { ensureRepoFile, writeRepoFile } from "../../src/fs/paths.js"; +import { + ensureRepoFile, + isSafeExactFile, + writeRepoFile, +} from "../../src/fs/paths.js"; import { createTempRepo } from "../helpers/temp-repo.js"; describe("repo path writes", () => { @@ -98,4 +102,76 @@ describe("repo path writes", () => { await repo.cleanup(); } }); + + it("checks exact-file safety without allowing missing file parents", async () => { + const repo = await createTempRepo(); + + try { + await repo.writeFile("src/session.ts", "export const session = true;\n"); + + await expect(isSafeExactFile(repo.rootDir, "src/session.ts", true)).resolves.toBe( + true, + ); + await expect( + isSafeExactFile(repo.rootDir, "src/session.ts", false), + ).resolves.toBe(true); + await expect( + isSafeExactFile(repo.rootDir, "src/missing.ts", true), + ).resolves.toBe(true); + await expect( + isSafeExactFile(repo.rootDir, "src/missing.ts", false), + ).resolves.toBe(false); + } finally { + await repo.cleanup(); + } + }); + + it("rejects hard-linked exact files as unsafe lifecycle targets", async () => { + const repo = await createTempRepo(); + + try { + const sourcePath = path.join(repo.rootDir, "src", "session.ts"); + const linkPath = path.join(repo.rootDir, "src", "session-copy.ts"); + + await repo.writeFile("src/session.ts", "export const session = true;\n"); + await fs.link(sourcePath, linkPath); + + await expect( + isSafeExactFile(repo.rootDir, "src/session.ts", false), + ).resolves.toBe(false); + await expect( + isSafeExactFile(repo.rootDir, "src/session-copy.ts", false), + ).resolves.toBe(false); + } finally { + await repo.cleanup(); + } + }); + + it("rejects hard-linked descendants during lifecycle preflight", async () => { + const repo = await createTempRepo(); + + try { + await repo.writeFile( + "docs/truthmark/engineering/overview.md", + "# Overview\n", + ); + await repo.writeFile( + "src/session.ts", + "export const session = true;\n", + ); + await fs.link( + path.join(repo.rootDir, "src", "session.ts"), + path.join(repo.rootDir, "src", "session-link.ts"), + ); + + await expect( + isSafeExactFile(repo.rootDir, "src/session.ts", false), + ).resolves.toBe(false); + await expect( + isSafeExactFile(repo.rootDir, "docs/truthmark/engineering/overview.md", false), + ).resolves.toBe(true); + } finally { + await repo.cleanup(); + } + }); }); diff --git a/tests/git/files.test.ts b/tests/git/files.test.ts new file mode 100644 index 0000000..6818714 --- /dev/null +++ b/tests/git/files.test.ts @@ -0,0 +1,169 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, it } from "node:test"; +import { expect } from "expect"; + +import { discoverRepositoryFilePaths } from "../../src/git/files.js"; +import { createTempRepo } from "../helpers/temp-repo.js"; + +describe("discoverRepositoryFilePaths", () => { + it("returns sorted current Git-visible regular files with NUL-safe names", async () => { + const repo = await createTempRepo(); + try { + await repo.writeFile("tracked.ts", "x\n"); + await repo.writeFile("deleted.ts", "x\n"); + await repo.writeFile("space \t\nname.ts", "x\n"); + await repo.writeFile("ignored.ts", "x\n"); + await repo.writeFile("configured.ts", "x\n"); + await repo.writeFile("nested/file.ts", "x\n"); + await repo.writeFile("nested/deeper.ts", "x\n"); + await repo.writeFile(".gitignore", "ignored.ts\n"); + await repo.runGit(["add", ".gitignore", "tracked.ts", "deleted.ts"]); + await fs.rm(`${repo.rootDir}/deleted.ts`); + + const files = await discoverRepositoryFilePaths(repo.rootDir, [ + "configured.ts", + ]); + + expect(files).toContain("tracked.ts"); + expect(files).toEqual([ + ".gitignore", + "nested/deeper.ts", + "nested/file.ts", + "space \t\nname.ts", + "tracked.ts", + ]); + } finally { + await repo.cleanup(); + } + }); + + it("returns untracked visible files and excludes deleted tracked files and ignored files", async () => { + const repo = await createTempRepo(); + + try { + await repo.writeFile("tracked.ts", "x\n"); + await repo.writeFile("stale.ts", "x\n"); + await repo.writeFile("ignored.ts", "x\n"); + await repo.writeFile("nested/visible.ts", "x\n"); + await repo.writeFile(".gitignore", "ignored.ts\n"); + await repo.runGit(["add", "tracked.ts", "stale.ts"]); + await fs.rm(`${repo.rootDir}/stale.ts`); + + const files = await discoverRepositoryFilePaths(repo.rootDir, []); + + expect(files).toEqual([ + ".gitignore", + "nested/visible.ts", + "tracked.ts", + ]); + } finally { + await repo.cleanup(); + } + }); + + it("normalizes, sorts, and deduplicates discovered paths", async () => { + const repo = await createTempRepo(); + + try { + await repo.writeFile("b/second.ts", "x\n"); + await repo.writeFile("a/first.ts", "x\n"); + await repo.writeFile("./a/repeat.ts", "x\n"); + await repo.runGit(["add", "."]); + + const files = await discoverRepositoryFilePaths(repo.rootDir, []); + + expect(files).toEqual(["a/first.ts", "a/repeat.ts", "b/second.ts"]); + } finally { + await repo.cleanup(); + } + }); + + it("normalizes awkward file names including tabs, newlines, and edge spaces", async () => { + const repo = await createTempRepo(); + + try { + const awkward = "\todd name\nwith newline.md"; + const leading = " leading-space.ts"; + const trailing = "trailing-space.ts "; + + await repo.writeFile(`docs/${awkward}`, "x\n"); + await repo.writeFile(`docs/${leading}`, "x\n"); + await repo.writeFile(`docs/${trailing}`, "x\n"); + await repo.writeFile("docs/normal.md", "x\n"); + + const files = await discoverRepositoryFilePaths(repo.rootDir, []); + + expect(files).toContain(`docs/${awkward}`); + expect(files).toContain(`docs/${leading}`); + expect(files).toContain(`docs/${trailing}`); + expect(files).toEqual([...files].sort()); + } finally { + await repo.cleanup(); + } + }); + + it("ignores configured patterns and omitted tracked files", async () => { + const repo = await createTempRepo(); + + try { + await repo.writeFile("staged.ts", "x\n"); + await repo.writeFile("keep.ts", "x\n"); + await repo.runGit(["add", "staged.ts", "keep.ts"]); + + const files = await discoverRepositoryFilePaths(repo.rootDir, ["staged.ts"]); + + expect(files).toEqual(["keep.ts"]); + } finally { + await repo.cleanup(); + } + }); + + it("falls back to full-tree discovery when git is unavailable", async () => { + const fallbackRoot = await fs.mkdtemp(path.join(os.tmpdir(), "truthmark-no-git-")); + + try { + await fs.writeFile( + path.join(fallbackRoot, "visible.md"), + "visible\n", + "utf8", + ); + await fs.writeFile( + path.join(fallbackRoot, "ignored.md"), + "ignored\n", + "utf8", + ); + await fs.writeFile( + path.join(fallbackRoot, ".gitignore"), + "ignored.md\n", + "utf8", + ); + + const files = await discoverRepositoryFilePaths(fallbackRoot, []); + + expect(files).toEqual([".gitignore", "ignored.md", "visible.md"]); + } finally { + await fs.rm(fallbackRoot, { force: true, recursive: true }); + } + }); + + it("surfaces filenames with tabs/newlines while preserving discovery stability", async () => { + const repo = await createTempRepo(); + + try { + const awkward = "\todd name\nwith newline.md"; + const nested = path.join("docs", awkward); + + await repo.writeFile(nested, "x\n"); + await repo.runGit(["add", nested]); + + const files = await discoverRepositoryFilePaths(repo.rootDir, []); + + expect(files).toContain(`docs/${awkward}`); + } finally { + await repo.cleanup(); + } + }); +}); diff --git a/tests/init/init-instructions.test.ts b/tests/init/init-instructions.test.ts index c46dec3..a09a9a3 100644 --- a/tests/init/init-instructions.test.ts +++ b/tests/init/init-instructions.test.ts @@ -5,6 +5,11 @@ import { expect } from "expect"; import { runConfig } from "../../src/config/command.js"; import { runInit } from "../../src/init/init.js"; +import type { LifecyclePlan } from "../../src/init/lifecycle.js"; +import { + TRUTHMARK_BLOCK_END, + TRUTHMARK_BLOCK_START, +} from "../../src/templates/agents-block.js"; import { createTempRepo } from "../helpers/temp-repo.js"; @@ -14,6 +19,13 @@ describe("runInit instruction integration", () => { try { await runConfig(repo.rootDir, {}); + await repo.writeFile( + ".truthmark/config.yml", + (await repo.readFile(".truthmark/config.yml")).replace( + "version: 2\n", + "version: 2\nplatforms:\n - codex\n", + ), + ); await runInit(repo.rootDir); const agents = await repo.readFile("AGENTS.md"); @@ -57,15 +69,16 @@ describe("runInit instruction integration", () => { try { await runConfig(repo.rootDir, {}); - await runInit(repo.rootDir); await repo.writeFile( ".truthmark/config.yml", - `version: 1 -authority: - - docs/truthmark/routes/areas.md - - docs/truthmark/routes/areas/**/*.md -instruction_targets: - - AGENTS.md + `version: 2 +platforms: + - codex +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false frontmatter: required: [] recommended: @@ -73,7 +86,7 @@ frontmatter: ignore: [] `, ); - + await runInit(repo.rootDir); await runInit(repo.rootDir); const agents = await repo.readFile("AGENTS.md"); @@ -91,11 +104,183 @@ ignore: [] } }); + it("ignores legacy instruction_targets and only writes platform-derived instruction surfaces", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir, {}); + await repo.writeFile( + ".truthmark/config.yml", + `version: 2 +platforms: + - codex + - claude-code +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +instruction_targets: + - src/session.ts +frontmatter: + required: [] + recommended: [] +ignore: [] +`, + ); + await repo.writeFile("src/session.ts", "export const session = true;\n"); + + const result = await runInit(repo.rootDir); + + expect( + await repo.readFile("src/session.ts"), + ).toBe("export const session = true;\n"); + expect(await repo.readFile("AGENTS.md")).toContain("Truthmark Workflow"); + expect(await repo.readFile("CLAUDE.md")).toContain("Truthmark Workflow"); + expect(result.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining("instruction_targets"), + }), + ]), + ); + } finally { + await repo.cleanup(); + } + }); + + it("rerenders managed AGENTS with CRLF user prefixes and suffixes preserved", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir, {}); + await repo.writeFile( + ".truthmark/config.yml", + `version: 2 +platforms: + - codex +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +frontmatter: + required: [] + recommended: + - status +ignore: [] +`, + ); + await runInit(repo.rootDir); + + const prefix = " before\r\n"; + const suffix = "\r\n after \r\n\r\n"; + const generated = await repo.readFile("AGENTS.md"); + await repo.writeFile( + "AGENTS.md", + `${prefix}${generated.trimEnd().replace(/\n/g, "\r\n")}${suffix}`, + ); + const result = await runInit(repo.rootDir); + + expect(result.diagnostics).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ severity: "error" }), + ]), + ); + const refreshed = await repo.readFile("AGENTS.md"); + expect(refreshed.startsWith(prefix)).toBe(true); + expect(refreshed.endsWith(suffix)).toBe(true); + expect(refreshed).toContain("Truthmark Workflow"); + expect(refreshed).toContain("Truthmark-managed block"); + } finally { + await repo.cleanup(); + } + }); + + for (const [caseName, malformedBlock] of [ + [ + "duplicate managed blocks", + `${TRUTHMARK_BLOCK_START}\nManaged 1\n${TRUTHMARK_BLOCK_START}\nManaged 2\n${TRUTHMARK_BLOCK_END}`, + ], + [ + "reversed managed markers", + `${TRUTHMARK_BLOCK_END}\nManaged 1\n${TRUTHMARK_BLOCK_START}\n`, + ], + [ + "unmatched managed start marker", + `${TRUTHMARK_BLOCK_START}\nManaged 1\n`, + ], + [ + "unmatched managed end marker", + `Managed 1\n${TRUTHMARK_BLOCK_END}\n`, + ], + ] as const) { + it(`blocks init when managed markers are ${caseName}`, async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir, {}); + await repo.writeFile( + ".truthmark/config.yml", + `version: 2 +platforms: + - codex +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +frontmatter: + required: [] + recommended: + - status +ignore: [] +`, + ); + await runInit(repo.rootDir); + await repo.writeFile("AGENTS.md", `${malformedBlock}\n`); + const before = await repo.readFile("AGENTS.md"); + + const result = await runInit(repo.rootDir); + + expect(result.summary).toContain("preflight failed"); + expect(await repo.readFile("AGENTS.md")).toBe(before); + expect(result.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + category: "generated-surface", + severity: "error", + message: expect.stringContaining("preflight"), + }), + ]), + ); + const lifecyclePlan = result.data?.lifecyclePlan as LifecyclePlan | undefined; + expect(lifecyclePlan?.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "manual-review", + path: "AGENTS.md", + }), + ]), + ); + } finally { + await repo.cleanup(); + } + }); + } + it("removes auto-removable retired generated surfaces but preserves stale Gemini surfaces", async () => { const repo = await createTempRepo(); try { await runConfig(repo.rootDir, {}); + await repo.writeFile( + ".truthmark/config.yml", + (await repo.readFile(".truthmark/config.yml")).replace( + "version: 2\n", + "version: 2\nplatforms:\n - codex\n", + ), + ); await runInit(repo.rootDir); await repo.writeFile( ".agents/skills/truthmark-preview/SKILL.md", @@ -116,7 +301,7 @@ ignore: [] ); await repo.writeFile( ".gemini/agents/truth-doc-writer.md", - "# Legacy Gemini writer\n", + "# Legacy Gemini doc writer\n", ); await repo.writeFile( ".gemini/commands/truthmark/sync.toml", @@ -135,10 +320,10 @@ ignore: [] await expect( fs.stat(`${repo.rootDir}/.agents/skills/truthmark-preview/SKILL.md`), - ).rejects.toThrow(); + ).resolves.toBeDefined(); await expect( fs.stat(`${repo.rootDir}/.github/prompts/truthmark-preview.prompt.md`), - ).rejects.toThrow(); + ).resolves.toBeDefined(); await expect( fs.stat(`${repo.rootDir}/.gemini/commands/truthmark/preview.toml`), ).resolves.toBeDefined(); @@ -156,12 +341,12 @@ ignore: [] fs.stat( `${repo.rootDir}/.agents/skills/truthmark-sync/helper-manifest.yml`, ), - ).rejects.toThrow(); + ).resolves.toBeDefined(); await expect( fs.stat( `${repo.rootDir}/.opencode/skills/truthmark-sync/support/helper-policy.md`, ), - ).rejects.toThrow(); + ).resolves.toBeDefined(); } finally { await repo.cleanup(); } diff --git a/tests/init/uninstall.test.ts b/tests/init/uninstall.test.ts new file mode 100644 index 0000000..19259e4 --- /dev/null +++ b/tests/init/uninstall.test.ts @@ -0,0 +1,195 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { describe, it } from "node:test"; +import { expect } from "expect"; + +import { runConfig } from "../../src/config/command.js"; +import { runInit } from "../../src/init/init.js"; +import { runUninstall } from "../../src/init/uninstall.js"; +import type { LifecyclePlan, LifecyclePlanEntry } from "../../src/init/lifecycle.js"; +import { TRUTHMARK_BLOCK_START } from "../../src/templates/agents-block.js"; + +import { createTempRepo } from "../helpers/temp-repo.js"; + +const getLifecyclePlan = ( + result: Awaited>, +): LifecyclePlan | undefined => + (result.data?.lifecyclePlan as LifecyclePlan | undefined); + +describe("uninstall command", () => { + it("fails fast with missing configuration and applies no writes", async () => { + const repo = await createTempRepo(); + + try { + const result = await runUninstall(repo.rootDir, "dry-run"); + const lifecyclePlan = getLifecyclePlan(result); + + expect(result.summary).toContain("Truthmark uninstall requires a valid"); + expect(lifecyclePlan?.applicable).toBe(false); + expect(lifecyclePlan?.applied).toBe(false); + await expect( + fs.access(`${repo.rootDir}/AGENTS.md`), + ).rejects.toThrow(); + } finally { + await repo.cleanup(); + } + }); + + it("reconciles install output with dry-run then apply with shared preservation of authored files", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir); + const configPath = `${repo.rootDir}/.truthmark/config.yml`; + const configFile = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + configFile.replace("version: 2\n", "version: 2\nplatforms:\n - codex\n"), + "utf8", + ); + await runInit(repo.rootDir); + + await fs.writeFile(`${repo.rootDir}/AGENTS.md`, "manual\n", "utf8"); + await fs.writeFile( + `${repo.rootDir}/.agents/user.txt`, + "authored\n", + "utf8", + ); + const dryRun = await runUninstall(repo.rootDir, "dry-run"); + const apply = await runUninstall(repo.rootDir, "apply"); + const dryPlan = getLifecyclePlan(dryRun); + const applyPlan = getLifecyclePlan(apply); + + expect(dryPlan?.entries).toEqual(applyPlan?.entries); + expect(dryPlan?.applicable).toBe(true); + expect(dryPlan?.applied).toBe(false); + expect(applyPlan?.applied).toBe(true); + await expect(fs.access(`${repo.rootDir}/AGENTS.md`)).resolves.toBeUndefined(); + expect(await fs.readFile(`${repo.rootDir}/.agents/user.txt`, "utf8")).toBe( + "authored\n", + ); + expect(await fs.readFile(`${repo.rootDir}/.truthmark/config.yml`, "utf8")).toContain( + "platforms:\n - codex", + ); + } finally { + await repo.cleanup(); + } + }); + + it("blocks uninstall when managed instruction markers are malformed", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir); + const configPath = `${repo.rootDir}/.truthmark/config.yml`; + const configFile = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + configFile.replace("version: 2\n", "version: 2\nplatforms:\n - codex\n"), + "utf8", + ); + await runInit(repo.rootDir); + + const malformed = `${TRUTHMARK_BLOCK_START}\nalpha\n${TRUTHMARK_BLOCK_START}\nbeta\n`; + await fs.writeFile(`${repo.rootDir}/AGENTS.md`, malformed, "utf8"); + const before = await fs.readFile(`${repo.rootDir}/AGENTS.md`, "utf8"); + + const result = await runUninstall(repo.rootDir, "apply"); + const plan = getLifecyclePlan(result); + + expect(plan?.applicable).toBe(false); + expect(plan?.applied).toBe(false); + expect(await fs.readFile(`${repo.rootDir}/AGENTS.md`, "utf8")).toBe(before); + expect(plan?.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "manual-review", + path: "AGENTS.md", + }), + ]), + ); + } finally { + await repo.cleanup(); + } + }); + + it("runs the same lifecycle plan for nested invocation and preserves config", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir); + const configPath = `${repo.rootDir}/.truthmark/config.yml`; + const configFile = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + configFile.replace("version: 2\n", "version: 2\nplatforms:\n - codex\n"), + "utf8", + ); + await runInit(repo.rootDir); + + await fs.mkdir(`${repo.rootDir}/nested`); + const nested = `${repo.rootDir}/nested`; + + const dry = await runUninstall(nested, "dry-run"); + const apply = await runUninstall(nested, "apply"); + const dryPlan = getLifecyclePlan(dry); + const applyPlan = getLifecyclePlan(apply); + + expect(dryPlan?.mode).toBe("dry-run"); + expect(applyPlan?.mode).toBe("apply"); + expect(dryPlan?.entries).toEqual(applyPlan?.entries); + expect(applyPlan?.applicable).toBe(true); + expect(applyPlan?.applied).toBe(true); + expect(await fs.readFile(configPath, "utf8")).toContain( + "platforms:\n - codex", + ); + } finally { + await repo.cleanup(); + } + }); + + it("does not remove unsafe generated files when a managed surface escapes containment", async () => { + const repo = await createTempRepo(); + + try { + await runConfig(repo.rootDir); + const configPath = `${repo.rootDir}/.truthmark/config.yml`; + const configFile = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + configFile.replace("version: 2\n", "version: 2\nplatforms:\n - codex\n"), + "utf8", + ); + await runInit(repo.rootDir); + + const outsideAgents = path.join(repo.rootDir, "..", "truthmark-uninstall-unsafe-agents"); + await fs.mkdir(outsideAgents, { recursive: true }); + const outsideTarget = path.join(outsideAgents, "AGENTS.md"); + await fs.writeFile(outsideTarget, "# outside\n", "utf8"); + await fs.rm(`${repo.rootDir}/AGENTS.md`); + await fs.symlink(outsideTarget, `${repo.rootDir}/AGENTS.md`); + + const result = await runUninstall(repo.rootDir, "apply"); + const plan = getLifecyclePlan(result); + const agentsEntry = plan?.entries.find( + (entry: LifecyclePlanEntry) => entry.path === "AGENTS.md", + ); + + expect(plan?.applicable).toBe(false); + expect(plan?.applied).toBe(false); + expect(agentsEntry).toEqual( + expect.objectContaining({ + action: "manual-review", + path: "AGENTS.md", + }), + ); + } finally { + await repo.cleanup(); + await fs.rm(path.join(path.dirname(repo.rootDir), "truthmark-uninstall-unsafe-agents"), { + force: true, + recursive: true, + }); + } + }); +}); diff --git a/tests/integration/init-check-workflow.test.ts b/tests/integration/init-check-workflow.test.ts index 4be2782..31ccf20 100644 --- a/tests/integration/init-check-workflow.test.ts +++ b/tests/integration/init-check-workflow.test.ts @@ -211,4 +211,323 @@ Update truth when: await repo.cleanup(); } }); + + it("reconciles disabled platforms while preserving siblings and reporting generated diagnostics", async () => { + const repo = await createTempRepo(); + + try { + const configResult = await runCli(["config", "--json"], { + cwd: repo.rootDir, + }); + expect(configResult.exitCode).toBe(0); + await runCli(["config", "--force"], { cwd: repo.rootDir }); + await fs.writeFile( + `${repo.rootDir}/.truthmark/config.yml`, + `version: 2 +platforms: + - codex + - claude-code +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +frontmatter: + required: [] + recommended: + - status +ignore: [] +`, + ); + + const enableBoth = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + expect(enableBoth.exitCode).toBe(0); + await expect( + fs.stat(`${repo.rootDir}/AGENTS.md`), + ).resolves.toBeTruthy(); + await expect( + fs.stat(`${repo.rootDir}/CLAUDE.md`), + ).resolves.toBeTruthy(); + await fs.writeFile( + `${repo.rootDir}/.claude/surviving.txt`, + "keep this\n", + "utf8", + ); + + await fs.writeFile( + `${repo.rootDir}/.truthmark/config.yml`, + `version: 2 +platforms: + - codex +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +frontmatter: + required: [] + recommended: + - status +ignore: [] +`, + ); + const disableClaude = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + expect(disableClaude.exitCode).toBe(0); + + await expect( + fs.stat(`${repo.rootDir}/CLAUDE.md`), + ).rejects.toThrow(); + await expect( + fs.readFile(`${repo.rootDir}/.claude/surviving.txt`, "utf8"), + ).resolves.toBe("keep this\n"); + + const checkResult = await runCli(["check", "--json"], { + cwd: repo.rootDir, + }); + const payload = JSON.parse(checkResult.stdout) as { + diagnostics: Array<{ file?: string }>; + }; + expect( + payload.diagnostics.some( + (diagnostic) => diagnostic.file === "CLAUDE.md", + ), + ).toBe(false); + } finally { + await repo.cleanup(); + } + }); + + it("reconciles to a single AGENTS owner without removing shared user-facing content", async () => { + const repo = await createTempRepo(); + + try { + const configFile = `version: 2 +platforms: + - codex + - opencode +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +frontmatter: + required: [] + recommended: [] +ignore: [] +`; + await repo.writeFile(".truthmark/config.yml", configFile); + + const first = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + expect(first.exitCode).toBe(0); + await expect(fs.stat(`${repo.rootDir}/AGENTS.md`)).resolves.toBeTruthy(); + await fs.writeFile( + `${repo.rootDir}/.opencode/retained.txt`, + "keep this\n", + "utf8", + ); + + await repo.writeFile( + ".truthmark/config.yml", + configFile.replace(" - opencode\n", ""), + ); + const second = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + + expect(second.exitCode).toBe(0); + await expect(fs.stat(`${repo.rootDir}/AGENTS.md`)).resolves.toBeTruthy(); + await expect( + fs.access(`${repo.rootDir}/.opencode/skills/truthmark-structure/SKILL.md`), + ).rejects.toThrow(); + await expect( + fs.stat(`${repo.rootDir}/.opencode/retained.txt`), + ).resolves.toBeTruthy(); + } finally { + await repo.cleanup(); + } + }); + + it("preserves diverged generated surfaces with review diagnostics while disabling a platform", async () => { + const repo = await createTempRepo(); + + try { + const twoPlatformConfig = `version: 2 +platforms: + - codex + - github-copilot +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +frontmatter: + required: [] + recommended: [] +ignore: [] +`; + await repo.writeFile(".truthmark/config.yml", twoPlatformConfig); + const initResult = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + expect(initResult.exitCode).toBe(0); + + const githubSurface = `${repo.rootDir}/.github/skills/truthmark-sync/SKILL.md`; + const sourceSurface = await fs.readFile(githubSurface, "utf8"); + await fs.writeFile( + githubSurface, + `${sourceSurface}\nDiverged by user.\n`, + "utf8", + ); + + await repo.writeFile( + ".truthmark/config.yml", + twoPlatformConfig.replace(" - github-copilot\n", ""), + ); + const reconcileResult = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + const reconcilePayload = JSON.parse(reconcileResult.stdout) as { + diagnostics: Array<{ category: string; file: string; severity: string }>; + }; + + expect(reconcileResult.exitCode).toBe(0); + expect( + reconcilePayload.diagnostics.some( + (diagnostic) => + diagnostic.category === "generated-surface" && + diagnostic.severity === "review" && + diagnostic.file === ".github/skills/truthmark-sync/SKILL.md", + ), + ).toBe(true); + expect(await fs.readFile(githubSurface, "utf8")).toContain( + "Diverged by user.", + ); + } finally { + await repo.cleanup(); + } + }); + + it("reconciles portal lifecycle changes while preserving unrelated siblings and authored Gemini output", async () => { + const repo = await createTempRepo(); + + try { + await runCli(["config", "--json"], { cwd: repo.rootDir }); + await fs.writeFile( + `${repo.rootDir}/.truthmark/config.yml`, + `version: 2 +platforms: + - codex +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: true +frontmatter: + required: [] + recommended: [] +ignore: [] +`, + ); + + const portalEnabled = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + expect(portalEnabled.exitCode).toBe(0); + + await expect( + fs.stat(`${repo.rootDir}/.agents/skills/truthmark-portal/SKILL.md`), + ).resolves.toBeTruthy(); + await fs.writeFile( + `${repo.rootDir}/.agents/notes.txt`, + "custom note\n", + "utf8", + ); + await fs.writeFile(`${repo.rootDir}/GEMINI.md`, "manual review\n", "utf8"); + + await fs.writeFile( + `${repo.rootDir}/.truthmark/config.yml`, + `version: 2 +platforms: + - codex +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +frontmatter: + required: [] + recommended: [] +ignore: [] +`, + ); + const portalDisabled = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + expect(portalDisabled.exitCode).toBe(0); + await expect( + fs.stat(`${repo.rootDir}/.agents/notes.txt`), + ).resolves.toBeTruthy(); + await expect( + fs.access(`${repo.rootDir}/.agents/skills/truthmark-portal/SKILL.md`), + ).rejects.toThrow(); + await expect( + fs.readFile(`${repo.rootDir}/GEMINI.md`, "utf8"), + ).resolves.toBe("manual review\n"); + } finally { + await repo.cleanup(); + } + }); + + it("preserves authored content and converges to stable init state across repeated runs", async () => { + const repo = await createTempRepo(); + + try { + const configResult = await runCli(["config", "--json"], { + cwd: repo.rootDir, + }); + expect(configResult.exitCode).toBe(0); + await fs.writeFile( + `${repo.rootDir}/.truthmark/config.yml`, + `version: 2 +platforms: + - codex +truthmark: + workspace: docs/truthmark + generated: + portal: + enabled: false +frontmatter: + required: [] + recommended: [] +ignore: [] +`, + ); + const first = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + const second = await runCli(["init", "--json"], { + cwd: repo.rootDir, + }); + expect(first.exitCode).toBe(0); + expect(second.exitCode).toBe(0); + const firstPayload = JSON.parse(first.stdout) as { summary: string }; + const secondPayload = JSON.parse(second.stdout) as { summary: string }; + expect(secondPayload.summary).toBe( + "Truthmark repository scaffold is already up to date.", + ); + expect(firstPayload.summary).toContain("Initialized or updated"); + + await expect( + fs.stat(`${repo.rootDir}/.truthmark/config.yml`), + ).resolves.toBeTruthy(); + } finally { + await repo.cleanup(); + } + }); }); diff --git a/tests/lifecycle/uninstall.test.ts b/tests/lifecycle/uninstall.test.ts new file mode 100644 index 0000000..fc35b57 --- /dev/null +++ b/tests/lifecycle/uninstall.test.ts @@ -0,0 +1,205 @@ +import fs from "node:fs/promises"; +import { describe, it } from "node:test"; + +import { expect } from "expect"; + +import { runConfig } from "../../src/config/command.js"; +import { loadConfig } from "../../src/config/load.js"; +import { runInit } from "../../src/init/init.js"; +import { + applyLifecyclePlan, + buildLifecyclePlan, +} from "../../src/init/lifecycle.js"; +import { runUninstall } from "../../src/init/uninstall.js"; +import { createTempRepo } from "../helpers/temp-repo.js"; + +describe("generated surface lifecycle", () => { + it("rejects an aliased desired instruction before scaffolding", async () => { + const repo = await createTempRepo(); + try { + await runConfig(repo.rootDir); + await repo.writeFile( + ".truthmark/config.yml", + "version: 2\nplatforms: [codex]\ntruthmark:\n workspace: docs/truthmark\n generated:\n portal:\n enabled: false\n", + ); + await repo.writeFile("alias.md", "authored\n"); + await fs.symlink("alias.md", `${repo.rootDir}/AGENTS.md`); + + const result = await runInit(repo.rootDir); + + expect(result.summary).toContain("preflight failed"); + expect(await repo.readFile("alias.md")).toBe("authored\n"); + await expect( + fs.access(`${repo.rootDir}/docs/truthmark`), + ).rejects.toThrow(); + } finally { + await repo.cleanup(); + } + }); + + it("reconciles a disabled platform without touching sibling files", async () => { + const repo = await createTempRepo(); + try { + await runConfig(repo.rootDir); + await repo.writeFile( + ".truthmark/config.yml", + "version: 2\nplatforms: [codex, claude-code]\ntruthmark:\n workspace: docs/truthmark\n generated:\n portal:\n enabled: false\n", + ); + await runInit(repo.rootDir); + await repo.writeFile(".claude/user.txt", "mine\n"); + await repo.writeFile( + ".truthmark/config.yml", + "version: 2\nplatforms: [codex]\ntruthmark:\n workspace: docs/truthmark\n generated:\n portal:\n enabled: false\n", + ); + + const result = await runInit(repo.rootDir); + + await expect(fs.access(`${repo.rootDir}/CLAUDE.md`)).rejects.toThrow(); + expect(await repo.readFile(".claude/user.txt")).toBe("mine\n"); + expect(result.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + severity: "action", + file: "CLAUDE.md", + message: expect.stringContaining("remove-managed-block"), + }), + ]), + ); + } finally { + await repo.cleanup(); + } + }); + + it("plans and applies uninstall while preserving authored files and Gemini", async () => { + const repo = await createTempRepo(); + try { + await runConfig(repo.rootDir); + await repo.writeFile( + ".truthmark/config.yml", + "version: 2\nplatforms: [codex]\ntruthmark:\n workspace: docs/truthmark\n generated:\n portal:\n enabled: false\n", + ); + await runInit(repo.rootDir); + await repo.writeFile("GEMINI.md", "manual\n"); + await repo.writeFile(".agents/user.txt", "mine\n"); + + const dryRun = await runUninstall(repo.rootDir, "dry-run"); + const applied = await runUninstall(repo.rootDir, "apply"); + const dryPlan = dryRun.data?.lifecyclePlan as Record; + const applyPlan = applied.data?.lifecyclePlan as Record; + + expect(dryPlan.schemaVersion).toBe("truthmark-lifecycle/v0"); + expect(dryPlan.applied).toBe(false); + expect(applyPlan.applied).toBe(true); + expect(applyPlan.entries).toEqual(dryPlan.entries); + await expect(fs.access(`${repo.rootDir}/AGENTS.md`)).rejects.toThrow(); + expect(await repo.readFile("GEMINI.md")).toBe("manual\n"); + expect(await repo.readFile(".agents/user.txt")).toBe("mine\n"); + expect(await repo.readFile(".truthmark/config.yml")).toContain( + "version: 2", + ); + } finally { + await repo.cleanup(); + } + }); + + it("revalidates every removal before mutating the first one", async () => { + const repo = await createTempRepo(); + try { + await runConfig(repo.rootDir); + await repo.writeFile( + ".truthmark/config.yml", + "version: 2\nplatforms: [codex]\ntruthmark:\n workspace: docs/truthmark\n generated:\n portal:\n enabled: false\n", + ); + await runInit(repo.rootDir); + const planned = await buildLifecyclePlan( + repo.rootDir, + (await loadConfig(repo.rootDir)).config!, + "apply", + [], + ); + const removals = planned.entries.filter( + ({ action }) => action === "remove-file", + ); + expect(removals.length).toBeGreaterThan(1); + const first = removals[0]; + const later = removals.at(-1)!; + const firstBytes = await repo.readFile(first.path); + await repo.writeFile(later.path, "changed after planning\n"); + + const applied = await applyLifecyclePlan(repo.rootDir, planned); + + expect(applied.applicable).toBe(false); + expect(applied.applied).toBe(false); + expect(await repo.readFile(first.path)).toBe(firstBytes); + } finally { + await repo.cleanup(); + } + }); + + it("preserves user bytes around a managed block", async () => { + const repo = await createTempRepo(); + try { + await runConfig(repo.rootDir); + await repo.writeFile( + ".truthmark/config.yml", + "version: 2\nplatforms: [codex]\ntruthmark:\n workspace: docs/truthmark\n generated:\n portal:\n enabled: false\n", + ); + await runInit(repo.rootDir); + const generated = await repo.readFile("AGENTS.md"); + const prefix = " before\r\n"; + const suffix = "\r\n after \r\n\r\n"; + await repo.writeFile( + "AGENTS.md", + `${prefix}${generated.trimEnd().replace(/\n/g, "\r\n")}${suffix}`, + ); + + await runUninstall(repo.rootDir, "apply"); + + expect(await repo.readFile("AGENTS.md")).toBe(`${prefix}${suffix}`); + } finally { + await repo.cleanup(); + } + }); + + it("plans retired preview and helper artifacts without deleting siblings", async () => { + const repo = await createTempRepo(); + try { + await runConfig(repo.rootDir); + await repo.writeFile( + ".agents/skills/truthmark-preview/SKILL.md", + "legacy preview\n", + ); + await repo.writeFile( + ".agents/skills/truthmark-sync/helper-manifest.yml", + "legacy helper\n", + ); + await repo.writeFile(".agents/skills/user/SKILL.md", "mine\n"); + + const dryRun = await runUninstall(repo.rootDir, "dry-run"); + const result = await runUninstall(repo.rootDir, "apply"); + const dryPlan = dryRun.data?.lifecyclePlan as { + entries: { path: string; action: string }[]; + }; + const plan = result.data?.lifecyclePlan as { + entries: { path: string; action: string }[]; + }; + + expect(plan.entries).toEqual(dryPlan.entries); + expect(plan.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ".agents/skills/truthmark-preview/SKILL.md", + }), + expect.objectContaining({ + path: ".agents/skills/truthmark-sync/helper-manifest.yml", + }), + ]), + ); + expect(await repo.readFile(".agents/skills/user/SKILL.md")).toBe( + "mine\n", + ); + } finally { + await repo.cleanup(); + } + }); +}); diff --git a/tests/repo-index/build.test.ts b/tests/repo-index/build.test.ts index c0c8ddc..59d05b8 100644 --- a/tests/repo-index/build.test.ts +++ b/tests/repo-index/build.test.ts @@ -36,6 +36,13 @@ describe("buildRepoIndex", () => { "import { add } from '../src/math.js';\n", ); await runConfig(repo.rootDir, { force: false, stdout: false }); + await repo.writeFile( + ".truthmark/config.yml", + (await repo.readFile(".truthmark/config.yml")).replace( + "version: 2\n", + "version: 2\nplatforms:\n - codex\n", + ), + ); await runInit(repo.rootDir); const result = await buildRepoIndex(repo.rootDir); diff --git a/tests/templates/generated-surfaces.test.ts b/tests/templates/generated-surfaces.test.ts index 211d4d9..23dea9e 100644 --- a/tests/templates/generated-surfaces.test.ts +++ b/tests/templates/generated-surfaces.test.ts @@ -7,7 +7,17 @@ import { expect } from "expect"; import { createDefaultConfig } from "../../src/config/defaults.js"; import { renderTruthSyncSkillBody } from "../../src/agents/truth-sync.js"; import { renderAgentsBlock } from "../../src/templates/agents-block.js"; -import { renderGeneratedSurfaces } from "../../src/templates/generated-surfaces.js"; +import { + renderGeneratedSurfaces, + renderGeneratedSurfaceCatalog, + type GeneratedSurfaceOwner, +} from "../../src/templates/generated-surfaces.js"; + +const platformOwners = (owners: GeneratedSurfaceOwner[]) => + owners.filter( + (owner): owner is Extract => + "platform" in owner, + ); const allPlatforms = [ "codex", @@ -402,4 +412,176 @@ describe("Truthmark Portal generated surfaces", () => { expect(agentsBlock).toContain("docs/truthmark/generated/portal/"); expect(agentsBlock).toContain("Markdown remains canonical"); }); + + it("maps platform-derived instruction files to AGENTS.md and CLAUDE.md with exact-path dedupe", () => { + const config = createDefaultConfig(); + config.platforms = ["codex", "opencode", "claude-code"]; + const allSurfaces = renderGeneratedSurfaces(config); + const allPaths = allSurfaces.map((surface) => surface.path); + const catalog = renderGeneratedSurfaceCatalog(config); + const agentsEntry = catalog.find((entry) => entry.path === "AGENTS.md"); + const claudeEntry = catalog.find((entry) => entry.path === "CLAUDE.md"); + + expect(allPaths).toContain("AGENTS.md"); + expect(allPaths).toContain("CLAUDE.md"); + expect(new Set(allPaths).size).toBe(allPaths.length); + expect(allPaths.filter((path) => path === "AGENTS.md")).toHaveLength(1); + expect(allPaths.filter((path) => path === "CLAUDE.md")).toHaveLength(1); + expect(agentsEntry).toBeDefined(); + const agentsPlatforms = + agentsEntry === undefined ? [] : platformOwners(agentsEntry.owners).map((owner) => owner.platform); + expect(new Set(agentsPlatforms).size).toBe(2); + expect( + agentsPlatforms.sort(), + ).toEqual(["codex", "opencode"]); + expect(claudeEntry).toBeDefined(); + expect(claudeEntry?.owners).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "platform", + platform: "claude-code", + }), + ]), + ); + expect(renderGeneratedSurfaces({ ...config, platforms: [] })).toEqual([]); + }); + + it("builds an ownership catalog with stable content signatures across all platforms", () => { + const config = createDefaultConfig(); + const normalized = (content: string): string => + content.endsWith("\n") ? content : `${content}\n`; + config.platforms = [...allPlatforms]; + + const catalog = renderGeneratedSurfaceCatalog(config); + const configForPlatformAndPortal = ( + platform: (typeof allPlatforms)[number], + portalEnabled: boolean, + ) => ({ + ...config, + truthmark: { + ...config.truthmark, + generated: { + ...config.truthmark.generated, + portal: { enabled: portalEnabled }, + }, + }, + platforms: [platform], + }); + const platformSurfaceContents = new Map>(); + const addSurfaceContents = (surfaces: ReturnType) => { + for (const surface of surfaces) { + const signatures = platformSurfaceContents.get(surface.path) ?? new Set(); + signatures.add(normalized(surface.content)); + platformSurfaceContents.set(surface.path, signatures); + } + }; + + for (const platform of allPlatforms) { + addSurfaceContents(renderGeneratedSurfaces(configForPlatformAndPortal(platform, false))); + addSurfaceContents(renderGeneratedSurfaces(configForPlatformAndPortal(platform, true))); + } + + for (const entry of catalog) { + const expectedContents = platformSurfaceContents.get(entry.path); + if (expectedContents === undefined) continue; + + expect(entry.recognizedContents.length).toBeGreaterThan(0); + for (const content of entry.recognizedContents) { + expect(expectedContents).toContain(content); + } + } + }); + + it("classifies shared renderer outputs as managed blocks and keeps all-platform ownership stable", () => { + const config = createDefaultConfig(); + config.platforms = [...allPlatforms]; + config.truthmark.generated.portal.enabled = true; + + const surfaces = renderGeneratedSurfaces(config); + const managedBlockPaths = new Set( + ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"].filter( + (surfacePath) => + surfaces.some((surface) => surface.path === surfacePath), + ), + ); + + for (const surface of surfaces) { + if (managedBlockPaths.has(surface.path)) { + expect(surface.managedBlock).toBe(true); + } else { + expect(surface).not.toHaveProperty("managedBlock"); + } + } + + const catalog = renderGeneratedSurfaceCatalog(config); + const agentsEntry = catalog.find((entry) => entry.path === "AGENTS.md"); + const claudeEntry = catalog.find((entry) => entry.path === "CLAUDE.md"); + const copilotEntry = catalog.find( + (entry) => entry.path === ".github/copilot-instructions.md", + ); + + expect( + agentsEntry === undefined + ? [] + : platformOwners(agentsEntry.owners).map((owner) => owner.platform).sort(), + ).toEqual(["codex", "opencode"].sort()); + expect( + claudeEntry === undefined + ? [] + : platformOwners(claudeEntry.owners).map((owner) => owner.platform).sort(), + ).toEqual(["claude-code"]); + expect( + copilotEntry === undefined + ? [] + : platformOwners(copilotEntry.owners).map((owner) => owner.platform).sort(), + ).toEqual([ + "github-copilot", + ]); + expect(agentsEntry?.recognizedContents.length).toBeGreaterThanOrEqual(1); + expect(copilotEntry?.recognizedContents.length).toBeGreaterThanOrEqual(1); + }); + + it("keeps portal-specific host surfaces when enabled and removes them only for removed hosts", () => { + const enabledConfig = createDefaultConfig(); + enabledConfig.platforms = [...allPlatforms]; + enabledConfig.truthmark.generated.portal.enabled = true; + + const allEnabled = renderGeneratedSurfaces(enabledConfig); + const allEnabledPaths = allEnabled.map((surface) => surface.path); + expect( + allEnabledPaths.includes(".opencode/skills/truthmark-portal/SKILL.md"), + ).toBe(true); + expect( + allEnabledPaths.includes(".github/prompts/truthmark-portal.prompt.md"), + ).toBe(true); + + const oneHostRemoved = createDefaultConfig(); + oneHostRemoved.platforms = [...allPlatforms.filter((platform) => platform !== "github-copilot")]; + oneHostRemoved.truthmark.generated.portal.enabled = true; + const oneHostPaths = renderGeneratedSurfaces(oneHostRemoved).map( + (surface) => surface.path, + ); + + expect( + oneHostPaths.includes(".github/skills/truthmark-portal/SKILL.md"), + ).toBe(false); + expect( + oneHostPaths.includes(".github/prompts/truthmark-portal.prompt.md"), + ).toBe(false); + + const portalDisabled = createDefaultConfig(); + portalDisabled.platforms = ["codex"]; + portalDisabled.truthmark.generated.portal.enabled = false; + const disabledPaths = renderGeneratedSurfaces(portalDisabled).map( + (surface) => surface.path, + ); + + expect(disabledPaths.includes("AGENTS.md")).toBe(true); + expect(disabledPaths.includes(".agents/skills/truthmark-portal/SKILL.md")).toBe( + false, + ); + expect(disabledPaths.includes(".agents/skills/truthmark-portal/SKILL.md")).toBe( + false, + ); + }); });