@mnstry/atelier 0.2.0-alpha.6 → 0.2.0-alpha.8
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 +176 -0
- package/README.md +59 -15
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/docs/assurance-controls.md +9 -1
- package/docs/atelier-sync.md +5 -4
- package/docs/blocks/claims.md +15 -10
- package/docs/blocks/will-not-do.md +4 -1
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/decisions.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +65 -9
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +227 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +687 -0
- package/docs/obsidian.md +387 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +88 -0
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +55 -2
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +57 -6
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-open/SKILL.md +5 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-open/SKILL.md +5 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/policy.mjs +57 -25
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +43 -3
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +16 -33
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/extension-pack.mjs +15 -8
- package/src/commands/init.mjs +44 -5
- package/src/commands/obsidian.mjs +368 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +8 -1
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +29 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +13 -0
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +64 -2
- package/src/graph/knowledge-graph.mjs +548 -24
- package/src/guides/contracts.mjs +54 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +26 -27
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +166 -0
- package/src/project/private-state.mjs +11 -1
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +139 -0
- package/src/projection/obsidian/recovery/index.mjs +6 -0
- package/src/projection/obsidian/recovery/journal.mjs +134 -0
- package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
- package/src/projection/obsidian/recovery/restart.mjs +316 -0
- package/src/projection/obsidian/recovery/store.mjs +234 -0
- package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
- package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
- package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
- package/src/projection/obsidian/selection-ui/index.mjs +12 -0
- package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
- package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
- package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
- package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
- package/src/projection/project.mjs +28 -19
- package/src/readiness-protocols/evidence.mjs +282 -0
- package/src/readiness-protocols/runtime.mjs +4 -3
- package/src/readiness-protocols/source-read.mjs +39 -0
- package/src/runtime/obsidian/app-capability.mjs +110 -0
- package/src/runtime/obsidian/app-production-seams.mjs +96 -0
- package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
- package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
- package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
- package/src/runtime/obsidian/contributions.mjs +27 -0
- package/src/runtime/obsidian/documents.mjs +37 -0
- package/src/runtime/obsidian/enablement.mjs +53 -0
- package/src/runtime/obsidian/engine.mjs +532 -0
- package/src/runtime/obsidian/errors.mjs +14 -0
- package/src/runtime/obsidian/extension-points.mjs +103 -0
- package/src/runtime/obsidian/index.mjs +34 -0
- package/src/runtime/obsidian/lifecycle.mjs +258 -0
- package/src/runtime/obsidian/machine-settings.mjs +260 -0
- package/src/runtime/obsidian/observation.mjs +158 -0
- package/src/runtime/obsidian/opening.mjs +243 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-main.mjs +89 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/supervisor.mjs +16 -1
- package/src/server/local-sidecar.mjs +44 -0
- package/src/server/server.mjs +1 -0
- package/src/skills/steward.mjs +780 -0
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +63 -28
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/project/README.md +2 -2
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import { scanMarkdownLinks } from '../../../graph/knowledge-graph.mjs'
|
|
2
|
+
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../contracts.mjs'
|
|
3
|
+
import { readMarkdownLens, sha256Digest } from '../materialize/byte-lens.mjs'
|
|
4
|
+
import { alignBodies, commonPrefixLength, commonSubsequenceLength, commonSuffixLength } from './align.mjs'
|
|
5
|
+
|
|
6
|
+
// The raw-byte edit lens. A person edits a generated NOTE; the lens turns the
|
|
7
|
+
// edited note bytes, the manifest entry the note was generated under, the
|
|
8
|
+
// published note bytes and the base source bytes into the exact new source
|
|
9
|
+
// bytes, or a typed refusal. Everything is a Buffer from start to finish: no
|
|
10
|
+
// newline is normalized, no byte outside the authored body is touched and
|
|
11
|
+
// generated text is never attributed to the author. Nothing is written.
|
|
12
|
+
//
|
|
13
|
+
// A note is three byte classes, in order:
|
|
14
|
+
//
|
|
15
|
+
// prefix bytes before regions.body.start: the source's byte order prefix
|
|
16
|
+
// or front matter, verbatim
|
|
17
|
+
// authored the body, with canonical link and embed targets rewritten; each
|
|
18
|
+
// rewrite is recorded as an inversion
|
|
19
|
+
// generated relations, outside-selection rows and, before them, the closing
|
|
20
|
+
// fence of a body that ends inside a code fence; every generated
|
|
21
|
+
// region begins with its own separator bytes
|
|
22
|
+
//
|
|
23
|
+
// Refusals carry codes, digests and byte offsets only: never note or source
|
|
24
|
+
// text, titles or machine paths.
|
|
25
|
+
|
|
26
|
+
const EXT = OBSIDIAN_EXT_KEY
|
|
27
|
+
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf])
|
|
28
|
+
const LF = 0x0a
|
|
29
|
+
const CR = 0x0d
|
|
30
|
+
const PIPE = 0x7c
|
|
31
|
+
const LINK_CLOSE = Buffer.from(']]')
|
|
32
|
+
|
|
33
|
+
export const EDIT_LENS_REFUSALS = Object.freeze([
|
|
34
|
+
'unknown-note',
|
|
35
|
+
'unsupported-wrapper-edit',
|
|
36
|
+
'baseline-mismatch',
|
|
37
|
+
'stale-base',
|
|
38
|
+
'manifest-mismatch',
|
|
39
|
+
'invalid-utf8',
|
|
40
|
+
'generated-region-edited',
|
|
41
|
+
'unsupported-frontmatter-edit',
|
|
42
|
+
'link-rewrite-edited',
|
|
43
|
+
'unsupported-structural-edit',
|
|
44
|
+
'ambiguous-link-alignment',
|
|
45
|
+
'edit-too-large-to-align',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
function refuse(code, message, detail = {}) {
|
|
49
|
+
throw new ObsidianContractRefusal(code, message, detail)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const endsWith = (buffer, tail) => tail.length <= buffer.length && buffer.subarray(buffer.length - tail.length).equals(tail)
|
|
53
|
+
const startsWith = (buffer, head) => head.length <= buffer.length && buffer.subarray(0, head.length).equals(head)
|
|
54
|
+
function countOccurrences(haystack, needle) {
|
|
55
|
+
let count = 0
|
|
56
|
+
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) count += 1
|
|
57
|
+
return count
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isStrictUtf8(buffer) {
|
|
61
|
+
try {
|
|
62
|
+
new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(buffer)
|
|
63
|
+
return true
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Generated regions
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
// What the published note says about its generated tail: the exact bytes, the
|
|
74
|
+
// leading bytes that are separator or fence closure (`lead`) and the text lines
|
|
75
|
+
// that follow them. The expected bytes are read from the published note under
|
|
76
|
+
// the manifest's ranges; nothing about their wording is restated here.
|
|
77
|
+
function publishedGenerated(noteEntry, published) {
|
|
78
|
+
const regions = noteEntry.regions.generated
|
|
79
|
+
const bodyEnd = noteEntry.regions.body.end
|
|
80
|
+
let cursor = bodyEnd
|
|
81
|
+
for (const region of regions) {
|
|
82
|
+
if (region.range.start !== cursor || region.range.end < region.range.start) refuse('manifest-mismatch', 'generated regions do not tile the end of the published note')
|
|
83
|
+
cursor = region.range.end
|
|
84
|
+
}
|
|
85
|
+
if (cursor !== published.length) refuse('manifest-mismatch', 'generated regions do not tile the end of the published note')
|
|
86
|
+
const bytes = published.subarray(bodyEnd)
|
|
87
|
+
const closureLength = regions[0]?.ext?.[EXT]?.fenceClosure?.byteLength ?? 0
|
|
88
|
+
let lead = closureLength
|
|
89
|
+
while (lead < bytes.length && (bytes[lead] === LF || bytes[lead] === CR)) lead += 1
|
|
90
|
+
const lines = []
|
|
91
|
+
let offset = lead
|
|
92
|
+
while (offset < bytes.length) {
|
|
93
|
+
const newline = bytes.indexOf(LF, offset)
|
|
94
|
+
const next = newline === -1 ? bytes.length : newline + 1
|
|
95
|
+
let end = newline === -1 ? bytes.length : newline
|
|
96
|
+
if (end > offset && bytes[end - 1] === CR) end -= 1
|
|
97
|
+
if (end > offset) lines.push(bytes.subarray(offset, end))
|
|
98
|
+
offset = next
|
|
99
|
+
}
|
|
100
|
+
return { bytes, lead: bytes.subarray(0, lead), lines }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Splits the edited note into its authored part and the state of its generated
|
|
104
|
+
// tail. Returns { authored, generated } with `authored` a view of the edited
|
|
105
|
+
// bytes (prefix included) and `generated` one of:
|
|
106
|
+
//
|
|
107
|
+
// 'none' the published note had no generated region
|
|
108
|
+
// 'intact' the edited note still ends with the exact generated bytes
|
|
109
|
+
// 'removed' the tail is gone and where the authored text ends is PROVEN
|
|
110
|
+
//
|
|
111
|
+
// Every other state (edited, moved, duplicated, cut in the middle of a line
|
|
112
|
+
// from either side) refuses: generated text is never guessed at and never
|
|
113
|
+
// reaches a source. A refusal that a person could have avoided is accepted; a
|
|
114
|
+
// generated byte in a source is not.
|
|
115
|
+
//
|
|
116
|
+
// Intact. The authored part is what precedes the tail. It must hold no
|
|
117
|
+
// complete generated line more often than the published authored part did
|
|
118
|
+
// (an author whose own text repeats a generated line is not refused for it),
|
|
119
|
+
// and it must not end in the beginning of the tail (a second, cut copy).
|
|
120
|
+
//
|
|
121
|
+
// Removed. With G the generated bytes, `lead` their leading separator and
|
|
122
|
+
// fence-closure bytes and P the published authored part, the edited note E is
|
|
123
|
+
// read as A + r, r = G[0, k) for one k <= |lead|, only under one of:
|
|
124
|
+
//
|
|
125
|
+
// unedited E = P + r. What is left of the separator is not the author's:
|
|
126
|
+
// the edit is no authored edit at all.
|
|
127
|
+
// anchored A ends with the anchor of P: the line ending before P's last
|
|
128
|
+
// non-empty line, that line, and P's trailing line endings, byte
|
|
129
|
+
// for byte; E does not begin with all of P; and exactly ONE k
|
|
130
|
+
// qualifies. The end of the authored text is then the published
|
|
131
|
+
// end, so nothing after it can be the author's, and a fragment f
|
|
132
|
+
// of the tail left before r would have to end in the anchor
|
|
133
|
+
// itself, which makes it a complete generated line (refused
|
|
134
|
+
// below) or empty. A P without such a line ending has no anchor.
|
|
135
|
+
//
|
|
136
|
+
// and only when, in addition:
|
|
137
|
+
//
|
|
138
|
+
// - E does not end with G[0, k) for any |lead| < k < |G| (cut from the right)
|
|
139
|
+
// - no complete generated line is left anywhere in E beyond P's own count
|
|
140
|
+
// - between the first and the last byte in which A differs from P, no run of
|
|
141
|
+
// FRAGMENT bytes of G that reaches past `lead` occurs more often than in
|
|
142
|
+
// the same span of P (a fragment left in the middle of the body)
|
|
143
|
+
//
|
|
144
|
+
// So text appended at the very end of the body together with a removed tail
|
|
145
|
+
// refuses, and so does an edit of the last authored line together with one.
|
|
146
|
+
const FRAGMENT = 8
|
|
147
|
+
|
|
148
|
+
function fragmentRuns(generated) {
|
|
149
|
+
const heads = new Set()
|
|
150
|
+
const runs = new Set()
|
|
151
|
+
for (let at = Math.max(0, generated.lead.length - FRAGMENT + 1); at + FRAGMENT <= generated.bytes.length; at += 1) {
|
|
152
|
+
heads.add(generated.bytes.readUInt32BE(at))
|
|
153
|
+
runs.add(generated.bytes.toString('latin1', at, at + FRAGMENT))
|
|
154
|
+
}
|
|
155
|
+
return { heads, runs }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function countRuns(bytes, from, to, { heads, runs }) {
|
|
159
|
+
const counts = new Map()
|
|
160
|
+
for (let at = Math.max(0, from); at + FRAGMENT <= Math.min(bytes.length, to); at += 1) {
|
|
161
|
+
if (!heads.has(bytes.readUInt32BE(at))) continue
|
|
162
|
+
const run = bytes.toString('latin1', at, at + FRAGMENT)
|
|
163
|
+
if (runs.has(run)) counts.set(run, (counts.get(run) ?? 0) + 1)
|
|
164
|
+
}
|
|
165
|
+
return counts
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The anchor of the published authored part, or null when it has none.
|
|
169
|
+
function authoredAnchor(publishedAuthored) {
|
|
170
|
+
let last = publishedAuthored.length
|
|
171
|
+
while (last > 0 && (publishedAuthored[last - 1] === LF || publishedAuthored[last - 1] === CR)) last -= 1
|
|
172
|
+
if (last === 0) return null
|
|
173
|
+
const lineStart = publishedAuthored.lastIndexOf(LF, last - 1)
|
|
174
|
+
return lineStart === -1 ? null : publishedAuthored.subarray(lineStart)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function splitGeneratedTail({ noteEntry, publishedNoteBytes, editedNoteBytes }) {
|
|
178
|
+
const generated = publishedGenerated(noteEntry, publishedNoteBytes)
|
|
179
|
+
const publishedAuthored = publishedNoteBytes.subarray(0, noteEntry.regions.body.end)
|
|
180
|
+
if (generated.bytes.length === 0) return { authored: editedNoteBytes, generated: 'none' }
|
|
181
|
+
const surplus = (haystack) => generated.lines.findIndex((line) => countOccurrences(haystack, line) > countOccurrences(publishedAuthored, line))
|
|
182
|
+
const endsInCutTail = (haystack) => {
|
|
183
|
+
for (let kept = generated.lead.length + 1; kept < generated.bytes.length; kept += 1) if (endsWith(haystack, generated.bytes.subarray(0, kept))) return true
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (endsWith(editedNoteBytes, generated.bytes)) {
|
|
188
|
+
const authored = editedNoteBytes.subarray(0, editedNoteBytes.length - generated.bytes.length)
|
|
189
|
+
if (surplus(authored) !== -1) refuse('generated-region-edited', 'generated text also appears inside the authored part of the edited note', { generatedLine: surplus(authored) })
|
|
190
|
+
if (endsInCutTail(authored)) refuse('generated-region-edited', 'the beginning of a generated region was copied in front of it')
|
|
191
|
+
return { authored, generated: 'intact' }
|
|
192
|
+
}
|
|
193
|
+
if (surplus(editedNoteBytes) !== -1) {
|
|
194
|
+
refuse('generated-region-edited', 'a generated region was edited, moved or partially deleted', { generatedLine: surplus(editedNoteBytes) })
|
|
195
|
+
}
|
|
196
|
+
if (endsInCutTail(editedNoteBytes)) refuse('generated-region-edited', 'a generated region was cut in the middle; what is left of it is not authored text')
|
|
197
|
+
if (startsWith(editedNoteBytes, publishedAuthored)) {
|
|
198
|
+
if (startsWith(generated.lead, editedNoteBytes.subarray(publishedAuthored.length))) return { authored: editedNoteBytes.subarray(0, publishedAuthored.length), generated: 'removed' }
|
|
199
|
+
refuse('generated-region-edited', 'a generated region was removed and the note no longer ends where its authored text ended')
|
|
200
|
+
}
|
|
201
|
+
const anchor = authoredAnchor(publishedAuthored)
|
|
202
|
+
const proven = []
|
|
203
|
+
for (let keep = 0; anchor !== null && keep <= generated.lead.length; keep += 1) {
|
|
204
|
+
if (!endsWith(editedNoteBytes, generated.lead.subarray(0, keep))) continue
|
|
205
|
+
const authored = editedNoteBytes.subarray(0, editedNoteBytes.length - keep)
|
|
206
|
+
if (endsWith(authored, anchor)) proven.push(authored)
|
|
207
|
+
}
|
|
208
|
+
if (proven.length !== 1) refuse('generated-region-edited', 'a generated region was removed and where the authored text ends cannot be proven', { readings: proven.length })
|
|
209
|
+
const [authored] = proven
|
|
210
|
+
const head = commonPrefixLength(authored, publishedAuthored)
|
|
211
|
+
const tail = commonSuffixLength(authored, publishedAuthored, authored.length, publishedAuthored.length, Math.min(authored.length, publishedAuthored.length) - head)
|
|
212
|
+
const runs = fragmentRuns(generated)
|
|
213
|
+
const before = countRuns(publishedAuthored, head - FRAGMENT + 1, publishedAuthored.length - tail + FRAGMENT - 1, runs)
|
|
214
|
+
for (const [run, count] of countRuns(authored, head - FRAGMENT + 1, authored.length - tail + FRAGMENT - 1, runs)) {
|
|
215
|
+
if (count > (before.get(run) ?? 0)) refuse('generated-region-edited', 'a fragment of a generated region was left inside the authored text')
|
|
216
|
+
}
|
|
217
|
+
return { authored, generated: 'removed' }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Prefix
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
// Returns the edited authored BODY. The prefix must be byte-identical. A byte
|
|
225
|
+
// order prefix is the one exception: it belongs to the source and is kept from
|
|
226
|
+
// the source whether or not the editor kept it in the note.
|
|
227
|
+
export function splitPrefix({ noteEntry, publishedNoteBytes, editedAuthored }) {
|
|
228
|
+
const prefix = publishedNoteBytes.subarray(0, noteEntry.regions.body.start)
|
|
229
|
+
const frontmatter = noteEntry.regions.frontmatter ?? null
|
|
230
|
+
let body
|
|
231
|
+
if (frontmatter === null) {
|
|
232
|
+
body = startsWith(editedAuthored, UTF8_BOM) ? editedAuthored.subarray(UTF8_BOM.length) : editedAuthored
|
|
233
|
+
} else {
|
|
234
|
+
if (!startsWith(editedAuthored, prefix)) {
|
|
235
|
+
refuse('unsupported-frontmatter-edit', 'the front matter of the note was edited; only the body can be replaced', { firstDifference: commonPrefixLength(editedAuthored, prefix) })
|
|
236
|
+
}
|
|
237
|
+
body = editedAuthored.subarray(prefix.length)
|
|
238
|
+
}
|
|
239
|
+
// The body must not read as front matter of its own, and the front matter
|
|
240
|
+
// must still end where it did, as the source lens reads the result.
|
|
241
|
+
let lens = null
|
|
242
|
+
try { lens = readMarkdownLens(Buffer.concat([frontmatter === null ? Buffer.alloc(0) : prefix, body])) } catch { /* refused below */ }
|
|
243
|
+
const expectedEnd = frontmatter === null ? null : frontmatter.end
|
|
244
|
+
if (lens === null || (lens.frontmatter?.end ?? null) !== expectedEnd) {
|
|
245
|
+
refuse('unsupported-frontmatter-edit', 'the edit changes where the front matter of the note begins or ends; only the body can be replaced')
|
|
246
|
+
}
|
|
247
|
+
return body
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Rewritten links and embeds
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
function inversionUnits({ manifest, noteEntry, publishedNoteBytes, baseSourceBytes }) {
|
|
255
|
+
const bodyStart = noteEntry.regions.body.start
|
|
256
|
+
const bodyEnd = noteEntry.regions.body.end
|
|
257
|
+
const records = [
|
|
258
|
+
...manifest.links.filter((link) => link.sourceNodeId === noteEntry.nodeId).flatMap((link) => link.inversions ?? []),
|
|
259
|
+
...(noteEntry.ext?.[EXT]?.assetEmbeds ?? []).flatMap((embed) => embed.inversions),
|
|
260
|
+
]
|
|
261
|
+
const units = records.map((record) => {
|
|
262
|
+
const ext = record.ext?.[EXT]
|
|
263
|
+
if (!ext || ext.encoding !== 'base64url') refuse('manifest-mismatch', 'an inversion record does not carry its byte strings')
|
|
264
|
+
const unit = {
|
|
265
|
+
pStart: record.note.start - bodyStart,
|
|
266
|
+
pEnd: record.note.end - bodyStart,
|
|
267
|
+
sourceStart: record.source.start,
|
|
268
|
+
sourceEnd: record.source.end,
|
|
269
|
+
original: Buffer.from(ext.original, 'base64url'),
|
|
270
|
+
emitted: Buffer.from(ext.emitted, 'base64url'),
|
|
271
|
+
}
|
|
272
|
+
const consistent = record.note.start >= bodyStart && record.note.end <= bodyEnd && unit.emitted.length > 0
|
|
273
|
+
&& publishedNoteBytes.subarray(record.note.start, record.note.end).equals(unit.emitted)
|
|
274
|
+
&& baseSourceBytes.subarray(record.source.start, record.source.end).equals(unit.original)
|
|
275
|
+
if (!consistent) refuse('manifest-mismatch', 'an inversion record does not describe the published note and the base source')
|
|
276
|
+
return unit
|
|
277
|
+
}).sort((left, right) => left.pStart - right.pStart || left.sourceStart - right.sourceStart)
|
|
278
|
+
units.forEach((unit, index) => {
|
|
279
|
+
const previous = units[index - 1]
|
|
280
|
+
if (previous && (unit.pStart < previous.pEnd || unit.sourceStart < previous.sourceEnd)) refuse('manifest-mismatch', 'inversion records overlap')
|
|
281
|
+
// An alias the emitter appended: nothing in the source, `|…` in the note,
|
|
282
|
+
// directly tied to the target rewrite before it.
|
|
283
|
+
unit.aliasOf = unit.original.length === 0 && unit.emitted[0] === PIPE && previous ? index - 1 : null
|
|
284
|
+
if (unit.original.length === 0 && unit.aliasOf === null) refuse('manifest-mismatch', 'an inserted alias has no target rewrite before it')
|
|
285
|
+
})
|
|
286
|
+
return units
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Where each rewrite is in the edited body. Every unit ends as
|
|
290
|
+
// { state: 'intact', eStart, eEnd } or { state: 'deleted' }; anything else
|
|
291
|
+
// refuses.
|
|
292
|
+
//
|
|
293
|
+
// intact the emitted bytes lie whole inside one matched run of the
|
|
294
|
+
// alignment, or, failing that, occur exactly once between the
|
|
295
|
+
// edited positions of the unit's matched neighbours
|
|
296
|
+
// or, when no byte of the unit is matched at all (the alignment
|
|
297
|
+
// paired its surroundings with other text), are found between the
|
|
298
|
+
// previous placement and the next unit the alignment did place
|
|
299
|
+
// deleted no byte of the unit is matched and its emitted bytes are not
|
|
300
|
+
// found by either search
|
|
301
|
+
// refused part of the unit survives (it was modified), it occurs more than
|
|
302
|
+
// once between its neighbours, or no search could align its range
|
|
303
|
+
export function placeUnits({ units, publishedBody, editedBody, align = alignBodies, wideSearch = true }) {
|
|
304
|
+
if (units.length === 0) return []
|
|
305
|
+
const wanted = (from, to) => units.some((unit) => unit.pStart < to && unit.pEnd > from)
|
|
306
|
+
const { runs, coarse } = publishedBody.equals(editedBody) ? { runs: [{ p: 0, e: 0, length: publishedBody.length }], coarse: [] } : align(publishedBody, editedBody, { wanted })
|
|
307
|
+
const aligned = units.map((unit) => {
|
|
308
|
+
const run = runs.find((item) => item.p <= unit.pStart && item.p + item.length >= unit.pEnd)
|
|
309
|
+
return run ? run.e + (unit.pStart - run.p) : null
|
|
310
|
+
})
|
|
311
|
+
let placedEnd = 0
|
|
312
|
+
return units.map((unit, index) => {
|
|
313
|
+
if (aligned[index] !== null) {
|
|
314
|
+
const eStart = aligned[index]
|
|
315
|
+
placedEnd = eStart + unit.emitted.length
|
|
316
|
+
return { state: 'intact', eStart, eEnd: placedEnd }
|
|
317
|
+
}
|
|
318
|
+
let lower = 0
|
|
319
|
+
let upper = editedBody.length
|
|
320
|
+
let matched = 0
|
|
321
|
+
for (const run of runs) {
|
|
322
|
+
const runEnd = run.p + run.length
|
|
323
|
+
if (run.p < unit.pStart) lower = run.e + (Math.min(runEnd, unit.pStart) - run.p)
|
|
324
|
+
matched += Math.max(0, Math.min(runEnd, unit.pEnd) - Math.max(run.p, unit.pStart))
|
|
325
|
+
if (runEnd > unit.pEnd) { upper = run.e + (Math.max(run.p, unit.pEnd) - run.p); break }
|
|
326
|
+
}
|
|
327
|
+
lower = Math.max(lower, placedEnd)
|
|
328
|
+
const gap = editedBody.subarray(lower, Math.max(lower, upper))
|
|
329
|
+
const first = gap.indexOf(unit.emitted)
|
|
330
|
+
const unique = first !== -1 && gap.indexOf(unit.emitted, first + 1) === -1
|
|
331
|
+
if (!unique && coarse.some((range) => unit.pStart < range.end && unit.pEnd > range.start)) {
|
|
332
|
+
refuse('edit-too-large-to-align', 'the edit around a rewritten link is too large to align with the published note', { unit: index })
|
|
333
|
+
}
|
|
334
|
+
if (first !== -1 && !unique) refuse('ambiguous-link-alignment', 'a rewritten link cannot be aligned with the published note unambiguously', { unit: index })
|
|
335
|
+
if (first !== -1) {
|
|
336
|
+
placedEnd = lower + first + unit.emitted.length
|
|
337
|
+
return { state: 'intact', eStart: lower + first, eEnd: placedEnd }
|
|
338
|
+
}
|
|
339
|
+
if (matched === 0 && wideSearch) {
|
|
340
|
+
// A shortest edit script may pair the text around an untouched rewrite
|
|
341
|
+
// with other text and call the rewrite itself inserted. Its bytes are
|
|
342
|
+
// then still in the edited body, before the next rewrite that was
|
|
343
|
+
// aligned. The first occurrence is taken only when every rewrite that
|
|
344
|
+
// could claim it inverts to the same source bytes.
|
|
345
|
+
let next = index + 1
|
|
346
|
+
while (next < units.length && aligned[next] === null) next += 1
|
|
347
|
+
const from = Math.max(lower, placedEnd)
|
|
348
|
+
const wide = editedBody.subarray(from, Math.max(from, next < units.length ? aligned[next] : editedBody.length))
|
|
349
|
+
const found = wide.indexOf(unit.emitted)
|
|
350
|
+
if (found !== -1) {
|
|
351
|
+
const rivals = units.slice(index + 1, next).some((other) => other.emitted.equals(unit.emitted) && !other.original.equals(unit.original))
|
|
352
|
+
if (rivals && wide.indexOf(unit.emitted, found + 1) !== -1) refuse('ambiguous-link-alignment', 'a rewritten link cannot be aligned with the published note unambiguously', { unit: index })
|
|
353
|
+
placedEnd = from + found + unit.emitted.length
|
|
354
|
+
return { state: 'intact', eStart: from + found, eEnd: placedEnd }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (matched > 0) {
|
|
358
|
+
if (unit.aliasOf !== null) refuse('link-rewrite-edited', 'the alias the emitter appended to a link was edited', { publishedStart: unit.pStart, publishedEnd: unit.pEnd })
|
|
359
|
+
refuse('unsupported-structural-edit', 'the target of a rewritten link was edited', { reason: 'rewritten-target-edited', publishedStart: unit.pStart, publishedEnd: unit.pEnd })
|
|
360
|
+
}
|
|
361
|
+
return { state: 'deleted' }
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function assertCoherentPlacements({ units, placements, publishedBody, editedBody }) {
|
|
366
|
+
const intactBefore = (index) => { for (let at = index - 1; at >= 0; at -= 1) if (placements[at].state === 'intact') return at; return -1 }
|
|
367
|
+
const intactAfter = (index) => { for (let at = index + 1; at < units.length; at += 1) if (placements[at].state === 'intact') return at; return -1 }
|
|
368
|
+
units.forEach((unit, index) => {
|
|
369
|
+
const state = placements[index].state
|
|
370
|
+
if (unit.aliasOf !== null && placements[unit.aliasOf].state !== state) {
|
|
371
|
+
if (state === 'deleted') refuse('link-rewrite-edited', 'the alias the emitter appended to a link was removed while its link was kept', { publishedStart: unit.pStart, publishedEnd: unit.pEnd })
|
|
372
|
+
refuse('unsupported-structural-edit', 'the target of a rewritten link was removed while the rest of the link was kept', { reason: 'rewritten-target-edited', publishedStart: unit.pStart, publishedEnd: unit.pEnd })
|
|
373
|
+
}
|
|
374
|
+
if (state !== 'deleted') return
|
|
375
|
+
// The same emitted bytes can stand for different source spellings of one
|
|
376
|
+
// target. When one of two such neighbours is deleted, which one survives
|
|
377
|
+
// decides the source bytes. The pairing the alignment chose must keep
|
|
378
|
+
// strictly more of the published bytes around them than the other
|
|
379
|
+
// pairing would; a tie, or a comparison too large to make, refuses.
|
|
380
|
+
for (const kept of [intactBefore(index), intactAfter(index)]) {
|
|
381
|
+
if (kept === -1 || !units[kept].emitted.equals(unit.emitted) || units[kept].original.equals(unit.original)) continue
|
|
382
|
+
const first = units[Math.min(index, kept)]
|
|
383
|
+
const second = units[Math.max(index, kept)]
|
|
384
|
+
const before = intactBefore(Math.min(index, kept))
|
|
385
|
+
const after = intactAfter(Math.max(index, kept))
|
|
386
|
+
const pFrom = before === -1 ? 0 : units[before].pEnd
|
|
387
|
+
const pTo = after === -1 ? publishedBody.length : units[after].pStart
|
|
388
|
+
const editedBefore = editedBody.subarray(before === -1 ? 0 : placements[before].eEnd, placements[kept].eStart)
|
|
389
|
+
const editedAfter = editedBody.subarray(placements[kept].eEnd, after === -1 ? editedBody.length : placements[after].eStart)
|
|
390
|
+
const score = (survivor) => {
|
|
391
|
+
const left = commonSubsequenceLength(publishedBody.subarray(pFrom, survivor.pStart), editedBefore)
|
|
392
|
+
const right = commonSubsequenceLength(publishedBody.subarray(survivor.pEnd, pTo), editedAfter)
|
|
393
|
+
return left === null || right === null ? null : left + right
|
|
394
|
+
}
|
|
395
|
+
const chosen = score(units[kept])
|
|
396
|
+
const other = score(units[kept] === first ? second : first)
|
|
397
|
+
if (chosen === null || other === null || chosen <= other) {
|
|
398
|
+
refuse('ambiguous-link-alignment', 'a deleted link cannot be told apart from a neighbouring link to the same note that the source spells differently', { unit: index })
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The final invariant, independent of how the alignment went: every rewrite
|
|
405
|
+
// of the published body is either substituted (intact) or gone (deleted), and
|
|
406
|
+
// what is left of the edited body, which becomes source bytes verbatim, holds
|
|
407
|
+
// no vault identity that the published authored text did not already hold.
|
|
408
|
+
// A vault identity is the `--<hex>` suffix of a note or attachment path of
|
|
409
|
+
// this view, wherever it appears: in a link, in code or in plain prose.
|
|
410
|
+
const IDENTITY = /--[0-9a-f]{12,64}(?![0-9a-f])/g
|
|
411
|
+
|
|
412
|
+
export function assertRewritesAccounted({ manifest, units, placements, publishedBody, editedBody }) {
|
|
413
|
+
if (placements.length !== units.length || placements.some((placement) => placement.state !== 'intact' && placement.state !== 'deleted')) {
|
|
414
|
+
refuse('unsupported-structural-edit', 'a rewritten link is neither kept nor deleted', { reason: 'rewrite-unaccounted' })
|
|
415
|
+
}
|
|
416
|
+
placements.forEach((placement, index) => {
|
|
417
|
+
if (placement.state === 'intact' && !editedBody.subarray(placement.eStart, placement.eEnd).equals(units[index].emitted)) {
|
|
418
|
+
refuse('unsupported-structural-edit', 'a rewritten link is not where it was placed', { reason: 'rewrite-unaccounted' })
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
const known = new Set()
|
|
422
|
+
for (const item of [...manifest.notes, ...manifest.attachments]) for (const match of item.path.matchAll(IDENTITY)) known.add(match[0])
|
|
423
|
+
const identities = (body, holes) => {
|
|
424
|
+
const found = []
|
|
425
|
+
let cursor = 0
|
|
426
|
+
for (const [start, end] of [...holes, [body.length, body.length]]) {
|
|
427
|
+
for (const match of body.toString('latin1', cursor, start).matchAll(IDENTITY)) if (known.has(match[0])) found.push({ identity: match[0], start: cursor + match.index, end: cursor + match.index + match[0].length })
|
|
428
|
+
cursor = end
|
|
429
|
+
}
|
|
430
|
+
return found
|
|
431
|
+
}
|
|
432
|
+
const allowance = new Map()
|
|
433
|
+
for (const item of identities(publishedBody, units.map((unit) => [unit.pStart, unit.pEnd]))) allowance.set(item.identity, (allowance.get(item.identity) ?? 0) + 1)
|
|
434
|
+
const surplus = []
|
|
435
|
+
for (const item of identities(editedBody, placements.filter((placement) => placement.state === 'intact').map((placement) => [placement.eStart, placement.eEnd]))) {
|
|
436
|
+
if ((allowance.get(item.identity) ?? 0) > 0) allowance.set(item.identity, allowance.get(item.identity) - 1)
|
|
437
|
+
else surplus.push({ bodyStart: item.start, bodyEnd: item.end })
|
|
438
|
+
}
|
|
439
|
+
return surplus
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const folded = (value) => value.normalize('NFC').toLowerCase()
|
|
443
|
+
|
|
444
|
+
// Every spelling under which the view's notes and attachments can be linked
|
|
445
|
+
// from inside the vault.
|
|
446
|
+
function vaultTargets(manifest) {
|
|
447
|
+
const targets = new Set()
|
|
448
|
+
const paths = [...manifest.notes.map((note) => note.path), ...manifest.attachments.map((attachment) => attachment.path)]
|
|
449
|
+
for (const filePath of paths) {
|
|
450
|
+
const base = filePath.split('/').at(-1)
|
|
451
|
+
for (const spelling of [filePath, base]) {
|
|
452
|
+
targets.add(folded(spelling))
|
|
453
|
+
if (spelling.endsWith('.md')) targets.add(folded(spelling.slice(0, -'.md'.length)))
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return targets
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function namesVaultFile(href, targets) {
|
|
460
|
+
const spellings = [href]
|
|
461
|
+
try { spellings.push(decodeURIComponent(href)) } catch { /* not percent-encoded */ }
|
|
462
|
+
return spellings.some((spelling) => {
|
|
463
|
+
const target = folded(spelling.replace(/^<|>$/g, '').replace(/^(?:\.\/)+/, '').trim())
|
|
464
|
+
return targets.has(target) || target.startsWith('notes/') || target.startsWith('attachments/')
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Links and embeds of `bytes` (a whole authored note, so front matter is
|
|
469
|
+
// skipped as the canonical scanner skips it) that name a vault file, with byte
|
|
470
|
+
// ranges relative to `bodyStart`. The canonical scanner is the only reader of
|
|
471
|
+
// link syntax.
|
|
472
|
+
function vaultLinkOccurrences(bytes, bodyStart, targets) {
|
|
473
|
+
const text = bytes.toString('utf8')
|
|
474
|
+
const found = scanMarkdownLinks(text).filter((occurrence) => namesVaultFile(occurrence.href, targets))
|
|
475
|
+
if (found.length === 0) return []
|
|
476
|
+
const offsets = [...new Set(found.flatMap((item) => [item.range.start, item.range.end, item.targetRange.start, item.targetRange.end]))].sort((a, b) => a - b)
|
|
477
|
+
const byteAt = new Map()
|
|
478
|
+
let previous = 0
|
|
479
|
+
let total = 0
|
|
480
|
+
for (const offset of offsets) {
|
|
481
|
+
total += Buffer.byteLength(text.slice(previous, offset), 'utf8')
|
|
482
|
+
byteAt.set(offset, total - bodyStart)
|
|
483
|
+
previous = offset
|
|
484
|
+
}
|
|
485
|
+
return found.map((item) => ({
|
|
486
|
+
start: byteAt.get(item.range.start), end: byteAt.get(item.range.end), targetStart: byteAt.get(item.targetRange.start), targetEnd: byteAt.get(item.targetRange.end),
|
|
487
|
+
}))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// A link or embed in the edited body that names a vault file must be one the
|
|
491
|
+
// emitter wrote and the person left alone. The one other thing it may be is a
|
|
492
|
+
// target the author's own source already contained, byte for byte and no more
|
|
493
|
+
// often (a source may link to a directory of its own called notes/). Anything else is a
|
|
494
|
+
// structural change: it is never written to a source as a vault-internal path.
|
|
495
|
+
//
|
|
496
|
+
// The reverse holds too. A rewritten target that was the whole target of a
|
|
497
|
+
// link in the published body must still be the whole target of a link, and an
|
|
498
|
+
// appended alias must still be what closes its link. Text typed against
|
|
499
|
+
// either one changes what the link points at once the rewrite is inverted.
|
|
500
|
+
export function findStructuralLinks({ manifest, notePrefix, publishedBody, editedBody, units, placements }) {
|
|
501
|
+
const targets = vaultTargets(manifest)
|
|
502
|
+
const within = (occurrence, start, end) => start >= occurrence.targetStart && end <= occurrence.targetEnd
|
|
503
|
+
const publishedLinks = vaultLinkOccurrences(Buffer.concat([notePrefix, publishedBody]), notePrefix.length, targets)
|
|
504
|
+
const editedLinks = vaultLinkOccurrences(Buffer.concat([notePrefix, editedBody]), notePrefix.length, targets)
|
|
505
|
+
const authoredAlready = new Map()
|
|
506
|
+
for (const occurrence of publishedLinks) {
|
|
507
|
+
if (units.some((unit) => within(occurrence, unit.pStart, unit.pEnd))) continue
|
|
508
|
+
const key = publishedBody.toString('latin1', occurrence.targetStart, occurrence.targetEnd)
|
|
509
|
+
authoredAlready.set(key, (authoredAlready.get(key) ?? 0) + 1)
|
|
510
|
+
}
|
|
511
|
+
const structural = []
|
|
512
|
+
for (const occurrence of editedLinks) {
|
|
513
|
+
if (placements.some((placement) => placement.state === 'intact' && within(occurrence, placement.eStart, placement.eEnd))) continue
|
|
514
|
+
const key = editedBody.toString('latin1', occurrence.targetStart, occurrence.targetEnd)
|
|
515
|
+
if ((authoredAlready.get(key) ?? 0) > 0) { authoredAlready.set(key, authoredAlready.get(key) - 1); continue }
|
|
516
|
+
structural.push({ bodyStart: occurrence.start, bodyEnd: occurrence.end })
|
|
517
|
+
}
|
|
518
|
+
units.forEach((unit, index) => {
|
|
519
|
+
const placement = placements[index]
|
|
520
|
+
if (placement.state !== 'intact') return
|
|
521
|
+
if (unit.aliasOf !== null) {
|
|
522
|
+
if (!editedBody.subarray(placement.eEnd, placement.eEnd + 2).equals(LINK_CLOSE)) structural.push({ bodyStart: placement.eStart, bodyEnd: placement.eEnd, alias: true })
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
const wasWholeTarget = publishedLinks.some((occurrence) => occurrence.targetStart === unit.pStart && occurrence.targetEnd === unit.pEnd)
|
|
526
|
+
const isWholeTarget = editedLinks.some((occurrence) => occurrence.targetStart === placement.eStart && occurrence.targetEnd === placement.eEnd)
|
|
527
|
+
if (wasWholeTarget && !isWholeTarget) structural.push({ bodyStart: placement.eStart, bodyEnd: placement.eEnd })
|
|
528
|
+
})
|
|
529
|
+
return structural.sort((left, right) => left.bodyStart - right.bodyStart)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// The lens
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
export const EDIT_LENS_PRIMITIVES = Object.freeze({
|
|
537
|
+
splitGeneratedTail,
|
|
538
|
+
splitPrefix,
|
|
539
|
+
placeUnits,
|
|
540
|
+
findStructuralLinks,
|
|
541
|
+
assertRewritesAccounted,
|
|
542
|
+
originalOf: (unit) => unit.original,
|
|
543
|
+
digestOf: sha256Digest,
|
|
544
|
+
isStrictUtf8,
|
|
545
|
+
isEditableSource: (source) => source?.kind === 'markdown',
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
function lensWith(primitives, { manifest, repoId, nodeId, publishedNoteBytes, editedNoteBytes, baseSourceBytes }) {
|
|
549
|
+
for (const bytes of [publishedNoteBytes, editedNoteBytes, baseSourceBytes]) if (!Buffer.isBuffer(bytes)) throw new TypeError('the edit lens reads Buffers only')
|
|
550
|
+
const noteEntry = manifest.notes.find((note) => note.repoId === repoId && note.nodeId === nodeId)
|
|
551
|
+
if (!noteEntry) refuse('unknown-note', 'the manifest holds no note for this identity')
|
|
552
|
+
const source = noteEntry.ext?.[EXT]?.source
|
|
553
|
+
if (!primitives.isEditableSource(source)) refuse('unsupported-wrapper-edit', 'the note represents a source that is not Markdown; it has no editable body')
|
|
554
|
+
const digests = { publishedNoteDigest: primitives.digestOf(publishedNoteBytes), editedNoteDigest: primitives.digestOf(editedNoteBytes), baseSourceDigest: primitives.digestOf(baseSourceBytes) }
|
|
555
|
+
if (digests.publishedNoteDigest !== noteEntry.noteDigest) refuse('baseline-mismatch', 'the published note bytes are not the bytes the manifest recorded', { expected: noteEntry.noteDigest, actual: digests.publishedNoteDigest })
|
|
556
|
+
if (digests.baseSourceDigest !== source.rawDigest) refuse('stale-base', 'the source is no longer the source the note was generated from', { expected: source.rawDigest, actual: digests.baseSourceDigest })
|
|
557
|
+
|
|
558
|
+
const bodyStart = noteEntry.regions.body.start
|
|
559
|
+
const notePrefix = publishedNoteBytes.subarray(0, bodyStart)
|
|
560
|
+
const sourceLens = readMarkdownLens(baseSourceBytes)
|
|
561
|
+
if (sourceLens.body.start !== bodyStart || sourceLens.body.end !== baseSourceBytes.length || !baseSourceBytes.subarray(0, bodyStart).equals(notePrefix)) {
|
|
562
|
+
refuse('manifest-mismatch', 'the recorded body does not begin where the body of the base source begins')
|
|
563
|
+
}
|
|
564
|
+
const units = inversionUnits({ manifest, noteEntry, publishedNoteBytes, baseSourceBytes })
|
|
565
|
+
if (!primitives.isStrictUtf8(editedNoteBytes)) refuse('invalid-utf8', 'the edited note is not valid UTF-8', { editedNoteDigest: digests.editedNoteDigest })
|
|
566
|
+
|
|
567
|
+
const tail = primitives.splitGeneratedTail({ noteEntry, publishedNoteBytes, editedNoteBytes })
|
|
568
|
+
const editedBody = primitives.splitPrefix({ noteEntry, publishedNoteBytes, editedAuthored: tail.authored })
|
|
569
|
+
const publishedBody = publishedNoteBytes.subarray(bodyStart, noteEntry.regions.body.end)
|
|
570
|
+
const placements = primitives.placeUnits({ units, publishedBody, editedBody })
|
|
571
|
+
assertCoherentPlacements({ units, placements, publishedBody, editedBody })
|
|
572
|
+
// Offsets in a refusal are offsets into the edited note.
|
|
573
|
+
const shift = tail.authored.length - editedBody.length
|
|
574
|
+
const structural = primitives.findStructuralLinks({ manifest, notePrefix: noteEntry.regions.frontmatter ? notePrefix : Buffer.alloc(0), publishedBody, editedBody, units, placements })
|
|
575
|
+
const aliasTouched = structural.find((item) => item.alias)
|
|
576
|
+
if (aliasTouched) {
|
|
577
|
+
refuse('link-rewrite-edited', 'text was added to the alias the emitter appended to a link', { noteStart: aliasTouched.bodyStart + shift, noteEnd: aliasTouched.bodyEnd + shift })
|
|
578
|
+
}
|
|
579
|
+
if (structural.length > 0) {
|
|
580
|
+
refuse('unsupported-structural-edit', 'the edit adds or changes a link to a file of the vault', {
|
|
581
|
+
reason: 'vault-link-changed', occurrences: structural.map((item) => ({ noteStart: item.bodyStart + shift, noteEnd: item.bodyEnd + shift })),
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const unaccounted = primitives.assertRewritesAccounted({ manifest, units, placements, publishedBody, editedBody }) ?? []
|
|
586
|
+
if (unaccounted.length > 0) {
|
|
587
|
+
refuse('unsupported-structural-edit', 'the edited body names a file of the vault outside the links the emitter rewrote', {
|
|
588
|
+
reason: 'vault-identity-in-authored-text', occurrences: unaccounted.map((item) => ({ noteStart: item.bodyStart + shift, noteEnd: item.bodyEnd + shift })),
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Between two surviving rewrites the edited bytes ARE the new source bytes.
|
|
593
|
+
const parts = [baseSourceBytes.subarray(0, bodyStart)]
|
|
594
|
+
const changedRanges = []
|
|
595
|
+
let written = bodyStart
|
|
596
|
+
let sourceCursor = bodyStart
|
|
597
|
+
let editedCursor = 0
|
|
598
|
+
const segment = (editedTo, sourceTo) => {
|
|
599
|
+
const next = editedBody.subarray(editedCursor, editedTo)
|
|
600
|
+
const base = baseSourceBytes.subarray(sourceCursor, sourceTo)
|
|
601
|
+
if (!next.equals(base)) {
|
|
602
|
+
const head = commonPrefixLength(next, base)
|
|
603
|
+
const end = commonSuffixLength(next, base, next.length, base.length, Math.min(next.length, base.length) - head)
|
|
604
|
+
changedRanges.push({ start: sourceCursor + head, end: sourceTo - end, newStart: written + head, newEnd: written + next.length - end })
|
|
605
|
+
}
|
|
606
|
+
parts.push(next)
|
|
607
|
+
written += next.length
|
|
608
|
+
}
|
|
609
|
+
units.forEach((unit, index) => {
|
|
610
|
+
const placement = placements[index]
|
|
611
|
+
if (placement.state !== 'intact') return
|
|
612
|
+
segment(placement.eStart, unit.sourceStart)
|
|
613
|
+
const original = primitives.originalOf(unit)
|
|
614
|
+
parts.push(original)
|
|
615
|
+
written += original.length
|
|
616
|
+
sourceCursor = unit.sourceEnd
|
|
617
|
+
editedCursor = placement.eEnd
|
|
618
|
+
})
|
|
619
|
+
segment(editedBody.length, baseSourceBytes.length)
|
|
620
|
+
const newSourceBytes = Buffer.concat(parts)
|
|
621
|
+
return {
|
|
622
|
+
kind: 'body-replacement',
|
|
623
|
+
newSourceBytes,
|
|
624
|
+
baseSourceDigest: digests.baseSourceDigest,
|
|
625
|
+
newSourceDigest: sha256Digest(newSourceBytes),
|
|
626
|
+
changedRanges,
|
|
627
|
+
unchanged: changedRanges.length === 0,
|
|
628
|
+
generated: tail.generated,
|
|
629
|
+
publishedNoteDigest: digests.publishedNoteDigest,
|
|
630
|
+
editedNoteDigest: digests.editedNoteDigest,
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// `applyEditLens` returns the body replacement or { kind: 'refusal', code,
|
|
635
|
+
// detail }. It never throws for something a person did to a note; it throws a
|
|
636
|
+
// TypeError for a caller that does not pass Buffers.
|
|
637
|
+
export function createEditLensForOracleTests(primitives = EDIT_LENS_PRIMITIVES) {
|
|
638
|
+
return function applyEditLens(input) {
|
|
639
|
+
try {
|
|
640
|
+
return lensWith(primitives, input)
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (!(error instanceof ObsidianContractRefusal)) throw error
|
|
643
|
+
return { kind: 'refusal', code: error.code, detail: error.detail ?? {} }
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export const applyEditLens = createEditLensForOracleTests()
|