@mnstry/atelier 0.2.0-alpha.5 → 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 +191 -0
- package/README.md +86 -25
- 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-repository-observation.v1.schema.json +163 -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 +12 -2
- package/docs/atelier-runtime.md +13 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/claims.md +28 -18
- package/docs/blocks/will-not-do.md +12 -3
- 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 +99 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +70 -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-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -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 +61 -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/content-rules.mjs +9 -4
- package/src/boundary/policy.mjs +74 -42
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +61 -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 +107 -0
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +35 -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/index.mjs +29 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +50 -49
- 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/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -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/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- 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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import fs from 'node:fs'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import { VALID_AUDIENCES } from '../projection/policy.mjs'
|
|
@@ -586,7 +587,9 @@ export function generatedFrontmatter({ id, repoName, rel, title, summary, domain
|
|
|
586
587
|
].join('\n')
|
|
587
588
|
}
|
|
588
589
|
|
|
589
|
-
|
|
590
|
+
// `sourceText`, when given, is the Markdown source already read as UTF-8 text,
|
|
591
|
+
// so a caller that holds the bytes does not read the file again.
|
|
592
|
+
export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig, sourceText = null) {
|
|
590
593
|
const inferredLifecycle = inferLifecycle(file.rel)
|
|
591
594
|
const inferredDomain = inferDomain(repoName, file.rel)
|
|
592
595
|
const inferredStatus = inferStatus(inferredLifecycle, file.rel)
|
|
@@ -601,7 +604,7 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
601
604
|
let atelier = { section: coverage.sections.get(file.rel) || null, status: null, kind: null }
|
|
602
605
|
|
|
603
606
|
if (file.ext === '.md') {
|
|
604
|
-
const raw = fs.readFileSync(file.abs, 'utf8')
|
|
607
|
+
const raw = typeof sourceText === 'string' ? sourceText : fs.readFileSync(file.abs, 'utf8')
|
|
605
608
|
const frontmatter = markdownFrontmatterState(raw)
|
|
606
609
|
metadata = frontmatter.metadata
|
|
607
610
|
if (frontmatter.kind !== 'valid' || !isPlainObject(metadata.kg)) {
|
|
@@ -693,27 +696,461 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
693
696
|
}
|
|
694
697
|
}
|
|
695
698
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
// Ordinary links: Markdown links and wikilinks
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
// Link findings are reported beside the graph, never inside a graph artifact,
|
|
704
|
+
// so committed graphs stay free of offsets and unresolved-link noise.
|
|
705
|
+
export const LINK_DIAGNOSTIC_CODES = Object.freeze([
|
|
706
|
+
'link-target-unresolved',
|
|
707
|
+
'link-target-outside-enrolled-roots',
|
|
708
|
+
'link-target-ambiguous',
|
|
709
|
+
'link-href-malformed',
|
|
710
|
+
'link-source-not-utf8',
|
|
711
|
+
])
|
|
712
|
+
|
|
713
|
+
const MARKDOWN_LINK_RE = /\[[^\]]+\]\(([^)\s#]+)(#[^)]+)?\)/g
|
|
714
|
+
const WIKILINK_RE = /\[\[([^[\]|#^]+)([#^][^[\]|]*)?(?:\|[^[\]]*)?\]\]/g
|
|
715
|
+
|
|
716
|
+
function frontMatterEnd(text) {
|
|
717
|
+
if (!/^---\r?\n/.test(text)) return 0
|
|
718
|
+
const closeRe = /\r?\n---[ \t]*\r?\n/g
|
|
719
|
+
closeRe.lastIndex = 3
|
|
720
|
+
const close = closeRe.exec(text)
|
|
721
|
+
return close ? close.index + close[0].length : 0
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Fenced code from `bodyStart` on: the closed blocks, and the fence still open
|
|
725
|
+
// when the text ends, if any. The one statement of the fence rules, shared by
|
|
726
|
+
// the link scanner and by `unclosedFenceAtEnd`.
|
|
727
|
+
function fencedBlocks(text, bodyStart) {
|
|
728
|
+
const ranges = []
|
|
729
|
+
let fence = null
|
|
730
|
+
let offset = bodyStart
|
|
731
|
+
while (offset < text.length) {
|
|
732
|
+
const eol = text.indexOf('\n', offset)
|
|
733
|
+
const next = eol === -1 ? text.length : eol + 1
|
|
734
|
+
const line = text.slice(offset, eol === -1 ? text.length : eol).replace(/\r$/, '')
|
|
735
|
+
if (fence) {
|
|
736
|
+
const close = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/)
|
|
737
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
|
|
738
|
+
ranges.push([fence.start, next])
|
|
739
|
+
fence = null
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
const open = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/)
|
|
743
|
+
if (open && !(open[2][0] === '`' && open[3].includes('`'))) {
|
|
744
|
+
fence = { char: open[2][0], length: open[2].length, indent: open[1].length, start: offset }
|
|
745
|
+
}
|
|
714
746
|
}
|
|
747
|
+
offset = next
|
|
715
748
|
}
|
|
716
|
-
return
|
|
749
|
+
return { ranges, open: fence }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// The fence a Markdown text ends inside, as the link scanner reads it:
|
|
753
|
+
// { char, length, indent }, or null when every fence closes. Front matter is
|
|
754
|
+
// never read for fences. Anything appended after such a text is code until a
|
|
755
|
+
// closing fence of the same character and at least the same length.
|
|
756
|
+
export function unclosedFenceAtEnd(raw) {
|
|
757
|
+
const text = String(raw ?? '')
|
|
758
|
+
const { open } = fencedBlocks(text, frontMatterEnd(text))
|
|
759
|
+
return open ? { char: open.char, length: open.length, indent: open.indent } : null
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Regions whose text is never read as a link: front matter (known or unknown
|
|
763
|
+
// YAML alike), fenced code and inline code. Offsets index the text as read,
|
|
764
|
+
// with no newline normalization.
|
|
765
|
+
function unscannedRanges(text) {
|
|
766
|
+
const bodyStart = frontMatterEnd(text)
|
|
767
|
+
const fenced = fencedBlocks(text, bodyStart)
|
|
768
|
+
const ranges = bodyStart > 0 ? [[0, bodyStart], ...fenced.ranges] : fenced.ranges
|
|
769
|
+
if (fenced.open) ranges.push([fenced.open.start, text.length])
|
|
770
|
+
|
|
771
|
+
// Inline code between the block ranges. A span closes on a backtick run of
|
|
772
|
+
// the same length inside the same paragraph; an unclosed run is plain text.
|
|
773
|
+
const blocks = [...ranges].sort((a, b) => a[0] - b[0])
|
|
774
|
+
let segmentStart = bodyStart
|
|
775
|
+
for (const [blockStart, blockEnd] of [...blocks.filter(([start]) => start >= bodyStart), [text.length, text.length]]) {
|
|
776
|
+
inlineCodeRanges(text, segmentStart, blockStart, ranges)
|
|
777
|
+
segmentStart = Math.max(segmentStart, blockEnd)
|
|
778
|
+
}
|
|
779
|
+
return ranges.sort((a, b) => a[0] - b[0])
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function inlineCodeRanges(text, from, to, ranges) {
|
|
783
|
+
const runRe = /`+/g
|
|
784
|
+
runRe.lastIndex = from
|
|
785
|
+
let open
|
|
786
|
+
while ((open = runRe.exec(text)) && open.index < to) {
|
|
787
|
+
const blankRe = /\n[ \t]*\r?\n/g
|
|
788
|
+
blankRe.lastIndex = open.index
|
|
789
|
+
const blank = blankRe.exec(text)
|
|
790
|
+
const paragraphEnd = Math.min(to, blank ? blank.index : text.length)
|
|
791
|
+
const closeRe = /`+/g
|
|
792
|
+
closeRe.lastIndex = open.index + open[0].length
|
|
793
|
+
let close
|
|
794
|
+
let closed = false
|
|
795
|
+
while ((close = closeRe.exec(text)) && close.index < paragraphEnd) {
|
|
796
|
+
if (close[0].length !== open[0].length) continue
|
|
797
|
+
ranges.push([open.index, close.index + close[0].length])
|
|
798
|
+
runRe.lastIndex = close.index + close[0].length
|
|
799
|
+
closed = true
|
|
800
|
+
break
|
|
801
|
+
}
|
|
802
|
+
if (!closed) runRe.lastIndex = open.index + open[0].length
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Every ordinary link occurrence in one Markdown text, in source order.
|
|
807
|
+
// `range` covers the whole construct and `targetRange` the written target, as
|
|
808
|
+
// half-open UTF-16 offsets into the text exactly as read; byte offsets are
|
|
809
|
+
// added by the resolver once the source is known to round-trip as UTF-8.
|
|
810
|
+
export function scanMarkdownLinks(raw) {
|
|
811
|
+
const text = String(raw ?? '')
|
|
812
|
+
const skipped = unscannedRanges(text)
|
|
813
|
+
const overlaps = (ranges, start, end) => ranges.some(([a, b]) => start < b && end > a)
|
|
814
|
+
// A construct is unscanned when it begins inside such a region. Inline code
|
|
815
|
+
// inside a link's own label ([`name`](target)) is an ordinary link.
|
|
816
|
+
const beginsInside = (ranges, start) => ranges.some(([a, b]) => start >= a && start < b)
|
|
817
|
+
const found = []
|
|
818
|
+
const wikiRanges = []
|
|
819
|
+
|
|
820
|
+
for (const m of text.matchAll(WIKILINK_RE)) {
|
|
821
|
+
const start = m.index
|
|
822
|
+
const end = start + m[0].length
|
|
823
|
+
if (beginsInside(skipped, start)) continue
|
|
824
|
+
wikiRanges.push([start, end])
|
|
825
|
+
found.push({
|
|
826
|
+
syntax: 'wikilink',
|
|
827
|
+
embed: text[start - 1] === '!',
|
|
828
|
+
href: m[1].trim(),
|
|
829
|
+
fragment: m[2] ?? '',
|
|
830
|
+
range: { start, end },
|
|
831
|
+
targetRange: { start: start + 2, end: start + 2 + m[1].length },
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
for (const m of text.matchAll(MARKDOWN_LINK_RE)) {
|
|
836
|
+
const start = m.index
|
|
837
|
+
const end = start + m[0].length
|
|
838
|
+
if (beginsInside(skipped, start) || overlaps(wikiRanges, start, end)) continue
|
|
839
|
+
const targetStart = start + m[0].indexOf('](') + 2
|
|
840
|
+
found.push({
|
|
841
|
+
syntax: 'markdown',
|
|
842
|
+
embed: text[start - 1] === '!',
|
|
843
|
+
href: m[1],
|
|
844
|
+
fragment: m[2] ?? '',
|
|
845
|
+
range: { start, end },
|
|
846
|
+
targetRange: { start: targetStart, end: targetStart + m[1].length },
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return found.sort((a, b) => a.range.start - b.range.start)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function addByteOffsets(text, occurrences) {
|
|
854
|
+
const offsets = [...new Set(occurrences.flatMap((item) => [item.range.start, item.range.end, item.targetRange.start, item.targetRange.end]))].sort(
|
|
855
|
+
(a, b) => a - b,
|
|
856
|
+
)
|
|
857
|
+
const bytes = new Map()
|
|
858
|
+
let previous = 0
|
|
859
|
+
let total = 0
|
|
860
|
+
for (const offset of offsets) {
|
|
861
|
+
total += Buffer.byteLength(text.slice(previous, offset), 'utf8')
|
|
862
|
+
bytes.set(offset, total)
|
|
863
|
+
previous = offset
|
|
864
|
+
}
|
|
865
|
+
for (const item of occurrences) {
|
|
866
|
+
for (const range of [item.range, item.targetRange]) {
|
|
867
|
+
range.byteStart = bytes.get(range.start)
|
|
868
|
+
range.byteEnd = bytes.get(range.end)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// `pathOf` and `repoNameOf` come from the census keys, so a caller's node
|
|
874
|
+
// needs only an id: markdownLinkEdges has always accepted such nodes.
|
|
875
|
+
function wikilinkCandidates(target, nodes, pathOf, repoNameOf) {
|
|
876
|
+
if (target.includes('/')) {
|
|
877
|
+
const wanted = new Set([target, `${target}.md`])
|
|
878
|
+
return { by: 'path', nodes: nodes.filter((node) => wanted.has(pathOf.get(node)) || wanted.has(`${repoNameOf.get(node)}/${pathOf.get(node)}`)) }
|
|
879
|
+
}
|
|
880
|
+
const byTitle = nodes.filter((node) => node.title !== undefined && node.title === target)
|
|
881
|
+
const byName = nodes.filter((node) => {
|
|
882
|
+
const base = path.posix.basename(pathOf.get(node))
|
|
883
|
+
return base === target || base.replace(/\.[^.]+$/, '') === target
|
|
884
|
+
})
|
|
885
|
+
const merged = [...new Map([...byTitle, ...byName].map((node) => [node.id, node])).values()]
|
|
886
|
+
return { by: byTitle.length ? 'title' : 'basename', nodes: merged }
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Embedded assets: files an embed names that are not documents of the census.
|
|
890
|
+
// An asset is a regular file inside an enrolled repository root: never a link
|
|
891
|
+
// on disk, never reached through one, never git-ignored, never inside `.git`,
|
|
892
|
+
// never Markdown and never a census node.
|
|
893
|
+
const assetId = (repoName, rel) => `${repoName}:asset:${rel}`
|
|
894
|
+
const insideGitDirectory = (rel) => rel.split('/').includes('.git')
|
|
895
|
+
const isMarkdownPath = (rel) => rel.toLowerCase().endsWith('.md')
|
|
896
|
+
|
|
897
|
+
// `gitIgnoreFilter` lists a fully ignored directory once, so every ancestor of
|
|
898
|
+
// a path is tested, not the path alone.
|
|
899
|
+
function ignoredAtAnyDepth(isIgnored, rel) {
|
|
900
|
+
const parts = rel.split('/')
|
|
901
|
+
return parts.some((_, index) => isIgnored(parts.slice(0, index + 1).join('/')))
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// True only for a regular file whose real location is exactly `rel` under the
|
|
905
|
+
// real repository root: a link anywhere on the way, or a name that matches
|
|
906
|
+
// only because the filesystem folds case, is not this file.
|
|
907
|
+
function isRegularFileInside(root, rel) {
|
|
908
|
+
try {
|
|
909
|
+
const abs = path.join(root, rel)
|
|
910
|
+
if (!fs.lstatSync(abs).isFile()) return false
|
|
911
|
+
return relPath(fs.realpathSync.native(root), fs.realpathSync.native(abs)) === rel
|
|
912
|
+
} catch {
|
|
913
|
+
return false
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// The one resolver for ordinary links across every enrolled repository.
|
|
918
|
+
// `repos` is [{ name, root, nodesByPath, isIgnored? }]. A target that is
|
|
919
|
+
// absent, outside the census or refused by `isLinkTargetEligible` is reported
|
|
920
|
+
// identically and never inspected, so a finding cannot confirm that a withheld
|
|
921
|
+
// target exists. `isAssetEligible({ repo, path })` gives embedded assets the
|
|
922
|
+
// same rule: a refused asset reads exactly as an absent one.
|
|
923
|
+
// The link scan of one Markdown source: its occurrences, with byte offsets
|
|
924
|
+
// when the bytes round-trip as UTF-8. A pure function of the bytes, which is
|
|
925
|
+
// what lets a builder keep it under the source's content digest.
|
|
926
|
+
export function scanMarkdownSource(buffer) {
|
|
927
|
+
const text = buffer.toString('utf8')
|
|
928
|
+
const occurrences = scanMarkdownLinks(text)
|
|
929
|
+
const utf8Exact = Buffer.from(text, 'utf8').equals(buffer)
|
|
930
|
+
if (utf8Exact) addByteOffsets(text, occurrences)
|
|
931
|
+
return { occurrences, utf8Exact }
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// `scanned` maps a census node to the scan of its source (scanMarkdownSource)
|
|
935
|
+
// when the caller already holds it; any other Markdown node is read here.
|
|
936
|
+
export function resolveWorkspaceLinks({ repos = [], isLinkTargetEligible = () => true, isAssetEligible = () => true, scanned = null } = {}) {
|
|
937
|
+
const links = []
|
|
938
|
+
const embeds = []
|
|
939
|
+
const diagnostics = []
|
|
940
|
+
const enrolled = repos.map((repo) => ({ ...repo, root: path.resolve(repo.root) }))
|
|
941
|
+
const ownerOf = new Map(enrolled.flatMap((repo) => [...repo.nodesByPath.values()].map((node) => [node, repo])))
|
|
942
|
+
const eligibleNodes = [...ownerOf.keys()].filter((node) => isLinkTargetEligible(node))
|
|
943
|
+
const pathOf = new Map(enrolled.flatMap((repo) => [...repo.nodesByPath].map(([rel, node]) => [node, rel])))
|
|
944
|
+
const repoNameOf = new Map([...ownerOf].map(([node, repo]) => [node, repo.name]))
|
|
945
|
+
|
|
946
|
+
const owningRepo = (abs) =>
|
|
947
|
+
enrolled
|
|
948
|
+
.filter((repo) => abs === repo.root || abs.startsWith(`${repo.root}${path.sep}`))
|
|
949
|
+
.sort((a, b) => b.root.length - a.root.length)[0] ?? null
|
|
950
|
+
|
|
951
|
+
// One ignore lookup per repository, made only when an embed needs it.
|
|
952
|
+
const ignoreFilters = new Map()
|
|
953
|
+
const ignoredIn = (owner) => {
|
|
954
|
+
if (!ignoreFilters.has(owner)) ignoreFilters.set(owner, owner.isIgnored ?? gitIgnoreFilter(owner.root))
|
|
955
|
+
return ignoreFilters.get(owner)
|
|
956
|
+
}
|
|
957
|
+
// Eligibility is asked before the file is looked at, so a refused asset and
|
|
958
|
+
// an absent one take the same path to the same finding.
|
|
959
|
+
const assetAt = (owner, rel) => {
|
|
960
|
+
if (!rel || rel.startsWith('../') || rel === '..' || path.posix.isAbsolute(rel)) return null
|
|
961
|
+
if (owner.nodesByPath.has(rel) || isMarkdownPath(rel) || insideGitDirectory(rel)) return null
|
|
962
|
+
if (isAssetEligible({ repo: owner.name, path: rel }) !== true) return null
|
|
963
|
+
if (ignoredAtAnyDepth(ignoredIn(owner), rel) || !isRegularFileInside(owner.root, rel)) return null
|
|
964
|
+
return { id: assetId(owner.name, rel), repo: owner.name, path: rel, extension: path.posix.extname(rel).slice(1).toLowerCase() }
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Basename index over every enrolled repository: built once, and only when a
|
|
968
|
+
// bare wikilink embed asks for it. It holds names only; eligibility is tested
|
|
969
|
+
// per candidate when a name is looked up.
|
|
970
|
+
let basenameIndex = null
|
|
971
|
+
const assetsNamed = (name) => {
|
|
972
|
+
if (!basenameIndex) {
|
|
973
|
+
basenameIndex = new Map()
|
|
974
|
+
const roots = new Set(enrolled.map((item) => item.root))
|
|
975
|
+
for (const owner of enrolled) {
|
|
976
|
+
const isIgnored = ignoredIn(owner)
|
|
977
|
+
const visit = (dir) => {
|
|
978
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
979
|
+
if (ent.name === '.git') continue
|
|
980
|
+
const abs = path.join(dir, ent.name)
|
|
981
|
+
const rel = relPath(owner.root, abs)
|
|
982
|
+
if (isIgnored(rel)) continue
|
|
983
|
+
// A nested enrolled repository owns its own files.
|
|
984
|
+
if (ent.isDirectory()) {
|
|
985
|
+
if (!roots.has(abs)) visit(abs)
|
|
986
|
+
} else if (ent.isFile() && !isMarkdownPath(rel) && !owner.nodesByPath.has(rel)) {
|
|
987
|
+
if (!basenameIndex.has(ent.name)) basenameIndex.set(ent.name, [])
|
|
988
|
+
basenameIndex.get(ent.name).push({ owner, rel })
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
visit(owner.root)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return (basenameIndex.get(name) ?? []).map(({ owner, rel }) => assetAt(owner, rel)).filter(Boolean)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// A wikilink embed names an asset by repository-relative path (the source's
|
|
999
|
+
// own repository first, then `<repository>/<path>`), or by bare file name.
|
|
1000
|
+
const wikilinkAssets = (repo, target) => {
|
|
1001
|
+
if (!target.includes('/')) return { by: 'basename', assets: assetsNamed(target) }
|
|
1002
|
+
const wanted = path.posix.normalize(target)
|
|
1003
|
+
const candidates = [
|
|
1004
|
+
assetAt(repo, wanted),
|
|
1005
|
+
...enrolled.filter((owner) => wanted.startsWith(`${owner.name}/`)).map((owner) => assetAt(owner, wanted.slice(owner.name.length + 1))),
|
|
1006
|
+
].filter(Boolean)
|
|
1007
|
+
return { by: 'path', assets: candidates.slice(0, 1) }
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
for (const repo of enrolled) {
|
|
1011
|
+
for (const [rel, node] of repo.nodesByPath) {
|
|
1012
|
+
if (!rel.endsWith('.md') || !isLinkTargetEligible(node)) continue
|
|
1013
|
+
const { occurrences, utf8Exact } = scanned?.get(node) ?? scanMarkdownSource(fs.readFileSync(path.join(repo.root, rel)))
|
|
1014
|
+
const finding = (code, occurrence, detail, extra = {}) =>
|
|
1015
|
+
diagnostics.push({
|
|
1016
|
+
severity: 'warning',
|
|
1017
|
+
type: code,
|
|
1018
|
+
code,
|
|
1019
|
+
node: node.id,
|
|
1020
|
+
repo: repo.name,
|
|
1021
|
+
path: rel,
|
|
1022
|
+
...(occurrence
|
|
1023
|
+
? { syntax: occurrence.syntax, href: portableText(occurrence.href), range: occurrence.range, targetRange: occurrence.targetRange }
|
|
1024
|
+
: {}),
|
|
1025
|
+
...extra,
|
|
1026
|
+
message: `${repo.name}/${rel}: ${detail}`,
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
if (!utf8Exact && occurrences.length) finding('link-source-not-utf8', null, 'source is not valid UTF-8; link byte offsets are withheld')
|
|
1030
|
+
|
|
1031
|
+
for (const occurrence of occurrences) {
|
|
1032
|
+
const shown = JSON.stringify(portableText(occurrence.href))
|
|
1033
|
+
let targetNode = null
|
|
1034
|
+
let asset = null
|
|
1035
|
+
let resolvedBy = 'path'
|
|
1036
|
+
|
|
1037
|
+
if (occurrence.syntax === 'wikilink') {
|
|
1038
|
+
const candidates = wikilinkCandidates(occurrence.href, eligibleNodes, pathOf, repoNameOf)
|
|
1039
|
+
if (candidates.nodes.length > 1) {
|
|
1040
|
+
finding('link-target-ambiguous', occurrence, `wikilink ${shown} matches ${candidates.nodes.length} documents; refusing to choose`, {
|
|
1041
|
+
candidates: candidates.nodes.map((item) => item.id).sort(),
|
|
1042
|
+
})
|
|
1043
|
+
continue
|
|
1044
|
+
}
|
|
1045
|
+
targetNode = candidates.nodes[0] ?? null
|
|
1046
|
+
resolvedBy = candidates.by
|
|
1047
|
+
if (!targetNode && occurrence.embed) {
|
|
1048
|
+
const found = wikilinkAssets(repo, occurrence.href)
|
|
1049
|
+
if (found.assets.length > 1) {
|
|
1050
|
+
finding('link-target-ambiguous', occurrence, `embed ${shown} matches ${found.assets.length} files; refusing to choose`, {
|
|
1051
|
+
candidates: found.assets.map((item) => item.id).sort(),
|
|
1052
|
+
})
|
|
1053
|
+
continue
|
|
1054
|
+
}
|
|
1055
|
+
asset = found.assets[0] ?? null
|
|
1056
|
+
resolvedBy = found.by
|
|
1057
|
+
}
|
|
1058
|
+
} else {
|
|
1059
|
+
const href = occurrence.href
|
|
1060
|
+
if (/^[a-z]+:/i.test(href) || href.startsWith('#')) continue
|
|
1061
|
+
let decoded
|
|
1062
|
+
try {
|
|
1063
|
+
decoded = decodeURIComponent(href)
|
|
1064
|
+
} catch {
|
|
1065
|
+
finding('link-href-malformed', occurrence, `link ${shown} is not valid percent-encoding`)
|
|
1066
|
+
continue
|
|
1067
|
+
}
|
|
1068
|
+
const abs = path.join(repo.root, path.dirname(rel), decoded)
|
|
1069
|
+
const owner = owningRepo(abs)
|
|
1070
|
+
if (!owner) {
|
|
1071
|
+
finding('link-target-outside-enrolled-roots', occurrence, `link ${shown} leaves every enrolled repository`)
|
|
1072
|
+
continue
|
|
1073
|
+
}
|
|
1074
|
+
// A link that climbs above its own repository and comes back in
|
|
1075
|
+
// through the checkout's directory name depends on where the
|
|
1076
|
+
// repository happens to be checked out. It is not a repository-local
|
|
1077
|
+
// link and never was one; only a link into ANOTHER enrolled
|
|
1078
|
+
// repository may leave the root.
|
|
1079
|
+
const lexical = path.posix.normalize(posixJoin(path.posix.dirname(rel), decoded.split(path.sep).join('/')))
|
|
1080
|
+
if (owner === repo && (lexical === '..' || lexical.startsWith('../'))) {
|
|
1081
|
+
finding('link-target-outside-enrolled-roots', occurrence, `link ${shown} leaves its repository and re-enters through the checkout path`)
|
|
1082
|
+
continue
|
|
1083
|
+
}
|
|
1084
|
+
const target = relPath(owner.root, abs)
|
|
1085
|
+
// Eligibility is tested per candidate, before choosing: a withheld
|
|
1086
|
+
// candidate is skipped exactly as an absent one, so it can never
|
|
1087
|
+
// shadow an eligible fallback and change the edge set.
|
|
1088
|
+
targetNode =
|
|
1089
|
+
[target, posixJoin(target, 'README.md'), posixJoin(target, 'index.md')]
|
|
1090
|
+
.map((candidate) => owner.nodesByPath.get(candidate))
|
|
1091
|
+
.find((candidate) => candidate && isLinkTargetEligible(candidate)) ?? null
|
|
1092
|
+
if (!targetNode && occurrence.embed) asset = assetAt(owner, target)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (asset) {
|
|
1096
|
+
embeds.push({
|
|
1097
|
+
source: node.id,
|
|
1098
|
+
type: 'embeds_asset',
|
|
1099
|
+
asset,
|
|
1100
|
+
syntax: occurrence.syntax,
|
|
1101
|
+
href: occurrence.href,
|
|
1102
|
+
fragment: occurrence.fragment,
|
|
1103
|
+
resolvedBy,
|
|
1104
|
+
crossRepository: asset.repo !== repo.name,
|
|
1105
|
+
sourceRepo: repo.name,
|
|
1106
|
+
sourcePath: rel,
|
|
1107
|
+
range: occurrence.range,
|
|
1108
|
+
targetRange: occurrence.targetRange,
|
|
1109
|
+
})
|
|
1110
|
+
continue
|
|
1111
|
+
}
|
|
1112
|
+
if (!targetNode) {
|
|
1113
|
+
finding('link-target-unresolved', occurrence, `link ${shown} does not resolve to an enrolled document`)
|
|
1114
|
+
continue
|
|
1115
|
+
}
|
|
1116
|
+
links.push({
|
|
1117
|
+
source: node.id,
|
|
1118
|
+
target: targetNode.id,
|
|
1119
|
+
type: 'links_to',
|
|
1120
|
+
syntax: occurrence.syntax,
|
|
1121
|
+
embed: occurrence.embed,
|
|
1122
|
+
href: occurrence.href,
|
|
1123
|
+
fragment: occurrence.fragment,
|
|
1124
|
+
resolvedBy,
|
|
1125
|
+
crossRepository: ownerOf.get(targetNode) !== repo,
|
|
1126
|
+
sourceRepo: repo.name,
|
|
1127
|
+
sourcePath: rel,
|
|
1128
|
+
targetRepo: ownerOf.get(targetNode).name,
|
|
1129
|
+
targetPath: targetNode.path,
|
|
1130
|
+
range: occurrence.range,
|
|
1131
|
+
targetRange: occurrence.targetRange,
|
|
1132
|
+
})
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const order = (item) => `${item.sourceRepo ?? item.repo}/${item.sourcePath ?? item.path}`
|
|
1138
|
+
const byPosition = (a, b) => order(a).localeCompare(order(b)) || (a.range?.start ?? -1) - (b.range?.start ?? -1)
|
|
1139
|
+
return { links: links.sort(byPosition), embeds: embeds.sort(byPosition), diagnostics: diagnostics.sort(byPosition) }
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// A repository's own artifact depends on that repository alone: path links
|
|
1143
|
+
// that stay inside it. Wikilinks and cross-repository links resolve against
|
|
1144
|
+
// the whole enrolment and belong to the workspace graph only.
|
|
1145
|
+
function repoLocalLink(link) {
|
|
1146
|
+
return !link.crossRepository && link.syntax === 'markdown'
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const linkEdge = (link) => ({ source: link.source, target: link.target, type: 'links_to' })
|
|
1150
|
+
|
|
1151
|
+
export function markdownLinkEdges(repoRoot, nodesByPath) {
|
|
1152
|
+
const { links } = resolveWorkspaceLinks({ repos: [{ name: path.basename(repoRoot), root: repoRoot, nodesByPath }] })
|
|
1153
|
+
return links.filter(repoLocalLink).map(linkEdge)
|
|
717
1154
|
}
|
|
718
1155
|
|
|
719
1156
|
export function declaredRelationEdges(nodes) {
|
|
@@ -854,6 +1291,57 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = [], { exte
|
|
|
854
1291
|
return errors
|
|
855
1292
|
}
|
|
856
1293
|
|
|
1294
|
+
// A per-file cache for repeated builds of one workspace: the census node and
|
|
1295
|
+
// the link scan of every Markdown source, kept under the sha256 of the bytes
|
|
1296
|
+
// they were derived from together with the per-file inputs that are not in
|
|
1297
|
+
// the bytes (coverage and the repository's read boundary). Both derivations
|
|
1298
|
+
// are pure functions of those inputs, so an entry is reused only when a build
|
|
1299
|
+
// with no cache would compute the same values; a file whose bytes or inputs
|
|
1300
|
+
// differ is parsed again, and a file no longer in the census leaves the cache.
|
|
1301
|
+
// Without `observedDigest` every source is read and hashed on every build: the
|
|
1302
|
+
// cache never trusts a digest it did not compute from the bytes. A caller that
|
|
1303
|
+
// already observes the sources by digest (the maintenance engine's index: stat
|
|
1304
|
+
// hint between full reconciliations, every file hashed on a full one) may pass
|
|
1305
|
+
// `observedDigest(repoName, rel)`, returning `sha256:<hex>` or null. An entry
|
|
1306
|
+
// whose digest equals the observed one is then reused without opening the
|
|
1307
|
+
// file; the tradeoff is exactly the observer's: bytes that change under an
|
|
1308
|
+
// unchanged stat hint are not seen until the observer hashes the file again,
|
|
1309
|
+
// and until then no view is rebuilt for them either. Any other file is read
|
|
1310
|
+
// and hashed here as always. Derived, droppable state.
|
|
1311
|
+
export function createGraphFileCache() {
|
|
1312
|
+
return { files: new Map() }
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function isGraphFileCache(cache) {
|
|
1316
|
+
return cache !== null && typeof cache === 'object' && cache.files instanceof Map
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
|
1320
|
+
|
|
1321
|
+
// The Markdown census entry for one file, from the cache under an equal digest
|
|
1322
|
+
// and equal inputs, or freshly derived. The returned node and scan are clones
|
|
1323
|
+
// so a caller's changes never reach the cache.
|
|
1324
|
+
function markdownCensus({ repoName, repoRoot, coverage, file, accessConfig, cache, next, observedDigest }) {
|
|
1325
|
+
const inputs = JSON.stringify([coverage.surfaced.has(file.rel), coverage.sections.get(file.rel) || null, repoReadBoundary(accessConfig, repoName)])
|
|
1326
|
+
const key = `${repoName}\u0000${file.rel}`
|
|
1327
|
+
const cached = cache?.files.get(key)
|
|
1328
|
+
const observed = cached && observedDigest ? observedDigest(repoName, file.rel) : null
|
|
1329
|
+
let entry
|
|
1330
|
+
let read = false
|
|
1331
|
+
if (cached && cached.inputs === inputs && typeof observed === 'string' && observed === `sha256:${cached.digest}`) {
|
|
1332
|
+
entry = cached
|
|
1333
|
+
} else {
|
|
1334
|
+
read = true
|
|
1335
|
+
const buffer = fs.readFileSync(file.abs)
|
|
1336
|
+
const digest = sha256Hex(buffer)
|
|
1337
|
+
entry = cached && cached.digest === digest && cached.inputs === inputs
|
|
1338
|
+
? cached
|
|
1339
|
+
: { digest, inputs, node: nodeForFile(repoName, repoRoot, coverage, file, accessConfig, buffer.toString('utf8')), scan: scanMarkdownSource(buffer) }
|
|
1340
|
+
}
|
|
1341
|
+
next?.set(key, entry)
|
|
1342
|
+
return { node: structuredClone(entry.node), scan: structuredClone(entry.scan), reused: entry === cached, read }
|
|
1343
|
+
}
|
|
1344
|
+
|
|
857
1345
|
export function buildKnowledgeGraph({
|
|
858
1346
|
workspaceRoot,
|
|
859
1347
|
repoAccessConfig,
|
|
@@ -863,8 +1351,14 @@ export function buildKnowledgeGraph({
|
|
|
863
1351
|
externalRepos = [],
|
|
864
1352
|
externalRelationPrefixes = [],
|
|
865
1353
|
externalRelationIds = [],
|
|
1354
|
+
isLinkTargetEligible = undefined,
|
|
1355
|
+
isAssetEligible = undefined,
|
|
1356
|
+
fileCache = null,
|
|
1357
|
+
observedDigest = null,
|
|
866
1358
|
} = {}) {
|
|
867
1359
|
if (!workspaceRoot) throw new Error('workspaceRoot is required')
|
|
1360
|
+
if (fileCache !== null && !isGraphFileCache(fileCache)) throw new Error('fileCache must come from createGraphFileCache')
|
|
1361
|
+
if (observedDigest !== null && typeof observedDigest !== 'function') throw new Error('observedDigest must be a function')
|
|
868
1362
|
const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
|
|
869
1363
|
const external = new Set(externalRepos)
|
|
870
1364
|
const discoveredEntries = repoEntries
|
|
@@ -896,6 +1390,10 @@ export function buildKnowledgeGraph({
|
|
|
896
1390
|
const workspaceOrphanSidecars = []
|
|
897
1391
|
const workspaceIgnoredSidecars = []
|
|
898
1392
|
const repoGraphs = []
|
|
1393
|
+
const census = []
|
|
1394
|
+
const scanned = new Map()
|
|
1395
|
+
const nextCache = fileCache ? new Map() : null
|
|
1396
|
+
const reuse = { reused: 0, derived: 0, read: 0 }
|
|
899
1397
|
|
|
900
1398
|
for (const entry of roots) {
|
|
901
1399
|
const repoRoot = entry.path
|
|
@@ -910,12 +1408,32 @@ export function buildKnowledgeGraph({
|
|
|
910
1408
|
const nodesByPath = new Map()
|
|
911
1409
|
|
|
912
1410
|
for (const file of files) {
|
|
913
|
-
|
|
1411
|
+
let node
|
|
1412
|
+
if (file.ext === '.md') {
|
|
1413
|
+
// One read per Markdown source: the census entry and the link scan come from the same bytes.
|
|
1414
|
+
const entry = markdownCensus({ repoName, repoRoot, coverage, file, accessConfig, cache: fileCache, next: nextCache, observedDigest })
|
|
1415
|
+
node = entry.node
|
|
1416
|
+
scanned.set(node, entry.scan)
|
|
1417
|
+
reuse[entry.reused ? 'reused' : 'derived'] += 1
|
|
1418
|
+
if (entry.read) reuse.read += 1
|
|
1419
|
+
} else {
|
|
1420
|
+
node = nodeForFile(repoName, repoRoot, coverage, file, accessConfig)
|
|
1421
|
+
}
|
|
914
1422
|
nodes.push(node)
|
|
915
1423
|
nodesByPath.set(file.rel, node)
|
|
916
1424
|
}
|
|
917
1425
|
|
|
918
|
-
|
|
1426
|
+
census.push({ name: repoName, root: repoRoot, nodes, nodesByPath, isIgnored })
|
|
1427
|
+
}
|
|
1428
|
+
// The cache holds exactly the Markdown sources of this census, each under the digest this build read.
|
|
1429
|
+
if (nextCache) fileCache.files = nextCache
|
|
1430
|
+
|
|
1431
|
+
// Links resolve once, against every enrolled repository together.
|
|
1432
|
+
const resolved = resolveWorkspaceLinks({ repos: census, scanned, ...(isLinkTargetEligible ? { isLinkTargetEligible } : {}), ...(isAssetEligible ? { isAssetEligible } : {}) })
|
|
1433
|
+
|
|
1434
|
+
for (const { name: repoName, root: repoRoot, nodes } of census) {
|
|
1435
|
+
const localLinks = resolved.links.filter((link) => link.sourceRepo === repoName && repoLocalLink(link))
|
|
1436
|
+
const edges = uniqueEdges([...localLinks.map(linkEdge), ...declaredRelationEdges(nodes)])
|
|
919
1437
|
const graph = {
|
|
920
1438
|
schema: KNOWLEDGE_GRAPH_SCHEMA,
|
|
921
1439
|
repo: repoName,
|
|
@@ -930,6 +1448,7 @@ export function buildKnowledgeGraph({
|
|
|
930
1448
|
workspaceNodes.push(...graph.nodes)
|
|
931
1449
|
workspaceEdges.push(...graph.edges)
|
|
932
1450
|
}
|
|
1451
|
+
workspaceEdges.push(...resolved.links.filter((link) => !repoLocalLink(link)).map(linkEdge))
|
|
933
1452
|
|
|
934
1453
|
const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars, {
|
|
935
1454
|
externalRelationPrefixes,
|
|
@@ -940,7 +1459,7 @@ export function buildKnowledgeGraph({
|
|
|
940
1459
|
workspace: path.basename(resolvedWorkspaceRoot),
|
|
941
1460
|
diagnostics: graphDiagnostics(workspaceNodes),
|
|
942
1461
|
nodes: workspaceNodes.sort((a, b) => `${a.repo}/${a.path}`.localeCompare(`${b.repo}/${b.path}`)),
|
|
943
|
-
edges: workspaceEdges.sort((a, b) => `${a.source}:${a.target}:${a.type}`.localeCompare(`${b.source}:${b.target}:${b.type}`)),
|
|
1462
|
+
edges: uniqueEdges(workspaceEdges).sort((a, b) => `${a.source}:${a.target}:${a.type}`.localeCompare(`${b.source}:${b.target}:${b.type}`)),
|
|
944
1463
|
}
|
|
945
1464
|
workspaceGraph.nodeCount = workspaceGraph.nodes.length
|
|
946
1465
|
workspaceGraph.edgeCount = workspaceGraph.edges.length
|
|
@@ -952,6 +1471,11 @@ export function buildKnowledgeGraph({
|
|
|
952
1471
|
workspaceGraph,
|
|
953
1472
|
orphanSidecars: workspaceOrphanSidecars,
|
|
954
1473
|
ignoredSidecars: workspaceIgnoredSidecars,
|
|
1474
|
+
resolvedLinks: resolved.links,
|
|
1475
|
+
resolvedEmbeds: resolved.embeds,
|
|
1476
|
+
linkDiagnostics: resolved.diagnostics,
|
|
1477
|
+
// How many Markdown census entries this build derived, how many it reused from the file cache, and how many sources it opened.
|
|
1478
|
+
fileCensus: reuse,
|
|
955
1479
|
}
|
|
956
1480
|
}
|
|
957
1481
|
|