@ybotok/loam 0.2.0-alpha.6 → 0.2.0-alpha.9
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 +1477 -1
- package/COMPARISON.md +91 -3
- package/CONTRIBUTING.md +37 -0
- package/MIGRATING-from-OpenSpec.md +40 -22
- package/QUICKSTART.md +181 -9
- package/README.md +256 -57
- package/ROADMAP.md +108 -11
- package/SCHEMA.md +721 -130
- package/WORKFLOW.md +272 -62
- 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/archive/archive.js +15 -18
- package/dist/commands/archive/archive.js.map +1 -1
- package/dist/commands/archive/plan/contracts/asyncapi.js +27 -41
- 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 +25 -42
- package/dist/commands/archive/plan/contracts/openapi.js.map +1 -1
- package/dist/commands/archive/plan/contracts/verdict-lines.js +41 -0
- package/dist/commands/archive/plan/contracts/verdict-lines.js.map +1 -0
- package/dist/commands/archive/plan/gate.js +3 -7
- package/dist/commands/archive/plan/gate.js.map +1 -1
- package/dist/commands/archive/plan/landscape.js +4 -4
- package/dist/commands/archive/plan/landscape.js.map +1 -1
- package/dist/commands/archive/plan/refusal.js +8 -18
- package/dist/commands/archive/plan/refusal.js.map +1 -1
- package/dist/commands/archive/plan/specs.js +134 -25
- package/dist/commands/archive/plan/specs.js.map +1 -1
- package/dist/commands/archive/plan/state.js +1 -0
- package/dist/commands/archive/plan/state.js.map +1 -1
- package/dist/commands/archive/run.js +82 -33
- 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 +4 -7
- package/dist/commands/context/context.js.map +1 -1
- package/dist/commands/context/print.js +2 -2
- package/dist/commands/context/print.js.map +1 -1
- package/dist/commands/delta/delta.js +44 -12
- package/dist/commands/delta/delta.js.map +1 -1
- package/dist/commands/delta/print.js +1 -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/explore.js +4 -8
- 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 +0 -4
- package/dist/commands/list/json.js.map +1 -1
- package/dist/commands/list/list.js +38 -23
- package/dist/commands/list/list.js.map +1 -1
- package/dist/commands/list/print.js +38 -6
- 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 +28 -33
- 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 +64 -13
- package/dist/commands/seed/plan.js.map +1 -1
- package/dist/commands/seed/seed.js +6 -9
- package/dist/commands/seed/seed.js.map +1 -1
- package/dist/commands/show/show.js +3 -7
- package/dist/commands/show/show.js.map +1 -1
- package/dist/commands/status/print.js +13 -4
- 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 +3 -7
- 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 +60 -25
- 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 +141 -71
- package/dist/commands/subsystem/sync.js.map +1 -1
- package/dist/commands/subsystem/txn/report.js +55 -5
- package/dist/commands/subsystem/txn/report.js.map +1 -1
- package/dist/commands/subsystem/txn/txn.js +121 -45
- package/dist/commands/subsystem/txn/txn.js.map +1 -1
- package/dist/commands/subsystem/txn/views.js +82 -12
- package/dist/commands/subsystem/txn/views.js.map +1 -1
- package/dist/commands/tasks/render.js +116 -0
- package/dist/commands/tasks/render.js.map +1 -0
- package/dist/commands/tasks/tasks.js +113 -0
- package/dist/commands/tasks/tasks.js.map +1 -0
- package/dist/commands/unarchive/manifest.js +6 -9
- package/dist/commands/unarchive/manifest.js.map +1 -1
- package/dist/commands/unarchive/restore.js +17 -28
- 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 +6 -15
- package/dist/commands/validate/arch-coverage.js.map +1 -1
- package/dist/commands/validate/checks/fleet-shape.js +4 -49
- 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/requirements.js +18 -8
- package/dist/commands/validate/checks/requirements.js.map +1 -1
- package/dist/commands/validate/checks/sources.js +6 -2
- package/dist/commands/validate/checks/sources.js.map +1 -1
- package/dist/commands/validate/feature.js +23 -19
- package/dist/commands/validate/feature.js.map +1 -1
- package/dist/commands/validate/fleet/census.js +26 -3
- package/dist/commands/validate/fleet/census.js.map +1 -1
- package/dist/commands/validate/fleet/landscape.js +35 -32
- package/dist/commands/validate/fleet/landscape.js.map +1 -1
- package/dist/commands/validate/fleet/map/attest.js +7 -12
- package/dist/commands/validate/fleet/map/attest.js.map +1 -1
- package/dist/commands/validate/fleet/map/bindings.js +16 -5
- package/dist/commands/validate/fleet/map/bindings.js.map +1 -1
- package/dist/commands/validate/fleet/map/consumers.js +43 -122
- package/dist/commands/validate/fleet/map/consumers.js.map +1 -1
- package/dist/commands/validate/fleet/map/usecases.js +5 -11
- package/dist/commands/validate/fleet/map/usecases.js.map +1 -1
- package/dist/commands/validate/fleet/obligations.js +1 -5
- package/dist/commands/validate/fleet/obligations.js.map +1 -1
- package/dist/commands/validate/fleet/scorecard/print.js +5 -1
- 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/usecases.js +6 -4
- package/dist/commands/validate/fleet/usecases/usecases.js.map +1 -1
- package/dist/commands/validate/fleet/views/fleet-project.js +1 -1
- package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -1
- package/dist/commands/validate/fleet/views/ids.js +33 -7
- package/dist/commands/validate/fleet/views/ids.js.map +1 -1
- package/dist/commands/validate/fleet/views/root-documents.js +17 -35
- package/dist/commands/validate/fleet/views/root-documents.js.map +1 -1
- package/dist/commands/validate/fleet/views/stale.js +31 -14
- 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/report.js +5 -4
- 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 +16 -104
- 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 +1 -1
- 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/service.js +16 -26
- package/dist/commands/validate/service/service.js.map +1 -1
- package/dist/commands/validate/service/specs.js +31 -38
- package/dist/commands/validate/service/specs.js.map +1 -1
- package/dist/commands/validate/service/spine.js +8 -11
- package/dist/commands/validate/service/spine.js.map +1 -1
- package/dist/commands/validate/service/usecases/usecases.js +3 -3
- package/dist/commands/validate/service/usecases/usecases.js.map +1 -1
- package/dist/commands/validate/validate.js +44 -26
- 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 +54 -51
- 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 +57 -102
- package/dist/core/agent/agents-md/command-map.js.map +1 -1
- package/dist/core/agent/agents-md/map/explain.js +4 -3
- package/dist/core/agent/agents-md/map/explain.js.map +1 -1
- package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
- 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 +9 -3
- package/dist/core/agent/agents-md/map/status.js.map +1 -1
- package/dist/core/agent/agents-md/map/subsystem.js +31 -17
- package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
- package/dist/core/agent/agents-md/map/usecases.js +24 -6
- package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
- package/dist/core/agent/agents-md/refusals.js +37 -21
- package/dist/core/agent/agents-md/refusals.js.map +1 -1
- package/dist/core/agent/agents-md.js +25 -10
- 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/scaffold.js +10 -15
- package/dist/core/agent/scaffold.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 +5 -3
- package/dist/core/agent/workflows/adopt.js.map +1 -1
- package/dist/core/agent/workflows/check.js +25 -22
- package/dist/core/agent/workflows/check.js.map +1 -1
- package/dist/core/agent/workflows/closing.js +54 -21
- package/dist/core/agent/workflows/closing.js.map +1 -1
- package/dist/core/agent/workflows/feature.js +79 -11
- package/dist/core/agent/workflows/feature.js.map +1 -1
- package/dist/core/agent/workflows/implement.js +91 -13
- package/dist/core/agent/workflows/implement.js.map +1 -1
- package/dist/core/agent/workflows/reference/authoring.js +3 -1
- 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/reference.js.map +1 -1
- package/dist/core/agent/workflows/reference/spine.js +62 -24
- 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 +30 -50
- package/dist/core/asyncapi/merge/markers.js.map +1 -1
- package/dist/core/asyncapi/merge/merge.js +31 -53
- package/dist/core/asyncapi/merge/merge.js.map +1 -1
- package/dist/core/asyncapi/merge/pin.js +10 -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 +30 -25
- package/dist/core/brief/brief.js.map +1 -1
- package/dist/core/brief/landscape.js +8 -12
- package/dist/core/brief/landscape.js.map +1 -1
- package/dist/core/brief/map/extend.js +8 -5
- package/dist/core/brief/map/extend.js.map +1 -1
- package/dist/core/brief/map/owed.js +8 -26
- package/dist/core/brief/map/owed.js.map +1 -1
- package/dist/core/brief/targets.js +79 -5
- package/dist/core/brief/targets.js.map +1 -1
- package/dist/core/brief/unchecked.js +1 -1
- package/dist/core/brief/unchecked.js.map +1 -1
- package/dist/core/c4/likec4.js +147 -16
- 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/values.js +54 -1
- package/dist/core/c4/parsed/values.js.map +1 -1
- package/dist/core/c4/prefetch/fleet.js +1 -9
- package/dist/core/c4/prefetch/fleet.js.map +1 -1
- package/dist/core/c4/project/architecture.js +31 -9
- package/dist/core/c4/project/architecture.js.map +1 -1
- package/dist/core/c4/project/documents.js +36 -10
- package/dist/core/c4/project/documents.js.map +1 -1
- package/dist/core/c4/project/load.js +14 -21
- package/dist/core/c4/project/load.js.map +1 -1
- package/dist/core/c4/project/staged.js +7 -3
- package/dist/core/c4/project/staged.js.map +1 -1
- 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 +25 -1
- package/dist/core/c4/resolve/service.js.map +1 -1
- package/dist/core/c4/seed/template.js +15 -1
- package/dist/core/c4/seed/template.js.map +1 -1
- package/dist/core/c4/service-model/shape.js +2 -22
- package/dist/core/c4/service-model/shape.js.map +1 -1
- package/dist/core/c4/source-mask.js +288 -4
- package/dist/core/c4/source-mask.js.map +1 -1
- package/dist/core/c4/source-scan.js +203 -147
- package/dist/core/c4/source-scan.js.map +1 -1
- package/dist/core/c4/splice/authored-source.js +19 -0
- package/dist/core/c4/splice/authored-source.js.map +1 -1
- package/dist/core/c4/splice/delta-blocks.js +5 -18
- package/dist/core/c4/splice/delta-blocks.js.map +1 -1
- package/dist/core/c4/splice/landscape-merge.js +13 -8
- package/dist/core/c4/splice/landscape-merge.js.map +1 -1
- package/dist/core/c4/splice/model/merge.js +10 -6
- package/dist/core/c4/splice/model/merge.js.map +1 -1
- package/dist/core/c4/splice/placement.js +108 -18
- package/dist/core/c4/splice/placement.js.map +1 -1
- package/dist/core/c4/workspace.js +13 -22
- 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/findings.js +15 -20
- 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/coherence.js +30 -16
- 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/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 +119 -8
- 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 +14 -82
- package/dist/core/coherence/events/lookups.js.map +1 -1
- 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 +1 -1
- package/dist/core/delta/delta.js.map +1 -1
- package/dist/core/deployment/delta.js +15 -16
- package/dist/core/deployment/delta.js.map +1 -1
- 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 +28 -16
- 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/doctor/agents.js +34 -18
- package/dist/core/doctor/agents.js.map +1 -1
- package/dist/core/doctor/report.js.map +1 -1
- package/dist/core/document/apply.js +10 -2
- 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 +17 -12
- package/dist/core/explain/families.js.map +1 -1
- package/dist/core/explain/refusals.js +3 -3
- package/dist/core/explain/refusals.js.map +1 -1
- package/dist/core/explain/terms.js +18 -9
- package/dist/core/explain/terms.js.map +1 -1
- package/dist/core/explore/explore.js +2 -7
- package/dist/core/explore/explore.js.map +1 -1
- package/dist/core/fleet-context.js +34 -95
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/gate/checks.js +10 -3
- package/dist/core/gate/checks.js.map +1 -1
- package/dist/core/gate/partners.js +64 -19
- 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/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/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 +5 -36
- package/dist/core/links/corpus.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 +22 -8
- 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 +56 -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 +26 -52
- package/dist/core/openapi/merge/merge.js.map +1 -1
- package/dist/core/openapi/merge/pin.js +15 -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 +2 -16
- package/dist/core/pack/living.js.map +1 -1
- 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/entries.js.map +1 -1
- package/dist/core/repo/paths.js +34 -29
- package/dist/core/repo/paths.js.map +1 -1
- package/dist/core/repo/repo.js +24 -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 +45 -6
- package/dist/core/repo/tree/render/body.js.map +1 -1
- 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 +19 -2
- package/dist/core/repo/tree/render/stale.js.map +1 -1
- package/dist/core/repo/tree/render/views.js +102 -33
- package/dist/core/repo/tree/render/views.js.map +1 -1
- package/dist/core/repo/tree/walk.js +8 -30
- 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/staging/commit.js +10 -12
- 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 +60 -1
- package/dist/core/staging/recovery/recover.js.map +1 -1
- package/dist/core/staging/snapshot/manifest.js +152 -0
- package/dist/core/staging/snapshot/manifest.js.map +1 -0
- package/dist/core/staging/{snapshot.js → snapshot/snapshot.js} +10 -34
- package/dist/core/staging/snapshot/snapshot.js.map +1 -0
- package/dist/core/status/actions/execution.js +11 -0
- package/dist/core/status/actions/execution.js.map +1 -1
- package/dist/core/status/feature/artifacts.js +2 -2
- package/dist/core/status/feature/artifacts.js.map +1 -1
- package/dist/core/status/feature/feature.js +5 -11
- package/dist/core/status/feature/feature.js.map +1 -1
- package/dist/core/status/fleet/fleet.js +6 -2
- package/dist/core/status/fleet/fleet.js.map +1 -1
- package/dist/core/status/fleet/next.js +26 -3
- package/dist/core/status/fleet/next.js.map +1 -1
- package/dist/core/status/report.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 +174 -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/flows.js +5 -7
- package/dist/core/usecases/delta/flows.js.map +1 -1
- package/dist/core/usecases/fleet.js +178 -31
- package/dist/core/usecases/fleet.js.map +1 -1
- package/dist/core/usecases/operations.js +5 -4
- package/dist/core/usecases/operations.js.map +1 -1
- package/dist/core/usecases/service/flows.js +9 -6
- package/dist/core/usecases/service/flows.js.map +1 -1
- 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 +45 -10
- 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 +1 -0
- package/dist/core/vocabulary/issue.js.map +1 -1
- package/dist/core/vocabulary/maturity.js +53 -3
- package/dist/core/vocabulary/maturity.js.map +1 -1
- package/examples/README.md +28 -9
- package/examples/docs/architecture/landscape.likec4 +21 -7
- package/examples/docs/architecture/services.likec4 +70 -0
- package/examples/docs/architecture/subsystems.likec4 +3 -0
- package/examples/docs/architecture/usecases/order-notification.likec4 +6 -2
- 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/archive/FEAT-088-refunds/specs/payment-service/openapi.yaml +13 -10
- package/examples/docs/services/order-service/asyncapi.yaml +3 -2
- package/examples/docs/services/order-service/model.likec4 +10 -8
- package/examples/docs/services/payment-service/model.likec4 +6 -4
- package/examples/docs/services/platform/identity-service/model.likec4 +9 -7
- package/examples/docs/services/platform/notification-service/asyncapi.yaml +2 -1
- package/examples/docs/services/platform/notification-service/model.likec4 +4 -5
- package/package.json +1 -1
- package/dist/commands/validate/checks/vocabulary.js +0 -16
- package/dist/commands/validate/checks/vocabulary.js.map +0 -1
- package/dist/core/agent/agents-md/artifacts.js +0 -221
- package/dist/core/agent/agents-md/artifacts.js.map +0 -1
- package/dist/core/agent/agents-md/cycle.js +0 -180
- 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 -256
- package/dist/core/diff/victims.js.map +0 -1
- package/dist/core/staging/snapshot.js.map +0 -1
- package/dist/core/verify/file.js.map +0 -1
package/dist/core/results.js
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* reports @Before/@After hook results in per-element `before[]`/`after[]`
|
|
24
24
|
* arrays (a teardown assertion fails there while every step stays `passed`),
|
|
25
25
|
* and behave carries an element-level `status` that a hook failure flips.
|
|
26
|
+
* cucumber-js puts its hooks among the `steps[]` instead, as entries marked
|
|
27
|
+
* `hidden` with the keyword `Before`/`After`, and they are read as hooks
|
|
28
|
+
* there too — the number of real steps is contract now, because a run is
|
|
29
|
+
* held to the step count of the scenario it claims to be (`runnerAnswers`).
|
|
26
30
|
* Only those fields are contract: unknown fields, tagless elements
|
|
27
31
|
* (backgrounds, hand-written scenarios) and stray entries are skipped without
|
|
28
32
|
* comment. What is NOT tolerated is a file that is not that array at all: a
|
|
@@ -127,6 +131,7 @@ export function readScenarioReport(doc, reportName) {
|
|
|
127
131
|
where: str(entry["test"]) ?? `(${SCENARIO_REPORT_MARKER} entry ${i})`,
|
|
128
132
|
digests: [digest],
|
|
129
133
|
steps: [status],
|
|
134
|
+
stepwise: false,
|
|
130
135
|
hooks: [],
|
|
131
136
|
});
|
|
132
137
|
}
|
|
@@ -137,7 +142,8 @@ export function readCucumberReport(doc, reportName) {
|
|
|
137
142
|
return {
|
|
138
143
|
ok: false,
|
|
139
144
|
message: `${reportName} is not a cucumber JSON report — expected the top-level array of features ` +
|
|
140
|
-
"that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits."
|
|
145
|
+
"that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits. " +
|
|
146
|
+
`A runner with no cucumber formatter answers through loam's own shape instead: {"${SCENARIO_REPORT_MARKER}": 1, "results": [{"digest": "…", "status": "passed"}]}.`,
|
|
141
147
|
};
|
|
142
148
|
}
|
|
143
149
|
const scenarios = [];
|
|
@@ -165,17 +171,24 @@ export function readCucumberReport(doc, reportName) {
|
|
|
165
171
|
// scenario, another tool's output. Invisible here, deliberately.
|
|
166
172
|
if (digests.length === 0)
|
|
167
173
|
continue;
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return raw.map((s) => {
|
|
172
|
-
const status = isRecord(s) && isRecord(s["result"]) ? str(s["result"]["status"]) : undefined;
|
|
173
|
-
return (status ?? "unknown").toLowerCase();
|
|
174
|
-
});
|
|
174
|
+
const statusOf = (s) => {
|
|
175
|
+
const status = isRecord(s) && isRecord(s["result"]) ? str(s["result"]["status"]) : undefined;
|
|
176
|
+
return (status ?? "unknown").toLowerCase();
|
|
175
177
|
};
|
|
176
|
-
const
|
|
178
|
+
const resultStatuses = (raw) => (Array.isArray(raw) ? raw.map(statusOf) : []);
|
|
179
|
+
// cucumber-js reports its Before/After hooks INSIDE `steps`, as entries
|
|
180
|
+
// marked `hidden` with the keyword `Before`/`After`; they are hooks, not
|
|
181
|
+
// steps of the scenario, and counting them would make every hooked run
|
|
182
|
+
// look like a different scenario from the one stamped.
|
|
183
|
+
const listed = Array.isArray(el["steps"]) ? el["steps"] : [];
|
|
184
|
+
const isHook = (s) => isRecord(s) && (s["hidden"] === true || /^(before|after)$/i.test(str(s["keyword"])?.trim() ?? ""));
|
|
185
|
+
const steps = listed.filter((s) => !isHook(s)).map(statusOf);
|
|
177
186
|
// cucumber-jvm's @Before/@After hooks: separate arrays, same result shape.
|
|
178
|
-
const hooks = [
|
|
187
|
+
const hooks = [
|
|
188
|
+
...resultStatuses(el["before"]),
|
|
189
|
+
...listed.filter(isHook).map(statusOf),
|
|
190
|
+
...resultStatuses(el["after"]),
|
|
191
|
+
];
|
|
179
192
|
// behave's element-level status — a hook failure flips it while the
|
|
180
193
|
// steps that already ran keep "passed".
|
|
181
194
|
const status = str(el["status"])?.toLowerCase();
|
|
@@ -183,6 +196,7 @@ export function readCucumberReport(doc, reportName) {
|
|
|
183
196
|
where: `${featureName} › ${str(el["name"]) ?? "(unnamed scenario)"}`,
|
|
184
197
|
digests,
|
|
185
198
|
steps,
|
|
199
|
+
stepwise: true,
|
|
186
200
|
hooks,
|
|
187
201
|
...(status === undefined ? {} : { status }),
|
|
188
202
|
});
|
|
@@ -236,6 +250,14 @@ export function contestedDigests(claims) {
|
|
|
236
250
|
* occurrence wins as failure, a skipped-only run is "skipped" not green, and
|
|
237
251
|
* no match at all is "not found in report". A digest two services share is
|
|
238
252
|
* refused before any of that: see {@link contestedDigests}.
|
|
253
|
+
*
|
|
254
|
+
* And a cucumber occurrence must have run as many steps as the stamped
|
|
255
|
+
* scenario has (`Claim.steps`). The digest is only a tag, and a tag is text:
|
|
256
|
+
* a one-step stub named "a step" carrying the right digest confirmed
|
|
257
|
+
* five-step scenarios, because nothing compared what ran with what was
|
|
258
|
+
* stamped. The count is the check every dialect can be held to; loam's own
|
|
259
|
+
* runner-neutral shape carries no steps to count, so its entries are held to
|
|
260
|
+
* their status alone, as that shape has always promised.
|
|
239
261
|
*/
|
|
240
262
|
export function runnerAnswers(claims, scenarios, reportName) {
|
|
241
263
|
const contested = contestedDigests(claims);
|
|
@@ -250,13 +272,16 @@ export function runnerAnswers(claims, scenarios, reportName) {
|
|
|
250
272
|
const tag = c.digest === undefined ? "a digest tag" : scenarioDigestTag(c.digest);
|
|
251
273
|
return answer(c.id, "unconfirmed", [], `not found in report — no scenario in ${reportName} carries ${tag}`);
|
|
252
274
|
}
|
|
253
|
-
const failing = runs.filter((r) => !passed(r));
|
|
275
|
+
const failing = runs.filter((r) => !passed(r) || !sameShape(r, c.steps));
|
|
254
276
|
if (failing.length === 0) {
|
|
255
277
|
return answer(c.id, "confirmed", runs.map((r) => `${reportName}: ${r.where}`));
|
|
256
278
|
}
|
|
257
279
|
const first = failing[0];
|
|
258
280
|
const rerun = runs.length > 1 ? ` — ${failing.length} of ${runs.length} matching runs failed` : "";
|
|
259
|
-
|
|
281
|
+
const why = passed(first)
|
|
282
|
+
? `ran ${first.steps.length} step(s) where the stamped scenario has ${c.steps} — the report's scenario is not the one \`loam gherkin\` stamped with this digest`
|
|
283
|
+
: reason(first);
|
|
284
|
+
return answer(c.id, "unconfirmed", [], `${why} (${reportName}: ${first.where})${rerun}`);
|
|
260
285
|
});
|
|
261
286
|
}
|
|
262
287
|
function answer(id, verdict, evidence, note) {
|
|
@@ -276,6 +301,16 @@ function passed(r) {
|
|
|
276
301
|
r.hooks.every((h) => h === "passed") &&
|
|
277
302
|
(r.status === undefined || r.status === "passed"));
|
|
278
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Did this run execute the scenario the claim stamped — as far as the report
|
|
306
|
+
* can say? A cucumber run must have run exactly the stamped number of steps.
|
|
307
|
+
* An entry with no step structure (loam's own shape), and a claim carrying no
|
|
308
|
+
* count (only `scenario.tested` claims carry one, and only they carry a digest
|
|
309
|
+
* to match), are held to their status alone.
|
|
310
|
+
*/
|
|
311
|
+
function sameShape(r, expected) {
|
|
312
|
+
return !r.stepwise || expected === undefined || r.steps.length === expected;
|
|
313
|
+
}
|
|
279
314
|
/** Why a run did not pass, naming the step, hook or element status that says so. */
|
|
280
315
|
function reason(r) {
|
|
281
316
|
if (r.steps.length === 0)
|
package/dist/core/results.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"results.js","sourceRoot":"","sources":["../../src/core/results.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam verify --results` — the cucumber report as an answer sheet.\n *\n * `loam gherkin` tags every generated scenario `@loam-digest-<16hex>`, and\n * cucumber's JSON format carries tags per scenario — so a test report says,\n * mechanically, which spec scenarios ran green. This module reads that report\n * and answers `scenario.tested` claims from it. The digest is the ONLY\n * identity: a claim's `digest` and the tag are the same 16 hex of the same\n * body hash, and names match nothing — rewording a scenario breaks the match,\n * which is the point, because an agent must not be able to SAY a scenario is\n * tested; only a green run may.\n *\n * That identity is CONTENT, and content stops one step short of a fleet: two\n * services whose specs word a scenario identically share a digest, so one\n * repository's report matches the other repository's claim. Those claims are\n * refused rather than answered — see {@link contestedDigests}.\n *\n * Parsing is deliberately tolerant of everything except the shape it matches\n * on. The format is what cucumber-js `--format json`, cucumber-jvm, behave and\n * SpecFlow emit — an array of features, each `elements[]` (scenarios) with\n * `name`, `tags[] {name}` and `steps[] {result {status}}`, plus the two\n * places those dialects put a failure the steps never see: cucumber-jvm\n * reports @Before/@After hook results in per-element `before[]`/`after[]`\n * arrays (a teardown assertion fails there while every step stays `passed`),\n * and behave carries an element-level `status` that a hook failure flips.\n * Only those fields are contract: unknown fields, tagless elements\n * (backgrounds, hand-written scenarios) and stray entries are skipped without\n * comment. What is NOT tolerated is a file that is not that array at all: a\n * report loam cannot recognize must refuse rather than quietly answer every\n * claim \"not found\".\n */\nimport { DIGEST_TAG_RE, scenarioDigestTag } from \"./gherkin/stamp.js\";\nimport { isRecord } from \"./kernel/records.js\";\nimport type { Answer } from \"./verify/answers.js\";\nimport type { Claim } from \"./verify/checklist.js\";\n\n/** One report scenario, reduced to what matching needs. */\nexport interface ReportScenario {\n /** `<feature uri or name> › <scenario name>` — where the run can be seen. */\n where: string;\n /** Digests carried as `@loam-digest-…` tags. Usually one; hand-tagging can add more. */\n digests: string[];\n /** Step statuses, lowercased, in order. */\n steps: string[];\n /** Hook statuses from cucumber-jvm's `before[]`/`after[]`, lowercased, in order. */\n hooks: string[];\n /** behave's element-level status, lowercased — absent in the other dialects. */\n status?: string;\n}\n\nexport type ReportRead =\n | { ok: true; scenarios: ReportScenario[] }\n | { ok: false; message: string };\n\n/**\n * Read a parsed cucumber JSON document down to its digest-tagged scenarios.\n * `reportName` is how the caller spelled the file, for the refusal message.\n */\n/** The marker key that makes a JSON document loam's own scenario shape, and the version this loam reads. */\nexport const SCENARIO_REPORT_MARKER = \"loamScenarioReport\";\nexport const SCENARIO_REPORT_VERSION = 1;\n\n/**\n * Is this document loam's own scenario-report shape rather than a cucumber one?\n *\n * Marker-only, deliberately: the caller must be able to choose a READER before\n * either has validated anything, so that a malformed `loamScenarioReport` file\n * refuses as a malformed report of ITS kind rather than as \"not a cucumber\n * array\". A file that claims the shape and then fails it must never fall\n * through to the other parser and be told it is the wrong sort of document.\n */\nexport function isScenarioReport(doc: unknown): boolean {\n return isRecord(doc) && doc[SCENARIO_REPORT_MARKER] !== undefined;\n}\n\n/**\n * `{\"loamScenarioReport\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}`\n * — the runner-neutral answer sheet for `scenario.tested` claims.\n *\n * WHY THIS EXISTS. `loam gherkin` stamps every generated scenario\n * `@loam-digest-<16hex>`, and until now only cucumber's JSON carried that tag\n * back. A fleet on JUnit, pytest, Playwright, Vitest or a house runner could\n * therefore never reach `verified` — not because its evidence was weaker, but\n * because of a file format. The example fleet's own shipped record is the proof\n * of the cost: every claim in it is `answered_by: agent`, so the product's\n * showcase demonstrates only the lesser verdict.\n *\n * THE STANDARD OF PROOF IS UNCHANGED, and that is the whole argument. The\n * contract was never the JSON dialect: it is the digest — content-derived, so\n * rewording a `Given` breaks the match — plus a status that says a real run\n * reported it green. Both halves are here. An answer from this file is\n * `answered_by: runner` exactly as a cucumber one is, because it is the same\n * claim answered to the same standard by the same identity.\n *\n * It also adds no forgeability. loam cannot prove any JSON came from executing\n * a commit — SCHEMA says so, and the record stores the file's sha256 and mtime\n * precisely because that is all it can honestly claim. A hand-written cucumber\n * array was always exactly as easy to write as this is.\n *\n * The strict/tolerant split mirrors `verify/evidence/contract.ts` for its\n * reason: a cucumber report is another tool's file full of entries loam has no\n * business judging, while this shape exists for one purpose, so a malformed\n * entry is a malformed report rather than scenery to skip. `status` must be\n * exactly `passed` or `failed`; `skipped`, `pending` and vendor spellings are\n * refused rather than guessed at, because guessing is how a not-run scenario\n * becomes a confirmation.\n */\nexport function readScenarioReport(doc: unknown, reportName: string): ReportRead {\n if (!isRecord(doc)) {\n return { ok: false, message: `${reportName} is not a JSON object.` };\n }\n const version = doc[SCENARIO_REPORT_MARKER];\n if (version !== SCENARIO_REPORT_VERSION) {\n return {\n ok: false,\n message:\n `${reportName} declares \\`${SCENARIO_REPORT_MARKER}: ${JSON.stringify(version)}\\`, and this loam reads ` +\n `version ${SCENARIO_REPORT_VERSION}. A report loam cannot read is refused rather than answered in part.`,\n };\n }\n const results = doc[\"results\"];\n if (!Array.isArray(results)) {\n return {\n ok: false,\n message: `${reportName} has no \\`results\\` array — the shape is {\"${SCENARIO_REPORT_MARKER}\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}.`,\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const [i, entry] of results.entries()) {\n const at = `${reportName} results[${i}]`;\n if (!isRecord(entry)) return { ok: false, message: `${at} is not an object.` };\n const digest = str(entry[\"digest\"]);\n if (digest === undefined || !/^[0-9a-f]{16}$/.test(digest)) {\n return {\n ok: false,\n message: `${at} has no \\`digest\\` of 16 lowercase hex characters — that is the \\`@loam-digest-…\\` tag \\`loam gherkin\\` stamped on the scenario.`,\n };\n }\n const status = str(entry[\"status\"]);\n if (status !== \"passed\" && status !== \"failed\") {\n return {\n ok: false,\n message:\n `${at} has \\`status: ${JSON.stringify(entry[\"status\"])}\\`; this shape accepts only \"passed\" or \"failed\". ` +\n \"A scenario that did not run has no place in an answer sheet — omit it, and its claim stays unanswered rather than becoming a confirmation.\",\n };\n }\n scenarios.push({\n // The runner's own name for the test, when it gave one — this is where a\n // reader goes to see the run, and it is the only free text here.\n where: str(entry[\"test\"]) ?? `(${SCENARIO_REPORT_MARKER} entry ${i})`,\n digests: [digest],\n steps: [status],\n hooks: [],\n });\n }\n return { ok: true, scenarios };\n}\n\nexport function readCucumberReport(doc: unknown, reportName: string): ReportRead {\n if (!Array.isArray(doc)) {\n return {\n ok: false,\n message:\n `${reportName} is not a cucumber JSON report — expected the top-level array of features ` +\n \"that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits.\",\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const feature of doc) {\n if (!isRecord(feature)) continue;\n const featureName = str(feature[\"uri\"]) ?? str(feature[\"name\"]) ?? \"(unnamed feature)\";\n const elements = feature[\"elements\"];\n if (!Array.isArray(elements)) continue;\n for (const el of elements) {\n if (!isRecord(el)) continue;\n const digests: string[] = [];\n const tags = el[\"tags\"];\n if (Array.isArray(tags)) {\n for (const t of tags) {\n const name = typeof t === \"string\" ? t : isRecord(t) ? str(t[\"name\"]) : undefined;\n const m = name === undefined ? null : DIGEST_TAG_RE.exec(name);\n if (m) digests.push(m[1]!);\n }\n }\n // No digest tag, nothing to match: a background, a hand-written\n // scenario, another tool's output. Invisible here, deliberately.\n if (digests.length === 0) continue;\n const resultStatuses = (raw: unknown): string[] => {\n if (!Array.isArray(raw)) return [];\n return raw.map((s) => {\n const status = isRecord(s) && isRecord(s[\"result\"]) ? str(s[\"result\"][\"status\"]) : undefined;\n return (status ?? \"unknown\").toLowerCase();\n });\n };\n const steps = resultStatuses(el[\"steps\"]);\n // cucumber-jvm's @Before/@After hooks: separate arrays, same result shape.\n const hooks = [...resultStatuses(el[\"before\"]), ...resultStatuses(el[\"after\"])];\n // behave's element-level status — a hook failure flips it while the\n // steps that already ran keep \"passed\".\n const status = str(el[\"status\"])?.toLowerCase();\n scenarios.push({\n where: `${featureName} › ${str(el[\"name\"]) ?? \"(unnamed scenario)\"}`,\n digests,\n steps,\n hooks,\n ...(status === undefined ? {} : { status }),\n });\n }\n }\n return { ok: true, scenarios };\n}\n\n/**\n * The digests that more than one service claims, with the services that claim\n * them — a scenario worded identically in two services' specs.\n *\n * The digest is a hash of the scenario body and of nothing else, so `loam\n * gherkin` stamps the same tag into both repositories and a report from either\n * one matches both claims. A report says which scenario ran; it never says\n * whose suite ran it, and the two repositories never ran each other's tests.\n * loam has no way to tell them apart, so it does not choose: the answer is\n * refused, with the services named, and `--service` is the way to give a report\n * an owner (it narrows the checklist to one repository's claims before the\n * matching starts, so nothing here is contested).\n *\n * Only the SERVICE boundary counts. One service's spec repeating a scenario\n * word for word is genuinely one test — that is the documented meaning of the\n * stamp — and both claims may be answered by the one run.\n */\nexport function contestedDigests(claims: readonly Claim[]): Map<string, string[]> {\n const owners = new Map<string, Set<string>>();\n for (const c of claims) {\n if (c.digest === undefined) continue;\n const seen = owners.get(c.digest) ?? new Set<string>();\n seen.add(c.subject);\n owners.set(c.digest, seen);\n }\n const contested = new Map<string, string[]>();\n for (const [digest, services] of owners) {\n if (services.size > 1) contested.set(digest, [...services].sort());\n }\n return contested;\n}\n\n/**\n * Answer every claim from the report — the runner's half of the record. Meant\n * for `scenario.tested` claims (the only kind carrying a digest); a claim\n * without one is answered `unconfirmed` rather than skipped, because silence\n * must never read as checked.\n *\n * Confirmation is strict on purpose: every matching occurrence — a digest the\n * report holds twice is a re-run, and all of them count — ran at least one\n * step, every step `passed`, every before/after hook `passed`, and the\n * element-level status (when the dialect reports one) is `passed`. One failed\n * occurrence wins as failure, a skipped-only run is \"skipped\" not green, and\n * no match at all is \"not found in report\". A digest two services share is\n * refused before any of that: see {@link contestedDigests}.\n */\nexport function runnerAnswers(\n claims: Claim[],\n scenarios: ReportScenario[],\n reportName: string,\n): Answer[] {\n const contested = contestedDigests(claims);\n return claims.map((c) => {\n const rivals = c.digest === undefined ? undefined : contested.get(c.digest);\n if (rivals !== undefined) {\n return answer(\n c.id,\n \"unconfirmed\",\n [],\n `${scenarioDigestTag(c.digest!)} is worded identically in ${rivals.join(\", \")}, so ${reportName} cannot say whose suite ran it. ` +\n \"Record each service's claims from its own repo with --service.\",\n );\n }\n const runs = c.digest === undefined ? [] : scenarios.filter((s) => s.digests.includes(c.digest!));\n if (runs.length === 0) {\n const tag = c.digest === undefined ? \"a digest tag\" : scenarioDigestTag(c.digest);\n return answer(c.id, \"unconfirmed\", [], `not found in report — no scenario in ${reportName} carries ${tag}`);\n }\n const failing = runs.filter((r) => !passed(r));\n if (failing.length === 0) {\n return answer(c.id, \"confirmed\", runs.map((r) => `${reportName}: ${r.where}`));\n }\n const first = failing[0]!;\n const rerun = runs.length > 1 ? ` — ${failing.length} of ${runs.length} matching runs failed` : \"\";\n return answer(c.id, \"unconfirmed\", [], `${reason(first)} (${reportName}: ${first.where})${rerun}`);\n });\n}\n\nfunction answer(id: string, verdict: Answer[\"verdict\"], evidence: string[], note?: string): Answer {\n return { id, verdict, evidence, ...(note === undefined ? {} : { note }), answered_by: \"runner\" };\n}\n\n/**\n * Green means green: at least one step, every step `passed`, every before/\n * after hook `passed`, and the element-level status (when the dialect carries\n * one) `passed` too. The last two exist because a run the runner itself\n * reports as FAILED — a teardown assertion in an @After hook, exactly the\n * shape the arch axis's outbox checks take — used to read as confirmed here:\n * the steps all passed, and the hook failure lived in fields nobody read.\n */\nfunction passed(r: ReportScenario): boolean {\n return (\n r.steps.length > 0 &&\n r.steps.every((s) => s === \"passed\") &&\n r.hooks.every((h) => h === \"passed\") &&\n (r.status === undefined || r.status === \"passed\")\n );\n}\n\n/** Why a run did not pass, naming the step, hook or element status that says so. */\nfunction reason(r: ReportScenario): string {\n if (r.steps.length === 0) return \"no steps ran\";\n const i = r.steps.findIndex((s) => s !== \"passed\" && s !== \"skipped\");\n if (i >= 0) return `${r.steps[i]} at step ${i + 1}`;\n if (r.steps.some((s) => s === \"skipped\")) return \"skipped\";\n const h = r.hooks.findIndex((s) => s !== \"passed\");\n if (h >= 0) return `${r.hooks[h]} in a before/after hook`;\n return `scenario status '${r.status ?? \"unknown\"}'`;\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" && v.trim().length > 0 ? v : undefined;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"results.js","sourceRoot":"","sources":["../../src/core/results.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam verify --results` — the cucumber report as an answer sheet.\n *\n * `loam gherkin` tags every generated scenario `@loam-digest-<16hex>`, and\n * cucumber's JSON format carries tags per scenario — so a test report says,\n * mechanically, which spec scenarios ran green. This module reads that report\n * and answers `scenario.tested` claims from it. The digest is the ONLY\n * identity: a claim's `digest` and the tag are the same 16 hex of the same\n * body hash, and names match nothing — rewording a scenario breaks the match,\n * which is the point, because an agent must not be able to SAY a scenario is\n * tested; only a green run may.\n *\n * That identity is CONTENT, and content stops one step short of a fleet: two\n * services whose specs word a scenario identically share a digest, so one\n * repository's report matches the other repository's claim. Those claims are\n * refused rather than answered — see {@link contestedDigests}.\n *\n * Parsing is deliberately tolerant of everything except the shape it matches\n * on. The format is what cucumber-js `--format json`, cucumber-jvm, behave and\n * SpecFlow emit — an array of features, each `elements[]` (scenarios) with\n * `name`, `tags[] {name}` and `steps[] {result {status}}`, plus the two\n * places those dialects put a failure the steps never see: cucumber-jvm\n * reports @Before/@After hook results in per-element `before[]`/`after[]`\n * arrays (a teardown assertion fails there while every step stays `passed`),\n * and behave carries an element-level `status` that a hook failure flips.\n * cucumber-js puts its hooks among the `steps[]` instead, as entries marked\n * `hidden` with the keyword `Before`/`After`, and they are read as hooks\n * there too — the number of real steps is contract now, because a run is\n * held to the step count of the scenario it claims to be (`runnerAnswers`).\n * Only those fields are contract: unknown fields, tagless elements\n * (backgrounds, hand-written scenarios) and stray entries are skipped without\n * comment. What is NOT tolerated is a file that is not that array at all: a\n * report loam cannot recognize must refuse rather than quietly answer every\n * claim \"not found\".\n */\nimport { DIGEST_TAG_RE, scenarioDigestTag } from \"./gherkin/stamp.js\";\nimport { isRecord } from \"./kernel/records.js\";\nimport type { Answer } from \"./verify/answers.js\";\nimport type { Claim } from \"./verify/checklist.js\";\n\n/** One report scenario, reduced to what matching needs. */\nexport interface ReportScenario {\n /** `<feature uri or name> › <scenario name>` — where the run can be seen. */\n where: string;\n /** Digests carried as `@loam-digest-…` tags. Usually one; hand-tagging can add more. */\n digests: string[];\n /** Step statuses, lowercased, in order — hooks excluded, wherever the dialect put them. */\n steps: string[];\n /**\n * Whether `steps` is the run's own step list — true for a cucumber report —\n * or a one-status stand-in: loam's runner-neutral shape carries a status per\n * scenario and no step structure at all, so there is nothing in it to hold\n * against the stamped scenario's step count.\n */\n stepwise: boolean;\n /** Hook statuses — cucumber-jvm's `before[]`/`after[]`, cucumber-js's hidden hook steps — lowercased, in order. */\n hooks: string[];\n /** behave's element-level status, lowercased — absent in the other dialects. */\n status?: string;\n}\n\nexport type ReportRead =\n | { ok: true; scenarios: ReportScenario[] }\n | { ok: false; message: string };\n\n/**\n * Read a parsed cucumber JSON document down to its digest-tagged scenarios.\n * `reportName` is how the caller spelled the file, for the refusal message.\n */\n/** The marker key that makes a JSON document loam's own scenario shape, and the version this loam reads. */\nexport const SCENARIO_REPORT_MARKER = \"loamScenarioReport\";\nexport const SCENARIO_REPORT_VERSION = 1;\n\n/**\n * Is this document loam's own scenario-report shape rather than a cucumber one?\n *\n * Marker-only, deliberately: the caller must be able to choose a READER before\n * either has validated anything, so that a malformed `loamScenarioReport` file\n * refuses as a malformed report of ITS kind rather than as \"not a cucumber\n * array\". A file that claims the shape and then fails it must never fall\n * through to the other parser and be told it is the wrong sort of document.\n */\nexport function isScenarioReport(doc: unknown): boolean {\n return isRecord(doc) && doc[SCENARIO_REPORT_MARKER] !== undefined;\n}\n\n/**\n * `{\"loamScenarioReport\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}`\n * — the runner-neutral answer sheet for `scenario.tested` claims.\n *\n * WHY THIS EXISTS. `loam gherkin` stamps every generated scenario\n * `@loam-digest-<16hex>`, and until now only cucumber's JSON carried that tag\n * back. A fleet on JUnit, pytest, Playwright, Vitest or a house runner could\n * therefore never reach `verified` — not because its evidence was weaker, but\n * because of a file format. The example fleet's own shipped record is the proof\n * of the cost: every claim in it is `answered_by: agent`, so the product's\n * showcase demonstrates only the lesser verdict.\n *\n * THE STANDARD OF PROOF IS UNCHANGED, and that is the whole argument. The\n * contract was never the JSON dialect: it is the digest — content-derived, so\n * rewording a `Given` breaks the match — plus a status that says a real run\n * reported it green. Both halves are here. An answer from this file is\n * `answered_by: runner` exactly as a cucumber one is, because it is the same\n * claim answered to the same standard by the same identity.\n *\n * It also adds no forgeability. loam cannot prove any JSON came from executing\n * a commit — SCHEMA says so, and the record stores the file's sha256 and mtime\n * precisely because that is all it can honestly claim. A hand-written cucumber\n * array was always exactly as easy to write as this is.\n *\n * The strict/tolerant split mirrors `verify/evidence/contract.ts` for its\n * reason: a cucumber report is another tool's file full of entries loam has no\n * business judging, while this shape exists for one purpose, so a malformed\n * entry is a malformed report rather than scenery to skip. `status` must be\n * exactly `passed` or `failed`; `skipped`, `pending` and vendor spellings are\n * refused rather than guessed at, because guessing is how a not-run scenario\n * becomes a confirmation.\n */\nexport function readScenarioReport(doc: unknown, reportName: string): ReportRead {\n if (!isRecord(doc)) {\n return { ok: false, message: `${reportName} is not a JSON object.` };\n }\n const version = doc[SCENARIO_REPORT_MARKER];\n if (version !== SCENARIO_REPORT_VERSION) {\n return {\n ok: false,\n message:\n `${reportName} declares \\`${SCENARIO_REPORT_MARKER}: ${JSON.stringify(version)}\\`, and this loam reads ` +\n `version ${SCENARIO_REPORT_VERSION}. A report loam cannot read is refused rather than answered in part.`,\n };\n }\n const results = doc[\"results\"];\n if (!Array.isArray(results)) {\n return {\n ok: false,\n message: `${reportName} has no \\`results\\` array — the shape is {\"${SCENARIO_REPORT_MARKER}\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}.`,\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const [i, entry] of results.entries()) {\n const at = `${reportName} results[${i}]`;\n if (!isRecord(entry)) return { ok: false, message: `${at} is not an object.` };\n const digest = str(entry[\"digest\"]);\n if (digest === undefined || !/^[0-9a-f]{16}$/.test(digest)) {\n return {\n ok: false,\n message: `${at} has no \\`digest\\` of 16 lowercase hex characters — that is the \\`@loam-digest-…\\` tag \\`loam gherkin\\` stamped on the scenario.`,\n };\n }\n const status = str(entry[\"status\"]);\n if (status !== \"passed\" && status !== \"failed\") {\n return {\n ok: false,\n message:\n `${at} has \\`status: ${JSON.stringify(entry[\"status\"])}\\`; this shape accepts only \"passed\" or \"failed\". ` +\n \"A scenario that did not run has no place in an answer sheet — omit it, and its claim stays unanswered rather than becoming a confirmation.\",\n };\n }\n scenarios.push({\n // The runner's own name for the test, when it gave one — this is where a\n // reader goes to see the run, and it is the only free text here.\n where: str(entry[\"test\"]) ?? `(${SCENARIO_REPORT_MARKER} entry ${i})`,\n digests: [digest],\n steps: [status],\n stepwise: false,\n hooks: [],\n });\n }\n return { ok: true, scenarios };\n}\n\nexport function readCucumberReport(doc: unknown, reportName: string): ReportRead {\n if (!Array.isArray(doc)) {\n return {\n ok: false,\n message:\n `${reportName} is not a cucumber JSON report — expected the top-level array of features ` +\n \"that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits. \" +\n `A runner with no cucumber formatter answers through loam's own shape instead: {\"${SCENARIO_REPORT_MARKER}\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}.`,\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const feature of doc) {\n if (!isRecord(feature)) continue;\n const featureName = str(feature[\"uri\"]) ?? str(feature[\"name\"]) ?? \"(unnamed feature)\";\n const elements = feature[\"elements\"];\n if (!Array.isArray(elements)) continue;\n for (const el of elements) {\n if (!isRecord(el)) continue;\n const digests: string[] = [];\n const tags = el[\"tags\"];\n if (Array.isArray(tags)) {\n for (const t of tags) {\n const name = typeof t === \"string\" ? t : isRecord(t) ? str(t[\"name\"]) : undefined;\n const m = name === undefined ? null : DIGEST_TAG_RE.exec(name);\n if (m) digests.push(m[1]!);\n }\n }\n // No digest tag, nothing to match: a background, a hand-written\n // scenario, another tool's output. Invisible here, deliberately.\n if (digests.length === 0) continue;\n const statusOf = (s: unknown): string => {\n const status = isRecord(s) && isRecord(s[\"result\"]) ? str(s[\"result\"][\"status\"]) : undefined;\n return (status ?? \"unknown\").toLowerCase();\n };\n const resultStatuses = (raw: unknown): string[] => (Array.isArray(raw) ? raw.map(statusOf) : []);\n // cucumber-js reports its Before/After hooks INSIDE `steps`, as entries\n // marked `hidden` with the keyword `Before`/`After`; they are hooks, not\n // steps of the scenario, and counting them would make every hooked run\n // look like a different scenario from the one stamped.\n const listed = Array.isArray(el[\"steps\"]) ? (el[\"steps\"] as unknown[]) : [];\n const isHook = (s: unknown): boolean =>\n isRecord(s) && (s[\"hidden\"] === true || /^(before|after)$/i.test(str(s[\"keyword\"])?.trim() ?? \"\"));\n const steps = listed.filter((s) => !isHook(s)).map(statusOf);\n // cucumber-jvm's @Before/@After hooks: separate arrays, same result shape.\n const hooks = [\n ...resultStatuses(el[\"before\"]),\n ...listed.filter(isHook).map(statusOf),\n ...resultStatuses(el[\"after\"]),\n ];\n // behave's element-level status — a hook failure flips it while the\n // steps that already ran keep \"passed\".\n const status = str(el[\"status\"])?.toLowerCase();\n scenarios.push({\n where: `${featureName} › ${str(el[\"name\"]) ?? \"(unnamed scenario)\"}`,\n digests,\n steps,\n stepwise: true,\n hooks,\n ...(status === undefined ? {} : { status }),\n });\n }\n }\n return { ok: true, scenarios };\n}\n\n/**\n * The digests that more than one service claims, with the services that claim\n * them — a scenario worded identically in two services' specs.\n *\n * The digest is a hash of the scenario body and of nothing else, so `loam\n * gherkin` stamps the same tag into both repositories and a report from either\n * one matches both claims. A report says which scenario ran; it never says\n * whose suite ran it, and the two repositories never ran each other's tests.\n * loam has no way to tell them apart, so it does not choose: the answer is\n * refused, with the services named, and `--service` is the way to give a report\n * an owner (it narrows the checklist to one repository's claims before the\n * matching starts, so nothing here is contested).\n *\n * Only the SERVICE boundary counts. One service's spec repeating a scenario\n * word for word is genuinely one test — that is the documented meaning of the\n * stamp — and both claims may be answered by the one run.\n */\nexport function contestedDigests(claims: readonly Claim[]): Map<string, string[]> {\n const owners = new Map<string, Set<string>>();\n for (const c of claims) {\n if (c.digest === undefined) continue;\n const seen = owners.get(c.digest) ?? new Set<string>();\n seen.add(c.subject);\n owners.set(c.digest, seen);\n }\n const contested = new Map<string, string[]>();\n for (const [digest, services] of owners) {\n if (services.size > 1) contested.set(digest, [...services].sort());\n }\n return contested;\n}\n\n/**\n * Answer every claim from the report — the runner's half of the record. Meant\n * for `scenario.tested` claims (the only kind carrying a digest); a claim\n * without one is answered `unconfirmed` rather than skipped, because silence\n * must never read as checked.\n *\n * Confirmation is strict on purpose: every matching occurrence — a digest the\n * report holds twice is a re-run, and all of them count — ran at least one\n * step, every step `passed`, every before/after hook `passed`, and the\n * element-level status (when the dialect reports one) is `passed`. One failed\n * occurrence wins as failure, a skipped-only run is \"skipped\" not green, and\n * no match at all is \"not found in report\". A digest two services share is\n * refused before any of that: see {@link contestedDigests}.\n *\n * And a cucumber occurrence must have run as many steps as the stamped\n * scenario has (`Claim.steps`). The digest is only a tag, and a tag is text:\n * a one-step stub named \"a step\" carrying the right digest confirmed\n * five-step scenarios, because nothing compared what ran with what was\n * stamped. The count is the check every dialect can be held to; loam's own\n * runner-neutral shape carries no steps to count, so its entries are held to\n * their status alone, as that shape has always promised.\n */\nexport function runnerAnswers(\n claims: Claim[],\n scenarios: ReportScenario[],\n reportName: string,\n): Answer[] {\n const contested = contestedDigests(claims);\n return claims.map((c) => {\n const rivals = c.digest === undefined ? undefined : contested.get(c.digest);\n if (rivals !== undefined) {\n return answer(\n c.id,\n \"unconfirmed\",\n [],\n `${scenarioDigestTag(c.digest!)} is worded identically in ${rivals.join(\", \")}, so ${reportName} cannot say whose suite ran it. ` +\n \"Record each service's claims from its own repo with --service.\",\n );\n }\n const runs = c.digest === undefined ? [] : scenarios.filter((s) => s.digests.includes(c.digest!));\n if (runs.length === 0) {\n const tag = c.digest === undefined ? \"a digest tag\" : scenarioDigestTag(c.digest);\n return answer(c.id, \"unconfirmed\", [], `not found in report — no scenario in ${reportName} carries ${tag}`);\n }\n const failing = runs.filter((r) => !passed(r) || !sameShape(r, c.steps));\n if (failing.length === 0) {\n return answer(c.id, \"confirmed\", runs.map((r) => `${reportName}: ${r.where}`));\n }\n const first = failing[0]!;\n const rerun = runs.length > 1 ? ` — ${failing.length} of ${runs.length} matching runs failed` : \"\";\n const why = passed(first)\n ? `ran ${first.steps.length} step(s) where the stamped scenario has ${c.steps} — the report's scenario is not the one \\`loam gherkin\\` stamped with this digest`\n : reason(first);\n return answer(c.id, \"unconfirmed\", [], `${why} (${reportName}: ${first.where})${rerun}`);\n });\n}\n\nfunction answer(id: string, verdict: Answer[\"verdict\"], evidence: string[], note?: string): Answer {\n return { id, verdict, evidence, ...(note === undefined ? {} : { note }), answered_by: \"runner\" };\n}\n\n/**\n * Green means green: at least one step, every step `passed`, every before/\n * after hook `passed`, and the element-level status (when the dialect carries\n * one) `passed` too. The last two exist because a run the runner itself\n * reports as FAILED — a teardown assertion in an @After hook, exactly the\n * shape the arch axis's outbox checks take — used to read as confirmed here:\n * the steps all passed, and the hook failure lived in fields nobody read.\n */\nfunction passed(r: ReportScenario): boolean {\n return (\n r.steps.length > 0 &&\n r.steps.every((s) => s === \"passed\") &&\n r.hooks.every((h) => h === \"passed\") &&\n (r.status === undefined || r.status === \"passed\")\n );\n}\n\n/**\n * Did this run execute the scenario the claim stamped — as far as the report\n * can say? A cucumber run must have run exactly the stamped number of steps.\n * An entry with no step structure (loam's own shape), and a claim carrying no\n * count (only `scenario.tested` claims carry one, and only they carry a digest\n * to match), are held to their status alone.\n */\nfunction sameShape(r: ReportScenario, expected: number | undefined): boolean {\n return !r.stepwise || expected === undefined || r.steps.length === expected;\n}\n\n/** Why a run did not pass, naming the step, hook or element status that says so. */\nfunction reason(r: ReportScenario): string {\n if (r.steps.length === 0) return \"no steps ran\";\n const i = r.steps.findIndex((s) => s !== \"passed\" && s !== \"skipped\");\n if (i >= 0) return `${r.steps[i]} at step ${i + 1}`;\n if (r.steps.some((s) => s === \"skipped\")) return \"skipped\";\n const h = r.hooks.findIndex((s) => s !== \"passed\");\n if (h >= 0) return `${r.hooks[h]} in a before/after hook`;\n return `scenario status '${r.status ?? \"unknown\"}'`;\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" && v.trim().length > 0 ? v : undefined;\n}\n"]}
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
17
|
import { link, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
18
|
-
import { basename, dirname,
|
|
18
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
19
|
+
import { isPathInside } from "../kernel/path-safety.js";
|
|
19
20
|
import { sameBytes, toBytes } from "./writes.js";
|
|
20
21
|
/**
|
|
21
22
|
* The target changed between the pre-image staging read and the swap.
|
|
@@ -169,20 +170,11 @@ async function discardStaged(staged) {
|
|
|
169
170
|
await quietRm(s.tmp);
|
|
170
171
|
const roots = staged.filter((s) => s.createdDir !== null).map((s) => resolve(s.createdDir));
|
|
171
172
|
for (const s of [...staged].reverse()) {
|
|
172
|
-
const root = roots.find((candidate) =>
|
|
173
|
+
const root = roots.find((candidate) => isPathInside(candidate, s.write.path));
|
|
173
174
|
if (root !== undefined)
|
|
174
175
|
await quietPruneEmptyParents(dirname(s.write.path), root);
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
|
-
function contains(parent, candidate) {
|
|
178
|
-
const rel = relative(parent, resolve(candidate));
|
|
179
|
-
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Undo the directories a write had to create, from `start` up to and including
|
|
183
|
-
* `boundary`. Remove only directories that are still EMPTY: anything else in
|
|
184
|
-
* there arrived after we made it, and belongs to whoever put it there.
|
|
185
|
-
*/
|
|
186
178
|
/** Remove `dir` if it is empty. Best effort — a directory left standing is noise, not corruption. */
|
|
187
179
|
export async function quietRmdir(dir) {
|
|
188
180
|
try {
|
|
@@ -192,10 +184,16 @@ export async function quietRmdir(dir) {
|
|
|
192
184
|
// Not empty, or not ours.
|
|
193
185
|
}
|
|
194
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Undo the directories a write had to create, from `start` up to and including
|
|
189
|
+
* `boundary` — or, for unarchive, the ones a restore's deletes left empty.
|
|
190
|
+
* Remove only directories that are still EMPTY: anything else in there arrived
|
|
191
|
+
* after we made it, and belongs to whoever put it there.
|
|
192
|
+
*/
|
|
195
193
|
export async function quietPruneEmptyParents(start, boundary) {
|
|
196
194
|
let cursor = resolve(start);
|
|
197
195
|
const root = resolve(boundary);
|
|
198
|
-
while (
|
|
196
|
+
while (isPathInside(root, cursor)) {
|
|
199
197
|
try {
|
|
200
198
|
await rmdir(cursor);
|
|
201
199
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"commit.js","sourceRoot":"","sources":["../../../src/core/staging/commit.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The commit itself, in three steps: stage every new version beside its\n * target, swap them in, roll back what already went in when a later step fails.\n *\n * This is what `archive` and `unarchive` share, and it moved out of archive.ts\n * verbatim once unarchive became its second consumer. The MERGE computations\n * stayed with archive, which is still their only caller — the split was about a\n * second CONSUMER, never about archive being long.\n *\n * `StagingRaceError` belongs here rather than with the lock, though both are\n * about a second writer: the lock is taken before a plan is computed, while\n * this is the compare-and-set immediately before each rename — the second line\n * of defence for exactly the window two racers who both broke the same stale\n * lock would land in.\n */\nimport { existsSync } from \"node:fs\";\nimport { link, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from \"node:fs/promises\";\nimport { basename, dirname, isAbsolute, join, relative, resolve, sep } from \"node:path\";\nimport { sameBytes, toBytes, type PlannedWrite, type StagedWrite } from \"./writes.js\";\n\n/**\n * The target changed between the pre-image staging read and the swap.\n *\n * The pre-image is what a rollback restores and what `unarchive` is handed, so\n * swapping over somebody else's newer bytes would both lose their work and\n * arm a rollback that writes a third version nobody wrote. Commands map this to\n * `merge-failed`: nothing of theirs went in, the docs are as the other writer\n * left them, and re-running can work.\n */\nexport class StagingRaceError extends Error {\n override readonly name = \"StagingRaceError\";\n\n constructor(readonly path: string) {\n super(`${path} changed while this command was planning its merge — nothing was written; re-run`);\n }\n}\n\n/**\n * Write every new version to a temp file beside its target, remembering what the\n * target said first. Nothing observable changes here: on failure the temp files\n * are removed and the docs are exactly as they were.\n *\n * The pre-image is read eagerly and deliberately: it is what a rollback restores\n * and what `unarchive` is given, so a file we cannot read is a file we cannot\n * safely rewrite, and the merge must stop before it starts.\n */\nexport async function stageWrites(writes: PlannedWrite[]): Promise<StagedWrite[]> {\n const staged: StagedWrite[] = [];\n try {\n for (const [i, write] of writes.entries()) {\n // mkdir(recursive) reports the topmost directory it had to create — the\n // handle a rollback needs to put the tree back the way it found it.\n const createdDir = (await mkdir(dirname(write.path), { recursive: true })) ?? null;\n const before = existsSync(write.path) ? await readFile(write.path) : null;\n const content = toBytes(write.content);\n let tmp: string | null = null;\n if (content !== null) {\n tmp = tempPath(write.path, i);\n await writeFile(tmp, content);\n }\n staged.push({ write, content, tmp, before, createdDir, swapped: false });\n }\n } catch (err) {\n await discardStaged(staged);\n throw err;\n }\n return staged;\n}\n\n/**\n * Swap the staged versions in, one file at a time. Each swap is a rename(2)\n * within a single directory, so a concurrent reader sees either the old bytes or\n * the new ones — never a half-written file. Across files it is not atomic: the\n * caller rolls back what has already gone in.\n *\n * Every swap re-reads its target first and compares it with the pre-image the\n * plan was computed from — a compare-and-set on the file's whole content. It is\n * half a CAS on purpose: read and rename cannot be one syscall, so a writer\n * landing inside that window still wins. What it does buy is the window that\n * actually matters — the seconds a plan spends parsing LikeC4 and merging YAML\n * between the pre-image read and this swap — and it costs one read per file.\n */\nexport async function swapStaged(staged: StagedWrite[]): Promise<void> {\n for (const s of staged) {\n const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;\n if (!sameBytes(now, s.before)) throw new StagingRaceError(s.write.path);\n if (s.tmp === null) {\n if (s.before !== null) await unlink(s.write.path);\n } else if (s.write.exclusive === true) {\n // link(2) is the same-filesystem, atomic no-clobber counterpart to rename:\n // it fails with EEXIST instead of replacing a file created after planning.\n await link(s.tmp, s.write.path);\n s.swapped = true;\n await unlink(s.tmp);\n s.tmp = null;\n continue;\n } else {\n await rename(s.tmp, s.write.path);\n }\n s.swapped = true;\n }\n}\n\n/**\n * Put back every file that was already swapped, newest first, from the bytes read\n * before the swap. Returns the paths it could NOT restore — the caller must say so\n * out loud rather than report a clean failure over a half-merged repo.\n *\n * A rollback only ever un-does ITS OWN write: if the file no longer holds the\n * bytes this run put there, somebody else has written it since, and restoring\n * the pre-image would destroy their work while reporting a clean failure. That\n * file is left exactly as found and named in the failures, which is what turns\n * the caller's answer from `merge-failed` (\"trust the repo\") into\n * `rollback-incomplete` (\"look at it by hand\") — the honest answer, because the\n * repo now holds one run's writes half-reverted next to another's.\n */\nexport async function rollbackStaged(staged: StagedWrite[]): Promise<string[]> {\n const failures: string[] = [];\n for (const s of [...staged].reverse()) {\n if (!s.swapped) continue;\n try {\n const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;\n if (!sameBytes(now, s.content)) {\n failures.push(`${s.write.path} (changed by another writer after this run wrote it — left as found)`);\n continue;\n }\n if (s.before === null) await rm(s.write.path, { force: true });\n else await atomicWrite(s.write.path, s.before);\n s.swapped = false;\n } catch (err) {\n failures.push(`${s.write.path} (${message(err)})`);\n }\n }\n await discardStaged(staged);\n return failures;\n}\n\n/** Write `content` to `path` through a temp file in the same directory. */\nexport async function atomicWrite(path: string, content: Buffer): Promise<void> {\n const tmp = tempPath(path, 0);\n await writeFile(tmp, content);\n await rename(tmp, path);\n}\n\n/** A hidden sibling of `path`: same directory, so the rename never crosses a filesystem. */\nexport function tempPath(path: string, n: number): string {\n return join(dirname(path), `.${basename(path)}.loam-${process.pid}-${n}-${Date.now()}.tmp`);\n}\n\n/** The shape `tempPath` writes — what a scan for leftovers of a killed run matches on. */\nexport const TEMP_FILE_RE = /^\\..*\\.loam-\\d+-\\d+-\\d+\\.tmp$/;\n\n/**\n * Everything staging left on disk: the temp files, and the directories it made\n * for them.\n *\n * Directories go by rmdir, never by recursive remove, and the walk stops the\n * moment one is not empty. The recursive form used to be reached for because\n * \"we created this directory, so it is ours\" — but a directory created at plan\n * time is shared by the time the rollback runs: a second archive can have put a\n * whole feature inside `features/archive/` in between, and `rm -r` on our own\n * created root took their work with it, silently, on OUR failure path.\n */\nasync function discardStaged(staged: StagedWrite[]): Promise<void> {\n for (const s of staged) if (s.tmp !== null) await quietRm(s.tmp);\n const roots = staged.filter((s) => s.createdDir !== null).map((s) => resolve(s.createdDir!));\n for (const s of [...staged].reverse()) {\n const root = roots.find((candidate) => contains(candidate, s.write.path));\n if (root !== undefined) await quietPruneEmptyParents(dirname(s.write.path), root);\n }\n}\n\nfunction contains(parent: string, candidate: string): boolean {\n const rel = relative(parent, resolve(candidate));\n return rel === \"\" || (!rel.startsWith(`..${sep}`) && rel !== \"..\" && !isAbsolute(rel));\n}\n\n/**\n * Undo the directories a write had to create, from `start` up to and including\n * `boundary`. Remove only directories that are still EMPTY: anything else in\n * there arrived after we made it, and belongs to whoever put it there.\n */\n/** Remove `dir` if it is empty. Best effort — a directory left standing is noise, not corruption. */\nexport async function quietRmdir(dir: string): Promise<void> {\n try {\n await rmdir(dir);\n } catch {\n // Not empty, or not ours.\n }\n}\n\nexport async function quietPruneEmptyParents(start: string, boundary: string): Promise<void> {\n let cursor = resolve(start);\n const root = resolve(boundary);\n while (contains(root, cursor)) {\n try {\n await rmdir(cursor);\n } catch {\n return;\n }\n if (cursor === root) return;\n const parent = dirname(cursor);\n if (parent === cursor) return;\n cursor = parent;\n }\n}\n\nexport async function quietRm(path: string): Promise<void> {\n try {\n await rm(path, { recursive: true, force: true });\n } catch {\n // Best effort — a leftover temp file is noise, not corruption.\n }\n}\n\n/**\n * The one spelling of the sentence a rollback failure prints, parameterised\n * only by the verb for what the files were mid-way through becoming. It\n * existed in three near-copies (archive's \"half-merged\", vouch's\n * \"half-stamped\", verify's \"half-written\") — the exact shape whose fix lands\n * in one copy while an operator greps their logs for another.\n */\nexport function rollbackMessage(err: unknown, failures: string[], what: string): string {\n return `${message(err)} — ROLLBACK INCOMPLETE, these files may be half-${what} and need checking by hand: ${failures.join(\", \")}`;\n}\n\n/** Say what the failure cost: nothing, or a repo that needs looking at by hand. */\nexport function rollbackError(err: unknown, failures: string[]): Error {\n if (failures.length === 0) {\n return new Error(`${message(err)} — the living docs were rolled back, nothing was merged`);\n }\n return new Error(\n rollbackMessage(err, failures, \"merged\"),\n );\n}\n\nexport function message(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n"]}
|
|
1
|
+
{"version":3,"file":"commit.js","sourceRoot":"","sources":["../../../src/core/staging/commit.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The commit itself, in three steps: stage every new version beside its\n * target, swap them in, roll back what already went in when a later step fails.\n *\n * This is what `archive` and `unarchive` share, and it moved out of archive.ts\n * verbatim once unarchive became its second consumer. The MERGE computations\n * stayed with archive, which is still their only caller — the split was about a\n * second CONSUMER, never about archive being long.\n *\n * `StagingRaceError` belongs here rather than with the lock, though both are\n * about a second writer: the lock is taken before a plan is computed, while\n * this is the compare-and-set immediately before each rename — the second line\n * of defence for exactly the window two racers who both broke the same stale\n * lock would land in.\n */\nimport { existsSync } from \"node:fs\";\nimport { link, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from \"node:fs/promises\";\nimport { basename, dirname, join, resolve } from \"node:path\";\nimport { isPathInside } from \"../kernel/path-safety.js\";\nimport { sameBytes, toBytes, type PlannedWrite, type StagedWrite } from \"./writes.js\";\n\n/**\n * The target changed between the pre-image staging read and the swap.\n *\n * The pre-image is what a rollback restores and what `unarchive` is handed, so\n * swapping over somebody else's newer bytes would both lose their work and\n * arm a rollback that writes a third version nobody wrote. Commands map this to\n * `merge-failed`: nothing of theirs went in, the docs are as the other writer\n * left them, and re-running can work.\n */\nexport class StagingRaceError extends Error {\n override readonly name = \"StagingRaceError\";\n\n constructor(readonly path: string) {\n super(`${path} changed while this command was planning its merge — nothing was written; re-run`);\n }\n}\n\n/**\n * Write every new version to a temp file beside its target, remembering what the\n * target said first. Nothing observable changes here: on failure the temp files\n * are removed and the docs are exactly as they were.\n *\n * The pre-image is read eagerly and deliberately: it is what a rollback restores\n * and what `unarchive` is given, so a file we cannot read is a file we cannot\n * safely rewrite, and the merge must stop before it starts.\n */\nexport async function stageWrites(writes: PlannedWrite[]): Promise<StagedWrite[]> {\n const staged: StagedWrite[] = [];\n try {\n for (const [i, write] of writes.entries()) {\n // mkdir(recursive) reports the topmost directory it had to create — the\n // handle a rollback needs to put the tree back the way it found it.\n const createdDir = (await mkdir(dirname(write.path), { recursive: true })) ?? null;\n const before = existsSync(write.path) ? await readFile(write.path) : null;\n const content = toBytes(write.content);\n let tmp: string | null = null;\n if (content !== null) {\n tmp = tempPath(write.path, i);\n await writeFile(tmp, content);\n }\n staged.push({ write, content, tmp, before, createdDir, swapped: false });\n }\n } catch (err) {\n await discardStaged(staged);\n throw err;\n }\n return staged;\n}\n\n/**\n * Swap the staged versions in, one file at a time. Each swap is a rename(2)\n * within a single directory, so a concurrent reader sees either the old bytes or\n * the new ones — never a half-written file. Across files it is not atomic: the\n * caller rolls back what has already gone in.\n *\n * Every swap re-reads its target first and compares it with the pre-image the\n * plan was computed from — a compare-and-set on the file's whole content. It is\n * half a CAS on purpose: read and rename cannot be one syscall, so a writer\n * landing inside that window still wins. What it does buy is the window that\n * actually matters — the seconds a plan spends parsing LikeC4 and merging YAML\n * between the pre-image read and this swap — and it costs one read per file.\n */\nexport async function swapStaged(staged: StagedWrite[]): Promise<void> {\n for (const s of staged) {\n const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;\n if (!sameBytes(now, s.before)) throw new StagingRaceError(s.write.path);\n if (s.tmp === null) {\n if (s.before !== null) await unlink(s.write.path);\n } else if (s.write.exclusive === true) {\n // link(2) is the same-filesystem, atomic no-clobber counterpart to rename:\n // it fails with EEXIST instead of replacing a file created after planning.\n await link(s.tmp, s.write.path);\n s.swapped = true;\n await unlink(s.tmp);\n s.tmp = null;\n continue;\n } else {\n await rename(s.tmp, s.write.path);\n }\n s.swapped = true;\n }\n}\n\n/**\n * Put back every file that was already swapped, newest first, from the bytes read\n * before the swap. Returns the paths it could NOT restore — the caller must say so\n * out loud rather than report a clean failure over a half-merged repo.\n *\n * A rollback only ever un-does ITS OWN write: if the file no longer holds the\n * bytes this run put there, somebody else has written it since, and restoring\n * the pre-image would destroy their work while reporting a clean failure. That\n * file is left exactly as found and named in the failures, which is what turns\n * the caller's answer from `merge-failed` (\"trust the repo\") into\n * `rollback-incomplete` (\"look at it by hand\") — the honest answer, because the\n * repo now holds one run's writes half-reverted next to another's.\n */\nexport async function rollbackStaged(staged: StagedWrite[]): Promise<string[]> {\n const failures: string[] = [];\n for (const s of [...staged].reverse()) {\n if (!s.swapped) continue;\n try {\n const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;\n if (!sameBytes(now, s.content)) {\n failures.push(`${s.write.path} (changed by another writer after this run wrote it — left as found)`);\n continue;\n }\n if (s.before === null) await rm(s.write.path, { force: true });\n else await atomicWrite(s.write.path, s.before);\n s.swapped = false;\n } catch (err) {\n failures.push(`${s.write.path} (${message(err)})`);\n }\n }\n await discardStaged(staged);\n return failures;\n}\n\n/** Write `content` to `path` through a temp file in the same directory. */\nexport async function atomicWrite(path: string, content: Buffer): Promise<void> {\n const tmp = tempPath(path, 0);\n await writeFile(tmp, content);\n await rename(tmp, path);\n}\n\n/** A hidden sibling of `path`: same directory, so the rename never crosses a filesystem. */\nexport function tempPath(path: string, n: number): string {\n return join(dirname(path), `.${basename(path)}.loam-${process.pid}-${n}-${Date.now()}.tmp`);\n}\n\n/** The shape `tempPath` writes — what a scan for leftovers of a killed run matches on. */\nexport const TEMP_FILE_RE = /^\\..*\\.loam-\\d+-\\d+-\\d+\\.tmp$/;\n\n/**\n * Everything staging left on disk: the temp files, and the directories it made\n * for them.\n *\n * Directories go by rmdir, never by recursive remove, and the walk stops the\n * moment one is not empty. The recursive form used to be reached for because\n * \"we created this directory, so it is ours\" — but a directory created at plan\n * time is shared by the time the rollback runs: a second archive can have put a\n * whole feature inside `features/archive/` in between, and `rm -r` on our own\n * created root took their work with it, silently, on OUR failure path.\n */\nasync function discardStaged(staged: StagedWrite[]): Promise<void> {\n for (const s of staged) if (s.tmp !== null) await quietRm(s.tmp);\n const roots = staged.filter((s) => s.createdDir !== null).map((s) => resolve(s.createdDir!));\n for (const s of [...staged].reverse()) {\n const root = roots.find((candidate) => isPathInside(candidate, s.write.path));\n if (root !== undefined) await quietPruneEmptyParents(dirname(s.write.path), root);\n }\n}\n\n/** Remove `dir` if it is empty. Best effort — a directory left standing is noise, not corruption. */\nexport async function quietRmdir(dir: string): Promise<void> {\n try {\n await rmdir(dir);\n } catch {\n // Not empty, or not ours.\n }\n}\n\n/**\n * Undo the directories a write had to create, from `start` up to and including\n * `boundary` — or, for unarchive, the ones a restore's deletes left empty.\n * Remove only directories that are still EMPTY: anything else in there arrived\n * after we made it, and belongs to whoever put it there.\n */\nexport async function quietPruneEmptyParents(start: string, boundary: string): Promise<void> {\n let cursor = resolve(start);\n const root = resolve(boundary);\n while (isPathInside(root, cursor)) {\n try {\n await rmdir(cursor);\n } catch {\n return;\n }\n if (cursor === root) return;\n const parent = dirname(cursor);\n if (parent === cursor) return;\n cursor = parent;\n }\n}\n\nexport async function quietRm(path: string): Promise<void> {\n try {\n await rm(path, { recursive: true, force: true });\n } catch {\n // Best effort — a leftover temp file is noise, not corruption.\n }\n}\n\n/**\n * The one spelling of the sentence a rollback failure prints, parameterised\n * only by the verb for what the files were mid-way through becoming. It\n * existed in three near-copies (archive's \"half-merged\", vouch's\n * \"half-stamped\", verify's \"half-written\") — the exact shape whose fix lands\n * in one copy while an operator greps their logs for another.\n */\nexport function rollbackMessage(err: unknown, failures: string[], what: string): string {\n return `${message(err)} — ROLLBACK INCOMPLETE, these files may be half-${what} and need checking by hand: ${failures.join(\", \")}`;\n}\n\n/** Say what the failure cost: nothing, or a repo that needs looking at by hand. */\nexport function rollbackError(err: unknown, failures: string[]): Error {\n if (failures.length === 0) {\n return new Error(`${message(err)} — the living docs were rolled back, nothing was merged`);\n }\n return new Error(\n rollbackMessage(err, failures, \"merged\"),\n );\n}\n\nexport function message(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n"]}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `lockIsStale` only reports it to `doctor`. They were written as twins and
|
|
9
9
|
* stay together so they cannot drift into two definitions of a dead holder.
|
|
10
10
|
*/
|
|
11
|
-
import { link, readFile,
|
|
11
|
+
import { link, readFile, rename, writeFile } from "node:fs/promises";
|
|
12
12
|
import { hostname } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
// Every `isRecord` below guards a record loam itself wrote and is reading back
|
|
@@ -61,18 +61,24 @@ export class DocsBusyError extends Error {
|
|
|
61
61
|
* microsecond. A create that dies before its link leaves only a staging temp
|
|
62
62
|
* `doctor` already names as litter. A holder that died without releasing would
|
|
63
63
|
* otherwise wedge the repo forever, so a lock naming a pid on THIS host that
|
|
64
|
-
* no longer exists is broken once and retried
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* no longer exists is broken once and retried. Breaking removes only the
|
|
65
|
+
* instance that was judged dead (see `breakStaleLock`), never a successor's
|
|
66
|
+
* live lock. This comment used to accept that two racers could both break the
|
|
67
|
+
* same stale lock, trusting the pre-image check in `swapStaged` as the second
|
|
68
|
+
* line of defence. That check is still kept, and is still not an
|
|
69
|
+
* optimisation. But it guards only the swap. A second holder's RECOVERY reads
|
|
70
|
+
* the first holder's in-flight journal as an interrupted commit and sweeps
|
|
71
|
+
* its staged temps, and no compare-and-set sits in front of that.
|
|
67
72
|
*/
|
|
68
73
|
/**
|
|
69
74
|
* Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with
|
|
70
75
|
* pid and a millisecond timestamp, which is unique across processes and not
|
|
71
76
|
* within one: two in-process acquirers in the same millisecond shared a temp
|
|
72
77
|
* name, the first's cleanup deleted it under the second, and the second's
|
|
73
|
-
* link(2) surfaced ENOENT as `internal`.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
78
|
+
* link(2) surfaced ENOENT as `internal`. The private name a stale-lock break
|
|
79
|
+
* takes the lock to is salted from the same counter, for the same reason. A
|
|
80
|
+
* bare counter is module-level mutable state, tolerated here because it feeds
|
|
81
|
+
* nothing but name uniqueness — no behaviour ever branches on its value.
|
|
76
82
|
*/
|
|
77
83
|
let lockAcquireSeq = 0;
|
|
78
84
|
export async function acquireDocsLock(docsDir) {
|
|
@@ -166,20 +172,76 @@ function pidGone(pid) {
|
|
|
166
172
|
return err.code === "ESRCH";
|
|
167
173
|
}
|
|
168
174
|
}
|
|
169
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Remove a lock whose holder is a dead process on this same host, and only the
|
|
177
|
+
* lock INSTANCE that verdict was about. Returns whether it did.
|
|
178
|
+
*
|
|
179
|
+
* The verdict comes from a read, the removal acts on a path, and other writers
|
|
180
|
+
* move in between. The removal used to be a bare unlink(2) of the path. Under
|
|
181
|
+
* CPU load a waiter read the holder's payload, then the holder released
|
|
182
|
+
* NORMALLY and exited, then a third writer linked a fresh lock, and only then
|
|
183
|
+
* did kill(pid, 0) answer ESRCH. The unlink deleted that third writer's LIVE
|
|
184
|
+
* lock. Two `loam gherkin` runs then shared one commit window, and the
|
|
185
|
+
* second's recovery read the first's in-flight journal as "interrupted" and
|
|
186
|
+
* swept its staged temps. The first's rename failed ENOENT
|
|
187
|
+
* (test/stress-gherkin.test.ts found it; test/lock-break.test.ts opens each
|
|
188
|
+
* window on purpose).
|
|
189
|
+
*
|
|
190
|
+
* So the removal is bound to the judged instance, twice:
|
|
191
|
+
* - the lock is re-read AFTER the verdict and must still name the judged
|
|
192
|
+
* holder. A holder that released normally removed its file before it
|
|
193
|
+
* exited, so a dead holder's lock still in place can only be a crashed
|
|
194
|
+
* one's, which is the one lock a break is for;
|
|
195
|
+
* - it is then TAKEN by rename(2) to a private name, which exactly one of
|
|
196
|
+
* several concurrent breakers can win, and judged in hand. If what moved is
|
|
197
|
+
* not the judged instance (another waiter broke the same crashed lock first
|
|
198
|
+
* and linked its own in between), it goes back by link(2), which never
|
|
199
|
+
* overwrites.
|
|
200
|
+
* One gap stays open, between that take and the put-back. A third writer can
|
|
201
|
+
* link in it, and then the displaced lock stays down. Or the displaced holder
|
|
202
|
+
* can release in it, and then the put-back restores a lock nobody holds, which
|
|
203
|
+
* stands until that process exits. Either needs a crashed holder AND two
|
|
204
|
+
* breakers racing for its lock. The gap is named here rather than closed,
|
|
205
|
+
* because closing it needs a kernel lock (flock) that Node does not expose and
|
|
206
|
+
* loam will not add a dependency for.
|
|
207
|
+
*/
|
|
170
208
|
async function breakStaleLock(path) {
|
|
171
|
-
const
|
|
209
|
+
const judged = await readLockHolder(path);
|
|
172
210
|
// Unreadable is deliberately NOT breakable: a lock we cannot understand is
|
|
173
211
|
// the one case where guessing is worst. `doctor` reports it as damage.
|
|
174
|
-
if (
|
|
212
|
+
if (judged.kind !== "held" || judged.host !== hostname() || !pidGone(judged.pid))
|
|
213
|
+
return false;
|
|
214
|
+
if (!sameHolder(await readLockHolder(path), judged))
|
|
175
215
|
return false;
|
|
216
|
+
lockAcquireSeq += 1;
|
|
217
|
+
const taken = tempPath(path, lockAcquireSeq);
|
|
176
218
|
try {
|
|
177
|
-
await
|
|
178
|
-
return true;
|
|
219
|
+
await rename(path, taken);
|
|
179
220
|
}
|
|
180
221
|
catch {
|
|
222
|
+
// Another breaker took it first, or the platform refused the rename (a
|
|
223
|
+
// Windows handle still open on it). Either way the next link(2) attempt
|
|
224
|
+
// decides, not this one.
|
|
181
225
|
return false;
|
|
182
226
|
}
|
|
227
|
+
if (sameHolder(await readLockHolder(taken), judged)) {
|
|
228
|
+
await quietRm(taken);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
await link(taken, path);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// The gap named above: a third writer linked in between, and putting the
|
|
236
|
+
// displaced lock back would overwrite a live one. It stays down. Its holder
|
|
237
|
+
// is unprotected from here, and nothing this process can do fixes that.
|
|
238
|
+
}
|
|
239
|
+
await quietRm(taken);
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
/** Is `now` the very lock instance `judged` was read from? Pid, host and timestamp are its identity. */
|
|
243
|
+
function sameHolder(now, judged) {
|
|
244
|
+
return now.kind === "held" && now.pid === judged.pid && now.host === judged.host && now.at === judged.at;
|
|
183
245
|
}
|
|
184
246
|
export async function describeLock(path) {
|
|
185
247
|
const holder = await readLockHolder(path);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lock.js","sourceRoot":"","sources":["../../../src/core/staging/lock.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The docs repo's advisory lock: take it, describe its holder, and break it\n * when that holder is a pid on this host that no longer exists.\n *\n * A module of its own because it is the only rule here that spans the whole\n * fleet rather than one file, and because the two readers of a held lock have\n * to agree on what \"stale\" means — `breakStaleLock` acts on the answer,\n * `lockIsStale` only reports it to `doctor`. They were written as twins and\n * stay together so they cannot drift into two definitions of a dead holder.\n */\nimport { link, readFile, unlink, writeFile } from \"node:fs/promises\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\n// Every `isRecord` below guards a record loam itself wrote and is reading back\n// — the lock, the snapshot manifest — whose fields are asked for on the very\n// next line, inside a REFUSAL path. `JSON.parse` answers with any JSON value,\n// not with an object, and that is where a thrown `TypeError` costs the most: it\n// does not make the command fail safely, it replaces a designed refusal with\n// `internal`.\nimport { isRecord } from \"../kernel/records.js\";\nimport { quietRm, tempPath } from \"./commit.js\";\n\n/** The lock file, in the docs repo root — one writer at a time across the whole fleet. */\nexport const DOCS_LOCK = \".loam-lock\";\n\n/**\n * How long a WAITING writer sits out a live holder before answering\n * `docs-busy`. One spelling for every writer that waits — including gherkin,\n * whose lock guards the SERVICE repo's emission root rather than the docs\n * repo: long enough to cover another writer's sub-second commit window with a\n * margin for a loaded host, bounded so a wedged lock cannot hang anyone.\n */\nexport const LOCK_WAIT_MS = 5_000;\n\n/** Another writer holds the docs repo. Commands map this to the `docs-busy` envelope code. */\nexport class DocsBusyError extends Error {\n override readonly name = \"DocsBusyError\";\n\n constructor(\n readonly lockPath: string,\n holder: string,\n ) {\n super(\n `another loam command is writing this docs repo (${holder}) — nothing was read or written. ` +\n `Wait for it to finish, or remove ${lockPath} if no such command is running.`,\n );\n }\n}\n\n/**\n * Take the docs repo's advisory lock for the whole plan+commit window, and\n * return the release.\n *\n * Advisory, and deliberately coarse: the destructive commands write the SAME\n * three shared files (a service spec, a service contract, the one landscape),\n * so two of them overlapping is not a merge to be resolved but a merge one of\n * them computed against a snapshot that no longer exists. Two archives used to\n * plan against the same landscape bytes and the second's splice silently\n * dropped the first's additions — exit 0 on both, and a `validate` that stayed\n * green because the surviving document was perfectly coherent.\n *\n * link(2) over a fully-written sibling is the primitive: as atomic as the\n * O_EXCL create it replaced, needs no daemon, and leaves a file a human can\n * read and delete — but unlike open-then-write, the lock file can never be\n * OBSERVED empty. `writeFile(path, …, \"wx\")` published existence first and\n * payload a syscall later, and in that window (or after a crash inside it) the\n * lock named no holder: `doctor` now grades exactly that shape as damage whose\n * fix is deletion, so the healthy path must never wear it, even for a\n * microsecond. A create that dies before its link leaves only a staging temp\n * `doctor` already names as litter. A holder that died without releasing would\n * otherwise wedge the repo forever, so a lock naming a pid on THIS host that\n * no longer exists is broken once and retried — two racers could both break\n * the same stale lock, which is why the pre-image check in `swapStaged`\n * remains the second line of defence rather than an optimisation.\n */\n/**\n * Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with\n * pid and a millisecond timestamp, which is unique across processes and not\n * within one: two in-process acquirers in the same millisecond shared a temp\n * name, the first's cleanup deleted it under the second, and the second's\n * link(2) surfaced ENOENT as `internal`. A bare counter is module-level\n * mutable state, tolerated here because it feeds nothing but name uniqueness —\n * no behaviour ever branches on its value.\n */\nlet lockAcquireSeq = 0;\n\nexport async function acquireDocsLock(docsDir: string): Promise<() => Promise<void>> {\n const path = join(docsDir, DOCS_LOCK);\n const payload = `${JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() })}\\n`;\n lockAcquireSeq += 1;\n const tmp = tempPath(path, lockAcquireSeq);\n await writeFile(tmp, payload);\n try {\n for (let attempt = 0; ; attempt += 1) {\n try {\n await link(tmp, path);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n if (attempt === 0 && (await breakStaleLock(path))) continue;\n throw new DocsBusyError(path, await describeLock(path));\n }\n let released = false;\n return async () => {\n if (released) return;\n released = true;\n await quietRm(path);\n };\n }\n } finally {\n await quietRm(tmp);\n }\n}\n\n/**\n * Take the docs lock, waiting out a live holder instead of refusing on first\n * contact. Same lock, different caller contract: `archive` refuses fast because\n * its holder may be mid-merge for seconds and its caller can re-run, but two\n * `verify --record` runs for different services of one feature are BOTH\n * supposed to land — the second must wait for the first's sub-second window and\n * then merge over the record it left, or one service's attestation is refused\n * for no reason a user can see. The wait is bounded: a holder that outlives\n * `waitMs` yields the same `DocsBusyError` the fast form throws, so the caller\n * maps it to `docs-busy` either way and nothing hangs on a wedged lock.\n */\nexport async function acquireDocsLockWaiting(docsDir: string, waitMs: number): Promise<() => Promise<void>> {\n const deadline = Date.now() + waitMs;\n for (;;) {\n try {\n return await acquireDocsLock(docsDir);\n } catch (err) {\n if (!(err instanceof DocsBusyError) || Date.now() >= deadline) throw err;\n }\n // Short enough that a released lock is picked up before a human notices;\n // long enough that the poll does not busy-spin the directory.\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\n/**\n * One read of the lock file, one interpretation, shared by every question\n * asked about it. These used to be four separate parse-and-guard copies\n * (`breakStaleLock`, `describeLock`, `lockIsStale`, `lockUnreadable`), each\n * catching to a different answer — which is how `doctor` came to read the file\n * three times in a row and report three different states of a lock that was\n * released between its reads.\n *\n * `absent` and `unreadable` are kept apart on purpose: a lock that is GONE is\n * the ordinary end of every command, while a lock that exists and cannot name\n * a holder — empty bytes from a crash between create and flush, JSON that is\n * not a record, a record with no string `host` or numeric `pid` — is damage:\n * `breakStaleLock` rightly refuses to guess about it, so nothing will ever\n * release it. A different host's lock is legible and simply not ours to judge.\n */\ntype LockHolder =\n | { kind: \"absent\" }\n | { kind: \"unreadable\" }\n | { kind: \"held\"; host: string; pid: number; at: string | undefined };\n\nasync function readLockHolder(path: string): Promise<LockHolder> {\n let text: string;\n try {\n text = await readFile(path, \"utf8\");\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ENOENT\" ? { kind: \"absent\" } : { kind: \"unreadable\" };\n }\n let holder: unknown;\n try {\n holder = JSON.parse(text);\n } catch {\n return { kind: \"unreadable\" };\n }\n // A lock file holding `null` (or a bare string, or an array) is JSON this\n // parses and nothing else understands. The dereference below used to run\n // outside the guard in `acquireDocsLock`'s breaker and threw, replacing the\n // `docs-busy` refusal — the designed answer — with an `internal` envelope.\n if (!isRecord(holder) || typeof holder.host !== \"string\" || typeof holder.pid !== \"number\") {\n return { kind: \"unreadable\" };\n }\n return { kind: \"held\", host: holder.host, pid: holder.pid, at: typeof holder.at === \"string\" ? holder.at : undefined };\n}\n\n/** Does this pid no longer exist on this host? Signal 0 asks without delivering; EPERM is alive-but-somebody-else's. */\nfunction pidGone(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return false;\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ESRCH\";\n }\n}\n\n/** Remove a lock whose holder is a dead process on this same host. Returns whether it did. */\nasync function breakStaleLock(path: string): Promise<boolean> {\n const holder = await readLockHolder(path);\n // Unreadable is deliberately NOT breakable: a lock we cannot understand is\n // the one case where guessing is worst. `doctor` reports it as damage.\n if (holder.kind !== \"held\" || holder.host !== hostname() || !pidGone(holder.pid)) return false;\n try {\n await unlink(path);\n return true;\n } catch {\n return false;\n }\n}\n\nexport async function describeLock(path: string): Promise<string> {\n const holder = await readLockHolder(path);\n return holder.kind === \"held\" ? `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}` : `see ${path}`;\n}\n\n/**\n * The lock's whole state for `doctor`, from ONE read. Null means no lock.\n * Three sequential reads used to answer this — exists, describe, stale — and a\n * lock released between them made `doctor` describe a file that was gone,\n * escalating an ordinary sub-second commit window into a blocker.\n */\nexport async function lockResidue(path: string): Promise<{ holder: string; stale: boolean; unreadable: boolean } | null> {\n const holder = await readLockHolder(path);\n if (holder.kind === \"absent\") return null;\n if (holder.kind === \"unreadable\") return { holder: `see ${path}`, stale: false, unreadable: true };\n return {\n holder: `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}`,\n stale: holder.host === hostname() && pidGone(holder.pid),\n unreadable: false,\n };\n}\n\n"]}
|
|
1
|
+
{"version":3,"file":"lock.js","sourceRoot":"","sources":["../../../src/core/staging/lock.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The docs repo's advisory lock: take it, describe its holder, and break it\n * when that holder is a pid on this host that no longer exists.\n *\n * A module of its own because it is the only rule here that spans the whole\n * fleet rather than one file, and because the two readers of a held lock have\n * to agree on what \"stale\" means — `breakStaleLock` acts on the answer,\n * `lockIsStale` only reports it to `doctor`. They were written as twins and\n * stay together so they cannot drift into two definitions of a dead holder.\n */\nimport { link, readFile, rename, writeFile } from \"node:fs/promises\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\n// Every `isRecord` below guards a record loam itself wrote and is reading back\n// — the lock, the snapshot manifest — whose fields are asked for on the very\n// next line, inside a REFUSAL path. `JSON.parse` answers with any JSON value,\n// not with an object, and that is where a thrown `TypeError` costs the most: it\n// does not make the command fail safely, it replaces a designed refusal with\n// `internal`.\nimport { isRecord } from \"../kernel/records.js\";\nimport { quietRm, tempPath } from \"./commit.js\";\n\n/** The lock file, in the docs repo root — one writer at a time across the whole fleet. */\nexport const DOCS_LOCK = \".loam-lock\";\n\n/**\n * How long a WAITING writer sits out a live holder before answering\n * `docs-busy`. One spelling for every writer that waits — including gherkin,\n * whose lock guards the SERVICE repo's emission root rather than the docs\n * repo: long enough to cover another writer's sub-second commit window with a\n * margin for a loaded host, bounded so a wedged lock cannot hang anyone.\n */\nexport const LOCK_WAIT_MS = 5_000;\n\n/** Another writer holds the docs repo. Commands map this to the `docs-busy` envelope code. */\nexport class DocsBusyError extends Error {\n override readonly name = \"DocsBusyError\";\n\n constructor(\n readonly lockPath: string,\n holder: string,\n ) {\n super(\n `another loam command is writing this docs repo (${holder}) — nothing was read or written. ` +\n `Wait for it to finish, or remove ${lockPath} if no such command is running.`,\n );\n }\n}\n\n/**\n * Take the docs repo's advisory lock for the whole plan+commit window, and\n * return the release.\n *\n * Advisory, and deliberately coarse: the destructive commands write the SAME\n * three shared files (a service spec, a service contract, the one landscape),\n * so two of them overlapping is not a merge to be resolved but a merge one of\n * them computed against a snapshot that no longer exists. Two archives used to\n * plan against the same landscape bytes and the second's splice silently\n * dropped the first's additions — exit 0 on both, and a `validate` that stayed\n * green because the surviving document was perfectly coherent.\n *\n * link(2) over a fully-written sibling is the primitive: as atomic as the\n * O_EXCL create it replaced, needs no daemon, and leaves a file a human can\n * read and delete — but unlike open-then-write, the lock file can never be\n * OBSERVED empty. `writeFile(path, …, \"wx\")` published existence first and\n * payload a syscall later, and in that window (or after a crash inside it) the\n * lock named no holder: `doctor` now grades exactly that shape as damage whose\n * fix is deletion, so the healthy path must never wear it, even for a\n * microsecond. A create that dies before its link leaves only a staging temp\n * `doctor` already names as litter. A holder that died without releasing would\n * otherwise wedge the repo forever, so a lock naming a pid on THIS host that\n * no longer exists is broken once and retried. Breaking removes only the\n * instance that was judged dead (see `breakStaleLock`), never a successor's\n * live lock. This comment used to accept that two racers could both break the\n * same stale lock, trusting the pre-image check in `swapStaged` as the second\n * line of defence. That check is still kept, and is still not an\n * optimisation. But it guards only the swap. A second holder's RECOVERY reads\n * the first holder's in-flight journal as an interrupted commit and sweeps\n * its staged temps, and no compare-and-set sits in front of that.\n */\n/**\n * Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with\n * pid and a millisecond timestamp, which is unique across processes and not\n * within one: two in-process acquirers in the same millisecond shared a temp\n * name, the first's cleanup deleted it under the second, and the second's\n * link(2) surfaced ENOENT as `internal`. The private name a stale-lock break\n * takes the lock to is salted from the same counter, for the same reason. A\n * bare counter is module-level mutable state, tolerated here because it feeds\n * nothing but name uniqueness — no behaviour ever branches on its value.\n */\nlet lockAcquireSeq = 0;\n\nexport async function acquireDocsLock(docsDir: string): Promise<() => Promise<void>> {\n const path = join(docsDir, DOCS_LOCK);\n const payload = `${JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() })}\\n`;\n lockAcquireSeq += 1;\n const tmp = tempPath(path, lockAcquireSeq);\n await writeFile(tmp, payload);\n try {\n for (let attempt = 0; ; attempt += 1) {\n try {\n await link(tmp, path);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n if (attempt === 0 && (await breakStaleLock(path))) continue;\n throw new DocsBusyError(path, await describeLock(path));\n }\n let released = false;\n return async () => {\n if (released) return;\n released = true;\n await quietRm(path);\n };\n }\n } finally {\n await quietRm(tmp);\n }\n}\n\n/**\n * Take the docs lock, waiting out a live holder instead of refusing on first\n * contact. Same lock, different caller contract: `archive` refuses fast because\n * its holder may be mid-merge for seconds and its caller can re-run, but two\n * `verify --record` runs for different services of one feature are BOTH\n * supposed to land — the second must wait for the first's sub-second window and\n * then merge over the record it left, or one service's attestation is refused\n * for no reason a user can see. The wait is bounded: a holder that outlives\n * `waitMs` yields the same `DocsBusyError` the fast form throws, so the caller\n * maps it to `docs-busy` either way and nothing hangs on a wedged lock.\n */\nexport async function acquireDocsLockWaiting(docsDir: string, waitMs: number): Promise<() => Promise<void>> {\n const deadline = Date.now() + waitMs;\n for (;;) {\n try {\n return await acquireDocsLock(docsDir);\n } catch (err) {\n if (!(err instanceof DocsBusyError) || Date.now() >= deadline) throw err;\n }\n // Short enough that a released lock is picked up before a human notices;\n // long enough that the poll does not busy-spin the directory.\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\n/**\n * One read of the lock file, one interpretation, shared by every question\n * asked about it. These used to be four separate parse-and-guard copies\n * (`breakStaleLock`, `describeLock`, `lockIsStale`, `lockUnreadable`), each\n * catching to a different answer — which is how `doctor` came to read the file\n * three times in a row and report three different states of a lock that was\n * released between its reads.\n *\n * `absent` and `unreadable` are kept apart on purpose: a lock that is GONE is\n * the ordinary end of every command, while a lock that exists and cannot name\n * a holder — empty bytes from a crash between create and flush, JSON that is\n * not a record, a record with no string `host` or numeric `pid` — is damage:\n * `breakStaleLock` rightly refuses to guess about it, so nothing will ever\n * release it. A different host's lock is legible and simply not ours to judge.\n */\ntype LockHolder =\n | { kind: \"absent\" }\n | { kind: \"unreadable\" }\n | { kind: \"held\"; host: string; pid: number; at: string | undefined };\n\nasync function readLockHolder(path: string): Promise<LockHolder> {\n let text: string;\n try {\n text = await readFile(path, \"utf8\");\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ENOENT\" ? { kind: \"absent\" } : { kind: \"unreadable\" };\n }\n let holder: unknown;\n try {\n holder = JSON.parse(text);\n } catch {\n return { kind: \"unreadable\" };\n }\n // A lock file holding `null` (or a bare string, or an array) is JSON this\n // parses and nothing else understands. The dereference below used to run\n // outside the guard in `acquireDocsLock`'s breaker and threw, replacing the\n // `docs-busy` refusal — the designed answer — with an `internal` envelope.\n if (!isRecord(holder) || typeof holder.host !== \"string\" || typeof holder.pid !== \"number\") {\n return { kind: \"unreadable\" };\n }\n return { kind: \"held\", host: holder.host, pid: holder.pid, at: typeof holder.at === \"string\" ? holder.at : undefined };\n}\n\n/** Does this pid no longer exist on this host? Signal 0 asks without delivering; EPERM is alive-but-somebody-else's. */\nfunction pidGone(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return false;\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ESRCH\";\n }\n}\n\n/**\n * Remove a lock whose holder is a dead process on this same host, and only the\n * lock INSTANCE that verdict was about. Returns whether it did.\n *\n * The verdict comes from a read, the removal acts on a path, and other writers\n * move in between. The removal used to be a bare unlink(2) of the path. Under\n * CPU load a waiter read the holder's payload, then the holder released\n * NORMALLY and exited, then a third writer linked a fresh lock, and only then\n * did kill(pid, 0) answer ESRCH. The unlink deleted that third writer's LIVE\n * lock. Two `loam gherkin` runs then shared one commit window, and the\n * second's recovery read the first's in-flight journal as \"interrupted\" and\n * swept its staged temps. The first's rename failed ENOENT\n * (test/stress-gherkin.test.ts found it; test/lock-break.test.ts opens each\n * window on purpose).\n *\n * So the removal is bound to the judged instance, twice:\n * - the lock is re-read AFTER the verdict and must still name the judged\n * holder. A holder that released normally removed its file before it\n * exited, so a dead holder's lock still in place can only be a crashed\n * one's, which is the one lock a break is for;\n * - it is then TAKEN by rename(2) to a private name, which exactly one of\n * several concurrent breakers can win, and judged in hand. If what moved is\n * not the judged instance (another waiter broke the same crashed lock first\n * and linked its own in between), it goes back by link(2), which never\n * overwrites.\n * One gap stays open, between that take and the put-back. A third writer can\n * link in it, and then the displaced lock stays down. Or the displaced holder\n * can release in it, and then the put-back restores a lock nobody holds, which\n * stands until that process exits. Either needs a crashed holder AND two\n * breakers racing for its lock. The gap is named here rather than closed,\n * because closing it needs a kernel lock (flock) that Node does not expose and\n * loam will not add a dependency for.\n */\nasync function breakStaleLock(path: string): Promise<boolean> {\n const judged = await readLockHolder(path);\n // Unreadable is deliberately NOT breakable: a lock we cannot understand is\n // the one case where guessing is worst. `doctor` reports it as damage.\n if (judged.kind !== \"held\" || judged.host !== hostname() || !pidGone(judged.pid)) return false;\n if (!sameHolder(await readLockHolder(path), judged)) return false;\n lockAcquireSeq += 1;\n const taken = tempPath(path, lockAcquireSeq);\n try {\n await rename(path, taken);\n } catch {\n // Another breaker took it first, or the platform refused the rename (a\n // Windows handle still open on it). Either way the next link(2) attempt\n // decides, not this one.\n return false;\n }\n if (sameHolder(await readLockHolder(taken), judged)) {\n await quietRm(taken);\n return true;\n }\n try {\n await link(taken, path);\n } catch {\n // The gap named above: a third writer linked in between, and putting the\n // displaced lock back would overwrite a live one. It stays down. Its holder\n // is unprotected from here, and nothing this process can do fixes that.\n }\n await quietRm(taken);\n return false;\n}\n\n/** Is `now` the very lock instance `judged` was read from? Pid, host and timestamp are its identity. */\nfunction sameHolder(now: LockHolder, judged: Extract<LockHolder, { kind: \"held\" }>): boolean {\n return now.kind === \"held\" && now.pid === judged.pid && now.host === judged.host && now.at === judged.at;\n}\n\nexport async function describeLock(path: string): Promise<string> {\n const holder = await readLockHolder(path);\n return holder.kind === \"held\" ? `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}` : `see ${path}`;\n}\n\n/**\n * The lock's whole state for `doctor`, from ONE read. Null means no lock.\n * Three sequential reads used to answer this — exists, describe, stale — and a\n * lock released between them made `doctor` describe a file that was gone,\n * escalating an ordinary sub-second commit window into a blocker.\n */\nexport async function lockResidue(path: string): Promise<{ holder: string; stale: boolean; unreadable: boolean } | null> {\n const holder = await readLockHolder(path);\n if (holder.kind === \"absent\") return null;\n if (holder.kind === \"unreadable\") return { holder: `see ${path}`, stale: false, unreadable: true };\n return {\n holder: `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}`,\n stale: holder.host === hostname() && pidGone(holder.pid),\n unreadable: false,\n };\n}\n\n"]}
|
|
@@ -22,11 +22,8 @@ const COMMIT_INTENT_VERSION = 1;
|
|
|
22
22
|
/**
|
|
23
23
|
* Write the intent record and get it onto the disk before the first swap.
|
|
24
24
|
*
|
|
25
|
-
* fsync, not just write
|
|
26
|
-
*
|
|
27
|
-
* machine dies describes nothing. The directory entry is fsynced too where the
|
|
28
|
-
* platform allows opening a directory — without that the file's bytes can be
|
|
29
|
-
* durable while its name is not.
|
|
25
|
+
* fsync, not just write — through `writeDurable`, whose doc says why: the
|
|
26
|
+
* point of the record is to survive the crash that interrupts the commit.
|
|
30
27
|
*/
|
|
31
28
|
export async function writeCommitIntent(docsDir, spec, staged) {
|
|
32
29
|
const intent = {
|