@ybotok/loam 0.2.0-alpha.1 → 0.2.0-alpha.13
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/CHANGELOG.md +3448 -0
- package/COMPARISON.md +91 -3
- package/CONTRIBUTING.md +37 -0
- package/MIGRATING-from-OpenSpec.md +47 -23
- package/QUICKSTART.md +189 -12
- package/README.md +274 -65
- package/ROADMAP.md +572 -31
- package/SCHEMA.md +1709 -155
- package/WORKFLOW.md +298 -72
- package/dist/cli.js +26 -8
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt/adopt.js +5 -10
- package/dist/commands/adopt/adopt.js.map +1 -1
- package/dist/commands/adopt/render.js +50 -9
- package/dist/commands/adopt/render.js.map +1 -1
- package/dist/commands/archive/archive.js +16 -19
- package/dist/commands/archive/archive.js.map +1 -1
- package/dist/commands/archive/closing.js +42 -0
- package/dist/commands/archive/closing.js.map +1 -0
- package/dist/commands/archive/plan/contracts/asyncapi.js +36 -42
- package/dist/commands/archive/plan/contracts/asyncapi.js.map +1 -1
- package/dist/commands/archive/plan/contracts/consumers.js +56 -0
- package/dist/commands/archive/plan/contracts/consumers.js.map +1 -0
- package/dist/commands/archive/plan/contracts/openapi.js +77 -43
- package/dist/commands/archive/plan/contracts/openapi.js.map +1 -1
- package/dist/commands/archive/plan/contracts/verdict-lines.js +103 -0
- package/dist/commands/archive/plan/contracts/verdict-lines.js.map +1 -0
- package/dist/commands/archive/plan/document/head.js +151 -0
- package/dist/commands/archive/plan/document/head.js.map +1 -0
- package/dist/commands/archive/plan/document/links.js +284 -0
- package/dist/commands/archive/plan/document/links.js.map +1 -0
- package/dist/commands/archive/plan/document/run.js +136 -0
- package/dist/commands/archive/plan/document/run.js.map +1 -0
- package/dist/commands/archive/plan/gate.js +21 -19
- package/dist/commands/archive/plan/gate.js.map +1 -1
- package/dist/commands/archive/plan/landscape.js +167 -30
- package/dist/commands/archive/plan/landscape.js.map +1 -1
- package/dist/commands/archive/plan/model/extending.js +85 -0
- package/dist/commands/archive/plan/model/extending.js.map +1 -0
- package/dist/commands/archive/plan/refusal.js +14 -19
- package/dist/commands/archive/plan/refusal.js.map +1 -1
- package/dist/commands/archive/plan/specs.js +137 -56
- package/dist/commands/archive/plan/specs.js.map +1 -1
- package/dist/commands/archive/plan/state.js +3 -0
- package/dist/commands/archive/plan/state.js.map +1 -1
- package/dist/commands/archive/run.js +134 -30
- package/dist/commands/archive/run.js.map +1 -1
- package/dist/commands/archive/shipped.js +49 -0
- package/dist/commands/archive/shipped.js.map +1 -0
- package/dist/commands/context/context.js +85 -18
- package/dist/commands/context/context.js.map +1 -1
- package/dist/commands/context/print.js +29 -7
- package/dist/commands/context/print.js.map +1 -1
- package/dist/commands/delta/delta.js +102 -14
- package/dist/commands/delta/delta.js.map +1 -1
- package/dist/commands/delta/print.js +17 -1
- package/dist/commands/delta/print.js.map +1 -1
- package/dist/commands/dependencies.js +3 -7
- package/dist/commands/dependencies.js.map +1 -1
- package/dist/commands/diff/diff.js +20 -27
- package/dist/commands/diff/diff.js.map +1 -1
- package/dist/commands/doctor.js +60 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/explain/explain.js +89 -13
- package/dist/commands/explain/explain.js.map +1 -1
- package/dist/commands/explore.js +16 -10
- package/dist/commands/explore.js.map +1 -1
- package/dist/commands/gate/gate.js +45 -8
- package/dist/commands/gate/gate.js.map +1 -1
- package/dist/commands/gate/print.js +15 -3
- package/dist/commands/gate/print.js.map +1 -1
- package/dist/commands/gherkin/commit.js +13 -54
- package/dist/commands/gherkin/commit.js.map +1 -1
- package/dist/commands/gherkin/gherkin.js +36 -20
- package/dist/commands/gherkin/gherkin.js.map +1 -1
- package/dist/commands/gherkin/reconcile.js +10 -13
- package/dist/commands/gherkin/reconcile.js.map +1 -1
- package/dist/commands/init/first-hour.js +44 -0
- package/dist/commands/init/first-hour.js.map +1 -1
- package/dist/commands/init/init.js +18 -22
- package/dist/commands/init/init.js.map +1 -1
- package/dist/commands/list/campaign/campaign.js +4 -6
- package/dist/commands/list/campaign/campaign.js.map +1 -1
- package/dist/commands/list/json.js +5 -7
- package/dist/commands/list/json.js.map +1 -1
- package/dist/commands/list/list.js +44 -29
- package/dist/commands/list/list.js.map +1 -1
- package/dist/commands/list/print.js +52 -9
- package/dist/commands/list/print.js.map +1 -1
- package/dist/commands/list/suites/suites.js +140 -0
- package/dist/commands/list/suites/suites.js.map +1 -0
- package/dist/commands/list/views.js +37 -34
- package/dist/commands/list/views.js.map +1 -1
- package/dist/commands/mcp/dispatch.js +91 -36
- package/dist/commands/mcp/dispatch.js.map +1 -1
- package/dist/commands/mcp/mcp.js +56 -15
- package/dist/commands/mcp/mcp.js.map +1 -1
- package/dist/commands/mcp/serve.js.map +1 -1
- package/dist/commands/migrate-openspec/materialize/active.js +27 -22
- package/dist/commands/migrate-openspec/materialize/active.js.map +1 -1
- package/dist/commands/migrate-openspec/materialize/stage.js +4 -3
- package/dist/commands/migrate-openspec/materialize/stage.js.map +1 -1
- package/dist/commands/migrate-openspec/materialize/target.js +2 -2
- package/dist/commands/migrate-openspec/materialize/target.js.map +1 -1
- package/dist/commands/migrate-openspec/openspec/decisions.js +9 -14
- package/dist/commands/migrate-openspec/openspec/decisions.js.map +1 -1
- package/dist/commands/migrate-openspec/openspec/error.js +7 -8
- package/dist/commands/migrate-openspec/openspec/error.js.map +1 -1
- package/dist/commands/migrate-openspec/openspec/mapping.js +1 -9
- package/dist/commands/migrate-openspec/openspec/mapping.js.map +1 -1
- package/dist/commands/migrate-openspec/openspec/paths.js +3 -6
- package/dist/commands/migrate-openspec/openspec/paths.js.map +1 -1
- package/dist/commands/new/capability-template.js +118 -0
- package/dist/commands/new/capability-template.js.map +1 -0
- package/dist/commands/new/commit.js +8 -22
- package/dist/commands/new/commit.js.map +1 -1
- package/dist/commands/new/modifies.js +206 -0
- package/dist/commands/new/modifies.js.map +1 -0
- package/dist/commands/new/new.js +59 -14
- package/dist/commands/new/new.js.map +1 -1
- package/dist/commands/new/templates.js +48 -124
- package/dist/commands/new/templates.js.map +1 -1
- package/dist/commands/open.js +3 -7
- package/dist/commands/open.js.map +1 -1
- package/dist/commands/policy/gate.js +27 -0
- package/dist/commands/policy/gate.js.map +1 -1
- package/dist/commands/rebase/plan.js +2 -2
- package/dist/commands/rebase/plan.js.map +1 -1
- package/dist/commands/rebase/rebase.js +60 -35
- package/dist/commands/rebase/rebase.js.map +1 -1
- package/dist/commands/seed/commit.js +55 -26
- package/dist/commands/seed/commit.js.map +1 -1
- package/dist/commands/seed/plan.js +158 -13
- package/dist/commands/seed/plan.js.map +1 -1
- package/dist/commands/seed/seed.js +13 -11
- package/dist/commands/seed/seed.js.map +1 -1
- package/dist/commands/show/feature.js +9 -5
- package/dist/commands/show/feature.js.map +1 -1
- package/dist/commands/show/introduced.js +157 -0
- package/dist/commands/show/introduced.js.map +1 -0
- package/dist/commands/show/marks.js +35 -6
- package/dist/commands/show/marks.js.map +1 -1
- package/dist/commands/show/service.js +41 -33
- package/dist/commands/show/service.js.map +1 -1
- package/dist/commands/show/show.js +17 -7
- package/dist/commands/show/show.js.map +1 -1
- package/dist/commands/status/fleet-next.js +90 -0
- package/dist/commands/status/fleet-next.js.map +1 -0
- package/dist/commands/status/json.js +2 -0
- package/dist/commands/status/json.js.map +1 -1
- package/dist/commands/status/print.js +89 -37
- package/dist/commands/status/print.js.map +1 -1
- package/dist/commands/status/status.js +3 -7
- package/dist/commands/status/status.js.map +1 -1
- package/dist/commands/steps/steps.js +103 -23
- package/dist/commands/steps/steps.js.map +1 -1
- package/dist/commands/subsystem/edit.js +7 -7
- package/dist/commands/subsystem/edit.js.map +1 -1
- package/dist/commands/subsystem/move.js +106 -46
- package/dist/commands/subsystem/move.js.map +1 -1
- package/dist/commands/subsystem/query.js +2 -4
- package/dist/commands/subsystem/query.js.map +1 -1
- package/dist/commands/subsystem/subsystem.js +4 -7
- package/dist/commands/subsystem/subsystem.js.map +1 -1
- package/dist/commands/subsystem/sync.js +289 -74
- package/dist/commands/subsystem/sync.js.map +1 -1
- package/dist/commands/subsystem/txn/exclude.js +132 -0
- package/dist/commands/subsystem/txn/exclude.js.map +1 -0
- package/dist/commands/subsystem/txn/links.js +128 -0
- package/dist/commands/subsystem/txn/links.js.map +1 -0
- package/dist/commands/subsystem/txn/report.js +174 -0
- package/dist/commands/subsystem/txn/report.js.map +1 -0
- package/dist/commands/subsystem/txn/txn.js +147 -45
- package/dist/commands/subsystem/txn/txn.js.map +1 -1
- package/dist/commands/subsystem/txn/views.js +200 -21
- package/dist/commands/subsystem/txn/views.js.map +1 -1
- package/dist/commands/tasks/actions.js +107 -0
- package/dist/commands/tasks/actions.js.map +1 -0
- package/dist/commands/tasks/render.js +253 -0
- package/dist/commands/tasks/render.js.map +1 -0
- package/dist/commands/tasks/tasks.js +119 -0
- package/dist/commands/tasks/tasks.js.map +1 -0
- package/dist/commands/unarchive/manifest.js +42 -26
- package/dist/commands/unarchive/manifest.js.map +1 -1
- package/dist/commands/unarchive/restore.js +83 -33
- package/dist/commands/unarchive/restore.js.map +1 -1
- package/dist/commands/unarchive/unarchive.js +22 -30
- package/dist/commands/unarchive/unarchive.js.map +1 -1
- package/dist/commands/validate/arch-coverage.js +78 -27
- package/dist/commands/validate/arch-coverage.js.map +1 -1
- package/dist/commands/validate/checks/fleet-shape.js +34 -126
- package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
- package/dist/commands/validate/checks/health-deps.js +9 -6
- package/dist/commands/validate/checks/health-deps.js.map +1 -1
- package/dist/commands/validate/checks/introduced.js +102 -0
- package/dist/commands/validate/checks/introduced.js.map +1 -0
- package/dist/commands/validate/checks/requirements.js +18 -8
- package/dist/commands/validate/checks/requirements.js.map +1 -1
- package/dist/commands/validate/checks/sources.js +11 -2
- package/dist/commands/validate/checks/sources.js.map +1 -1
- package/dist/commands/validate/coverage/covers-after.js +105 -0
- package/dist/commands/validate/coverage/covers-after.js.map +1 -0
- package/dist/commands/validate/feature.js +26 -20
- package/dist/commands/validate/feature.js.map +1 -1
- package/dist/commands/validate/fleet/census.js +37 -13
- package/dist/commands/validate/fleet/census.js.map +1 -1
- package/dist/commands/validate/fleet/landscape.js +144 -126
- package/dist/commands/validate/fleet/landscape.js.map +1 -1
- package/dist/commands/validate/fleet/load.js +66 -0
- package/dist/commands/validate/fleet/load.js.map +1 -1
- package/dist/commands/validate/fleet/map/attest.js +98 -0
- package/dist/commands/validate/fleet/map/attest.js.map +1 -0
- package/dist/commands/validate/fleet/map/bindings.js +107 -0
- package/dist/commands/validate/fleet/map/bindings.js.map +1 -0
- package/dist/commands/validate/fleet/map/consumers.js +244 -0
- package/dist/commands/validate/fleet/map/consumers.js.map +1 -0
- package/dist/commands/validate/fleet/map/isolation.js +116 -0
- package/dist/commands/validate/fleet/map/isolation.js.map +1 -0
- package/dist/commands/validate/fleet/map/usecases.js +54 -0
- package/dist/commands/validate/fleet/map/usecases.js.map +1 -0
- package/dist/commands/validate/fleet/obligations.js +44 -14
- package/dist/commands/validate/fleet/obligations.js.map +1 -1
- package/dist/commands/validate/fleet/scorecard/print.js +10 -2
- package/dist/commands/validate/fleet/scorecard/print.js.map +1 -1
- package/dist/commands/validate/fleet/scorecard/scorecard.js +17 -11
- package/dist/commands/validate/fleet/scorecard/scorecard.js.map +1 -1
- package/dist/commands/validate/fleet/usecases/place.js.map +1 -1
- package/dist/commands/validate/fleet/views/fleet-project.js +217 -0
- package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -0
- package/dist/commands/validate/fleet/views/ids.js +50 -15
- package/dist/commands/validate/fleet/views/ids.js.map +1 -1
- package/dist/commands/validate/fleet/views/projects.js +278 -0
- package/dist/commands/validate/fleet/views/projects.js.map +1 -0
- package/dist/commands/validate/fleet/views/root-documents.js +83 -0
- package/dist/commands/validate/fleet/views/root-documents.js.map +1 -0
- package/dist/commands/validate/fleet/views/stale.js +40 -47
- package/dist/commands/validate/fleet/views/stale.js.map +1 -1
- package/dist/commands/validate/links/corpus.js +1 -4
- package/dist/commands/validate/links/corpus.js.map +1 -1
- package/dist/commands/validate/links/glossary.js.map +1 -1
- package/dist/commands/validate/render/repeats.js +63 -0
- package/dist/commands/validate/render/repeats.js.map +1 -0
- package/dist/commands/validate/render/rollups.js +115 -0
- package/dist/commands/validate/render/rollups.js.map +1 -0
- package/dist/commands/validate/render/text.js +199 -0
- package/dist/commands/validate/render/text.js.map +1 -0
- package/dist/commands/validate/report.js +36 -160
- package/dist/commands/validate/report.js.map +1 -1
- package/dist/commands/validate/scope.js +113 -0
- package/dist/commands/validate/scope.js.map +1 -0
- package/dist/commands/validate/service/api.js +21 -107
- package/dist/commands/validate/service/api.js.map +1 -1
- package/dist/commands/validate/service/contracts/generated.js +134 -0
- package/dist/commands/validate/service/contracts/generated.js.map +1 -0
- package/dist/commands/validate/service/events/events.js +36 -14
- package/dist/commands/validate/service/events/events.js.map +1 -1
- package/dist/commands/validate/service/events/message-producers.js +1 -1
- package/dist/commands/validate/service/events/message-producers.js.map +1 -1
- package/dist/commands/validate/service/evidence-pins.js +56 -10
- package/dist/commands/validate/service/evidence-pins.js.map +1 -1
- package/dist/commands/validate/service/model/diverged.js +226 -0
- package/dist/commands/validate/service/model/diverged.js.map +1 -0
- package/dist/commands/validate/service/model/grade.js +163 -0
- package/dist/commands/validate/service/model/grade.js.map +1 -0
- package/dist/commands/validate/service/service.js +121 -61
- package/dist/commands/validate/service/service.js.map +1 -1
- package/dist/commands/validate/service/specs.js +88 -28
- package/dist/commands/validate/service/specs.js.map +1 -1
- package/dist/commands/validate/service/spine.js +38 -3
- package/dist/commands/validate/service/spine.js.map +1 -1
- package/dist/commands/validate/service/usecases/tags.js +113 -0
- package/dist/commands/validate/service/usecases/tags.js.map +1 -0
- package/dist/commands/validate/service/usecases/usecases.js +209 -0
- package/dist/commands/validate/service/usecases/usecases.js.map +1 -0
- package/dist/commands/validate/validate.js +85 -60
- package/dist/commands/validate/validate.js.map +1 -1
- package/dist/commands/verify/cross/diff.js +2 -6
- package/dist/commands/verify/cross/diff.js.map +1 -1
- package/dist/commands/verify/evidence/contract.js +1 -1
- package/dist/commands/verify/evidence/contract.js.map +1 -1
- package/dist/commands/verify/evidence/read.js +12 -3
- package/dist/commands/verify/evidence/read.js.map +1 -1
- package/dist/commands/verify/frozen.js +45 -23
- package/dist/commands/verify/frozen.js.map +1 -1
- package/dist/commands/verify/record.js +69 -17
- package/dist/commands/verify/record.js.map +1 -1
- package/dist/commands/verify/report.js +27 -44
- package/dist/commands/verify/report.js.map +1 -1
- package/dist/commands/verify/results.js +112 -30
- package/dist/commands/verify/results.js.map +1 -1
- package/dist/commands/verify/verify.js +55 -52
- package/dist/commands/verify/verify.js.map +1 -1
- package/dist/commands/vouch/pack/pack.js +2 -2
- package/dist/commands/vouch/pack/pack.js.map +1 -1
- package/dist/commands/vouch/pack/print.js +1 -6
- package/dist/commands/vouch/pack/print.js.map +1 -1
- package/dist/commands/vouch/pack/sections.js +1 -2
- package/dist/commands/vouch/pack/sections.js.map +1 -1
- package/dist/commands/vouch/run.js +26 -81
- package/dist/commands/vouch/run.js.map +1 -1
- package/dist/commands/vouch/sample/plan.js +20 -34
- package/dist/commands/vouch/sample/plan.js.map +1 -1
- package/dist/commands/vouch/vet/verify.js +39 -7
- package/dist/commands/vouch/vet/verify.js.map +1 -1
- package/dist/commands/vouch/vouch.js +10 -17
- package/dist/commands/vouch/vouch.js.map +1 -1
- package/dist/core/agent/agents-md/command-map.js +81 -106
- package/dist/core/agent/agents-md/command-map.js.map +1 -1
- package/dist/core/agent/agents-md/map/explain.js +21 -9
- package/dist/core/agent/agents-md/map/explain.js.map +1 -1
- package/dist/core/agent/agents-md/map/lenses/context.js +17 -8
- package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
- package/dist/core/agent/agents-md/map/lenses/diff.js +33 -20
- package/dist/core/agent/agents-md/map/lenses/diff.js.map +1 -1
- package/dist/core/agent/agents-md/map/lenses/gate.js +13 -5
- package/dist/core/agent/agents-md/map/lenses/gate.js.map +1 -1
- package/dist/core/agent/agents-md/map/mcp.js +10 -7
- package/dist/core/agent/agents-md/map/mcp.js.map +1 -1
- package/dist/core/agent/agents-md/map/status.js +24 -8
- package/dist/core/agent/agents-md/map/status.js.map +1 -1
- package/dist/core/agent/agents-md/map/subsystem.js +80 -8
- package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
- package/dist/core/agent/agents-md/map/usecases.js +88 -10
- package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
- package/dist/core/agent/agents-md/refusals.js +49 -22
- package/dist/core/agent/agents-md/refusals.js.map +1 -1
- package/dist/core/agent/agents-md.js +39 -6
- package/dist/core/agent/agents-md.js.map +1 -1
- package/dist/core/agent/agents-stamp.js +10 -0
- package/dist/core/agent/agents-stamp.js.map +1 -1
- package/dist/core/agent/protocol.js +1 -1
- package/dist/core/agent/protocol.js.map +1 -1
- package/dist/core/agent/scaffold.js +10 -15
- package/dist/core/agent/scaffold.js.map +1 -1
- package/dist/core/agent/support/report.js +46 -5
- package/dist/core/agent/support/report.js.map +1 -1
- package/dist/core/agent/tools/dialects.js +1 -0
- package/dist/core/agent/tools/dialects.js.map +1 -1
- package/dist/core/agent/tools/registry.js +41 -76
- package/dist/core/agent/tools/registry.js.map +1 -1
- package/dist/core/agent/workflows/adopt.js +158 -11
- package/dist/core/agent/workflows/adopt.js.map +1 -1
- package/dist/core/agent/workflows/check.js +78 -39
- package/dist/core/agent/workflows/check.js.map +1 -1
- package/dist/core/agent/workflows/closing.js +88 -27
- package/dist/core/agent/workflows/closing.js.map +1 -1
- package/dist/core/agent/workflows/feature.js +128 -13
- package/dist/core/agent/workflows/feature.js.map +1 -1
- package/dist/core/agent/workflows/implement.js +96 -13
- package/dist/core/agent/workflows/implement.js.map +1 -1
- package/dist/core/agent/workflows/reference/authoring.js +106 -4
- package/dist/core/agent/workflows/reference/authoring.js.map +1 -1
- package/dist/core/agent/workflows/reference/codes.js +8 -6
- package/dist/core/agent/workflows/reference/codes.js.map +1 -1
- package/dist/core/agent/workflows/reference/done-check.js +37 -15
- package/dist/core/agent/workflows/reference/done-check.js.map +1 -1
- package/dist/core/agent/workflows/reference/drawing/drawing.js +253 -0
- package/dist/core/agent/workflows/reference/drawing/drawing.js.map +1 -0
- package/dist/core/agent/workflows/reference/reference.js +6 -3
- package/dist/core/agent/workflows/reference/reference.js.map +1 -1
- package/dist/core/agent/workflows/reference/spine.js +68 -27
- package/dist/core/agent/workflows/reference/spine.js.map +1 -1
- package/dist/core/asyncapi/baseline/plan.js +14 -81
- package/dist/core/asyncapi/baseline/plan.js.map +1 -1
- package/dist/core/asyncapi/digest.js +30 -25
- package/dist/core/asyncapi/digest.js.map +1 -1
- package/dist/core/asyncapi/merge/components.js +5 -4
- package/dist/core/asyncapi/merge/components.js.map +1 -1
- package/dist/core/asyncapi/merge/error.js +7 -2
- package/dist/core/asyncapi/merge/error.js.map +1 -1
- package/dist/core/asyncapi/merge/markers.js +32 -50
- package/dist/core/asyncapi/merge/markers.js.map +1 -1
- package/dist/core/asyncapi/merge/merge.js +33 -53
- package/dist/core/asyncapi/merge/merge.js.map +1 -1
- package/dist/core/asyncapi/merge/pin.js +12 -36
- package/dist/core/asyncapi/merge/pin.js.map +1 -1
- package/dist/core/asyncapi/model.js.map +1 -1
- package/dist/core/asyncapi/read.js +36 -12
- package/dist/core/asyncapi/read.js.map +1 -1
- package/dist/core/brief/brief.js +96 -36
- package/dist/core/brief/brief.js.map +1 -1
- package/dist/core/brief/checks.js +138 -3
- package/dist/core/brief/checks.js.map +1 -1
- package/dist/core/brief/landscape.js +209 -130
- package/dist/core/brief/landscape.js.map +1 -1
- package/dist/core/brief/map/extend.js +84 -0
- package/dist/core/brief/map/extend.js.map +1 -0
- package/dist/core/brief/map/owed.js +266 -0
- package/dist/core/brief/map/owed.js.map +1 -0
- package/dist/core/brief/targets.js +109 -31
- package/dist/core/brief/targets.js.map +1 -1
- package/dist/core/brief/unchecked.js +14 -3
- package/dist/core/brief/unchecked.js.map +1 -1
- package/dist/core/c4/arch.js +90 -182
- package/dist/core/c4/arch.js.map +1 -1
- package/dist/core/c4/direction/direction.js +114 -0
- package/dist/core/c4/direction/direction.js.map +1 -0
- package/dist/core/c4/likec4.js +169 -14
- package/dist/core/c4/likec4.js.map +1 -1
- package/dist/core/c4/locate/unlocated.js +150 -0
- package/dist/core/c4/locate/unlocated.js.map +1 -0
- package/dist/core/c4/parsed/deployment.js +160 -0
- package/dist/core/c4/parsed/deployment.js.map +1 -0
- package/dist/core/c4/parsed/dynamic-views.js.map +1 -1
- package/dist/core/c4/parsed/values.js +54 -1
- package/dist/core/c4/parsed/values.js.map +1 -1
- package/dist/core/c4/parsed/view-ids.js +39 -1
- package/dist/core/c4/parsed/view-ids.js.map +1 -1
- package/dist/core/c4/prefetch/fleet.js +134 -0
- package/dist/core/c4/prefetch/fleet.js.map +1 -0
- package/dist/core/c4/project/architecture.js +123 -7
- package/dist/core/c4/project/architecture.js.map +1 -1
- package/dist/core/c4/project/batch.js +140 -0
- package/dist/core/c4/project/batch.js.map +1 -0
- package/dist/core/c4/project/documents.js +43 -12
- package/dist/core/c4/project/documents.js.map +1 -1
- package/dist/core/c4/project/load.js +79 -43
- package/dist/core/c4/project/load.js.map +1 -1
- package/dist/core/c4/project/staged.js +277 -0
- package/dist/core/c4/project/staged.js.map +1 -0
- package/dist/core/c4/resolve/attested.js +0 -0
- package/dist/core/c4/resolve/attested.js.map +1 -0
- package/dist/core/c4/resolve/external.js +51 -0
- package/dist/core/c4/resolve/external.js.map +1 -0
- package/dist/core/c4/resolve/service.js +78 -17
- package/dist/core/c4/resolve/service.js.map +1 -1
- package/dist/core/c4/resolve/steps.js +290 -0
- package/dist/core/c4/resolve/steps.js.map +1 -0
- package/dist/core/c4/resolve/systems.js +154 -0
- package/dist/core/c4/resolve/systems.js.map +1 -0
- package/dist/core/c4/root-project/exclude.js +352 -0
- package/dist/core/c4/root-project/exclude.js.map +1 -0
- package/dist/core/c4/seed/template.js +65 -1
- package/dist/core/c4/seed/template.js.map +1 -1
- package/dist/core/c4/service-model/created/models.js +96 -0
- package/dist/core/c4/service-model/created/models.js.map +1 -0
- package/dist/core/c4/service-model/documents.js +47 -0
- package/dist/core/c4/service-model/documents.js.map +1 -0
- package/dist/core/c4/service-model/fleet/extending.js +82 -0
- package/dist/core/c4/service-model/fleet/extending.js.map +1 -0
- package/dist/core/c4/service-model/load.js +165 -0
- package/dist/core/c4/service-model/load.js.map +1 -0
- package/dist/core/c4/service-model/renderer.js +128 -0
- package/dist/core/c4/service-model/renderer.js.map +1 -0
- package/dist/core/c4/service-model/shape.js +106 -0
- package/dist/core/c4/service-model/shape.js.map +1 -0
- package/dist/core/c4/service-model/slice.js +188 -0
- package/dist/core/c4/service-model/slice.js.map +1 -0
- package/dist/core/c4/source-mask.js +297 -10
- package/dist/core/c4/source-mask.js.map +1 -1
- package/dist/core/c4/source-scan.js +222 -141
- package/dist/core/c4/source-scan.js.map +1 -1
- package/dist/core/c4/splice/authored-source.js +79 -13
- package/dist/core/c4/splice/authored-source.js.map +1 -1
- package/dist/core/c4/splice/contract.js +20 -0
- package/dist/core/c4/splice/contract.js.map +1 -1
- package/dist/core/c4/splice/delta-blocks.js +20 -20
- package/dist/core/c4/splice/delta-blocks.js.map +1 -1
- package/dist/core/c4/splice/identity/declared.js +87 -0
- package/dist/core/c4/splice/identity/declared.js.map +1 -0
- package/dist/core/c4/splice/identity/edges.js +37 -0
- package/dist/core/c4/splice/identity/edges.js.map +1 -1
- package/dist/core/c4/splice/identity/existing.js +80 -0
- package/dist/core/c4/splice/identity/existing.js.map +1 -0
- package/dist/core/c4/splice/identity/interior.js +80 -0
- package/dist/core/c4/splice/identity/interior.js.map +1 -0
- package/dist/core/c4/splice/identity/owner.js +50 -0
- package/dist/core/c4/splice/identity/owner.js.map +1 -0
- package/dist/core/c4/splice/landscape-merge.js +182 -114
- package/dist/core/c4/splice/landscape-merge.js.map +1 -1
- package/dist/core/c4/splice/model/merge.js +151 -0
- package/dist/core/c4/splice/model/merge.js.map +1 -0
- package/dist/core/c4/splice/placement.js +160 -56
- package/dist/core/c4/splice/placement.js.map +1 -1
- package/dist/core/c4/splice/text/spot.js +131 -0
- package/dist/core/c4/splice/text/spot.js.map +1 -0
- package/dist/core/c4/workspace.js +13 -19
- package/dist/core/c4/workspace.js.map +1 -1
- package/dist/core/capabilities/capabilities.js +6 -6
- package/dist/core/capabilities/capabilities.js.map +1 -1
- package/dist/core/capabilities/delta/doc.js +7 -10
- package/dist/core/capabilities/delta/doc.js.map +1 -1
- package/dist/core/capabilities/delta/uncovered.js +2 -2
- package/dist/core/capabilities/delta/uncovered.js.map +1 -1
- package/dist/core/capabilities/findings.js +18 -22
- package/dist/core/capabilities/findings.js.map +1 -1
- package/dist/core/capabilities/realizes/findings.js +12 -19
- package/dist/core/capabilities/realizes/findings.js.map +1 -1
- package/dist/core/capabilities/realizes/join.js +0 -20
- package/dist/core/capabilities/realizes/join.js.map +1 -1
- package/dist/core/capabilities/rollup.js +6 -0
- package/dist/core/capabilities/rollup.js.map +1 -1
- package/dist/core/capabilities/tree.js +6 -28
- package/dist/core/capabilities/tree.js.map +1 -1
- package/dist/core/capabilities/usecase-join.js +3 -13
- package/dist/core/capabilities/usecase-join.js.map +1 -1
- package/dist/core/coherence/authoring/scaffold.js +11 -3
- package/dist/core/coherence/authoring/scaffold.js.map +1 -1
- package/dist/core/coherence/authoring/sentinels.js +2 -1
- package/dist/core/coherence/authoring/sentinels.js.map +1 -1
- package/dist/core/coherence/calls/called.js +52 -0
- package/dist/core/coherence/calls/called.js.map +1 -0
- package/dist/core/coherence/coherence.js +103 -22
- package/dist/core/coherence/coherence.js.map +1 -1
- package/dist/core/coherence/consumed/operations.js +70 -0
- package/dist/core/coherence/consumed/operations.js.map +1 -0
- package/dist/core/coherence/consumed/refusal.js +38 -0
- package/dist/core/coherence/consumed/refusal.js.map +1 -0
- package/dist/core/coherence/covers/health.js +85 -0
- package/dist/core/coherence/covers/health.js.map +1 -0
- package/dist/core/coherence/declared.js +60 -34
- package/dist/core/coherence/declared.js.map +1 -1
- package/dist/core/coherence/events/declared.js +44 -59
- package/dist/core/coherence/events/declared.js.map +1 -1
- package/dist/core/coherence/events/events.js +147 -16
- package/dist/core/coherence/events/events.js.map +1 -1
- package/dist/core/coherence/events/grades.js +31 -8
- package/dist/core/coherence/events/grades.js.map +1 -1
- package/dist/core/coherence/events/lookups.js +28 -87
- package/dist/core/coherence/events/lookups.js.map +1 -1
- package/dist/core/coherence/events/produced.js +100 -0
- package/dist/core/coherence/events/produced.js.map +1 -0
- package/dist/core/coherence/lookups.js +18 -78
- package/dist/core/coherence/lookups.js.map +1 -1
- package/dist/core/coherence/retired/archived.js +285 -0
- package/dist/core/coherence/retired/archived.js.map +1 -0
- package/dist/core/coherence/retired/event-pair.js +62 -0
- package/dist/core/coherence/retired/event-pair.js.map +1 -0
- package/dist/core/coherence/retired/merged.js +79 -0
- package/dist/core/coherence/retired/merged.js.map +1 -0
- package/dist/core/coherence/retired/retirement.js +49 -0
- package/dist/core/coherence/retired/retirement.js.map +1 -0
- package/dist/core/coherence/retired/revival/arrivals.js +131 -0
- package/dist/core/coherence/retired/revival/arrivals.js.map +1 -0
- package/dist/core/coherence/retired/revival/revived.js +297 -0
- package/dist/core/coherence/retired/revival/revived.js.map +1 -0
- package/dist/core/consumers/after.js +113 -0
- package/dist/core/consumers/after.js.map +1 -0
- package/dist/core/consumers/consumers.js +296 -0
- package/dist/core/consumers/consumers.js.map +1 -0
- package/dist/core/consumers/current.js +211 -0
- package/dist/core/consumers/current.js.map +1 -0
- package/dist/core/consumers/scan.js +46 -0
- package/dist/core/consumers/scan.js.map +1 -0
- package/dist/core/consumers/state.js +27 -0
- package/dist/core/consumers/state.js.map +1 -0
- package/dist/core/delta/delta.js +51 -6
- package/dist/core/delta/delta.js.map +1 -1
- package/dist/core/dependencies/fanin.js +2 -2
- package/dist/core/dependencies/fanin.js.map +1 -1
- package/dist/core/deployment/delta.js +191 -0
- package/dist/core/deployment/delta.js.map +1 -0
- package/dist/core/deployment/objects.js +145 -0
- package/dist/core/deployment/objects.js.map +1 -0
- package/dist/core/diff/base-git.js +94 -12
- package/dist/core/diff/base-git.js.map +1 -1
- package/dist/core/diff/base-state.js +49 -21
- package/dist/core/diff/base-state.js.map +1 -1
- package/dist/core/diff/joins.js +136 -0
- package/dist/core/diff/joins.js.map +1 -0
- package/dist/core/diff/scope/changed-paths.js +22 -6
- package/dist/core/diff/scope/changed-paths.js.map +1 -1
- package/dist/core/diff/scope/edges.js +82 -0
- package/dist/core/diff/scope/edges.js.map +1 -0
- package/dist/core/diff/semantic.js +100 -31
- package/dist/core/diff/semantic.js.map +1 -1
- package/dist/core/docs.js +31 -22
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor/agents.js +34 -18
- package/dist/core/doctor/agents.js.map +1 -1
- package/dist/core/doctor/config.js +7 -3
- package/dist/core/doctor/config.js.map +1 -1
- package/dist/core/doctor/doctor.js +91 -11
- package/dist/core/doctor/doctor.js.map +1 -1
- package/dist/core/doctor/report.js.map +1 -1
- package/dist/core/doctor/reports/scan.js +242 -0
- package/dist/core/doctor/reports/scan.js.map +1 -0
- package/dist/core/document/apply.js +158 -11
- package/dist/core/document/apply.js.map +1 -1
- package/dist/core/document/frontmatter.js +55 -0
- package/dist/core/document/frontmatter.js.map +1 -1
- package/dist/core/document/parse.js +29 -6
- package/dist/core/document/parse.js.map +1 -1
- package/dist/core/document/scenarios.js +17 -0
- package/dist/core/document/scenarios.js.map +1 -1
- package/dist/core/document/spec.js +4 -11
- package/dist/core/document/spec.js.map +1 -1
- package/dist/core/envelope/config.js +10 -0
- package/dist/core/envelope/config.js.map +1 -1
- package/dist/core/envelope/json.js +34 -0
- package/dist/core/envelope/json.js.map +1 -1
- package/dist/core/explain/families.js +26 -21
- package/dist/core/explain/families.js.map +1 -1
- package/dist/core/explain/lookup.js +14 -1
- package/dist/core/explain/lookup.js.map +1 -1
- package/dist/core/explain/refusals.js +5 -5
- package/dist/core/explain/refusals.js.map +1 -1
- package/dist/core/explain/terms/documents.js +227 -0
- package/dist/core/explain/terms/documents.js.map +1 -0
- package/dist/core/explain/terms/done-check.js +144 -0
- package/dist/core/explain/terms/done-check.js.map +1 -0
- package/dist/core/explain/terms/entry.js +14 -0
- package/dist/core/explain/terms/entry.js.map +1 -0
- package/dist/core/explain/terms/stages.js +71 -0
- package/dist/core/explain/terms/stages.js.map +1 -0
- package/dist/core/explain/terms/tasks.js +97 -0
- package/dist/core/explain/terms/tasks.js.map +1 -0
- package/dist/core/explain/terms.js +68 -109
- package/dist/core/explain/terms.js.map +1 -1
- package/dist/core/explore/explore.js +40 -10
- package/dist/core/explore/explore.js.map +1 -1
- package/dist/core/fleet-context.js +81 -95
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/gate/checks.js +11 -4
- package/dist/core/gate/checks.js.map +1 -1
- package/dist/core/gate/partners.js +69 -28
- package/dist/core/gate/partners.js.map +1 -1
- package/dist/core/gate/report.js.map +1 -1
- package/dist/core/gate/verification.js +75 -12
- package/dist/core/gate/verification.js.map +1 -1
- package/dist/core/gherkin/stale.js +13 -37
- package/dist/core/gherkin/stale.js.map +1 -1
- package/dist/core/glossary/delta.js +10 -7
- package/dist/core/glossary/delta.js.map +1 -1
- package/dist/core/glossary/tree.js +6 -19
- package/dist/core/glossary/tree.js.map +1 -1
- package/dist/core/kernel/concurrency.js +19 -0
- package/dist/core/kernel/concurrency.js.map +1 -1
- package/dist/core/kernel/ids/fqn/ancestors.js +65 -0
- package/dist/core/kernel/ids/fqn/ancestors.js.map +1 -0
- package/dist/core/kernel/path-safety.js +38 -0
- package/dist/core/kernel/path-safety.js.map +1 -1
- package/dist/core/kernel/records.js +11 -0
- package/dist/core/kernel/records.js.map +1 -1
- package/dist/core/links/backlinks.js +1 -3
- package/dist/core/links/backlinks.js.map +1 -1
- package/dist/core/links/corpus.js +51 -37
- package/dist/core/links/corpus.js.map +1 -1
- package/dist/core/links/findings.js.map +1 -1
- package/dist/core/links/parse.js +38 -9
- package/dist/core/links/parse.js.map +1 -1
- package/dist/core/mcp/argv.js +101 -0
- package/dist/core/mcp/argv.js.map +1 -1
- package/dist/core/mcp/author-tools.js +2 -1
- package/dist/core/mcp/author-tools.js.map +1 -1
- package/dist/core/mcp/protocol.js +2 -2
- package/dist/core/mcp/protocol.js.map +1 -1
- package/dist/core/mcp/tools.js +43 -33
- package/dist/core/mcp/tools.js.map +1 -1
- package/dist/core/openapi/baseline/gate.js +53 -12
- package/dist/core/openapi/baseline/gate.js.map +1 -1
- package/dist/core/openapi/baseline/plan.js +40 -10
- package/dist/core/openapi/baseline/plan.js.map +1 -1
- package/dist/core/openapi/baseline/record.js +0 -1
- package/dist/core/openapi/baseline/record.js.map +1 -1
- package/dist/core/openapi/contract-text.js +33 -0
- package/dist/core/openapi/contract-text.js.map +1 -0
- package/dist/core/openapi/contract-tree.js +301 -0
- package/dist/core/openapi/contract-tree.js.map +1 -0
- package/dist/core/openapi/depth.js +38 -24
- package/dist/core/openapi/depth.js.map +1 -1
- package/dist/core/openapi/digest.js +52 -2
- package/dist/core/openapi/digest.js.map +1 -1
- package/dist/core/openapi/doc.js +12 -4
- package/dist/core/openapi/doc.js.map +1 -1
- package/dist/core/openapi/merge/components.js +25 -60
- package/dist/core/openapi/merge/components.js.map +1 -1
- package/dist/core/openapi/merge/error.js +3 -13
- package/dist/core/openapi/merge/error.js.map +1 -1
- package/dist/core/openapi/merge/markers.js +26 -30
- package/dist/core/openapi/merge/markers.js.map +1 -1
- package/dist/core/openapi/merge/merge.js +28 -52
- package/dist/core/openapi/merge/merge.js.map +1 -1
- package/dist/core/openapi/merge/pin.js +17 -35
- package/dist/core/openapi/merge/pin.js.map +1 -1
- package/dist/core/openspec/decide/verdict.js.map +1 -1
- package/dist/core/openspec/inventory.js +4 -5
- package/dist/core/openspec/inventory.js.map +1 -1
- package/dist/core/openspec/model/mapping.js +30 -28
- package/dist/core/openspec/model/mapping.js.map +1 -1
- package/dist/core/openspec/model/model.js +12 -7
- package/dist/core/openspec/model/model.js.map +1 -1
- package/dist/core/openspec/scan/changes.js +10 -4
- package/dist/core/openspec/scan/changes.js.map +1 -1
- package/dist/core/openspec/scan/config.js +7 -16
- package/dist/core/openspec/scan/config.js.map +1 -1
- package/dist/core/openspec/scan/shape.js +63 -19
- package/dist/core/openspec/scan/shape.js.map +1 -1
- package/dist/core/openspec/scan/walk.js +21 -38
- package/dist/core/openspec/scan/walk.js.map +1 -1
- package/dist/core/pack/living.js +43 -4
- package/dist/core/pack/living.js.map +1 -1
- package/dist/core/pack/pack.js +50 -6
- package/dist/core/pack/pack.js.map +1 -1
- package/dist/core/projection/arch-slice.js +2 -2
- package/dist/core/projection/arch-slice.js.map +1 -1
- package/dist/core/projection/introduced.js +14 -0
- package/dist/core/projection/introduced.js.map +1 -0
- package/dist/core/provenance/findings.js +48 -12
- package/dist/core/provenance/findings.js.map +1 -1
- package/dist/core/provenance/git.js +33 -57
- package/dist/core/provenance/git.js.map +1 -1
- package/dist/core/provenance/gitq/moves.js +71 -114
- package/dist/core/provenance/gitq/moves.js.map +1 -1
- package/dist/core/provenance/gitq/vouched-ancestor.js +2 -2
- package/dist/core/provenance/gitq/vouched-ancestor.js.map +1 -1
- package/dist/core/provenance/sources.js +23 -6
- package/dist/core/provenance/sources.js.map +1 -1
- package/dist/core/provenance/stamp.js +5 -17
- package/dist/core/provenance/stamp.js.map +1 -1
- package/dist/core/repo/authored/paths.js +18 -0
- package/dist/core/repo/authored/paths.js.map +1 -1
- package/dist/core/repo/entries.js.map +1 -1
- package/dist/core/repo/paths.js +92 -6
- package/dist/core/repo/paths.js.map +1 -1
- package/dist/core/repo/repo.js +74 -14
- package/dist/core/repo/repo.js.map +1 -1
- package/dist/core/repo/tree/find.js +12 -3
- package/dist/core/repo/tree/find.js.map +1 -1
- package/dist/core/repo/tree/fs.js +27 -1
- package/dist/core/repo/tree/fs.js.map +1 -1
- package/dist/core/repo/tree/render/body.js +258 -0
- package/dist/core/repo/tree/render/body.js.map +1 -0
- package/dist/core/repo/tree/render/projects.js +182 -0
- package/dist/core/repo/tree/render/projects.js.map +1 -0
- package/dist/core/repo/tree/render/service/header.js +125 -0
- package/dist/core/repo/tree/render/service/header.js.map +1 -0
- package/dist/core/repo/tree/render/service/peers.js +28 -0
- package/dist/core/repo/tree/render/service/peers.js.map +1 -0
- package/dist/core/repo/tree/render/service/view-id.js +67 -0
- package/dist/core/repo/tree/render/service/view-id.js.map +1 -0
- package/dist/core/repo/tree/render/service/views.js +78 -0
- package/dist/core/repo/tree/render/service/views.js.map +1 -0
- package/dist/core/repo/tree/render/stale.js +42 -0
- package/dist/core/repo/tree/render/stale.js.map +1 -0
- package/dist/core/repo/tree/render/text.js +84 -0
- package/dist/core/repo/tree/render/text.js.map +1 -0
- package/dist/core/repo/tree/render/views.js +293 -0
- package/dist/core/repo/tree/render/views.js.map +1 -0
- package/dist/core/repo/tree/walk.js +53 -32
- package/dist/core/repo/tree/walk.js.map +1 -1
- package/dist/core/results.js +47 -12
- package/dist/core/results.js.map +1 -1
- package/dist/core/scaffold/landscape.js +121 -13
- package/dist/core/scaffold/landscape.js.map +1 -1
- package/dist/core/scaffold/legacy.js +271 -0
- package/dist/core/scaffold/legacy.js.map +1 -0
- package/dist/core/staging/commit.js +26 -15
- package/dist/core/staging/commit.js.map +1 -1
- package/dist/core/staging/lock.js +74 -12
- package/dist/core/staging/lock.js.map +1 -1
- package/dist/core/staging/recovery/intent.js +2 -5
- package/dist/core/staging/recovery/intent.js.map +1 -1
- package/dist/core/staging/recovery/recover.js +84 -6
- package/dist/core/staging/recovery/recover.js.map +1 -1
- package/dist/core/staging/snapshot/manifest.js +188 -0
- package/dist/core/staging/snapshot/manifest.js.map +1 -0
- package/dist/core/staging/{snapshot.js → snapshot/snapshot.js} +39 -45
- package/dist/core/staging/snapshot/snapshot.js.map +1 -0
- package/dist/core/staging/txn/forward.js +24 -14
- package/dist/core/staging/txn/forward.js.map +1 -1
- package/dist/core/staging/writes.js.map +1 -1
- package/dist/core/status/actions/execution.js +11 -0
- package/dist/core/status/actions/execution.js.map +1 -1
- package/dist/core/status/contracts.js +39 -15
- package/dist/core/status/contracts.js.map +1 -1
- package/dist/core/status/feature/artifacts.js +33 -8
- package/dist/core/status/feature/artifacts.js.map +1 -1
- package/dist/core/status/feature/feature.js +24 -17
- package/dist/core/status/feature/feature.js.map +1 -1
- package/dist/core/status/feature/next.js +20 -7
- package/dist/core/status/feature/next.js.map +1 -1
- package/dist/core/status/feature/words.js +96 -0
- package/dist/core/status/feature/words.js.map +1 -0
- package/dist/core/status/fleet/fleet.js +43 -14
- package/dist/core/status/fleet/fleet.js.map +1 -1
- package/dist/core/status/fleet/next.js +153 -58
- package/dist/core/status/fleet/next.js.map +1 -1
- package/dist/core/status/report.js.map +1 -1
- package/dist/core/status/scan.js.map +1 -1
- package/dist/core/status/verification.js +30 -15
- package/dist/core/status/verification.js.map +1 -1
- package/dist/core/tasks/packet.js +199 -0
- package/dist/core/tasks/packet.js.map +1 -0
- package/dist/core/tasks/record.js +43 -0
- package/dist/core/tasks/record.js.map +1 -0
- package/dist/core/tasks/sources.js +181 -0
- package/dist/core/tasks/sources.js.map +1 -0
- package/dist/core/tasks/stamps.js +76 -0
- package/dist/core/tasks/stamps.js.map +1 -0
- package/dist/core/tasks/unit.js +0 -0
- package/dist/core/tasks/unit.js.map +1 -0
- package/dist/core/usecases/capability.js +11 -14
- package/dist/core/usecases/capability.js.map +1 -1
- package/dist/core/usecases/delta/claims.js +31 -7
- package/dist/core/usecases/delta/claims.js.map +1 -1
- package/dist/core/usecases/delta/flows.js +5 -7
- package/dist/core/usecases/delta/flows.js.map +1 -1
- package/dist/core/usecases/delta/modified.js +315 -0
- package/dist/core/usecases/delta/modified.js.map +1 -0
- package/dist/core/usecases/delta/overlay.js +32 -191
- package/dist/core/usecases/delta/overlay.js.map +1 -1
- package/dist/core/usecases/fleet.js +195 -36
- package/dist/core/usecases/fleet.js.map +1 -1
- package/dist/{commands/validate/fleet/usecases → core/usecases/grade}/capability-tag.js +8 -9
- package/dist/core/usecases/grade/capability-tag.js.map +1 -0
- package/dist/core/usecases/grade/feature.js +88 -0
- package/dist/core/usecases/grade/feature.js.map +1 -0
- package/dist/{commands/validate/fleet/usecases → core/usecases/grade}/requirement-tag.js +3 -4
- package/dist/core/usecases/grade/requirement-tag.js.map +1 -0
- package/dist/{commands/validate/fleet/usecases → core/usecases/grade}/steps.js +102 -33
- package/dist/core/usecases/grade/steps.js.map +1 -0
- package/dist/{commands/validate/fleet/usecases → core/usecases/grade}/usecases.js +23 -10
- package/dist/core/usecases/grade/usecases.js.map +1 -0
- package/dist/core/usecases/operations.js +26 -14
- package/dist/core/usecases/operations.js.map +1 -1
- package/dist/core/usecases/place.js.map +1 -1
- package/dist/core/usecases/service/exclude.js +119 -0
- package/dist/core/usecases/service/exclude.js.map +1 -0
- package/dist/core/usecases/service/flows.js +164 -0
- package/dist/core/usecases/service/flows.js.map +1 -0
- package/dist/core/usecases/suites/participants.js +83 -0
- package/dist/core/usecases/suites/participants.js.map +1 -0
- package/dist/core/usecases/suites/suites.js +45 -0
- package/dist/core/usecases/suites/suites.js.map +1 -0
- package/dist/core/verify/answers.js +12 -5
- package/dist/core/verify/answers.js.map +1 -1
- package/dist/core/verify/build.js +102 -82
- package/dist/core/verify/build.js.map +1 -1
- package/dist/core/verify/checklist.js +70 -12
- package/dist/core/verify/checklist.js.map +1 -1
- package/dist/core/verify/location.js +67 -0
- package/dist/core/verify/location.js.map +1 -0
- package/dist/core/verify/pins/lint.js +3 -2
- package/dist/core/verify/pins/lint.js.map +1 -1
- package/dist/core/verify/pins/pin.js +1 -1
- package/dist/core/verify/pins/pin.js.map +1 -1
- package/dist/core/verify/read/file.js +224 -0
- package/dist/core/verify/read/file.js.map +1 -0
- package/dist/core/verify/{file.js → read/shape.js} +16 -104
- package/dist/core/verify/read/shape.js.map +1 -0
- package/dist/core/verify/read/union.js +105 -0
- package/dist/core/verify/read/union.js.map +1 -0
- package/dist/core/verify/record.js +35 -12
- package/dist/core/verify/record.js.map +1 -1
- package/dist/core/verify/store/commit.js +40 -30
- package/dist/core/verify/store/commit.js.map +1 -1
- package/dist/core/verify/store/render.js +8 -4
- package/dist/core/verify/store/render.js.map +1 -1
- package/dist/core/vocabulary/issue.js +4 -0
- package/dist/core/vocabulary/issue.js.map +1 -1
- package/dist/core/vocabulary/maturity.js +75 -4
- package/dist/core/vocabulary/maturity.js.map +1 -1
- package/dist/core/vocabulary/report.js +16 -1
- package/dist/core/vocabulary/report.js.map +1 -1
- package/examples/README.md +54 -11
- package/examples/docs/architecture/landscape.likec4 +62 -6
- package/examples/docs/architecture/services.likec4 +70 -0
- package/examples/docs/architecture/subsystems.likec4 +7 -0
- package/examples/docs/architecture/usecases/order-notification.likec4 +16 -7
- package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-service/asyncapi.yaml +11 -8
- package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-split-service/openapi.yaml +6 -6
- package/examples/docs/features/FEAT-112-retire-order-v1/specs/order-service/openapi.yaml +4 -1
- package/examples/docs/features/archive/FEAT-088-refunds/specs/payment-service/openapi.yaml +13 -10
- package/examples/docs/likec4.config.json +0 -1
- package/examples/docs/services/checkout-web/model.likec4 +39 -56
- package/examples/docs/services/order-service/arch.spec.md +13 -6
- package/examples/docs/services/order-service/asyncapi.yaml +3 -2
- package/examples/docs/services/order-service/model.likec4 +58 -70
- package/examples/docs/services/payment-service/arch.spec.md +7 -4
- package/examples/docs/services/payment-service/model.likec4 +38 -37
- package/examples/docs/services/platform/identity-service/arch.spec.md +7 -4
- package/examples/docs/services/platform/identity-service/model.likec4 +35 -37
- package/examples/docs/services/platform/notification-service/asyncapi.yaml +2 -1
- package/examples/docs/services/platform/notification-service/health.yaml +8 -3
- package/examples/docs/services/platform/notification-service/model.likec4 +43 -64
- package/package.json +1 -1
- package/dist/commands/validate/checks/vocabulary.js +0 -31
- package/dist/commands/validate/checks/vocabulary.js.map +0 -1
- package/dist/commands/validate/fleet/usecases/capability-tag.js.map +0 -1
- package/dist/commands/validate/fleet/usecases/requirement-tag.js.map +0 -1
- package/dist/commands/validate/fleet/usecases/steps.js.map +0 -1
- package/dist/commands/validate/fleet/usecases/usecases.js.map +0 -1
- package/dist/core/agent/agents-md/artifacts.js +0 -220
- package/dist/core/agent/agents-md/artifacts.js.map +0 -1
- package/dist/core/agent/agents-md/cycle.js +0 -165
- package/dist/core/agent/agents-md/cycle.js.map +0 -1
- package/dist/core/agent/agents-md/shipped/archive-gate.js +0 -98
- package/dist/core/agent/agents-md/shipped/archive-gate.js.map +0 -1
- package/dist/core/agent/agents-md/spine.js +0 -40
- package/dist/core/agent/agents-md/spine.js.map +0 -1
- package/dist/core/coherence/events/merged.js +0 -40
- package/dist/core/coherence/events/merged.js.map +0 -1
- package/dist/core/diff/current-state.js +0 -96
- package/dist/core/diff/current-state.js.map +0 -1
- package/dist/core/diff/victims.js +0 -247
- package/dist/core/diff/victims.js.map +0 -1
- package/dist/core/repo/tree/views.js +0 -172
- package/dist/core/repo/tree/views.js.map +0 -1
- package/dist/core/staging/snapshot.js.map +0 -1
- package/dist/core/verify/file.js.map +0 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the undo snapshot lives and what its manifest says — the half of
|
|
3
|
+
* `./snapshot.ts` a command can know without being able to take a snapshot.
|
|
4
|
+
*
|
|
5
|
+
* Split from that module when the snapshot became a package of its own: taking
|
|
6
|
+
* a snapshot (and refusing to clobber one) is a commit, and the MCP server's
|
|
7
|
+
* read-only claim is DERIVED from the import graph — a served command whose
|
|
8
|
+
* imports reach `./snapshot.ts` is, by that test, a command that can commit.
|
|
9
|
+
* The layout below is pure naming and shape, and the readers that only need
|
|
10
|
+
* to find a snapshot, or read what one recorded, import it from here.
|
|
11
|
+
*/
|
|
12
|
+
import { isUtf8 } from "node:buffer";
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { resolvePortableFileInsideLexically } from "../../kernel/path-safety.js";
|
|
16
|
+
import { isRecord } from "../../kernel/records.js";
|
|
17
|
+
import { sha256 } from "../writes.js";
|
|
18
|
+
/** Where archive parks the pre-images, inside the feature directory so they travel with it — `./snapshot.ts`'s module doc says why. */
|
|
19
|
+
export const SNAPSHOT_DIR = ".loam-before";
|
|
20
|
+
export const SNAPSHOT_MANIFEST = "manifest.json";
|
|
21
|
+
/**
|
|
22
|
+
* Bumped only when the layout changes in a way `unarchive` must refuse to guess
|
|
23
|
+
* at. 2 added `before`: a version-1 snapshot says nothing about the bytes it
|
|
24
|
+
* would RESTORE, so an edited pre-image read back as an intact one. 3 re-keys
|
|
25
|
+
* `services/` entries by `(service, artifact)`: a literal path pins the service
|
|
26
|
+
* directory where it stood on the day of the archive, and once the subsystem
|
|
27
|
+
* tree lets it move — re-cutting is expected to be routine — an undo addressed
|
|
28
|
+
* by path restores into a location that is gone. The service id never moves.
|
|
29
|
+
* 4 stores each pre-image under its own name PLUS {@link PRE_IMAGE_SUFFIX}: a
|
|
30
|
+
* pre-image kept under its living name is a second copy of a living document
|
|
31
|
+
* that every tool reading by extension takes for one. The snapshot of a
|
|
32
|
+
* landscape is a `.likec4` inside the archived feature, so a feature with its
|
|
33
|
+
* own `likec4.config.json` — or rendered alone, `likec4 start
|
|
34
|
+
* features/archive/<FEAT>`, as the brief recommends — loaded the map twice
|
|
35
|
+
* beside the delta and failed on duplicate elements, and a fleet project that
|
|
36
|
+
* did not exclude `features/` failed the same way.
|
|
37
|
+
*/
|
|
38
|
+
export const SNAPSHOT_VERSION = 4;
|
|
39
|
+
/**
|
|
40
|
+
* Version 2 is still READ, forever: version-2 snapshots already sit inside
|
|
41
|
+
* archived features, which carry them across loam upgrades, and each must keep
|
|
42
|
+
* restoring — and keep being graded for staleness by a retried archive — under
|
|
43
|
+
* its own literal-path rules. Only version 1 is refused (no restore digests).
|
|
44
|
+
*/
|
|
45
|
+
export const SNAPSHOT_COMPAT_VERSION = 2;
|
|
46
|
+
/**
|
|
47
|
+
* Version 3 is read forever for the same reason, under its own rules: rows
|
|
48
|
+
* keyed by `(service, artifact)` like version 4's, pre-images stored under
|
|
49
|
+
* their living names. A version-3 snapshot keeps its layout — it is restored
|
|
50
|
+
* from where it was written, never migrated, because rewriting a snapshot is
|
|
51
|
+
* rewriting the only copy of what the living docs once said.
|
|
52
|
+
*/
|
|
53
|
+
const SNAPSHOT_KEYED_VERSION = 3;
|
|
54
|
+
/** Appended to every pre-image a version-4 snapshot stores; see {@link SNAPSHOT_VERSION}. */
|
|
55
|
+
export const PRE_IMAGE_SUFFIX = ".before";
|
|
56
|
+
/** A manifest version this loam reads: the one it writes and the two it reads forever. */
|
|
57
|
+
export function isReadableSnapshotVersion(version) {
|
|
58
|
+
return version === SNAPSHOT_COMPAT_VERSION || version === SNAPSHOT_KEYED_VERSION || version === 4;
|
|
59
|
+
}
|
|
60
|
+
/** Does a manifest of this version key its `services/` rows by `(service, artifact)`? Version 3 on. */
|
|
61
|
+
export function keysServiceRows(version) {
|
|
62
|
+
return version >= SNAPSHOT_KEYED_VERSION;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Where a row's pre-image sits inside `files/`, forward slashes, under the
|
|
66
|
+
* layout the manifest's VERSION names — never the layout this loam would
|
|
67
|
+
* write, since a snapshot is read where it was written. Suffixed from version
|
|
68
|
+
* 4 on.
|
|
69
|
+
*/
|
|
70
|
+
export function preImageRel(version, path) {
|
|
71
|
+
return version >= 4 ? `${path}${PRE_IMAGE_SUFFIX}` : path;
|
|
72
|
+
}
|
|
73
|
+
export function snapshotDir(featureDir) {
|
|
74
|
+
return join(featureDir, SNAPSHOT_DIR);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The checklist digest an archived feature's snapshot captured as it merged,
|
|
78
|
+
* or null when it carries none.
|
|
79
|
+
*
|
|
80
|
+
* It lives in the manifest because the manifest is the one file `archive`
|
|
81
|
+
* already writes into the feature at the moment of the merge, travels with it
|
|
82
|
+
* into `features/archive/`, and is taken away by `unarchive` — so a feature
|
|
83
|
+
* archived again gets a fresh one, captured against what it then asked. A
|
|
84
|
+
* frozen verification record cannot be judged against a checklist re-derived
|
|
85
|
+
* after the merge (the merge moved the feature's operations into the living
|
|
86
|
+
* contract, so the derivation can only mismatch); this is the question as it
|
|
87
|
+
* stood when the feature shipped, which is what `loam gate --since` holds the
|
|
88
|
+
* record to. Here rather than beside the writer for the reason this module
|
|
89
|
+
* exists: the gate is a read-only command, served over MCP as one.
|
|
90
|
+
*
|
|
91
|
+
* Tolerant to the last byte, and every failure is "none captured": a manifest
|
|
92
|
+
* an older loam wrote, a feature archived with no record, a manifest that is
|
|
93
|
+
* missing or does not parse. The reader that grades on it reads null as the
|
|
94
|
+
* frozen history it always read, and says so — it never refuses a deploy
|
|
95
|
+
* question over bookkeeping.
|
|
96
|
+
*/
|
|
97
|
+
export async function readSnapshotChecklist(featureDir) {
|
|
98
|
+
const manifest = await manifestRecord(featureDir);
|
|
99
|
+
if (manifest === null)
|
|
100
|
+
return null;
|
|
101
|
+
const { checklist } = manifest;
|
|
102
|
+
return typeof checklist === "string" && checklist !== "" ? checklist : null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The bytes one service artifact held the moment this feature merged — the
|
|
106
|
+
* pre-image the snapshot parked under `files/` — or one of the two answers
|
|
107
|
+
* that say the merge took nothing out of it ({@link SnapshotPreImage}), or
|
|
108
|
+
* null when there is nothing to trust.
|
|
109
|
+
*
|
|
110
|
+
* Read for the questions only the pre-image can answer, both asked by the
|
|
111
|
+
* retired-operation revival check (core/coherence/retired/): what a merge TOOK
|
|
112
|
+
* that its delta never spelled — a channel-slot removal in an asyncapi delta is
|
|
113
|
+
* a bare marker, while the messages it retired with the channel are written in
|
|
114
|
+
* the living contract it merged into and nowhere else — and whether a removal
|
|
115
|
+
* marker took anything at all, since the merge deletes only what it finds at
|
|
116
|
+
* the marker's slot. Here, beside `readSnapshotChecklist`, for that reader's
|
|
117
|
+
* reason: the readers are read-only commands, and this module is the half of
|
|
118
|
+
* the snapshot they may import.
|
|
119
|
+
*
|
|
120
|
+
* The entry is found by `(service, artifact)` on a keyed (version 3 or 4) manifest — the
|
|
121
|
+
* key that survives a subsystem re-cut — and by the literal path's last two
|
|
122
|
+
* segments on a version-2 one, which recorded nothing else. Tolerant to the
|
|
123
|
+
* last byte, like the checklist reader: no manifest, a layout this loam would
|
|
124
|
+
* have to guess at, no row for the artifact while some row cannot be read (it
|
|
125
|
+
* might have been this one's, so absence cannot be concluded past it), a path
|
|
126
|
+
* that does not stay inside `files/`, a pre-image that no longer hashes to the digest
|
|
127
|
+
* the manifest recorded for it, or bytes that are not UTF-8 — every one is
|
|
128
|
+
* null, and the caller grades without it. An edited pre-image is refused
|
|
129
|
+
* rather than read for the reason `unarchive` refuses to restore one: its
|
|
130
|
+
* bytes are no longer what the merge saw.
|
|
131
|
+
*/
|
|
132
|
+
export async function readSnapshotPreImage(featureDir, key) {
|
|
133
|
+
const manifest = await manifestRecord(featureDir);
|
|
134
|
+
if (manifest === null || !Array.isArray(manifest.files))
|
|
135
|
+
return null;
|
|
136
|
+
const { version } = manifest;
|
|
137
|
+
if (!isReadableSnapshotVersion(version))
|
|
138
|
+
return null;
|
|
139
|
+
// `Array.isArray` narrows to `any[]`; naming the element type keeps that
|
|
140
|
+
// `any` from spreading past the row being read (snapshot.ts's reason).
|
|
141
|
+
const rows = manifest.files;
|
|
142
|
+
const entry = rows.find((row) => isRecord(row) && entryIs(row, key));
|
|
143
|
+
if (entry === undefined) {
|
|
144
|
+
// Absence is an answer only when every row could be read: a row that is
|
|
145
|
+
// not a record with a path might have been this artifact's.
|
|
146
|
+
return rows.every((row) => isRecord(row) && typeof row.path === "string") ? { kind: "unwritten" } : null;
|
|
147
|
+
}
|
|
148
|
+
if (!isRecord(entry) || typeof entry.path !== "string")
|
|
149
|
+
return null;
|
|
150
|
+
if (entry.existed === false && entry.before === null)
|
|
151
|
+
return { kind: "created" };
|
|
152
|
+
if (typeof entry.before !== "string")
|
|
153
|
+
return null;
|
|
154
|
+
let bytes;
|
|
155
|
+
try {
|
|
156
|
+
// The manifest is data and this path is one we go and read: the lexical
|
|
157
|
+
// containment `unarchive` and the staleness check apply to the same field.
|
|
158
|
+
bytes = await readFile(resolvePortableFileInsideLexically(join(snapshotDir(featureDir), "files"), preImageRel(version, entry.path), "snapshot pre-image"));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (sha256(bytes) !== entry.before || !isUtf8(bytes))
|
|
164
|
+
return null;
|
|
165
|
+
return { kind: "held", text: bytes.toString("utf8") };
|
|
166
|
+
}
|
|
167
|
+
/** Does this manifest row record `key`'s artifact? The `(service, artifact)` key when it has one, the literal path otherwise. */
|
|
168
|
+
function entryIs(row, key) {
|
|
169
|
+
if (typeof row.service === "string" && typeof row.artifact === "string") {
|
|
170
|
+
return row.service === key.service && row.artifact === key.artifact;
|
|
171
|
+
}
|
|
172
|
+
if (typeof row.path !== "string")
|
|
173
|
+
return false;
|
|
174
|
+
const parts = row.path.split("/");
|
|
175
|
+
return parts.length >= 3 && parts[0] === "services" && parts.at(-2) === key.service && parts.at(-1) === key.artifact;
|
|
176
|
+
}
|
|
177
|
+
/** The manifest as a record, or null — the one tolerant read behind both readers above. */
|
|
178
|
+
async function manifestRecord(featureDir) {
|
|
179
|
+
let manifest;
|
|
180
|
+
try {
|
|
181
|
+
manifest = JSON.parse(await readFile(join(snapshotDir(featureDir), SNAPSHOT_MANIFEST), "utf8"));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return isRecord(manifest) ? manifest : null;
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../../../../src/core/staging/snapshot/manifest.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Where the undo snapshot lives and what its manifest says — the half of\n * `./snapshot.ts` a command can know without being able to take a snapshot.\n *\n * Split from that module when the snapshot became a package of its own: taking\n * a snapshot (and refusing to clobber one) is a commit, and the MCP server's\n * read-only claim is DERIVED from the import graph — a served command whose\n * imports reach `./snapshot.ts` is, by that test, a command that can commit.\n * The layout below is pure naming and shape, and the readers that only need\n * to find a snapshot, or read what one recorded, import it from here.\n */\nimport { isUtf8 } from \"node:buffer\";\nimport { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { resolvePortableFileInsideLexically } from \"../../kernel/path-safety.js\";\nimport { isRecord } from \"../../kernel/records.js\";\nimport { sha256 } from \"../writes.js\";\n\n/** Where archive parks the pre-images, inside the feature directory so they travel with it — `./snapshot.ts`'s module doc says why. */\nexport const SNAPSHOT_DIR = \".loam-before\";\nexport const SNAPSHOT_MANIFEST = \"manifest.json\";\n/**\n * Bumped only when the layout changes in a way `unarchive` must refuse to guess\n * at. 2 added `before`: a version-1 snapshot says nothing about the bytes it\n * would RESTORE, so an edited pre-image read back as an intact one. 3 re-keys\n * `services/` entries by `(service, artifact)`: a literal path pins the service\n * directory where it stood on the day of the archive, and once the subsystem\n * tree lets it move — re-cutting is expected to be routine — an undo addressed\n * by path restores into a location that is gone. The service id never moves.\n * 4 stores each pre-image under its own name PLUS {@link PRE_IMAGE_SUFFIX}: a\n * pre-image kept under its living name is a second copy of a living document\n * that every tool reading by extension takes for one. The snapshot of a\n * landscape is a `.likec4` inside the archived feature, so a feature with its\n * own `likec4.config.json` — or rendered alone, `likec4 start\n * features/archive/<FEAT>`, as the brief recommends — loaded the map twice\n * beside the delta and failed on duplicate elements, and a fleet project that\n * did not exclude `features/` failed the same way.\n */\nexport const SNAPSHOT_VERSION = 4;\n/**\n * Version 2 is still READ, forever: version-2 snapshots already sit inside\n * archived features, which carry them across loam upgrades, and each must keep\n * restoring — and keep being graded for staleness by a retried archive — under\n * its own literal-path rules. Only version 1 is refused (no restore digests).\n */\nexport const SNAPSHOT_COMPAT_VERSION = 2;\n/**\n * Version 3 is read forever for the same reason, under its own rules: rows\n * keyed by `(service, artifact)` like version 4's, pre-images stored under\n * their living names. A version-3 snapshot keeps its layout — it is restored\n * from where it was written, never migrated, because rewriting a snapshot is\n * rewriting the only copy of what the living docs once said.\n */\nconst SNAPSHOT_KEYED_VERSION = 3;\n/** Appended to every pre-image a version-4 snapshot stores; see {@link SNAPSHOT_VERSION}. */\nexport const PRE_IMAGE_SUFFIX = \".before\";\n\n/**\n * The layouts this loam reads, by LITERAL — never \"the version this loam\n * writes\". Each rule below names the version that introduced it, so the next\n * bump cannot quietly re-read an older layout under the new one's rules: the\n * key rule was spelled `version === SNAPSHOT_VERSION` until this bump, and\n * would have stopped keying every version-3 row the day the constant moved. A\n * version added to the writer and not to this union is a compile error at the\n * writer's own call to {@link preImageRel}.\n */\nexport type ReadableSnapshotVersion = typeof SNAPSHOT_COMPAT_VERSION | typeof SNAPSHOT_KEYED_VERSION | 4;\n\n/** A manifest version this loam reads: the one it writes and the two it reads forever. */\nexport function isReadableSnapshotVersion(version: unknown): version is ReadableSnapshotVersion {\n return version === SNAPSHOT_COMPAT_VERSION || version === SNAPSHOT_KEYED_VERSION || version === 4;\n}\n\n/** Does a manifest of this version key its `services/` rows by `(service, artifact)`? Version 3 on. */\nexport function keysServiceRows(version: ReadableSnapshotVersion): boolean {\n return version >= SNAPSHOT_KEYED_VERSION;\n}\n\n/**\n * Where a row's pre-image sits inside `files/`, forward slashes, under the\n * layout the manifest's VERSION names — never the layout this loam would\n * write, since a snapshot is read where it was written. Suffixed from version\n * 4 on.\n */\nexport function preImageRel(version: ReadableSnapshotVersion, path: string): string {\n return version >= 4 ? `${path}${PRE_IMAGE_SUFFIX}` : path;\n}\n\nexport interface SnapshotEntry {\n /**\n * Docs-repo-relative path, forward slashes, AS ARCHIVED — where the archive\n * left the bytes. Load-bearing even beside the key below: it names the\n * pre-image under `.loam-before/files/` (through {@link preImageRel}), and\n * it is the literal fallback when the id stops being enumerated.\n *\n * For the feature's OWN documents — the ones the archive relinks for the\n * move (`commands/archive/plan/document/links.ts`) — that is inside\n * `features/archive/<FEAT>/`, not where they were written: the rename\n * carried them there, and that is where `unarchive` finds and restores them\n * before it moves the directory back. Recorded that way, every reader of\n * this layout restores them by its literal-path rule with nothing new to\n * learn, which is why the rows needed no version of their own; the two\n * readers that meet a snapshot before its feature moved — the clobber guard\n * and the crash repair — look across the move for them.\n */\n path: string;\n /**\n * For a `services/` entry: the service id (the leaf directory name — the one\n * thing a re-cut never changes) and the artifact's path inside its directory,\n * resolved through the CURRENT enumeration at restore time. Absent on\n * landscape and `features/` entries: the landscape does not move, and a\n * feature's own documents move only with the feature, which `path` already\n * says.\n */\n service?: string;\n artifact?: string;\n /** False when archive CREATED the file: restoring it means deleting it again. */\n existed: boolean;\n /** sha256 of what archive wrote, so unarchive can tell its own merge from later edits. */\n after: string;\n /**\n * sha256 of the pre-image beside this manifest — the bytes `unarchive` will\n * RESTORE. Null when archive created the file (there is no pre-image).\n * `after` only describes what archive wrote; while nothing described the\n * restore source, a pre-image edited in the archived feature directory — a\n * bad merge, a rebase, a stray editor — was written back verbatim and every\n * loam surface certified the result.\n */\n before: string | null;\n}\n\nexport interface SnapshotManifest {\n version: number;\n feature: string;\n dirName: string;\n archivedAt: string;\n /**\n * The digest of the checklist a fresh `loam verify` derived for the feature\n * just before this merge — present when the feature carried a record then,\n * absent otherwise and in every manifest an older loam wrote. Additive, so\n * the layout version does not move: `unarchive` never reads it, and a reader\n * that predates it ignores it. See {@link readSnapshotChecklist}.\n */\n checklist?: string;\n files: SnapshotEntry[];\n}\n\nexport function snapshotDir(featureDir: string): string {\n return join(featureDir, SNAPSHOT_DIR);\n}\n\n/**\n * The checklist digest an archived feature's snapshot captured as it merged,\n * or null when it carries none.\n *\n * It lives in the manifest because the manifest is the one file `archive`\n * already writes into the feature at the moment of the merge, travels with it\n * into `features/archive/`, and is taken away by `unarchive` — so a feature\n * archived again gets a fresh one, captured against what it then asked. A\n * frozen verification record cannot be judged against a checklist re-derived\n * after the merge (the merge moved the feature's operations into the living\n * contract, so the derivation can only mismatch); this is the question as it\n * stood when the feature shipped, which is what `loam gate --since` holds the\n * record to. Here rather than beside the writer for the reason this module\n * exists: the gate is a read-only command, served over MCP as one.\n *\n * Tolerant to the last byte, and every failure is \"none captured\": a manifest\n * an older loam wrote, a feature archived with no record, a manifest that is\n * missing or does not parse. The reader that grades on it reads null as the\n * frozen history it always read, and says so — it never refuses a deploy\n * question over bookkeeping.\n */\nexport async function readSnapshotChecklist(featureDir: string): Promise<string | null> {\n const manifest = await manifestRecord(featureDir);\n if (manifest === null) return null;\n const { checklist } = manifest;\n return typeof checklist === \"string\" && checklist !== \"\" ? checklist : null;\n}\n\n/**\n * What a snapshot says about one service artifact as this feature's merge found\n * it. `held` is the pre-image itself. The other two are answers too, and the\n * same one for the question the reader asks — what did the merge take OUT of\n * the artifact: nothing. `created` is a row archive wrote for a file it\n * created (there was nothing before it); `unwritten` is an intact manifest\n * with no row for the artifact at all — archive records every file it writes,\n * so the artifact is what the merge found. Only the EVENT merge leaves a\n * contract unwritten: it returns no text when it changes nothing\n * (asyncapi/merge/merge.ts, `text: null` — a delta whose every marker missed\n * its target is exactly that). The OpenAPI merge returns text whenever the\n * delta has a path entry, so its no-op is an ordinary row whose pre-image\n * equals what it wrote — `held`, which answers the same question the same\n * way: every marker misses against it again.\n */\nexport type SnapshotPreImage =\n | { kind: \"held\"; text: string }\n | { kind: \"created\" }\n | { kind: \"unwritten\" };\n\n/**\n * The bytes one service artifact held the moment this feature merged — the\n * pre-image the snapshot parked under `files/` — or one of the two answers\n * that say the merge took nothing out of it ({@link SnapshotPreImage}), or\n * null when there is nothing to trust.\n *\n * Read for the questions only the pre-image can answer, both asked by the\n * retired-operation revival check (core/coherence/retired/): what a merge TOOK\n * that its delta never spelled — a channel-slot removal in an asyncapi delta is\n * a bare marker, while the messages it retired with the channel are written in\n * the living contract it merged into and nowhere else — and whether a removal\n * marker took anything at all, since the merge deletes only what it finds at\n * the marker's slot. Here, beside `readSnapshotChecklist`, for that reader's\n * reason: the readers are read-only commands, and this module is the half of\n * the snapshot they may import.\n *\n * The entry is found by `(service, artifact)` on a keyed (version 3 or 4) manifest — the\n * key that survives a subsystem re-cut — and by the literal path's last two\n * segments on a version-2 one, which recorded nothing else. Tolerant to the\n * last byte, like the checklist reader: no manifest, a layout this loam would\n * have to guess at, no row for the artifact while some row cannot be read (it\n * might have been this one's, so absence cannot be concluded past it), a path\n * that does not stay inside `files/`, a pre-image that no longer hashes to the digest\n * the manifest recorded for it, or bytes that are not UTF-8 — every one is\n * null, and the caller grades without it. An edited pre-image is refused\n * rather than read for the reason `unarchive` refuses to restore one: its\n * bytes are no longer what the merge saw.\n */\nexport async function readSnapshotPreImage(\n featureDir: string,\n key: { service: string; artifact: string },\n): Promise<SnapshotPreImage | null> {\n const manifest = await manifestRecord(featureDir);\n if (manifest === null || !Array.isArray(manifest.files)) return null;\n const { version } = manifest;\n if (!isReadableSnapshotVersion(version)) return null;\n // `Array.isArray` narrows to `any[]`; naming the element type keeps that\n // `any` from spreading past the row being read (snapshot.ts's reason).\n const rows: unknown[] = manifest.files;\n const entry = rows.find((row) => isRecord(row) && entryIs(row, key));\n if (entry === undefined) {\n // Absence is an answer only when every row could be read: a row that is\n // not a record with a path might have been this artifact's.\n return rows.every((row) => isRecord(row) && typeof row.path === \"string\") ? { kind: \"unwritten\" } : null;\n }\n if (!isRecord(entry) || typeof entry.path !== \"string\") return null;\n if (entry.existed === false && entry.before === null) return { kind: \"created\" };\n if (typeof entry.before !== \"string\") return null;\n let bytes: Buffer;\n try {\n // The manifest is data and this path is one we go and read: the lexical\n // containment `unarchive` and the staleness check apply to the same field.\n bytes = await readFile(\n resolvePortableFileInsideLexically(\n join(snapshotDir(featureDir), \"files\"),\n preImageRel(version, entry.path),\n \"snapshot pre-image\",\n ),\n );\n } catch {\n return null;\n }\n if (sha256(bytes) !== entry.before || !isUtf8(bytes)) return null;\n return { kind: \"held\", text: bytes.toString(\"utf8\") };\n}\n\n/** Does this manifest row record `key`'s artifact? The `(service, artifact)` key when it has one, the literal path otherwise. */\nfunction entryIs(row: Record<string, unknown>, key: { service: string; artifact: string }): boolean {\n if (typeof row.service === \"string\" && typeof row.artifact === \"string\") {\n return row.service === key.service && row.artifact === key.artifact;\n }\n if (typeof row.path !== \"string\") return false;\n const parts = row.path.split(\"/\");\n return parts.length >= 3 && parts[0] === \"services\" && parts.at(-2) === key.service && parts.at(-1) === key.artifact;\n}\n\n/** The manifest as a record, or null — the one tolerant read behind both readers above. */\nasync function manifestRecord(featureDir: string): Promise<Record<string, unknown> | null> {\n let manifest: unknown;\n try {\n manifest = JSON.parse(await readFile(join(snapshotDir(featureDir), SNAPSHOT_MANIFEST), \"utf8\"));\n } catch {\n return null;\n }\n return isRecord(manifest) ? manifest : null;\n}\n"]}
|
|
@@ -11,37 +11,15 @@
|
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
12
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
|
-
import { repoPath } from "
|
|
15
|
-
import { resolvePortableFileInsideLexically, UnsafePathError } from "
|
|
16
|
-
// Every `isRecord` below guards
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
import { isDigest, sha256 } from "./writes.js";
|
|
24
|
-
import { message, quietRm } from "./commit.js";
|
|
25
|
-
/** Where archive parks the pre-images, inside the feature directory so they travel with it — the module doc above says why. */
|
|
26
|
-
export const SNAPSHOT_DIR = ".loam-before";
|
|
27
|
-
export const SNAPSHOT_MANIFEST = "manifest.json";
|
|
28
|
-
/**
|
|
29
|
-
* Bumped only when the layout changes in a way `unarchive` must refuse to guess
|
|
30
|
-
* at. 2 added `before`: a version-1 snapshot says nothing about the bytes it
|
|
31
|
-
* would RESTORE, so an edited pre-image read back as an intact one. 3 re-keys
|
|
32
|
-
* `services/` entries by `(service, artifact)`: a literal path pins the service
|
|
33
|
-
* directory where it stood on the day of the archive, and once the subsystem
|
|
34
|
-
* tree lets it move — re-cutting is expected to be routine — an undo addressed
|
|
35
|
-
* by path restores into a location that is gone. The service id never moves.
|
|
36
|
-
*/
|
|
37
|
-
export const SNAPSHOT_VERSION = 3;
|
|
38
|
-
/**
|
|
39
|
-
* Version 2 is still READ, forever: version-2 snapshots already sit inside
|
|
40
|
-
* archived features, which carry them across loam upgrades, and each must keep
|
|
41
|
-
* restoring — and keep being graded for staleness by a retried archive — under
|
|
42
|
-
* its own literal-path rules. Only version 1 is refused (no restore digests).
|
|
43
|
-
*/
|
|
44
|
-
export const SNAPSHOT_COMPAT_VERSION = 2;
|
|
14
|
+
import { repoPath } from "../../envelope/json.js";
|
|
15
|
+
import { acrossRename, resolvePortableFileInsideLexically, UnsafePathError } from "../../kernel/path-safety.js";
|
|
16
|
+
// Every `isRecord` below guards the manifest loam itself wrote, read back inside
|
|
17
|
+
// a REFUSAL path — `../lock.ts` records why that is where a thrown `TypeError`
|
|
18
|
+
// costs the most.
|
|
19
|
+
import { isRecord } from "../../kernel/records.js";
|
|
20
|
+
import { isDigest, sha256 } from "../writes.js";
|
|
21
|
+
import { message, quietRm } from "../commit.js";
|
|
22
|
+
import { isReadableSnapshotVersion, preImageRel, SNAPSHOT_DIR, SNAPSHOT_MANIFEST, SNAPSHOT_VERSION, snapshotDir, } from "./manifest.js";
|
|
45
23
|
/** A snapshot directory that must not be replaced, because it is the only copy of the true pre-images. */
|
|
46
24
|
export class SnapshotClobberError extends Error {
|
|
47
25
|
dir;
|
|
@@ -54,19 +32,18 @@ export class SnapshotClobberError extends Error {
|
|
|
54
32
|
this.dir = dir;
|
|
55
33
|
}
|
|
56
34
|
}
|
|
57
|
-
export function snapshotDir(featureDir) {
|
|
58
|
-
return join(featureDir, SNAPSHOT_DIR);
|
|
59
|
-
}
|
|
60
35
|
export async function writeSnapshot(request) {
|
|
61
36
|
const { featureDir, docsDir, staged } = request;
|
|
62
37
|
const { featureId, dirName } = request.feature;
|
|
63
38
|
const dir = snapshotDir(featureDir);
|
|
64
|
-
await assertSnapshotReplaceable(
|
|
39
|
+
await assertSnapshotReplaceable(request);
|
|
65
40
|
// A leftover from a rolled-back run would describe a merge that never happened.
|
|
66
41
|
await quietRm(dir);
|
|
67
42
|
const files = [];
|
|
68
43
|
for (const s of staged) {
|
|
69
|
-
|
|
44
|
+
// The feature's own documents by where the move leaves them; everything
|
|
45
|
+
// else by where it is written (`SnapshotRequest.movesTo` says why).
|
|
46
|
+
const rel = repoPath(docsDir, acrossRename(s.write.path, featureDir, request.movesTo));
|
|
70
47
|
// services/ entries are re-keyed by (service, artifact) so they survive the
|
|
71
48
|
// service directory moving between archive and unarchive; the literal path
|
|
72
49
|
// is still recorded — it keys the pre-image below, and it is the fallback.
|
|
@@ -79,7 +56,10 @@ export async function writeSnapshot(request) {
|
|
|
79
56
|
before: s.before === null ? null : sha256(s.before),
|
|
80
57
|
});
|
|
81
58
|
if (s.before !== null) {
|
|
82
|
-
|
|
59
|
+
// Under the suffixed name (`./manifest.ts`, version 4): a pre-image kept
|
|
60
|
+
// under its living name is a second living document to every tool that
|
|
61
|
+
// reads by extension.
|
|
62
|
+
const dest = join(dir, "files", preImageRel(SNAPSHOT_VERSION, rel));
|
|
83
63
|
await mkdir(dirname(dest), { recursive: true });
|
|
84
64
|
await writeFile(dest, s.before);
|
|
85
65
|
}
|
|
@@ -89,6 +69,7 @@ export async function writeSnapshot(request) {
|
|
|
89
69
|
feature: featureId,
|
|
90
70
|
dirName,
|
|
91
71
|
archivedAt: new Date().toISOString(),
|
|
72
|
+
...(request.checklist === undefined ? {} : { checklist: request.checklist }),
|
|
92
73
|
files,
|
|
93
74
|
};
|
|
94
75
|
// Manifest last: its presence is what says the pre-images beside it are complete.
|
|
@@ -108,11 +89,16 @@ export async function writeSnapshot(request) {
|
|
|
108
89
|
* clear turned a repairable half-merge into a permanent one — after which
|
|
109
90
|
* `unarchive` restored the half-merged text and said the docs were back.
|
|
110
91
|
*
|
|
111
|
-
* Version 2 manifests are graded by the same staleness question
|
|
112
|
-
* literal paths — refusing them as "a different loam" would
|
|
113
|
-
*
|
|
92
|
+
* Version 2 and 3 manifests are graded by the same staleness question — version
|
|
93
|
+
* 2 over its literal paths — because refusing them as "a different loam" would
|
|
94
|
+
* turn every leftover snapshot an older loam wrote into a permanent archive
|
|
95
|
+
* refusal on upgrade. The question reads only the manifest and the living docs,
|
|
96
|
+
* never a pre-image, so where each layout stores its pre-images does not enter
|
|
97
|
+
* into it.
|
|
114
98
|
*/
|
|
115
|
-
async function assertSnapshotReplaceable(
|
|
99
|
+
async function assertSnapshotReplaceable(request) {
|
|
100
|
+
const { docsDir, serviceDirOf } = request;
|
|
101
|
+
const dir = snapshotDir(request.featureDir);
|
|
116
102
|
const manifestPath = join(dir, SNAPSHOT_MANIFEST);
|
|
117
103
|
// The manifest is written LAST, so a `files/` tree without one is a snapshot
|
|
118
104
|
// whose run died before it could swap anything: it describes no merge.
|
|
@@ -134,8 +120,7 @@ async function assertSnapshotReplaceable(dir, docsDir, serviceDirOf) {
|
|
|
134
120
|
if (!isRecord(manifest)) {
|
|
135
121
|
throw new SnapshotClobberError(dir, `${SNAPSHOT_MANIFEST} cannot be read, so nothing can say whether it is stale`);
|
|
136
122
|
}
|
|
137
|
-
if ((manifest.version
|
|
138
|
-
!Array.isArray(manifest.files)) {
|
|
123
|
+
if (!isReadableSnapshotVersion(manifest.version) || !Array.isArray(manifest.files)) {
|
|
139
124
|
throw new SnapshotClobberError(dir, `it was written by a different loam and cannot be checked against the living docs`);
|
|
140
125
|
}
|
|
141
126
|
// `Array.isArray` narrows to `any[]`; naming the element type here is what
|
|
@@ -172,12 +157,21 @@ async function assertSnapshotReplaceable(dir, docsDir, serviceDirOf) {
|
|
|
172
157
|
throw err;
|
|
173
158
|
throw new SnapshotClobberError(dir, `${message(err)}, so nothing can say whether it is stale`);
|
|
174
159
|
}
|
|
175
|
-
|
|
160
|
+
// A row of the feature's own documents names where the move WOULD have
|
|
161
|
+
// left it, and a snapshot found here is one whose feature never moved: the
|
|
162
|
+
// bytes to grade are still under this feature directory. Graded at the
|
|
163
|
+
// literal path instead, every such row read as absent — a landed merge —
|
|
164
|
+
// and an archive that a crash or a rollback interrupted could never be
|
|
165
|
+
// retried over its own leftover. Named where it was graded, too: the
|
|
166
|
+
// refusal sends a person to restore the file from version control, and
|
|
167
|
+
// the archived path names a directory that does not exist yet.
|
|
168
|
+
const graded = acrossRename(target, request.movesTo, request.featureDir);
|
|
169
|
+
const now = existsSync(graded) ? sha256(await readFile(graded)) : null;
|
|
176
170
|
// `existed: false` means archive created the file, so "nothing landed"
|
|
177
171
|
// means it is still absent.
|
|
178
172
|
const untouched = entry.existed ? now === entry.before : now === null;
|
|
179
173
|
if (!untouched)
|
|
180
|
-
landed.push(entry.path);
|
|
174
|
+
landed.push(graded === target ? entry.path : repoPath(docsDir, graded));
|
|
181
175
|
}
|
|
182
176
|
if (landed.length > 0)
|
|
183
177
|
throw new SnapshotClobberError(dir, `${landed.length} file(s) already carry that merge: ${landed.join(", ")}`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot.js","sourceRoot":"","sources":["../../../../src/core/staging/snapshot/snapshot.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The undo snapshot: the bytes `archive` is about to overwrite, parked inside\n * the feature directory so they travel with it into `features/archive/`.\n *\n * Separate from the commit because the merge is not invertible — a MODIFIED\n * requirement's previous text appears nowhere in the delta, and a landscape\n * rewritten by hand since cannot be un-rewritten by re-reading one. `unarchive`\n * puts bytes back; it does not recompute them. Nothing on the commit path reads\n * this module: only `archive` writes a snapshot and only `unarchive` honours one.\n */\nimport { existsSync } from \"node:fs\";\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { repoPath } from \"../../envelope/json.js\";\nimport { acrossRename, resolvePortableFileInsideLexically, UnsafePathError } from \"../../kernel/path-safety.js\";\n// Every `isRecord` below guards the manifest loam itself wrote, read back inside\n// a REFUSAL path — `../lock.ts` records why that is where a thrown `TypeError`\n// costs the most.\nimport { isRecord } from \"../../kernel/records.js\";\nimport { isDigest, sha256, type StagedWrite } from \"../writes.js\";\nimport { message, quietRm } from \"../commit.js\";\nimport {\n isReadableSnapshotVersion,\n preImageRel,\n SNAPSHOT_DIR,\n SNAPSHOT_MANIFEST,\n SNAPSHOT_VERSION,\n snapshotDir,\n type SnapshotEntry,\n type SnapshotManifest,\n} from \"./manifest.js\";\n\n/** A snapshot directory that must not be replaced, because it is the only copy of the true pre-images. */\nexport class SnapshotClobberError extends Error {\n override readonly name = \"SnapshotClobberError\";\n\n constructor(readonly dir: string, detail: string) {\n super(\n `${dir} already holds an undo snapshot that does NOT match the living docs (${detail}) — a previous archive of ` +\n `this feature wrote to the living docs and never took them back, and these are the only copies of what they ` +\n `said before. Overwriting them would make that merge permanent and unreportable. Run \\`loam archive\\` again ` +\n `after restoring the living docs from version control, or delete ${SNAPSHOT_DIR}/ if you know the merge never landed.`,\n );\n }\n}\n\n/** The (service, artifact) key a `services/` path decomposes into. */\nexport interface ServiceKey {\n service: string;\n artifact: string;\n}\n\n/**\n * Everything `writeSnapshot` needs, as one object. The resolvers are INJECTED\n * by the caller — `archive` builds them from its view of the tree — because\n * how a path decomposes into a service identity is repo-layout knowledge, and\n * this module's subject is parking and checking bytes.\n */\nexport interface SnapshotRequest {\n featureDir: string;\n /**\n * Where the archive moves `featureDir` — the directory this snapshot is\n * about to travel into. A staged write INSIDE `featureDir` is one of the\n * feature's own documents (the archive re-expresses their links for the\n * move), and it is recorded at its image under here: that is where its bytes\n * sit whenever an undo can be asked for, so `unarchive` restores it by its\n * literal path before moving the directory back, exactly as it restores\n * every other row. The other reader, the clobber guard below, meets a\n * snapshot BEFORE its feature moved and grades such a row at its image back\n * under `featureDir`.\n */\n movesTo: string;\n docsDir: string;\n feature: { featureId: string; dirName: string };\n staged: StagedWrite[];\n /** Docs-relative path → the service it belongs to; null for landscape/features paths. */\n serviceKeyOf: (rel: string) => ServiceKey | null;\n /** Service id → its CURRENT docs-relative directory; null when not enumerated. */\n serviceDirOf: (service: string) => string | null;\n /**\n * Recorded verbatim as the manifest's `checklist` when given. Opaque here —\n * this module parks bytes and never learns what a checklist is; `archive`\n * derives it and `gate --since` reads it back (`./manifest.ts`).\n */\n checklist?: string;\n}\n\nexport async function writeSnapshot(request: SnapshotRequest): Promise<void> {\n const { featureDir, docsDir, staged } = request;\n const { featureId, dirName } = request.feature;\n const dir = snapshotDir(featureDir);\n await assertSnapshotReplaceable(request);\n // A leftover from a rolled-back run would describe a merge that never happened.\n await quietRm(dir);\n\n const files: SnapshotEntry[] = [];\n for (const s of staged) {\n // The feature's own documents by where the move leaves them; everything\n // else by where it is written (`SnapshotRequest.movesTo` says why).\n const rel = repoPath(docsDir, acrossRename(s.write.path, featureDir, request.movesTo));\n // services/ entries are re-keyed by (service, artifact) so they survive the\n // service directory moving between archive and unarchive; the literal path\n // is still recorded — it keys the pre-image below, and it is the fallback.\n const key = request.serviceKeyOf(rel);\n files.push({\n path: rel,\n ...key,\n existed: s.before !== null,\n after: sha256(s.content ?? Buffer.alloc(0)),\n before: s.before === null ? null : sha256(s.before),\n });\n if (s.before !== null) {\n // Under the suffixed name (`./manifest.ts`, version 4): a pre-image kept\n // under its living name is a second living document to every tool that\n // reads by extension.\n const dest = join(dir, \"files\", preImageRel(SNAPSHOT_VERSION, rel));\n await mkdir(dirname(dest), { recursive: true });\n await writeFile(dest, s.before);\n }\n }\n\n const manifest: SnapshotManifest = {\n version: SNAPSHOT_VERSION,\n feature: featureId,\n dirName,\n archivedAt: new Date().toISOString(),\n ...(request.checklist === undefined ? {} : { checklist: request.checklist }),\n files,\n };\n // Manifest last: its presence is what says the pre-images beside it are complete.\n await mkdir(dir, { recursive: true });\n await writeFile(join(dir, SNAPSHOT_MANIFEST), JSON.stringify(manifest, null, 2) + \"\\n\", \"utf8\");\n}\n\n/**\n * Refuse to replace a snapshot whose pre-images are still the only record of\n * what the living docs said.\n *\n * A snapshot in an ACTIVE feature directory means a previous archive of this\n * same feature wrote it — and since a clean rollback deletes it, finding one\n * means that run either died mid-commit or reported `rollback-incomplete`. If\n * its pre-images still match the living docs, nothing of that run landed and\n * the snapshot is stale bookkeeping. If they do not, the living docs carry\n * that run's writes, these files are the only way back, and the unconditional\n * clear turned a repairable half-merge into a permanent one — after which\n * `unarchive` restored the half-merged text and said the docs were back.\n *\n * Version 2 and 3 manifests are graded by the same staleness question — version\n * 2 over its literal paths — because refusing them as \"a different loam\" would\n * turn every leftover snapshot an older loam wrote into a permanent archive\n * refusal on upgrade. The question reads only the manifest and the living docs,\n * never a pre-image, so where each layout stores its pre-images does not enter\n * into it.\n */\nasync function assertSnapshotReplaceable(request: SnapshotRequest): Promise<void> {\n const { docsDir, serviceDirOf } = request;\n const dir = snapshotDir(request.featureDir);\n const manifestPath = join(dir, SNAPSHOT_MANIFEST);\n // The manifest is written LAST, so a `files/` tree without one is a snapshot\n // whose run died before it could swap anything: it describes no merge.\n if (!existsSync(manifestPath)) return;\n let manifest: unknown;\n try {\n manifest = JSON.parse(await readFile(manifestPath, \"utf8\"));\n } catch {\n throw new SnapshotClobberError(dir, `${SNAPSHOT_MANIFEST} cannot be read, so nothing can say whether it is stale`);\n }\n // The cast that used to stand here asserted the shape rather than checking\n // it: well-formed JSON that is not an object answered `undefined` to every\n // field below and slipped through as \"a different loam\", and a bare `null`\n // threw a TypeError out of the refusal itself. Both left this directory —\n // which may hold the only copies of what the living docs said — one step from\n // being replaced, without the staleness question ever having been asked.\n if (!isRecord(manifest)) {\n throw new SnapshotClobberError(dir, `${SNAPSHOT_MANIFEST} cannot be read, so nothing can say whether it is stale`);\n }\n if (!isReadableSnapshotVersion(manifest.version) || !Array.isArray(manifest.files)) {\n throw new SnapshotClobberError(dir, `it was written by a different loam and cannot be checked against the living docs`);\n }\n // `Array.isArray` narrows to `any[]`; naming the element type here is what\n // keeps that `any` from spreading past the one row being read.\n const rows: unknown[] = manifest.files;\n const landed: string[] = [];\n for (const row of rows) {\n const entry = readSnapshotClaim(row);\n if (entry === null) {\n throw new SnapshotClobberError(dir, `${SNAPSHOT_MANIFEST} is malformed, so nothing can say whether it is stale`);\n }\n let target: string;\n try {\n // These paths are data, and they are the ones this function goes and\n // reads. They name living documents, so containment is lexical: a docs\n // repo may legitimately mount a service directory through a symlink, and\n // `unarchive` resolves the same field the same way before it writes back.\n target = resolvePortableFileInsideLexically(docsDir, entry.path, `snapshot path '${entry.path}'`);\n // A re-keyed entry's CURRENT location is wherever the enumeration says\n // its service lives today — grading staleness at the old address would\n // call a merely-moved file a landed merge. The literal path was still\n // resolved above on purpose: its containment is part of what \"loam wrote\n // this manifest\" means, and it stays the pre-image's key. An id the\n // enumeration no longer answers falls back to that literal path.\n if (entry.service !== undefined && entry.artifact !== undefined) {\n const current = serviceDirOf(entry.service);\n if (current !== null) {\n target = resolvePortableFileInsideLexically(\n docsDir,\n `${current}/${entry.artifact}`,\n `snapshot path '${entry.path}'`,\n );\n }\n }\n } catch (err) {\n if (!(err instanceof UnsafePathError)) throw err;\n throw new SnapshotClobberError(dir, `${message(err)}, so nothing can say whether it is stale`);\n }\n // A row of the feature's own documents names where the move WOULD have\n // left it, and a snapshot found here is one whose feature never moved: the\n // bytes to grade are still under this feature directory. Graded at the\n // literal path instead, every such row read as absent — a landed merge —\n // and an archive that a crash or a rollback interrupted could never be\n // retried over its own leftover. Named where it was graded, too: the\n // refusal sends a person to restore the file from version control, and\n // the archived path names a directory that does not exist yet.\n const graded = acrossRename(target, request.movesTo, request.featureDir);\n const now = existsSync(graded) ? sha256(await readFile(graded)) : null;\n // `existed: false` means archive created the file, so \"nothing landed\"\n // means it is still absent.\n const untouched = entry.existed ? now === entry.before : now === null;\n if (!untouched) landed.push(graded === target ? entry.path : repoPath(docsDir, graded));\n }\n if (landed.length > 0) throw new SnapshotClobberError(dir, `${landed.length} file(s) already carry that merge: ${landed.join(\", \")}`);\n}\n\n/**\n * One manifest row read back as the record `writeSnapshot` wrote, or null when\n * the file does not actually say.\n *\n * Every field the staleness question consumes is checked, because an unchecked\n * field does not read as missing here — it reads as an ANSWER. An `existed`\n * that is not a boolean is falsy, and a `false` claim is graded by asking\n * whether the file is ABSENT: a manifest recording a pre-image for a deleted\n * file therefore graded as stale bookkeeping and was cleared, taking the only\n * copy of that pre-image with it. A `before` of the wrong type equals no digest\n * at all, so the opposite row accused an untouched file of carrying a merge.\n *\n * `unarchive` asks a stricter version of the same question of the same file\n * (`after`, `archivedAt`, the feature name, no duplicates) because it is about\n * to write THROUGH it; that check stays where it is, with its own refusals.\n *\n * `service`/`artifact` are read when present (a keyed `services/` row) and\n * must arrive as a pair: half a key is a manifest that does not say where the\n * file lives, and grading through the literal path anyway would be a guess.\n */\ninterface SnapshotClaim {\n path: string;\n existed: boolean;\n before: string | null;\n service?: string;\n artifact?: string;\n}\n\nfunction readSnapshotClaim(value: unknown): SnapshotClaim | null {\n if (!isRecord(value)) return null;\n const { path, existed, before, service, artifact } = value;\n if (typeof path !== \"string\" || typeof existed !== \"boolean\") return null;\n let key: { service: string; artifact: string } | Record<string, never>;\n if (service === undefined && artifact === undefined) key = {};\n else if (typeof service === \"string\" && typeof artifact === \"string\") key = { service, artifact };\n else return null;\n if (existed) return isDigest(before) ? { path, existed, before, ...key } : null;\n return before === null ? { path, existed, before, ...key } : null;\n}\n\n"]}
|
|
@@ -23,21 +23,7 @@ import { sha256 } from "../writes.js";
|
|
|
23
23
|
import { COMMIT_INTENT, InterruptedCommitError, sweepPidTemps } from "../interrupted.js";
|
|
24
24
|
import { clearTxnIntent } from "./journal.js";
|
|
25
25
|
export async function recoverForward(root, intent) {
|
|
26
|
-
const target = (f) => join(root, ...f.path.split("/"));
|
|
27
26
|
const abs = (rel) => join(root, ...rel.split("/"));
|
|
28
|
-
const done = [];
|
|
29
|
-
const pending = [];
|
|
30
|
-
const unknown = [];
|
|
31
|
-
for (const f of intent.files) {
|
|
32
|
-
const file = target(f);
|
|
33
|
-
const now = existsSync(file) ? sha256(await readFile(file)) : null;
|
|
34
|
-
if (now === f.after)
|
|
35
|
-
done.push(f);
|
|
36
|
-
else if (now === f.before)
|
|
37
|
-
pending.push(f);
|
|
38
|
-
else
|
|
39
|
-
unknown.push(f.path);
|
|
40
|
-
}
|
|
41
27
|
// A directory rename is classified by pure existence — a rename moves the
|
|
42
28
|
// inode, so "which end exists" is exactly as discriminating as a digest is
|
|
43
29
|
// for a file. Both ends existing (or neither) is a state no step of the
|
|
@@ -56,6 +42,30 @@ export async function recoverForward(root, intent) {
|
|
|
56
42
|
else
|
|
57
43
|
unknownMoves.push(`${m.from} -> ${m.to}`);
|
|
58
44
|
}
|
|
45
|
+
// Where a recorded file IS now. The journal records a file at the path its
|
|
46
|
+
// swap wrote — and a `subsystem move` swaps the documents whose links it
|
|
47
|
+
// rewrites INSIDE a directory it then renames, so once that rename has
|
|
48
|
+
// landed the file is under the moved directory, not at the recorded path.
|
|
49
|
+
// Looked for there, it would read as absent: "edited since", a refusal over
|
|
50
|
+
// a commit that only needed its journal cleared. Classified moves first for
|
|
51
|
+
// exactly this; a file under a move still pending is where it was recorded.
|
|
52
|
+
const target = (f) => {
|
|
53
|
+
const landed = movesDone.find((m) => f.path.startsWith(`${m.from}/`));
|
|
54
|
+
return landed === undefined ? abs(f.path) : abs(`${landed.to}${f.path.slice(landed.from.length)}`);
|
|
55
|
+
};
|
|
56
|
+
const done = [];
|
|
57
|
+
const pending = [];
|
|
58
|
+
const unknown = [];
|
|
59
|
+
for (const f of intent.files) {
|
|
60
|
+
const file = target(f);
|
|
61
|
+
const now = existsSync(file) ? sha256(await readFile(file)) : null;
|
|
62
|
+
if (now === f.after)
|
|
63
|
+
done.push(f);
|
|
64
|
+
else if (now === f.before)
|
|
65
|
+
pending.push(f);
|
|
66
|
+
else
|
|
67
|
+
unknown.push(f.path);
|
|
68
|
+
}
|
|
59
69
|
if (unknown.length > 0 || unknownMoves.length > 0) {
|
|
60
70
|
// Two vocabularies on purpose: a FILE in neither recorded state holds
|
|
61
71
|
// edited bytes, while a DIRECTORY rename in neither state means both ends
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"forward.js","sourceRoot":"","sources":["../../../../src/core/staging/txn/forward.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Roll a version-2 or version-3 journal FORWARD: finish the renames a killed\n * commit did not reach, from the staged temps that are already durable on\n * disk (and, for a version-3 record, the directory renames after them).\n *\n * Forward and not undo, because that is what is actually durable: the\n * after-bytes were written and (per stageWrites) fully on disk before the\n * journal existed, while the OVERWRITTEN files' pre-images are durable\n * nowhere — the rename that replaced them destroyed the only copy. The plan\n * was validated completely before the first rename, so the post-state is\n * exactly what a successful run would have produced; unarchive's\n * finish-never-undo recovery is the precedent.\n *\n * The refuse-to-choose doctrine is rollbackStaged's, verbatim: a file whose\n * bytes are neither the before- nor the after-digest was edited by somebody\n * since the crash, and completing over it would destroy one of two truths.\n */\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { readFile, rename, rm } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { quietRmdir } from \"../commit.js\";\nimport { sha256 } from \"../writes.js\";\nimport { COMMIT_INTENT, type CommitRecovery, InterruptedCommitError, sweepPidTemps } from \"../interrupted.js\";\nimport { clearTxnIntent, type TxnIntent, type TxnIntentFile, type TxnIntentMove } from \"./journal.js\";\n\nexport async function recoverForward(root: string, intent: TxnIntent): Promise<CommitRecovery> {\n const target = (f: TxnIntentFile): string => join(root, ...f.path.split(\"/\"));\n const abs = (rel: string): string => join(root, ...rel.split(\"/\"));\n const done: TxnIntentFile[] = [];\n const pending: TxnIntentFile[] = [];\n const unknown: string[] = [];\n for (const f of intent.files) {\n const file = target(f);\n const now = existsSync(file) ? sha256(await readFile(file)) : null;\n if (now === f.after) done.push(f);\n else if (now === f.before) pending.push(f);\n else unknown.push(f.path);\n }\n // A directory rename is classified by pure existence — a rename moves the\n // inode, so \"which end exists\" is exactly as discriminating as a digest is\n // for a file. Both ends existing (or neither) is a state no step of the\n // commit produces: somebody made a directory since the crash, and choosing\n // which tree to keep is the refuse-to-choose case below.\n const movesDone: TxnIntentMove[] = [];\n const movesPending: TxnIntentMove[] = [];\n const unknownMoves: string[] = [];\n for (const m of intent.moves) {\n const fromExists = existsSync(abs(m.from));\n const toExists = existsSync(abs(m.to));\n if (toExists && !fromExists) movesDone.push(m);\n else if (fromExists && !toExists) movesPending.push(m);\n else unknownMoves.push(`${m.from} -> ${m.to}`);\n }\n if (unknown.length > 0 || unknownMoves.length > 0) {\n // Two vocabularies on purpose: a FILE in neither recorded state holds\n // edited bytes, while a DIRECTORY rename in neither state means both ends\n // exist (or neither does) — nothing was \"edited\", somebody made or\n // restored a directory since the crash. One sentence covering both told\n // the operator to hunt for edited bytes in a directory pair. And the\n // interrupted work is named by what it WAS (`command` + `target`), not by\n // `intent.rerun` — that string is the REPAIR spelling (`loam subsystem\n // sync` repairs all four subsystem verbs), so \"a `loam subsystem sync`\n // was interrupted\" named a command nobody ran.\n const parts = [\n ...(unknown.length > 0\n ? [\n `${unknown.length} of the file(s) it was writing now hold bytes that are neither what it found ` +\n `nor what it wrote — somebody edited them since: ${unknown.join(\", \")}`,\n ]\n : []),\n ...(unknownMoves.length > 0\n ? [\n `the directory rename(s) ${unknownMoves.join(\", \")} are in neither recorded state — both ends ` +\n `exist, or neither does, so loam cannot tell which tree to keep`,\n ]\n : []),\n ];\n throw new InterruptedCommitError(\n `a \\`${intent.command}\\` commit for '${intent.target}' was interrupted mid-commit ` +\n `(pid ${intent.pid} on ${intent.host}, ${intent.at}) and ${parts.join(\"; \")}. ` +\n `loam will not choose between those two truths. ` +\n `Reconcile them against version control, delete ${COMMIT_INTENT}, then re-run \\`${intent.rerun}\\`.`,\n );\n }\n\n // Whether forward is still POSSIBLE is decided before a single byte moves:\n // completing some files and then refusing on a missing temp would leave the\n // repo in a state neither the old nor the new tree ever had.\n const missing = pending.filter((f) => f.after !== null && !usableTemp(root, f));\n if (missing.length > 0) {\n // A file whose before and after digests are EQUAL — an unchanged file\n // carried through a commit — reads as \"done\" in whichever state it is in,\n // so it is not evidence that any rename landed. Only a file that could\n // tell the two states apart counts as one — and a landed directory MOVE\n // always can, so it counts too.\n const evidence = done.filter((f) => f.before !== f.after);\n if (evidence.length === 0 && movesDone.length === 0) {\n // Nothing ever swapped and the temps are gone — this is the tree a\n // completed ROLLBACK leaves when the crash landed between the rollback\n // and the journal's removal. The complete pre-state is on disk, which\n // is one of the two states recovery promises; declaring it beats\n // refusing over bytes that are already consistent.\n await finish(root, intent);\n return { command: intent.command, feature: intent.target, outcome: \"consistent\", repaired: [] };\n }\n throw new InterruptedCommitError(\n `a \\`${intent.command}\\` commit for '${intent.target}' was interrupted and ${missing.map((f) => f.path).join(\", \")} ` +\n `${missing.length === 1 ? \"is\" : \"are\"} still in the pre-commit state, but the staged bytes that would ` +\n `finish ${missing.length === 1 ? \"it\" : \"them\"} are gone or no longer match the digest recorded before the ` +\n `crash. Restore the tree from version control, delete ${COMMIT_INTENT}, then re-run \\`${intent.rerun}\\`.`,\n );\n }\n\n const repaired: string[] = [];\n for (const f of pending) {\n const file = target(f);\n if (f.after === null) {\n await rm(file, { force: true });\n await quietRmdir(dirname(file));\n } else {\n await rename(join(root, ...f.tmp!.split(\"/\")), file);\n }\n repaired.push(f.path);\n }\n // Renames after the file swaps, in recorded order — the order the commit\n // itself performs them. One that cannot land now (the destination's parent\n // deleted since the crash, a Windows EPERM on an open handle) is a refusal\n // naming the pair, not a half-finished loop: nothing before it is undone,\n // and the journal stays for the next attempt.\n for (const m of movesPending) {\n try {\n await rename(abs(m.from), abs(m.to));\n } catch (err) {\n throw new InterruptedCommitError(\n `a \\`${intent.command}\\` commit for '${intent.target}' was interrupted and the rename ${m.from} -> ${m.to} ` +\n `that would finish it cannot be performed now (${err instanceof Error ? err.message : String(err)}). ` +\n `Complete or undo it by hand against version control, delete ${COMMIT_INTENT}, then re-run \\`${intent.rerun}\\`.`,\n );\n }\n repaired.push(m.to);\n }\n await finish(root, intent);\n return {\n command: intent.command,\n feature: intent.target,\n outcome: repaired.length > 0 ? \"repaired\" : \"completed\",\n repaired,\n };\n}\n\n/** Is the recorded temp still there with exactly the bytes the journal promised? */\nfunction usableTemp(root: string, f: TxnIntentFile): boolean {\n if (f.tmp === null) return false;\n const path = join(root, ...f.tmp.split(\"/\"));\n try {\n return sha256(readFileSync(path)) === f.after;\n } catch {\n return false;\n }\n}\n\nasync function finish(root: string, intent: TxnIntent): Promise<void> {\n const dirs = intent.files.flatMap((f) => {\n const out = [dirname(join(root, ...f.path.split(\"/\")))];\n if (f.tmp !== null) out.push(dirname(join(root, ...f.tmp.split(\"/\"))));\n return out;\n });\n await sweepPidTemps(dirs, intent.pid);\n await clearTxnIntent(root);\n}\n\n"]}
|
|
1
|
+
{"version":3,"file":"forward.js","sourceRoot":"","sources":["../../../../src/core/staging/txn/forward.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Roll a version-2 or version-3 journal FORWARD: finish the renames a killed\n * commit did not reach, from the staged temps that are already durable on\n * disk (and, for a version-3 record, the directory renames after them).\n *\n * Forward and not undo, because that is what is actually durable: the\n * after-bytes were written and (per stageWrites) fully on disk before the\n * journal existed, while the OVERWRITTEN files' pre-images are durable\n * nowhere — the rename that replaced them destroyed the only copy. The plan\n * was validated completely before the first rename, so the post-state is\n * exactly what a successful run would have produced; unarchive's\n * finish-never-undo recovery is the precedent.\n *\n * The refuse-to-choose doctrine is rollbackStaged's, verbatim: a file whose\n * bytes are neither the before- nor the after-digest was edited by somebody\n * since the crash, and completing over it would destroy one of two truths.\n */\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { readFile, rename, rm } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { quietRmdir } from \"../commit.js\";\nimport { sha256 } from \"../writes.js\";\nimport { COMMIT_INTENT, type CommitRecovery, InterruptedCommitError, sweepPidTemps } from \"../interrupted.js\";\nimport { clearTxnIntent, type TxnIntent, type TxnIntentFile, type TxnIntentMove } from \"./journal.js\";\n\nexport async function recoverForward(root: string, intent: TxnIntent): Promise<CommitRecovery> {\n const abs = (rel: string): string => join(root, ...rel.split(\"/\"));\n // A directory rename is classified by pure existence — a rename moves the\n // inode, so \"which end exists\" is exactly as discriminating as a digest is\n // for a file. Both ends existing (or neither) is a state no step of the\n // commit produces: somebody made a directory since the crash, and choosing\n // which tree to keep is the refuse-to-choose case below.\n const movesDone: TxnIntentMove[] = [];\n const movesPending: TxnIntentMove[] = [];\n const unknownMoves: string[] = [];\n for (const m of intent.moves) {\n const fromExists = existsSync(abs(m.from));\n const toExists = existsSync(abs(m.to));\n if (toExists && !fromExists) movesDone.push(m);\n else if (fromExists && !toExists) movesPending.push(m);\n else unknownMoves.push(`${m.from} -> ${m.to}`);\n }\n // Where a recorded file IS now. The journal records a file at the path its\n // swap wrote — and a `subsystem move` swaps the documents whose links it\n // rewrites INSIDE a directory it then renames, so once that rename has\n // landed the file is under the moved directory, not at the recorded path.\n // Looked for there, it would read as absent: \"edited since\", a refusal over\n // a commit that only needed its journal cleared. Classified moves first for\n // exactly this; a file under a move still pending is where it was recorded.\n const target = (f: TxnIntentFile): string => {\n const landed = movesDone.find((m) => f.path.startsWith(`${m.from}/`));\n return landed === undefined ? abs(f.path) : abs(`${landed.to}${f.path.slice(landed.from.length)}`);\n };\n const done: TxnIntentFile[] = [];\n const pending: TxnIntentFile[] = [];\n const unknown: string[] = [];\n for (const f of intent.files) {\n const file = target(f);\n const now = existsSync(file) ? sha256(await readFile(file)) : null;\n if (now === f.after) done.push(f);\n else if (now === f.before) pending.push(f);\n else unknown.push(f.path);\n }\n if (unknown.length > 0 || unknownMoves.length > 0) {\n // Two vocabularies on purpose: a FILE in neither recorded state holds\n // edited bytes, while a DIRECTORY rename in neither state means both ends\n // exist (or neither does) — nothing was \"edited\", somebody made or\n // restored a directory since the crash. One sentence covering both told\n // the operator to hunt for edited bytes in a directory pair. And the\n // interrupted work is named by what it WAS (`command` + `target`), not by\n // `intent.rerun` — that string is the REPAIR spelling (`loam subsystem\n // sync` repairs all four subsystem verbs), so \"a `loam subsystem sync`\n // was interrupted\" named a command nobody ran.\n const parts = [\n ...(unknown.length > 0\n ? [\n `${unknown.length} of the file(s) it was writing now hold bytes that are neither what it found ` +\n `nor what it wrote — somebody edited them since: ${unknown.join(\", \")}`,\n ]\n : []),\n ...(unknownMoves.length > 0\n ? [\n `the directory rename(s) ${unknownMoves.join(\", \")} are in neither recorded state — both ends ` +\n `exist, or neither does, so loam cannot tell which tree to keep`,\n ]\n : []),\n ];\n throw new InterruptedCommitError(\n `a \\`${intent.command}\\` commit for '${intent.target}' was interrupted mid-commit ` +\n `(pid ${intent.pid} on ${intent.host}, ${intent.at}) and ${parts.join(\"; \")}. ` +\n `loam will not choose between those two truths. ` +\n `Reconcile them against version control, delete ${COMMIT_INTENT}, then re-run \\`${intent.rerun}\\`.`,\n );\n }\n\n // Whether forward is still POSSIBLE is decided before a single byte moves:\n // completing some files and then refusing on a missing temp would leave the\n // repo in a state neither the old nor the new tree ever had.\n const missing = pending.filter((f) => f.after !== null && !usableTemp(root, f));\n if (missing.length > 0) {\n // A file whose before and after digests are EQUAL — an unchanged file\n // carried through a commit — reads as \"done\" in whichever state it is in,\n // so it is not evidence that any rename landed. Only a file that could\n // tell the two states apart counts as one — and a landed directory MOVE\n // always can, so it counts too.\n const evidence = done.filter((f) => f.before !== f.after);\n if (evidence.length === 0 && movesDone.length === 0) {\n // Nothing ever swapped and the temps are gone — this is the tree a\n // completed ROLLBACK leaves when the crash landed between the rollback\n // and the journal's removal. The complete pre-state is on disk, which\n // is one of the two states recovery promises; declaring it beats\n // refusing over bytes that are already consistent.\n await finish(root, intent);\n return { command: intent.command, feature: intent.target, outcome: \"consistent\", repaired: [] };\n }\n throw new InterruptedCommitError(\n `a \\`${intent.command}\\` commit for '${intent.target}' was interrupted and ${missing.map((f) => f.path).join(\", \")} ` +\n `${missing.length === 1 ? \"is\" : \"are\"} still in the pre-commit state, but the staged bytes that would ` +\n `finish ${missing.length === 1 ? \"it\" : \"them\"} are gone or no longer match the digest recorded before the ` +\n `crash. Restore the tree from version control, delete ${COMMIT_INTENT}, then re-run \\`${intent.rerun}\\`.`,\n );\n }\n\n const repaired: string[] = [];\n for (const f of pending) {\n const file = target(f);\n if (f.after === null) {\n await rm(file, { force: true });\n await quietRmdir(dirname(file));\n } else {\n await rename(join(root, ...f.tmp!.split(\"/\")), file);\n }\n repaired.push(f.path);\n }\n // Renames after the file swaps, in recorded order — the order the commit\n // itself performs them. One that cannot land now (the destination's parent\n // deleted since the crash, a Windows EPERM on an open handle) is a refusal\n // naming the pair, not a half-finished loop: nothing before it is undone,\n // and the journal stays for the next attempt.\n for (const m of movesPending) {\n try {\n await rename(abs(m.from), abs(m.to));\n } catch (err) {\n throw new InterruptedCommitError(\n `a \\`${intent.command}\\` commit for '${intent.target}' was interrupted and the rename ${m.from} -> ${m.to} ` +\n `that would finish it cannot be performed now (${err instanceof Error ? err.message : String(err)}). ` +\n `Complete or undo it by hand against version control, delete ${COMMIT_INTENT}, then re-run \\`${intent.rerun}\\`.`,\n );\n }\n repaired.push(m.to);\n }\n await finish(root, intent);\n return {\n command: intent.command,\n feature: intent.target,\n outcome: repaired.length > 0 ? \"repaired\" : \"completed\",\n repaired,\n };\n}\n\n/** Is the recorded temp still there with exactly the bytes the journal promised? */\nfunction usableTemp(root: string, f: TxnIntentFile): boolean {\n if (f.tmp === null) return false;\n const path = join(root, ...f.tmp.split(\"/\"));\n try {\n return sha256(readFileSync(path)) === f.after;\n } catch {\n return false;\n }\n}\n\nasync function finish(root: string, intent: TxnIntent): Promise<void> {\n const dirs = intent.files.flatMap((f) => {\n const out = [dirname(join(root, ...f.path.split(\"/\")))];\n if (f.tmp !== null) out.push(dirname(join(root, ...f.tmp.split(\"/\"))));\n return out;\n });\n await sweepPidTemps(dirs, intent.pid);\n await clearTxnIntent(root);\n}\n\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"writes.js","sourceRoot":"","sources":["../../../src/core/staging/writes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AA8B5C,mDAAmD;AACnD,MAAM,UAAU,OAAO,CAAC,OAA+B;IACrD,OAAO,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AAC9E,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,OAA+B;IACrE,IAAI,OAAO,KAAK,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IACrF,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAC3B,CAAC;AAsBD;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAO,YAAa,SAAQ,KAAK;IAI1B,IAAI;IAHG,IAAI,GAAG,cAAc,CAAC;IAExC,YACW,IAAY,EACrB,MAAc;QAEd,KAAK,CAAC,GAAG,IAAI,IAAI,MAAM,yDAAyD,CAAC,CAAC;oBAHzE,IAAI;IAIf,CAAC;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,SAAS,UAAU,CAAC,KAAa,EAAE,IAAY;IAC7C,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QACnB,MAAM,IAAI,YAAY,CACpB,IAAI,EACJ,mGAAmG;YACjG,gFAAgF,CACnF,CAAC;IACJ,CAAC;IACD,MAAM,IAAI,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACpC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,YAAY,CACpB,IAAI,EACJ,2GAA2G;YACzG,iGAAiG;YACjG,yGAAyG;YACzG,0DAA0D,CAC7D,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,oGAAoG;AACpG,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY;IACzC,OAAO,UAAU,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC;AAChD,CAAC;AAED,6FAA6F;AAC7F,MAAM,UAAU,SAAS,CAAC,CAAgB,EAAE,CAAgB;IAC1D,OAAO,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AAC1D,CAAC;AAED,wEAAwE;AACxE,yEAAyE;AACzE,wEAAwE;AAExE,qFAAqF;AACrF,MAAM,UAAU,QAAQ,CAAC,KAAc;IACrC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,OAAwB;IAC7C,OAAO,OAAO,OAAO,KAAK,QAAQ;QAChC,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;QAC5D,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACzD,CAAC","sourcesContent":["/**\n * What a write IS before anything touches disk: the planned record, the staged\n * record, and the bytes both are made of.\n *\n * This is the package's floor. Everything else here — the lock, the swap, the\n * snapshot, the recovery journal — is a rule about WHEN bytes may land; only\n * this module decides what they are. Decoding lives here for the same reason:\n * text is produced only where a parser needs it and `readUtf8` is the one door,\n * so no later addition can reach a docs file through `readFile(…, \"utf8\")` and\n * silently substitute U+FFFD for bytes loam did not understand.\n */\nimport { isUtf8 } from \"node:buffer\";\nimport { createHash } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\n\n/**\n * A planned file write — the merge is computed fully before anything touches\n * disk. `content: null` means \"delete this file\"; only `unarchive` plans those,\n * to take back a file the archive created.\n *\n * BYTES, not text. The whole write path — pre-image, staged content, snapshot,\n * the compare-and-set before each swap — moves Buffers, because a docs repo is\n * not guaranteed to hold text loam can decode: `Buffer.toString(\"utf8\")` never\n * fails, it substitutes U+FFFD, so reading a file as a string and writing the\n * string back is a silent, permanent rewrite of every byte loam did not\n * understand. It reached the snapshot too, so `unarchive` restored the damage\n * and reported success. Text is now produced only where a PARSER needs it, by\n * `decodeUtf8`, which refuses instead of substituting.\n */\nexport interface PlannedWrite {\n path: string;\n /**\n * A `string` is accepted for the callers whose new version IS text they\n * composed from nothing (a re-pinned delta, a restamped spec) and is encoded\n * as UTF-8 once, at staging. A caller holding BYTES — anything derived from a\n * file loam read — must hand over the Buffer: the round trip through a string\n * is the corruption above.\n */\n content: string | Buffer | null;\n /** Create the target atomically without replacing a concurrently created file. */\n exclusive?: boolean;\n}\n\n/** The one place a planned write becomes bytes. */\nexport function toBytes(content: string | Buffer | null): Buffer | null {\n return typeof content === \"string\" ? Buffer.from(content, \"utf8\") : content;\n}\n\n/**\n * The one place that decides whether a planned write is a CREATE *from the\n * filesystem*. (`core/verify/store/commit.ts` is the deliberate second rule:\n * it decides from the caller's held pre-image instead, because a CAS must\n * follow the read it promised, not what `existsSync` says now.)\n *\n * `exclusive` is a no-clobber create (link(2)), so it can only be asked of a\n * path that does not exist yet: asking it of an overwrite would fail EEXIST on\n * every single run. Deciding here — rather than at each of the dozen\n * `writes.push` sites in archive and unarchive — is what keeps the two commands\n * from disagreeing about which of their writes may safely lose a race. An\n * OVERWRITE is not left unguarded: `swapStaged` compares the target against the\n * pre-image immediately before the rename, so a file another writer changed\n * under us stops the commit instead of silently burying their bytes.\n */\nexport function planWrite(path: string, content: string | Buffer | null): PlannedWrite {\n if (content !== null && !existsSync(path)) return { path, content, exclusive: true };\n return { path, content };\n}\n\n/** A planned write with its new bytes parked next to the target, ready to swap in. */\nexport interface StagedWrite {\n write: PlannedWrite;\n /** What this write puts on disk — `write.content` encoded once. Null for a delete. */\n content: Buffer | null;\n /** Temp file holding the new bytes, in the target's OWN directory. Null for a delete. */\n tmp: string | null;\n /** The target's bytes before the swap; null when it did not exist. */\n before: Buffer | null;\n /**\n * Topmost directory this write had to create, if any — a rollback owes the repo\n * its directories back too. An empty `services/<svc>/` left standing is the\n * fleet claiming a service that was never merged.\n */\n createdDir: string | null;\n swapped: boolean;\n}\n\n\n\n/**\n * A file loam must PARSE that does not decode as the UTF-8 text loam owns.\n *\n * Commands map this to `merge-failed`: nothing was written, the file is named,\n * and re-saving it as UTF-8 makes the command work. The read-only migration\n * ingest already refuses non-UTF-8 input outright (core/openspec/) —\n * the destructive path has strictly more to lose, so it refuses the same way\n * rather than substituting U+FFFD and rewriting the file over the author.\n *\n * `reason` says what the bytes actually ARE, because the two ways a document\n * fails this test destroy different things: one substitutes bytes, the other\n * erases the whole document's meaning. A reader told the wrong one goes looking\n * in the wrong place. The advice that follows is the same either way, so it is\n * spelled once, here.\n */\nexport class NotUtf8Error extends Error {\n override readonly name = \"NotUtf8Error\";\n\n constructor(\n readonly path: string,\n reason: string,\n ) {\n super(`${path} ${reason} Nothing was written. Re-save it as UTF-8, then re-run.`);\n }\n}\n\n/**\n * Decode bytes a parser is about to be handed, or refuse naming the file.\n *\n * Two tests, the same two the read path makes (document-bytes.ts's\n * `decodeDocument`), because this side has strictly more to lose by missing\n * either. `isUtf8` catches the byte sequences that cannot be UTF-8 at all —\n * which is what a byte-order mark makes UTF-16 into. Without a BOM, UTF-16LE of\n * ASCII is a sequence of perfectly VALID UTF-8 bytes: every other byte is NUL,\n * so `isUtf8` says yes, the file decodes with a U+0000 between every character,\n * and every parser loam owns reads its requirements, frontmatter and headings\n * as absent rather than as unreadable. On the read path that is a document\n * graded green while nothing in it was checked; here it is worse, because\n * `archive` merges against that empty baseline and writes the result back — the\n * author's requirements are not mis-read, they are gone, and the undo snapshot\n * records the same emptiness as the pre-image.\n */\nfunction decodeUtf8(bytes: Buffer, path: string): string {\n if (!isUtf8(bytes)) {\n throw new NotUtf8Error(\n path,\n `is not valid UTF-8 — loam parses this file as text and writes it back, which would replace every ` +\n `undecodable byte with U+FFFD, in the living document AND in the undo snapshot.`,\n );\n }\n const text = bytes.toString(\"utf8\");\n if (text.includes(\"\\u0000\")) {\n throw new NotUtf8Error(\n path,\n `holds NUL characters, which no document loam owns does — almost always UTF-16 saved without a byte-order ` +\n `mark. Its bytes are valid UTF-8, so it decodes without complaint into text whose requirements, ` +\n `frontmatter and headings all parse as ABSENT: the merge would compute over an empty baseline and write ` +\n `that back, taking every requirement in the file with it.`,\n );\n }\n return text;\n}\n\n/** Read a file loam is going to parse. The only supported way to turn a docs file into a string. */\nexport async function readUtf8(path: string): Promise<string> {\n return decodeUtf8(await readFile(path), path);\n}\n\n/** Same bytes? Absence is its own value: a file that is gone is not a file that is empty. */\nexport function sameBytes(a: Buffer | null, b: Buffer | null): boolean {\n return a === null || b === null ? a === b : a.equals(b);\n}\n\n/* ------------------------------------------------------------------ */\n/* Reading loam's own records back off the disk */\n/* ------------------------------------------------------------------ */\n\n/** The one spelling of a sha256 loam wrote, for reading one back out of a record. */\nexport function isDigest(value: unknown): value is string {\n return typeof value === \"string\" && /^[0-9a-f]{64}$/.test(value);\n}\n\nexport function sha256(content: Buffer | string): string {\n return typeof content === \"string\"\n ? createHash(\"sha256\").update(content, \"utf8\").digest(\"hex\")\n : createHash(\"sha256\").update(content).digest(\"hex\");\n}\n\n"]}
|
|
1
|
+
{"version":3,"file":"writes.js","sourceRoot":"","sources":["../../../src/core/staging/writes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AA+B5C,mDAAmD;AACnD,MAAM,UAAU,OAAO,CAAC,OAA+B;IACrD,OAAO,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AAC9E,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,OAA+B;IACrE,IAAI,OAAO,KAAK,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IACrF,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAC3B,CAAC;AAuBD;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAO,YAAa,SAAQ,KAAK;IAI1B,IAAI;IAHG,IAAI,GAAG,cAAc,CAAC;IAExC,YACW,IAAY,EACrB,MAAc;QAEd,KAAK,CAAC,GAAG,IAAI,IAAI,MAAM,yDAAyD,CAAC,CAAC;oBAHzE,IAAI;IAIf,CAAC;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,SAAS,UAAU,CAAC,KAAa,EAAE,IAAY;IAC7C,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QACnB,MAAM,IAAI,YAAY,CACpB,IAAI,EACJ,mGAAmG;YACjG,gFAAgF,CACnF,CAAC;IACJ,CAAC;IACD,MAAM,IAAI,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACpC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,YAAY,CACpB,IAAI,EACJ,2GAA2G;YACzG,iGAAiG;YACjG,yGAAyG;YACzG,0DAA0D,CAC7D,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,oGAAoG;AACpG,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY;IACzC,OAAO,UAAU,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC;AAChD,CAAC;AAED,6FAA6F;AAC7F,MAAM,UAAU,SAAS,CAAC,CAAgB,EAAE,CAAgB;IAC1D,OAAO,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AAC1D,CAAC;AAED,wEAAwE;AACxE,yEAAyE;AACzE,wEAAwE;AAExE,qFAAqF;AACrF,MAAM,UAAU,QAAQ,CAAC,KAAc;IACrC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,OAAwB;IAC7C,OAAO,OAAO,OAAO,KAAK,QAAQ;QAChC,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;QAC5D,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACzD,CAAC","sourcesContent":["/**\n * What a write IS before anything touches disk: the planned record, the staged\n * record, and the bytes both are made of.\n *\n * This is the package's floor. Everything else here — the lock, the swap, the\n * snapshot, the recovery journal — is a rule about WHEN bytes may land; only\n * this module decides what they are. Decoding lives here for the same reason:\n * text is produced only where a parser needs it and `readUtf8` is the one door,\n * so no later addition can reach a docs file through `readFile(…, \"utf8\")` and\n * silently substitute U+FFFD for bytes loam did not understand.\n */\nimport { isUtf8 } from \"node:buffer\";\nimport { createHash } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\n\n/**\n * A planned file write — the merge is computed fully before anything touches\n * disk. `content: null` means \"delete this file\": `unarchive` takes back a file the\n * archive created, and `subsystem sync` removes the generated views file and a\n * stray per-service project file beside an extending model.\n *\n * BYTES, not text. The whole write path — pre-image, staged content, snapshot,\n * the compare-and-set before each swap — moves Buffers, because a docs repo is\n * not guaranteed to hold text loam can decode: `Buffer.toString(\"utf8\")` never\n * fails, it substitutes U+FFFD, so reading a file as a string and writing the\n * string back is a silent, permanent rewrite of every byte loam did not\n * understand. It reached the snapshot too, so `unarchive` restored the damage\n * and reported success. Text is now produced only where a PARSER needs it, by\n * `decodeUtf8`, which refuses instead of substituting.\n */\nexport interface PlannedWrite {\n path: string;\n /**\n * A `string` is accepted for the callers whose new version IS text they\n * composed from nothing (a re-pinned delta, a restamped spec) and is encoded\n * as UTF-8 once, at staging. A caller holding BYTES — anything derived from a\n * file loam read — must hand over the Buffer: the round trip through a string\n * is the corruption above.\n */\n content: string | Buffer | null;\n /** Create the target atomically without replacing a concurrently created file. */\n exclusive?: boolean;\n}\n\n/** The one place a planned write becomes bytes. */\nexport function toBytes(content: string | Buffer | null): Buffer | null {\n return typeof content === \"string\" ? Buffer.from(content, \"utf8\") : content;\n}\n\n/**\n * The one place that decides whether a planned write is a CREATE *from the\n * filesystem*. (`core/verify/store/commit.ts` is the deliberate second rule:\n * it decides from the caller's held pre-image instead, because a CAS must\n * follow the read it promised, not what `existsSync` says now.)\n *\n * `exclusive` is a no-clobber create (link(2)), so it can only be asked of a\n * path that does not exist yet: asking it of an overwrite would fail EEXIST on\n * every single run. Deciding here — rather than at each of the dozen\n * `writes.push` sites in archive and unarchive — is what keeps the two commands\n * from disagreeing about which of their writes may safely lose a race. An\n * OVERWRITE is not left unguarded: `swapStaged` compares the target against the\n * pre-image immediately before the rename, so a file another writer changed\n * under us stops the commit instead of silently burying their bytes.\n */\nexport function planWrite(path: string, content: string | Buffer | null): PlannedWrite {\n if (content !== null && !existsSync(path)) return { path, content, exclusive: true };\n return { path, content };\n}\n\n/** A planned write with its new bytes parked next to the target, ready to swap in. */\nexport interface StagedWrite {\n write: PlannedWrite;\n /** What this write puts on disk — `write.content` encoded once. Null for a delete. */\n content: Buffer | null;\n /** Temp file holding the new bytes, in the target's OWN directory. Null for a delete. */\n tmp: string | null;\n /** The target's bytes before the swap; null when it did not exist. */\n before: Buffer | null;\n /**\n * Topmost directory this write had to create, if any — a rollback owes the repo\n * its directories back too. An empty `services/<svc>/` left standing is the\n * fleet claiming a service that was never merged. Spelled as\n * `mkdirReportingCreated` returns it, never as Node's raw `mkdir` report.\n */\n createdDir: string | null;\n swapped: boolean;\n}\n\n\n\n/**\n * A file loam must PARSE that does not decode as the UTF-8 text loam owns.\n *\n * Commands map this to `merge-failed`: nothing was written, the file is named,\n * and re-saving it as UTF-8 makes the command work. The read-only migration\n * ingest already refuses non-UTF-8 input outright (core/openspec/) —\n * the destructive path has strictly more to lose, so it refuses the same way\n * rather than substituting U+FFFD and rewriting the file over the author.\n *\n * `reason` says what the bytes actually ARE, because the two ways a document\n * fails this test destroy different things: one substitutes bytes, the other\n * erases the whole document's meaning. A reader told the wrong one goes looking\n * in the wrong place. The advice that follows is the same either way, so it is\n * spelled once, here.\n */\nexport class NotUtf8Error extends Error {\n override readonly name = \"NotUtf8Error\";\n\n constructor(\n readonly path: string,\n reason: string,\n ) {\n super(`${path} ${reason} Nothing was written. Re-save it as UTF-8, then re-run.`);\n }\n}\n\n/**\n * Decode bytes a parser is about to be handed, or refuse naming the file.\n *\n * Two tests, the same two the read path makes (document-bytes.ts's\n * `decodeDocument`), because this side has strictly more to lose by missing\n * either. `isUtf8` catches the byte sequences that cannot be UTF-8 at all —\n * which is what a byte-order mark makes UTF-16 into. Without a BOM, UTF-16LE of\n * ASCII is a sequence of perfectly VALID UTF-8 bytes: every other byte is NUL,\n * so `isUtf8` says yes, the file decodes with a U+0000 between every character,\n * and every parser loam owns reads its requirements, frontmatter and headings\n * as absent rather than as unreadable. On the read path that is a document\n * graded green while nothing in it was checked; here it is worse, because\n * `archive` merges against that empty baseline and writes the result back — the\n * author's requirements are not mis-read, they are gone, and the undo snapshot\n * records the same emptiness as the pre-image.\n */\nfunction decodeUtf8(bytes: Buffer, path: string): string {\n if (!isUtf8(bytes)) {\n throw new NotUtf8Error(\n path,\n `is not valid UTF-8 — loam parses this file as text and writes it back, which would replace every ` +\n `undecodable byte with U+FFFD, in the living document AND in the undo snapshot.`,\n );\n }\n const text = bytes.toString(\"utf8\");\n if (text.includes(\"\\u0000\")) {\n throw new NotUtf8Error(\n path,\n `holds NUL characters, which no document loam owns does — almost always UTF-16 saved without a byte-order ` +\n `mark. Its bytes are valid UTF-8, so it decodes without complaint into text whose requirements, ` +\n `frontmatter and headings all parse as ABSENT: the merge would compute over an empty baseline and write ` +\n `that back, taking every requirement in the file with it.`,\n );\n }\n return text;\n}\n\n/** Read a file loam is going to parse. The only supported way to turn a docs file into a string. */\nexport async function readUtf8(path: string): Promise<string> {\n return decodeUtf8(await readFile(path), path);\n}\n\n/** Same bytes? Absence is its own value: a file that is gone is not a file that is empty. */\nexport function sameBytes(a: Buffer | null, b: Buffer | null): boolean {\n return a === null || b === null ? a === b : a.equals(b);\n}\n\n/* ------------------------------------------------------------------ */\n/* Reading loam's own records back off the disk */\n/* ------------------------------------------------------------------ */\n\n/** The one spelling of a sha256 loam wrote, for reading one back out of a record. */\nexport function isDigest(value: unknown): value is string {\n return typeof value === \"string\" && /^[0-9a-f]{64}$/.test(value);\n}\n\nexport function sha256(content: Buffer | string): string {\n return typeof content === \"string\"\n ? createHash(\"sha256\").update(content, \"utf8\").digest(\"hex\")\n : createHash(\"sha256\").update(content).digest(\"hex\");\n}\n\n"]}
|