get-tbd 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -6
- package/dist/bin.mjs +3494 -949
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +1827 -1312
- package/dist/cli.mjs.map +1 -1
- package/dist/{config-1ouUTKQr.mjs → config-B1w3pAkY.mjs} +142 -290
- package/dist/config-B1w3pAkY.mjs.map +1 -0
- package/dist/config-DXhifxOw.mjs +3 -0
- package/dist/doc-cache-CamtXfi4.mjs +1035 -0
- package/dist/doc-cache-CamtXfi4.mjs.map +1 -0
- package/dist/doc-cache-CiBwpDTf.mjs +3 -0
- package/dist/doc-fork-BMjqzfAs.mjs +3 -0
- package/dist/doc-fork-CoPi1G1N.mjs +304 -0
- package/dist/doc-fork-CoPi1G1N.mjs.map +1 -0
- package/dist/docref-C7g0sjvL.mjs +167 -0
- package/dist/docref-C7g0sjvL.mjs.map +1 -0
- package/dist/docs/README.md +38 -6
- package/dist/docs/SKILL.md +13 -4
- package/dist/docs/guidelines/backward-compatibility-rules.md +1 -0
- package/dist/docs/guidelines/bun-monorepo-patterns.md +1 -0
- package/dist/docs/guidelines/cli-agent-skill-patterns.md +124 -38
- package/dist/docs/guidelines/commit-conventions.md +1 -0
- package/dist/docs/guidelines/common-doc-guidelines.md +1 -0
- package/dist/docs/guidelines/convex-limits-best-practices.md +1 -0
- package/dist/docs/guidelines/convex-rules.md +1 -0
- package/dist/docs/guidelines/electron-app-development-patterns.md +1 -0
- package/dist/docs/guidelines/error-handling-rules.md +1 -0
- package/dist/docs/guidelines/general-coding-rules.md +1 -0
- package/dist/docs/guidelines/general-comment-rules.md +1 -0
- package/dist/docs/guidelines/general-eng-agent-principles.md +1 -0
- package/dist/docs/guidelines/general-tdd-guidelines.md +1 -0
- package/dist/docs/guidelines/general-testing-rules.md +1 -0
- package/dist/docs/guidelines/golden-testing-guidelines.md +1 -0
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +1 -0
- package/dist/docs/guidelines/python-cli-patterns.md +1 -0
- package/dist/docs/guidelines/python-modern-guidelines.md +1 -0
- package/dist/docs/guidelines/python-rules.md +1 -0
- package/dist/docs/guidelines/release-notes-guidelines.md +1 -0
- package/dist/docs/guidelines/supply-chain-hardening.md +3 -2
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +1 -0
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +1 -0
- package/dist/docs/guidelines/typescript-code-coverage.md +1 -0
- package/dist/docs/guidelines/typescript-rules.md +1 -0
- package/dist/docs/guidelines/typescript-sorting-patterns.md +1 -0
- package/dist/docs/guidelines/typescript-yaml-handling-rules.md +1 -0
- package/dist/docs/references/docmap-format.md +64 -0
- package/dist/docs/references/docref-format.md +71 -0
- package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +17 -4
- package/dist/docs/shortcuts/standard/new-shortcut.md +5 -5
- package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +1 -1
- package/dist/docs/shortcuts/standard/suggest-upstream-improvements.md +69 -0
- package/dist/docs/shortcuts/standard/sync-failure-recovery.md +1 -1
- package/dist/docs/shortcuts/standard/welcome-user.md +28 -0
- package/dist/docs/shortcuts/system/skill-baseline.md +13 -4
- package/dist/docs/tbd-design.md +141 -0
- package/dist/docs/tbd-docs.md +169 -5
- package/dist/docs/tbd-prime.md +0 -1
- package/dist/docs/templates/qa-playbook.md +3 -1
- package/dist/docs/templates/research-brief.md +2 -2
- package/dist/fork-manifest-ByU7U2do.mjs +253 -0
- package/dist/fork-manifest-ByU7U2do.mjs.map +1 -0
- package/dist/fork-manifest-C7lGRq-6.mjs +3 -0
- package/dist/{id-mapping-mtoSP9Qt.mjs → id-mapping-D0iitY-F.mjs} +1 -1
- package/dist/{id-mapping-687_UEsy.mjs → id-mapping-DAIeLKzm.mjs} +8 -200
- package/dist/id-mapping-DAIeLKzm.mjs.map +1 -0
- package/dist/index.d.mts +90 -13
- package/dist/index.mjs +3 -3
- package/dist/lockfile-BR0laSDy.mjs +198 -0
- package/dist/lockfile-BR0laSDy.mjs.map +1 -0
- package/dist/paths-C1DpnZJW.mjs +405 -0
- package/dist/paths-C1DpnZJW.mjs.map +1 -0
- package/dist/{schemas-f0EcuAVu.mjs → schemas-lCwRk30L.mjs} +19 -6
- package/dist/schemas-lCwRk30L.mjs.map +1 -0
- package/dist/{src-DTyyuaG_.mjs → src-CxKOynr1.mjs} +3 -3
- package/dist/src-CxKOynr1.mjs.map +1 -0
- package/dist/tbd +3494 -949
- package/dist/yaml-utils-BPy991by.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/config-1ouUTKQr.mjs.map +0 -1
- package/dist/config-YRRW9l89.mjs +0 -3
- package/dist/id-mapping-687_UEsy.mjs.map +0 -1
- package/dist/schemas-f0EcuAVu.mjs.map +0 -1
- package/dist/src-DTyyuaG_.mjs.map +0 -1
package/dist/docs/tbd-docs.md
CHANGED
|
@@ -156,13 +156,11 @@ The recommended way to initialize tbd and configure agent integrations.
|
|
|
156
156
|
|
|
157
157
|
```bash
|
|
158
158
|
tbd setup --auto # Full setup with auto-detection (recommended)
|
|
159
|
-
tbd setup --interactive # Interactive setup with prompts
|
|
160
159
|
tbd setup --from-beads # Migrate from existing Beads setup
|
|
161
160
|
```
|
|
162
161
|
|
|
163
162
|
Options:
|
|
164
163
|
- `--auto` - Automatic mode: auto-detect prefix, migrate beads if present
|
|
165
|
-
- `--interactive` - Interactive mode: prompt for all options
|
|
166
164
|
- `--from-beads` - Migrate issues from existing Beads setup
|
|
167
165
|
- `--prefix <name>` - Override auto-detected prefix
|
|
168
166
|
|
|
@@ -576,6 +574,7 @@ Options:
|
|
|
576
574
|
|
|
577
575
|
> **Note:** `tbd import --from-beads` is deprecated.
|
|
578
576
|
> Use `tbd setup --auto` or `tbd setup --from-beads` instead for migrating from Beads.
|
|
577
|
+
|
|
579
578
|
- `--validate` - Validate existing import against Beads source
|
|
580
579
|
|
|
581
580
|
### beads
|
|
@@ -674,12 +673,23 @@ and skips reinstallation.
|
|
|
674
673
|
|
|
675
674
|
### Documentation Commands
|
|
676
675
|
|
|
677
|
-
|
|
676
|
+
Managed docs (the `tbd docs` group):
|
|
677
|
+
|
|
678
|
+
```bash
|
|
679
|
+
tbd docs # Status overview of managed docs
|
|
680
|
+
tbd docs list # All docs across kinds, with state markers
|
|
681
|
+
tbd docs show <name> # Read any doc by name (kind-agnostic)
|
|
682
|
+
tbd docs show tbd-docs # The CLI manual (alias: tbd docs manual)
|
|
683
|
+
tbd docs show tbd-docs --sections # List the manual's sections
|
|
684
|
+
tbd docs show tbd-docs --section <name> # Read one manual section
|
|
685
|
+
tbd docs sync # Refresh the gitignored docs cache
|
|
686
|
+
tbd docs fork / unfork / update / diff / status # Forked docs (see below)
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
Other built-in viewers:
|
|
678
690
|
|
|
679
691
|
```bash
|
|
680
692
|
tbd readme # Display README (same as GitHub landing page)
|
|
681
|
-
tbd docs # Display CLI reference documentation
|
|
682
|
-
tbd docs --list # List available documentation sections
|
|
683
693
|
tbd design # Display design documentation
|
|
684
694
|
tbd design --list # List design doc sections
|
|
685
695
|
tbd closing # Display session closing protocol reminder
|
|
@@ -714,6 +724,69 @@ On HTTP 403, fetching falls back to `gh api` for authenticated access.
|
|
|
714
724
|
User-added shortcuts go to `shortcuts/custom/` (separate from bundled
|
|
715
725
|
`shortcuts/standard/`).
|
|
716
726
|
|
|
727
|
+
### Managing Docs: Two Modes
|
|
728
|
+
|
|
729
|
+
Every managed doc is served through one search path; where the file lives is a per-doc
|
|
730
|
+
choice between two modes that serve identical content:
|
|
731
|
+
|
|
732
|
+
- **Hidden cache (the default).** Docs live in the gitignored `.tbd/docs/` cache: always
|
|
733
|
+
active, zero repo footprint, refreshed by `tbd docs sync` (and by setup).
|
|
734
|
+
- **Forked.** `tbd docs fork <name>` (or `--all`) copies a doc into `docs/tbd/`, tracked
|
|
735
|
+
in git: visible on GitHub, reviewable in PRs, and editable; your copy shadows the
|
|
736
|
+
cache everywhere the upstream one was served.
|
|
737
|
+
`tbd docs unfork` returns to the cache; `tbd docs update` three-way merges upstream
|
|
738
|
+
changes into your copy after an upgrade.
|
|
739
|
+
|
|
740
|
+
Forking changes nothing about how docs work.
|
|
741
|
+
It only makes them explicit and editable.
|
|
742
|
+
Four update surfaces stay deliberately separate:
|
|
743
|
+
|
|
744
|
+
| Command | Scope | Touches | Modifies tracked files? |
|
|
745
|
+
| --- | --- | --- | --- |
|
|
746
|
+
| `tbd sync` | project data (issues/beads) | sync worktree + `tbd-sync` branch; also refreshes the doc cache and *reports* fork drift | never |
|
|
747
|
+
| `tbd setup --auto` | installation + integrations | skills, hooks, settings, `AGENTS.md`; invokes a docs-cache sync | only generated integration files |
|
|
748
|
+
| `tbd docs sync` | doc cache | gitignored `.tbd/docs/` only | never |
|
|
749
|
+
| `tbd docs update` | your forked docs | fork dir + bases + manifest (offline, against the cache) | **yes, the only doc command that does** |
|
|
750
|
+
|
|
751
|
+
Disambiguation worth stating once: `tbd update <id>` is an issue operation,
|
|
752
|
+
`tbd docs update` a doc operation; the noun scope always disambiguates.
|
|
753
|
+
|
|
754
|
+
### Forked Docs in Your Repo (docs/tbd/)
|
|
755
|
+
|
|
756
|
+
`tbd docs fork` copies managed docs into `docs/tbd/`, laid out **by kind, flat within
|
|
757
|
+
each kind**, with a generated `README.md` index (regenerated on every
|
|
758
|
+
fork/unfork/update):
|
|
759
|
+
|
|
760
|
+
```
|
|
761
|
+
docs/tbd/
|
|
762
|
+
├── README.md # generated index — what this folder is, one line per doc
|
|
763
|
+
├── guidelines/<name>.md
|
|
764
|
+
├── shortcuts/<name>.md
|
|
765
|
+
└── templates/<name>.md
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
Two rules make everything below predictable: **names are identity** (a doc is
|
|
769
|
+
`<kind>/<name>.md`; nested subfolders are not scanned), and **tracking is derived, not
|
|
770
|
+
stored** (the canonical model (copies, invariants, flows) is `tbd-design.md` §2.9; this
|
|
771
|
+
table is its user-facing summary); every doc’s state is recomputed from content hashes
|
|
772
|
+
(your file vs its recorded base vs current upstream), so no git operation can
|
|
773
|
+
desynchronize tbd from the folder.
|
|
774
|
+
Whatever you or your agent do to these files, `tbd docs status` gives a defined answer:
|
|
775
|
+
|
|
776
|
+
| You (or your agent)… | State | What happens / what to do |
|
|
777
|
+
| --- | --- | --- |
|
|
778
|
+
| Edit a forked file | `customized` | Served as-is; `tbd docs update` three-way merges upstream changes in |
|
|
779
|
+
| Delete a forked file | `missing` | Serving falls back to upstream; restore with `tbd docs fork <name> --force` or finalize with `tbd docs unfork <name>` |
|
|
780
|
+
| Rename a forked file | `missing` + `local` | A rename is delete + add: finalize the old name (`unfork`), keep the new file as `local` |
|
|
781
|
+
| Add a new `.md` file | `local` | Served with top precedence; nothing to update or unfork (no upstream) |
|
|
782
|
+
| Move a file into a subfolder | invisible | Subfolders are not scanned; keep files at `<kind>/<name>.md` |
|
|
783
|
+
| Delete `.tbd/doc-forks/` (the manifest) | all `local` | Files keep being served; re-fork with `--force` to re-establish update tracking (overwrites with upstream; re-apply edits after) |
|
|
784
|
+
| Commit / pull / merge / revert any of it | recomputed | States derive from content, so collaborators see the same answers from the same files |
|
|
785
|
+
|
|
786
|
+
Awareness without surprise mutations: `tbd sync` prints a one-line notice when forked
|
|
787
|
+
docs are stale, conflicted, or missing, and `tbd docs status` shows the full picture,
|
|
788
|
+
but only the explicit `tbd docs update` ever modifies tracked files.
|
|
789
|
+
|
|
717
790
|
### uninstall
|
|
718
791
|
|
|
719
792
|
Remove tbd from a repository.
|
|
@@ -1022,8 +1095,32 @@ sync:
|
|
|
1022
1095
|
branch: tbd-sync # Sync branch name
|
|
1023
1096
|
remote: origin # Remote name
|
|
1024
1097
|
auto_sync: true # Auto-sync after writes
|
|
1098
|
+
|
|
1099
|
+
docs_cache:
|
|
1100
|
+
files: # Docs synced into the cache: destination -> docref
|
|
1101
|
+
guidelines/python-rules.md: internal:guidelines/python-rules.md
|
|
1102
|
+
guidelines/my-team-rules.md: github:my-org/docs@main//rules.md
|
|
1103
|
+
lookup_path: # Search paths for doc lookup (earlier wins)
|
|
1104
|
+
- .tbd/docs/shortcuts/system
|
|
1105
|
+
- .tbd/docs/shortcuts/standard
|
|
1025
1106
|
```
|
|
1026
1107
|
|
|
1108
|
+
`docs_cache.files` values, like the fork manifest’s `source` values in
|
|
1109
|
+
`.tbd/doc-forks/forks.yml`, are **docrefs**: one URI-like address grammar (`internal:…`,
|
|
1110
|
+
anchored local paths, URLs, `github:owner/repo@ref//path`). For the full grammar see
|
|
1111
|
+
`tbd docs show docref-format`; for the docmap structure that doc listings and their
|
|
1112
|
+
`--json` output follow, see `tbd docs show docmap-format`.
|
|
1113
|
+
|
|
1114
|
+
Two further `docs_cache` keys:
|
|
1115
|
+
|
|
1116
|
+
- `docs_cache.local_dirs`: an ordered list of `./`-prefixed local docrefs naming extra
|
|
1117
|
+
in-repo doc directories, served between the fork dir and the cache.
|
|
1118
|
+
Docs found there are first-class for reading (`list`, `show`, the per-kind readers,
|
|
1119
|
+
with a `(serving local doc: …)` note) and report state `local`; they are not forkable
|
|
1120
|
+
or updatable; they already live in the repo.
|
|
1121
|
+
- `docs_cache.fork_dir`: reserved in the f05 format era but **planned, not yet read**:
|
|
1122
|
+
the fork-dir location is currently fixed at `docs/tbd/`.
|
|
1123
|
+
|
|
1027
1124
|
## Priority Scale
|
|
1028
1125
|
|
|
1029
1126
|
| Value | Alias | Meaning |
|
|
@@ -1118,6 +1215,73 @@ ls "$(git rev-parse --path-format=absolute --git-common-dir)/tbd/data-sync-workt
|
|
|
1118
1215
|
ls "$(git rev-parse --path-format=absolute --git-common-dir)/tbd/data-sync-worktree/.tbd/data-sync/issues/" | sort
|
|
1119
1216
|
```
|
|
1120
1217
|
|
|
1218
|
+
### Aborting a Format Upgrade
|
|
1219
|
+
|
|
1220
|
+
Upgrading tbd can bump the repository format (`tbd_format` in `.tbd/config.yml`, e.g.
|
|
1221
|
+
f04 → f05). The bump happens automatically on the first command after upgrading, and
|
|
1222
|
+
older tbd versions then refuse the repository until they are upgraded.
|
|
1223
|
+
If an upgrade hits unexpected bugs, you can cleanly abort and return to the previous
|
|
1224
|
+
version. This is everything a format upgrade can touch:
|
|
1225
|
+
|
|
1226
|
+
| State | Location | In git? | Written by | Revert |
|
|
1227
|
+
| --- | --- | --- | --- | --- |
|
|
1228
|
+
| Project config | `.tbd/config.yml` | tracked | the migration (format stamp) | `git checkout -- .tbd/config.yml`, or `git revert` the bump commit |
|
|
1229
|
+
| Agent surfaces | `AGENTS.md`, `.claude/`, `.agents/`, `.codex/` | tracked | only `tbd setup --auto` (marker refresh) | `git checkout --` those paths |
|
|
1230
|
+
| Shared layout stamp | `$GIT_COMMON_DIR/tbd/layout.yml` | machine-local, not in git | the migration (re-stamp) | delete it; it regenerates from whatever the config says |
|
|
1231
|
+
| Forked docs (f05) | `docs/tbd/`, `.tbd/doc-forks/` | tracked once committed | only `tbd docs fork` | `git checkout --`/`git revert` if committed; delete if never committed |
|
|
1232
|
+
| Docs cache | `.tbd/docs/` | gitignored | doc sync (unchanged by migration) | none needed; always safe to delete and re-sync |
|
|
1233
|
+
| Issue data | `tbd-sync` branch + `$GIT_COMMON_DIR/tbd/data-sync-worktree/` | git branch | **never touched by migration** | none needed; the worktree re-materializes from the branch |
|
|
1234
|
+
|
|
1235
|
+
**Abort recipe** (works from any state, including a crash mid-upgrade):
|
|
1236
|
+
|
|
1237
|
+
```bash
|
|
1238
|
+
# 1. Restore the tracked files (or `git revert` the format-bump commit):
|
|
1239
|
+
git checkout -- .tbd/config.yml
|
|
1240
|
+
git checkout -- AGENTS.md .claude .agents .codex # only if `tbd setup --auto` ran
|
|
1241
|
+
|
|
1242
|
+
# 2. Delete the machine-local format stamp (regenerates from the config):
|
|
1243
|
+
rm "$(git rev-parse --path-format=absolute --git-common-dir)/tbd/layout.yml"
|
|
1244
|
+
|
|
1245
|
+
# 3. Only if docs were forked and never committed:
|
|
1246
|
+
rm -rf docs/tbd .tbd/doc-forks
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
After this, the previous tbd version works again, and re-running the upgrade later is
|
|
1250
|
+
safe; the migration is idempotent from any of these states.
|
|
1251
|
+
|
|
1252
|
+
Reverting `.tbd/config.yml` is enough to drop the format gate even if forks were already
|
|
1253
|
+
committed: compatibility is decided only by `tbd_format` in the config, not by the
|
|
1254
|
+
presence of `docs/tbd/` or `.tbd/doc-forks/`. Committed fork files simply become inert
|
|
1255
|
+
`local` docs under the older version; harmless to leave in place, so step 3 is only for
|
|
1256
|
+
cleanup, never required to abort.
|
|
1257
|
+
|
|
1258
|
+
Notes:
|
|
1259
|
+
|
|
1260
|
+
- **The migration never writes issue data**, so the recipe above cannot lose issues; it
|
|
1261
|
+
touches only the two stamps and tracked files.
|
|
1262
|
+
A bigger hammer also exists: deleting the entire `$GIT_COMMON_DIR/tbd/` directory is
|
|
1263
|
+
recoverable (layout and the data-sync worktree re-materialize from the config and the
|
|
1264
|
+
`tbd-sync` branch on the next command, or via `tbd doctor --fix`); **but only for
|
|
1265
|
+
synced data**. Issue changes made with `--no-sync` since the last `tbd sync` live as
|
|
1266
|
+
uncommitted files inside that worktree and would be lost, so run `tbd sync` first if
|
|
1267
|
+
you must delete it. This is why the recipe deletes only `layout.yml`, never the whole
|
|
1268
|
+
directory.
|
|
1269
|
+
- **Interrupted upgrades self-heal.** If the process dies between the two stamp writes
|
|
1270
|
+
(layout updated but not config, or config but not layout), the next command with the
|
|
1271
|
+
new version completes the migration; the abort recipe above also works from either
|
|
1272
|
+
partial state.
|
|
1273
|
+
- **Quiesce other tbd processes first.** The same self-healing re-stamp that completes
|
|
1274
|
+
an interrupted upgrade can also undo an abort.
|
|
1275
|
+
Any concurrent `tbd` write (another worktree, a background agent, an editor hook)
|
|
1276
|
+
re-stamps `layout.yml` from whatever `.tbd/config.yml` currently says.
|
|
1277
|
+
If you delete `layout.yml` while the config is still on the new format, or before the
|
|
1278
|
+
config revert in step 1 has landed, the next write recreates the stamp and reopens the
|
|
1279
|
+
migration. Stop other agents and worktrees, do step 1 (revert the config) before step 2
|
|
1280
|
+
(delete the stamp), and the abort sticks.
|
|
1281
|
+
- Teammates each migrate their own machine-local stamp automatically; only the
|
|
1282
|
+
`.tbd/config.yml` change is shared (via your branch), so reverting that commit is the
|
|
1283
|
+
team-wide rollback.
|
|
1284
|
+
|
|
1121
1285
|
### Performance
|
|
1122
1286
|
|
|
1123
1287
|
For large repositories with many issues:
|
package/dist/docs/tbd-prime.md
CHANGED
|
@@ -109,7 +109,6 @@ tbd dep add <tests-id> <feature-id> # Tests depend on feature
|
|
|
109
109
|
## Setup Commands
|
|
110
110
|
|
|
111
111
|
- `tbd setup --auto` - Non-interactive setup with smart defaults (for agents/scripts)
|
|
112
|
-
- `tbd setup --interactive` - Interactive setup with prompts (for humans)
|
|
113
112
|
- `tbd setup --from-beads` - Migrate from Beads to tbd
|
|
114
113
|
|
|
115
114
|
## Quick Reference
|
|
@@ -14,6 +14,7 @@ Manual QA playbook for [brief description of what is being tested].
|
|
|
14
14
|
> This is a kind of “manual test”: it is not a strict end-to-end integration test,
|
|
15
15
|
> because it is too costly or pass/fail is not clear.
|
|
16
16
|
> Goals:
|
|
17
|
+
>
|
|
17
18
|
> - Document expected behavior that are difficult to capture fully in unit, integration,
|
|
18
19
|
> or end-to-end/golden tests.
|
|
19
20
|
> - Make it possible for an agent to follow the instructions and evaluate if things are
|
|
@@ -54,9 +55,10 @@ Manual QA playbook for [brief description of what is being tested].
|
|
|
54
55
|
|
|
55
56
|
* * *
|
|
56
57
|
|
|
57
|
-
## Related Documentation
|
|
58
|
+
## Related Documentation: Read for Context
|
|
58
59
|
|
|
59
60
|
> Include links to relevant documentation that provides context for this test, e.g.
|
|
61
|
+
>
|
|
60
62
|
> - [spec-name.md](path/to/spec) - [Brief description]
|
|
61
63
|
> - [architecture-doc.md](path/to/arch) - [Brief description]
|
|
62
64
|
> - [other-qa-playbook.md](path/to/qa) - [Brief description]
|
|
@@ -46,7 +46,7 @@ author: Joshua Levy (github.com/jlevy) with LLM assistance
|
|
|
46
46
|
> This section is for the “so what?”—conclusions that aren’t obvious from the individual
|
|
47
47
|
> findings alone.
|
|
48
48
|
|
|
49
|
-
## Comparison Matrix *(optional
|
|
49
|
+
## Comparison Matrix *(optional, for comparative research)*
|
|
50
50
|
|
|
51
51
|
> When evaluating multiple options or technologies, a structured comparison table is
|
|
52
52
|
> often the most useful artifact.
|
|
@@ -103,7 +103,7 @@ author: Joshua Levy (github.com/jlevy) with LLM assistance
|
|
|
103
103
|
- [ ] Action item 1
|
|
104
104
|
- [ ] Action item 2
|
|
105
105
|
|
|
106
|
-
## Methodology *(optional
|
|
106
|
+
## Methodology *(optional, for complex or high-stakes research)*
|
|
107
107
|
|
|
108
108
|
> How the research was conducted: what searches were run, what sources were
|
|
109
109
|
> cross-referenced, what information was not found or remains uncertain.
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { s as stringifyYaml } from "./yaml-utils-BPy991by.mjs";
|
|
2
|
+
import { P as resolveSharedTbdPaths } from "./paths-C1DpnZJW.mjs";
|
|
3
|
+
import { n as withLockfile } from "./lockfile-BR0laSDy.mjs";
|
|
4
|
+
import { n as isDocRef } from "./docref-C7g0sjvL.mjs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { writeFile } from "atomically";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
//#region src/file/fork-manifest.ts
|
|
13
|
+
/**
|
|
14
|
+
* Fork manifest and base snapshots for the forkable-docs workflow.
|
|
15
|
+
*
|
|
16
|
+
* All fork state lives under one committed directory, `.tbd/doc-forks/`:
|
|
17
|
+
* forks.yml : the manifest (provenance, hashes, revisions)
|
|
18
|
+
* base/<kind>/<name>.md : verbatim base snapshots (the merge bases)
|
|
19
|
+
*
|
|
20
|
+
* The base of every fork is a stored snapshot of the upstream content the fork
|
|
21
|
+
* was created from. Together with `base_hash`, this makes "customized?",
|
|
22
|
+
* "stale vs upstream?", and three-way merging cheap, exact, offline operations.
|
|
23
|
+
*
|
|
24
|
+
* The hashing and state computation here are pure functions (see
|
|
25
|
+
* {@link hashContent} and {@link computeForkStatus}); only the read/write helpers
|
|
26
|
+
* touch the filesystem.
|
|
27
|
+
*/
|
|
28
|
+
/** Directory (repo-relative under `.tbd/`) holding all fork state. */
|
|
29
|
+
const DOC_FORKS_DIR = ".tbd/doc-forks";
|
|
30
|
+
/** Manifest filename within {@link DOC_FORKS_DIR}. */
|
|
31
|
+
const FORKS_FILE = "forks.yml";
|
|
32
|
+
/** Subdirectory within {@link DOC_FORKS_DIR} holding base snapshots. */
|
|
33
|
+
const BASE_SUBDIR = "base";
|
|
34
|
+
/** Doc kinds that can be forked. */
|
|
35
|
+
const FORK_KINDS = [
|
|
36
|
+
"guideline",
|
|
37
|
+
"shortcut",
|
|
38
|
+
"template",
|
|
39
|
+
"reference"
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* A safe doc name: no path separators, no `..`, no leading dot, no NUL.
|
|
43
|
+
* Names are used to build filesystem paths (and are a doc's identity), so a
|
|
44
|
+
* crafted manifest must not be able to escape the fork dir (e.g. via a
|
|
45
|
+
* `../../../../victim` name through `unfork --force`). Allows the punctuation
|
|
46
|
+
* real doc names use (letters, digits, `.`, `_`, `-`).
|
|
47
|
+
*/
|
|
48
|
+
const SAFE_DOC_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
49
|
+
function isSafeDocName(name) {
|
|
50
|
+
return SAFE_DOC_NAME.test(name) && !name.includes("..") && !name.endsWith(".md");
|
|
51
|
+
}
|
|
52
|
+
const ForkEntrySchema = z.object({
|
|
53
|
+
name: z.string().min(1).refine(isSafeDocName, { message: "invalid doc name (no path separators, \"..\", or leading dot)" }),
|
|
54
|
+
kind: z.enum(FORK_KINDS),
|
|
55
|
+
path: z.string().min(1),
|
|
56
|
+
source: z.string().min(1).refine(isDocRef, { message: "source must be a valid docref" }),
|
|
57
|
+
base_hash: z.string().min(1),
|
|
58
|
+
tbd_version: z.string().optional(),
|
|
59
|
+
source_revision: z.string().optional(),
|
|
60
|
+
source_tag: z.string().optional(),
|
|
61
|
+
conflicted: z.boolean().optional()
|
|
62
|
+
});
|
|
63
|
+
const ForkManifestSchema = z.object({ forks: z.array(ForkEntrySchema).default([]) });
|
|
64
|
+
/** An empty manifest (no forks). */
|
|
65
|
+
function emptyManifest() {
|
|
66
|
+
return { forks: [] };
|
|
67
|
+
}
|
|
68
|
+
/** Normalize line endings to LF so hashes are stable across platforms. */
|
|
69
|
+
function normalizeLineEndings(content) {
|
|
70
|
+
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
71
|
+
}
|
|
72
|
+
/** sha256: hash of the LF-normalized content. */
|
|
73
|
+
function hashContent(content) {
|
|
74
|
+
return `sha256:${createHash("sha256").update(normalizeLineEndings(content), "utf8").digest("hex")}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The labels tbd writes into its three-way merge conflict markers. Detection of
|
|
78
|
+
* an *unresolved* conflict keys off these specific labels (not generic marker
|
|
79
|
+
* lines) so a forked doc that legitimately contains conflict-marker examples
|
|
80
|
+
* (e.g. a git tutorial, or our own golden-testing guideline) is not stuck
|
|
81
|
+
* `conflicted` forever after one unrelated `update --merge`.
|
|
82
|
+
*/
|
|
83
|
+
const CONFLICT_LABELS = {
|
|
84
|
+
ours: "ours (your fork)",
|
|
85
|
+
base: "base (fork point)",
|
|
86
|
+
theirs: "theirs (upstream)"
|
|
87
|
+
};
|
|
88
|
+
/** Whether `content` still carries tbd's own unresolved conflict markers. */
|
|
89
|
+
function hasUnresolvedConflict(content) {
|
|
90
|
+
return content.includes(`<<<<<<< ${CONFLICT_LABELS.ours}`) && content.includes(`>>>>>>> ${CONFLICT_LABELS.theirs}`);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Loose semver comparison on major.minor.patch (prerelease ignored). Returns
|
|
94
|
+
* null when either version is unparseable; callers must not guard on a version
|
|
95
|
+
* they cannot parse (treat null as "do not block").
|
|
96
|
+
*/
|
|
97
|
+
function compareVersionsLoose(a, b) {
|
|
98
|
+
const parse = (v) => {
|
|
99
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
100
|
+
return m ? [
|
|
101
|
+
Number(m[1]),
|
|
102
|
+
Number(m[2]),
|
|
103
|
+
Number(m[3])
|
|
104
|
+
] : null;
|
|
105
|
+
};
|
|
106
|
+
const pa = parse(a);
|
|
107
|
+
const pb = parse(b);
|
|
108
|
+
if (!pa || !pb) return null;
|
|
109
|
+
for (let i = 0; i < 3; i++) {
|
|
110
|
+
const x = pa[i];
|
|
111
|
+
const y = pb[i];
|
|
112
|
+
if (x < y) return -1;
|
|
113
|
+
if (x > y) return 1;
|
|
114
|
+
}
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Run `fn` while holding the doc-forks manifest lock, serializing the
|
|
119
|
+
* read-modify-write of `forks.yml` across concurrent fork/unfork/update so
|
|
120
|
+
* entries are not lost to last-writer-wins. The lock lives in the machine-local
|
|
121
|
+
* git common dir (never committed), alongside the data-sync lock.
|
|
122
|
+
*/
|
|
123
|
+
async function withForkManifestLock(tbdRoot, fn) {
|
|
124
|
+
const paths = await resolveSharedTbdPaths(tbdRoot);
|
|
125
|
+
await mkdir(paths.sharedLocksDir, { recursive: true });
|
|
126
|
+
return withLockfile(join(paths.sharedLocksDir, "doc-forks.lock"), fn);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Compute a doc's fork status from hashes and flags. Pure and total; every
|
|
130
|
+
* combination of inputs maps to exactly one {@link ForkStatus}.
|
|
131
|
+
*/
|
|
132
|
+
function computeForkStatus(input) {
|
|
133
|
+
const none = {
|
|
134
|
+
customized: false,
|
|
135
|
+
stale: false,
|
|
136
|
+
conflicted: false,
|
|
137
|
+
orphaned: false
|
|
138
|
+
};
|
|
139
|
+
if (!input.inManifest) return {
|
|
140
|
+
state: input.forkFileExists ? "local" : "upstream",
|
|
141
|
+
...none
|
|
142
|
+
};
|
|
143
|
+
if (!input.forkFileExists) return {
|
|
144
|
+
state: "missing",
|
|
145
|
+
...none
|
|
146
|
+
};
|
|
147
|
+
const customized = input.forkHash !== input.baseHash;
|
|
148
|
+
const orphaned = input.cacheHash === void 0;
|
|
149
|
+
const stale = !orphaned && input.cacheHash !== input.baseHash;
|
|
150
|
+
const conflicted = Boolean(input.conflictedFlag && input.markersPresent);
|
|
151
|
+
let state;
|
|
152
|
+
if (conflicted) state = "conflicted";
|
|
153
|
+
else if (orphaned) state = "orphaned";
|
|
154
|
+
else if (customized) state = "customized";
|
|
155
|
+
else if (stale) state = "stale";
|
|
156
|
+
else state = "forked";
|
|
157
|
+
return {
|
|
158
|
+
state,
|
|
159
|
+
customized,
|
|
160
|
+
stale,
|
|
161
|
+
conflicted,
|
|
162
|
+
orphaned
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Find a fork entry by name, optionally constrained to a kind. */
|
|
166
|
+
function findFork(manifest, name, kind) {
|
|
167
|
+
return manifest.forks.find((f) => f.name === name && (kind === void 0 || f.kind === kind));
|
|
168
|
+
}
|
|
169
|
+
/** Return a new manifest with `entry` inserted or replaced (matched by kind+name). */
|
|
170
|
+
function upsertFork(manifest, entry) {
|
|
171
|
+
const forks = manifest.forks.filter((f) => !(f.name === entry.name && f.kind === entry.kind));
|
|
172
|
+
forks.push(entry);
|
|
173
|
+
forks.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
|
|
174
|
+
return { forks };
|
|
175
|
+
}
|
|
176
|
+
/** Return a new manifest with the matching entry removed. */
|
|
177
|
+
function removeFork(manifest, name, kind) {
|
|
178
|
+
return { forks: manifest.forks.filter((f) => !(f.name === name && (kind === void 0 || f.kind === kind))) };
|
|
179
|
+
}
|
|
180
|
+
function forksDir(tbdRoot) {
|
|
181
|
+
return join(tbdRoot, DOC_FORKS_DIR);
|
|
182
|
+
}
|
|
183
|
+
function forksFilePath(tbdRoot) {
|
|
184
|
+
return join(forksDir(tbdRoot), FORKS_FILE);
|
|
185
|
+
}
|
|
186
|
+
function baseFilePath(tbdRoot, kind, name) {
|
|
187
|
+
return join(forksDir(tbdRoot), BASE_SUBDIR, kind, `${name}.md`);
|
|
188
|
+
}
|
|
189
|
+
function isNotFound(err) {
|
|
190
|
+
return err?.code === "ENOENT";
|
|
191
|
+
}
|
|
192
|
+
/** The outer manifest shape, before per-entry validation. */
|
|
193
|
+
const ForkManifestEnvelopeSchema = z.object({ forks: z.array(z.unknown()).default([]) });
|
|
194
|
+
/**
|
|
195
|
+
* Read the fork manifest, returning an empty manifest if none exists.
|
|
196
|
+
*
|
|
197
|
+
* Parsing is tolerant per entry: a malformed or unsafe entry (bad name/kind,
|
|
198
|
+
* path-traversal attempt) is dropped with a warning rather than aborting the
|
|
199
|
+
* whole read. This both fails closed on a crafted entry (it is never returned,
|
|
200
|
+
* so commands never act on it; no out-of-tree deletes) and keeps one corrupt
|
|
201
|
+
* entry from taking down status/update for every other fork.
|
|
202
|
+
*/
|
|
203
|
+
async function readForkManifest(tbdRoot) {
|
|
204
|
+
let content;
|
|
205
|
+
try {
|
|
206
|
+
content = await readFile(forksFilePath(tbdRoot), "utf-8");
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (isNotFound(err)) return emptyManifest();
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
const envelope = ForkManifestEnvelopeSchema.parse(parse(content) ?? { forks: [] });
|
|
212
|
+
const forks = [];
|
|
213
|
+
let dropped = 0;
|
|
214
|
+
for (const raw of envelope.forks) {
|
|
215
|
+
const parsed = ForkEntrySchema.safeParse(raw);
|
|
216
|
+
if (parsed.success) forks.push(parsed.data);
|
|
217
|
+
else dropped++;
|
|
218
|
+
}
|
|
219
|
+
if (dropped > 0) process.stderr.write(`• Ignored ${dropped} invalid fork manifest entr${dropped === 1 ? "y" : "ies"} in ${FORKS_FILE} (bad name/kind or unsafe path). Run 'tbd doctor' to review.\n`);
|
|
220
|
+
return { forks };
|
|
221
|
+
}
|
|
222
|
+
/** Write the fork manifest (creating `.tbd/doc-forks/` as needed). */
|
|
223
|
+
async function writeForkManifest(tbdRoot, manifest) {
|
|
224
|
+
await mkdir(forksDir(tbdRoot), { recursive: true });
|
|
225
|
+
const yaml = stringifyYaml(manifest, {
|
|
226
|
+
lineWidth: 0,
|
|
227
|
+
sortMapEntries: false
|
|
228
|
+
});
|
|
229
|
+
await writeFile(forksFilePath(tbdRoot), yaml);
|
|
230
|
+
}
|
|
231
|
+
/** Read a base snapshot's content, or null if it is absent. */
|
|
232
|
+
async function readBaseContent(tbdRoot, kind, name) {
|
|
233
|
+
try {
|
|
234
|
+
return await readFile(baseFilePath(tbdRoot, kind, name), "utf-8");
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (isNotFound(err)) return null;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Write a base snapshot verbatim (creating parent dirs as needed). */
|
|
241
|
+
async function writeBaseContent(tbdRoot, kind, name, content) {
|
|
242
|
+
const path = baseFilePath(tbdRoot, kind, name);
|
|
243
|
+
await mkdir(dirname(path), { recursive: true });
|
|
244
|
+
await writeFile(path, content);
|
|
245
|
+
}
|
|
246
|
+
/** Remove a base snapshot if present. */
|
|
247
|
+
async function removeBaseContent(tbdRoot, kind, name) {
|
|
248
|
+
await rm(baseFilePath(tbdRoot, kind, name), { force: true });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
//#endregion
|
|
252
|
+
export { withForkManifestLock as C, upsertFork as S, writeForkManifest as T, normalizeLineEndings as _, FORK_KINDS as a, removeBaseContent as b, compareVersionsLoose as c, findFork as d, forksDir as f, isSafeDocName as g, hashContent as h, FORKS_FILE as i, computeForkStatus as l, hasUnresolvedConflict as m, CONFLICT_LABELS as n, ForkEntrySchema as o, forksFilePath as p, DOC_FORKS_DIR as r, baseFilePath as s, BASE_SUBDIR as t, emptyManifest as u, readBaseContent as v, writeBaseContent as w, removeFork as x, readForkManifest as y };
|
|
253
|
+
//# sourceMappingURL=fork-manifest-ByU7U2do.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fork-manifest-ByU7U2do.mjs","names":["parseYaml"],"sources":["../src/file/fork-manifest.ts"],"sourcesContent":["/**\n * Fork manifest and base snapshots for the forkable-docs workflow.\n *\n * All fork state lives under one committed directory, `.tbd/doc-forks/`:\n * forks.yml : the manifest (provenance, hashes, revisions)\n * base/<kind>/<name>.md : verbatim base snapshots (the merge bases)\n *\n * The base of every fork is a stored snapshot of the upstream content the fork\n * was created from. Together with `base_hash`, this makes \"customized?\",\n * \"stale vs upstream?\", and three-way merging cheap, exact, offline operations.\n *\n * The hashing and state computation here are pure functions (see\n * {@link hashContent} and {@link computeForkStatus}); only the read/write helpers\n * touch the filesystem.\n */\n\nimport { createHash } from 'node:crypto';\nimport { readFile, mkdir, rm } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\n\nimport { writeFile } from 'atomically';\nimport { parse as parseYaml } from 'yaml';\nimport { z } from 'zod';\n\nimport { stringifyYaml } from '../utils/yaml-utils.js';\nimport { withLockfile } from '../utils/lockfile.js';\nimport { resolveSharedTbdPaths } from '../lib/paths.js';\nimport { isDocRef } from '../docref/index.js';\n\n/** Directory (repo-relative under `.tbd/`) holding all fork state. */\nexport const DOC_FORKS_DIR = '.tbd/doc-forks';\n/** Manifest filename within {@link DOC_FORKS_DIR}. */\nexport const FORKS_FILE = 'forks.yml';\n/** Subdirectory within {@link DOC_FORKS_DIR} holding base snapshots. */\nexport const BASE_SUBDIR = 'base';\n\n/** Doc kinds that can be forked. */\nexport const FORK_KINDS = ['guideline', 'shortcut', 'template', 'reference'] as const;\nexport type ForkKind = (typeof FORK_KINDS)[number];\n\n// =============================================================================\n// Schema\n// =============================================================================\n\n/**\n * A safe doc name: no path separators, no `..`, no leading dot, no NUL.\n * Names are used to build filesystem paths (and are a doc's identity), so a\n * crafted manifest must not be able to escape the fork dir (e.g. via a\n * `../../../../victim` name through `unfork --force`). Allows the punctuation\n * real doc names use (letters, digits, `.`, `_`, `-`).\n */\nconst SAFE_DOC_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\nexport function isSafeDocName(name: string): boolean {\n return SAFE_DOC_NAME.test(name) && !name.includes('..') && !name.endsWith('.md');\n}\n\nexport const ForkEntrySchema = z.object({\n /** Doc name (e.g. \"python-rules\"). Constrained so it cannot escape the fork dir. */\n name: z.string().min(1).refine(isSafeDocName, {\n message: 'invalid doc name (no path separators, \"..\", or leading dot)',\n }),\n /** Doc kind: must be one of the known fork kinds. */\n kind: z.enum(FORK_KINDS),\n /** Repo-relative path of the forked file (e.g. \"docs/tbd/guidelines/python-rules.md\"). */\n path: z.string().min(1),\n /** Provenance docref the fork was created from (docref-everywhere rule). */\n source: z.string().min(1).refine(isDocRef, { message: 'source must be a valid docref' }),\n /** sha256: of the LF-normalized base content. */\n base_hash: z.string().min(1),\n /** tbd version when the base was last set. */\n tbd_version: z.string().optional(),\n /** Upstream commit at base time (git-hosted sources only). */\n source_revision: z.string().optional(),\n /** Exact/matching tag at base time, when one exists. */\n source_tag: z.string().optional(),\n /** Set by `update --merge` when it leaves conflict markers; auto-clears. */\n conflicted: z.boolean().optional(),\n});\n\nexport type ForkEntry = z.infer<typeof ForkEntrySchema>;\n\nexport const ForkManifestSchema = z.object({\n forks: z.array(ForkEntrySchema).default([]),\n});\n\nexport type ForkManifest = z.infer<typeof ForkManifestSchema>;\n\n/** An empty manifest (no forks). */\nexport function emptyManifest(): ForkManifest {\n return { forks: [] };\n}\n\n// =============================================================================\n// Hashing (pure)\n// =============================================================================\n\n/** Normalize line endings to LF so hashes are stable across platforms. */\nexport function normalizeLineEndings(content: string): string {\n return content.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n}\n\n/** sha256: hash of the LF-normalized content. */\nexport function hashContent(content: string): string {\n const hex = createHash('sha256').update(normalizeLineEndings(content), 'utf8').digest('hex');\n return `sha256:${hex}`;\n}\n\n/**\n * Whether `content` still contains git conflict markers. Requires all three\n * standard markers so prose that merely discusses one marker is not flagged.\n */\nexport function hasConflictMarkers(content: string): boolean {\n return /^<{7}/m.test(content) && /^={7}\\s*$/m.test(content) && /^>{7}/m.test(content);\n}\n\n/**\n * The labels tbd writes into its three-way merge conflict markers. Detection of\n * an *unresolved* conflict keys off these specific labels (not generic marker\n * lines) so a forked doc that legitimately contains conflict-marker examples\n * (e.g. a git tutorial, or our own golden-testing guideline) is not stuck\n * `conflicted` forever after one unrelated `update --merge`.\n */\nexport const CONFLICT_LABELS = {\n ours: 'ours (your fork)',\n base: 'base (fork point)',\n theirs: 'theirs (upstream)',\n} as const;\n\n/** Whether `content` still carries tbd's own unresolved conflict markers. */\nexport function hasUnresolvedConflict(content: string): boolean {\n return (\n content.includes(`<<<<<<< ${CONFLICT_LABELS.ours}`) &&\n content.includes(`>>>>>>> ${CONFLICT_LABELS.theirs}`)\n );\n}\n\n/**\n * Loose semver comparison on major.minor.patch (prerelease ignored). Returns\n * null when either version is unparseable; callers must not guard on a version\n * they cannot parse (treat null as \"do not block\").\n */\nexport function compareVersionsLoose(a: string, b: string): -1 | 0 | 1 | null {\n const parse = (v: string): [number, number, number] | null => {\n const m = /^(\\d+)\\.(\\d+)\\.(\\d+)/.exec(v.trim());\n return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n };\n const pa = parse(a);\n const pb = parse(b);\n if (!pa || !pb) return null;\n for (let i = 0; i < 3; i++) {\n const x = pa[i]!;\n const y = pb[i]!;\n if (x < y) return -1;\n if (x > y) return 1;\n }\n return 0;\n}\n\n/**\n * Run `fn` while holding the doc-forks manifest lock, serializing the\n * read-modify-write of `forks.yml` across concurrent fork/unfork/update so\n * entries are not lost to last-writer-wins. The lock lives in the machine-local\n * git common dir (never committed), alongside the data-sync lock.\n */\nexport async function withForkManifestLock<T>(tbdRoot: string, fn: () => Promise<T>): Promise<T> {\n const paths = await resolveSharedTbdPaths(tbdRoot);\n await mkdir(paths.sharedLocksDir, { recursive: true });\n return withLockfile(join(paths.sharedLocksDir, 'doc-forks.lock'), fn);\n}\n\n// =============================================================================\n// State computation (pure)\n// =============================================================================\n\nexport type DocState =\n | 'upstream'\n | 'forked'\n | 'customized'\n | 'stale'\n | 'conflicted'\n | 'local'\n | 'missing'\n | 'orphaned';\n\n/** Inputs for {@link computeForkStatus}; all comparisons are by hash. */\nexport interface ForkStatusInput {\n /** Whether a manifest entry exists for this doc. */\n inManifest: boolean;\n /** Whether the forked file is present on disk. */\n forkFileExists: boolean;\n /** sha256: of the current forked file (if present). */\n forkHash?: string;\n /** Recorded `base_hash` from the manifest entry. */\n baseHash?: string;\n /** sha256: of the current upstream/cache content; undefined if the source is gone. */\n cacheHash?: string;\n /** Manifest `conflicted` flag. */\n conflictedFlag?: boolean;\n /** Whether conflict markers are present in the current file. */\n markersPresent?: boolean;\n}\n\n/**\n * Computed lifecycle status of a doc. `state` is the headline for display; the\n * booleans are orthogonal modifiers (`customized` and `stale` can combine).\n */\nexport interface ForkStatus {\n state: DocState;\n customized: boolean;\n stale: boolean;\n conflicted: boolean;\n orphaned: boolean;\n}\n\n/**\n * Compute a doc's fork status from hashes and flags. Pure and total; every\n * combination of inputs maps to exactly one {@link ForkStatus}.\n */\nexport function computeForkStatus(input: ForkStatusInput): ForkStatus {\n const none = { customized: false, stale: false, conflicted: false, orphaned: false };\n\n if (!input.inManifest) {\n // A file in the fork dir with no manifest entry is a hand-authored local doc;\n // otherwise the doc is simply served from upstream via the cache.\n return { state: input.forkFileExists ? 'local' : 'upstream', ...none };\n }\n\n if (!input.forkFileExists) {\n return { state: 'missing', ...none };\n }\n\n const customized = input.forkHash !== input.baseHash;\n const orphaned = input.cacheHash === undefined;\n const stale = !orphaned && input.cacheHash !== input.baseHash;\n const conflicted = Boolean(input.conflictedFlag && input.markersPresent);\n\n // Headline state for display. `customized` and `stale` can combine; the\n // renderer composes \"customized, stale\" from state + the stale modifier.\n let state: DocState;\n if (conflicted) {\n state = 'conflicted';\n } else if (orphaned) {\n state = 'orphaned';\n } else if (customized) {\n state = 'customized';\n } else if (stale) {\n state = 'stale';\n } else {\n state = 'forked';\n }\n\n return { state, customized, stale, conflicted, orphaned };\n}\n\n// =============================================================================\n// Manifest helpers (pure)\n// =============================================================================\n\n/** Find a fork entry by name, optionally constrained to a kind. */\nexport function findFork(\n manifest: ForkManifest,\n name: string,\n kind?: string,\n): ForkEntry | undefined {\n return manifest.forks.find((f) => f.name === name && (kind === undefined || f.kind === kind));\n}\n\n/** Return a new manifest with `entry` inserted or replaced (matched by kind+name). */\nexport function upsertFork(manifest: ForkManifest, entry: ForkEntry): ForkManifest {\n const forks = manifest.forks.filter((f) => !(f.name === entry.name && f.kind === entry.kind));\n forks.push(entry);\n forks.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));\n return { forks };\n}\n\n/** Return a new manifest with the matching entry removed. */\nexport function removeFork(manifest: ForkManifest, name: string, kind?: string): ForkManifest {\n return {\n forks: manifest.forks.filter(\n (f) => !(f.name === name && (kind === undefined || f.kind === kind)),\n ),\n };\n}\n\n// =============================================================================\n// Paths\n// =============================================================================\n\nexport function forksDir(tbdRoot: string): string {\n return join(tbdRoot, DOC_FORKS_DIR);\n}\n\nexport function forksFilePath(tbdRoot: string): string {\n return join(forksDir(tbdRoot), FORKS_FILE);\n}\n\nexport function baseFilePath(tbdRoot: string, kind: string, name: string): string {\n return join(forksDir(tbdRoot), BASE_SUBDIR, kind, `${name}.md`);\n}\n\n// =============================================================================\n// Filesystem I/O\n// =============================================================================\n\nfunction isNotFound(err: unknown): boolean {\n return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';\n}\n\n/** The outer manifest shape, before per-entry validation. */\nconst ForkManifestEnvelopeSchema = z.object({\n forks: z.array(z.unknown()).default([]),\n});\n\n/**\n * Read the fork manifest, returning an empty manifest if none exists.\n *\n * Parsing is tolerant per entry: a malformed or unsafe entry (bad name/kind,\n * path-traversal attempt) is dropped with a warning rather than aborting the\n * whole read. This both fails closed on a crafted entry (it is never returned,\n * so commands never act on it; no out-of-tree deletes) and keeps one corrupt\n * entry from taking down status/update for every other fork.\n */\nexport async function readForkManifest(tbdRoot: string): Promise<ForkManifest> {\n let content: string;\n try {\n content = await readFile(forksFilePath(tbdRoot), 'utf-8');\n } catch (err) {\n if (isNotFound(err)) {\n return emptyManifest();\n }\n throw err;\n }\n const envelope = ForkManifestEnvelopeSchema.parse(parseYaml(content) ?? { forks: [] });\n const forks: ForkEntry[] = [];\n let dropped = 0;\n for (const raw of envelope.forks) {\n const parsed = ForkEntrySchema.safeParse(raw);\n if (parsed.success) {\n forks.push(parsed.data);\n } else {\n dropped++;\n }\n }\n if (dropped > 0) {\n process.stderr.write(\n `• Ignored ${dropped} invalid fork manifest entr${dropped === 1 ? 'y' : 'ies'} ` +\n `in ${FORKS_FILE} (bad name/kind or unsafe path). Run 'tbd doctor' to review.\\n`,\n );\n }\n return { forks };\n}\n\n/** Write the fork manifest (creating `.tbd/doc-forks/` as needed). */\nexport async function writeForkManifest(tbdRoot: string, manifest: ForkManifest): Promise<void> {\n await mkdir(forksDir(tbdRoot), { recursive: true });\n const yaml = stringifyYaml(manifest, { lineWidth: 0, sortMapEntries: false });\n await writeFile(forksFilePath(tbdRoot), yaml);\n}\n\n/** Read a base snapshot's content, or null if it is absent. */\nexport async function readBaseContent(\n tbdRoot: string,\n kind: string,\n name: string,\n): Promise<string | null> {\n try {\n return await readFile(baseFilePath(tbdRoot, kind, name), 'utf-8');\n } catch (err) {\n if (isNotFound(err)) {\n return null;\n }\n throw err;\n }\n}\n\n/** Write a base snapshot verbatim (creating parent dirs as needed). */\nexport async function writeBaseContent(\n tbdRoot: string,\n kind: string,\n name: string,\n content: string,\n): Promise<void> {\n const path = baseFilePath(tbdRoot, kind, name);\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, content);\n}\n\n/** Remove a base snapshot if present. */\nexport async function removeBaseContent(\n tbdRoot: string,\n kind: string,\n name: string,\n): Promise<void> {\n await rm(baseFilePath(tbdRoot, kind, name), { force: true });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { C as withForkManifestLock, S as upsertFork, T as writeForkManifest, _ as normalizeLineEndings, a as FORK_KINDS, b as removeBaseContent, c as compareVersionsLoose, d as findFork, f as forksDir, g as isSafeDocName, h as hashContent, i as FORKS_FILE, l as computeForkStatus, m as hasUnresolvedConflict, n as CONFLICT_LABELS, o as ForkEntrySchema, p as forksFilePath, r as DOC_FORKS_DIR, s as baseFilePath, t as BASE_SUBDIR, u as emptyManifest, v as readBaseContent, w as writeBaseContent, x as removeFork, y as readForkManifest } from "./fork-manifest-ByU7U2do.mjs";
|
|
2
|
+
|
|
3
|
+
export { DOC_FORKS_DIR, FORKS_FILE, findFork, hasUnresolvedConflict, hashContent, readForkManifest, removeBaseContent, removeFork, withForkManifestLock, writeForkManifest };
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as hasShortId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, i as generateUniqueShortId, l as reconcileMappings, n as calculateOptimalLength, o as loadIdMapping, r as createShortIdMapping, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts } from "./id-mapping-
|
|
1
|
+
import { a as hasShortId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, i as generateUniqueShortId, l as reconcileMappings, n as calculateOptimalLength, o as loadIdMapping, r as createShortIdMapping, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts } from "./id-mapping-DAIeLKzm.mjs";
|
|
2
2
|
|
|
3
3
|
export { loadIdMapping, mergeIdMappings, parseIdMappingFromYaml, reconcileMappings, resolveIdMappingConflicts, saveIdMapping };
|