@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.11
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 +413 -0
- package/README.md +402 -205
- 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/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +49 -0
- package/docs/atelier-runtime.md +28 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/audience-visibility.md +9 -0
- package/docs/blocks/claims.md +61 -0
- package/docs/blocks/conformance-admission.md +15 -0
- package/docs/blocks/will-not-do.md +18 -0
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/continuity.md +3 -4
- package/docs/decisions.md +167 -0
- package/docs/design.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 +99 -16
- 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/knowledge-graph.md +8 -4
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +328 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +698 -0
- package/docs/obsidian.md +484 -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 +174 -12
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +155 -5
- 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/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +86 -10
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-open/SKILL.md +11 -5
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -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-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-open/SKILL.md +11 -5
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -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 +283 -20
- package/src/boundary/policy.mjs +213 -91
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +78 -10
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +348 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +17 -9
- package/src/commands/init.mjs +46 -6
- package/src/commands/obsidian.mjs +371 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/server.mjs +1 -4
- 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 +206 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +176 -316
- package/src/graph/knowledge-graph.mjs +643 -43
- package/src/guides/contracts.mjs +54 -0
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +41 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +112 -52
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +180 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +120 -0
- 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 +212 -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 +138 -0
- package/src/runtime/obsidian/app-production-seams.mjs +107 -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 +280 -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-entry-path.mjs +9 -0
- package/src/runtime/obsidian/service-main.mjs +93 -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 +125 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +4 -2
- package/src/skills/steward.mjs +780 -0
- package/src/support/feedback-report.mjs +4 -3
- 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 +65 -29
- 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,7 +1,8 @@
|
|
|
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'
|
|
4
|
-
import { generatedProjectionBasenames } from '../project/file-class.mjs'
|
|
5
|
+
import { generatedProjectionBasenames, generatedProjectionDirectoryBasenames } from '../project/file-class.mjs'
|
|
5
6
|
import { gitIgnoreFilter } from '../project/git-ignore.mjs'
|
|
6
7
|
|
|
7
8
|
export const KNOWLEDGE_GRAPH_SCHEMA = 'mnstry.knowledge-graph@v1'
|
|
@@ -14,7 +15,7 @@ export const VALID_STATUSES = new Set(['active', 'draft', 'archived', 'template'
|
|
|
14
15
|
export const VALID_KG_TYPES = new Set(['document', 'artifact', 'evidence', 'source', 'index', 'contract', 'guide', 'runbook', 'policy', 'report', 'prototype', 'research', 'decision', 'map', 'manifest', 'html', 'pdf', 'docx'])
|
|
15
16
|
export const VALID_SIDECAR_KG_TYPES = new Set(['html', 'pdf', 'docx', 'artifact', 'evidence', 'source', 'prototype', 'research', 'report', 'manifest'])
|
|
16
17
|
|
|
17
|
-
const SKIP_DIRS = new Set(['.git', '.agents', '.claude', '.github', 'node_modules', 'output', 'uploads', 'scripts', 'lib'])
|
|
18
|
+
const SKIP_DIRS = new Set(['.git', '.agents', '.claude', '.github', 'node_modules', 'output', 'uploads', 'scripts', 'lib', ...generatedProjectionDirectoryBasenames()])
|
|
18
19
|
// Derived from the kit's file-class declaration, never restated here.
|
|
19
20
|
const GENERATED_FILES = generatedProjectionBasenames()
|
|
20
21
|
const PRIVATE_AUDIENCES = new Set(['private', 'sensitive'])
|
|
@@ -100,7 +101,7 @@ function uniqueStringArrayErrors(value, label) {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
export function splitFrontmatter(raw) {
|
|
103
|
-
const normalized = raw.replace(/\r\n
|
|
104
|
+
const normalized = String(raw).replace(/\r\n?/g, '\n')
|
|
104
105
|
if (!normalized.startsWith('---\n')) return null
|
|
105
106
|
const rest = normalized.slice(4)
|
|
106
107
|
const match = rest.match(/\n---\s*\n/)
|
|
@@ -188,11 +189,50 @@ export function markdownMetadata(raw) {
|
|
|
188
189
|
return fm ? parseYamlSubset(fm.yaml) : {}
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
function markdownFrontmatterState(raw) {
|
|
193
|
+
const fm = splitFrontmatter(raw)
|
|
194
|
+
if (!fm) return { kind: 'absent', metadata: {} }
|
|
195
|
+
if (!fm.yaml.trim()) return { kind: 'empty', metadata: {} }
|
|
196
|
+
|
|
197
|
+
const metadata = parseYamlSubset(fm.yaml)
|
|
198
|
+
const lines = fm.yaml.replace(/\t/g, ' ').replace(/\r\n/g, '\n').split('\n')
|
|
199
|
+
let malformed = false
|
|
200
|
+
const containerIndents = [-1]
|
|
201
|
+
const containerKinds = ['object']
|
|
202
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
203
|
+
const rawLine = lines[index]
|
|
204
|
+
const trimmed = rawLine.trim()
|
|
205
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
206
|
+
const indent = rawLine.match(/^\s*/)[0].length
|
|
207
|
+
while (containerIndents.length > 1 && indent <= containerIndents.at(-1)) {
|
|
208
|
+
containerIndents.pop()
|
|
209
|
+
containerKinds.pop()
|
|
210
|
+
}
|
|
211
|
+
if (trimmed.startsWith('- ')) {
|
|
212
|
+
if (containerKinds.at(-1) !== 'array' || !trimmed.slice(2).trim()) malformed = true
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
const match = trimmed.match(/^([^:]+):(.*)$/)
|
|
216
|
+
if (!match || !match[1].trim()) {
|
|
217
|
+
malformed = true
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
if (!match[2].trim()) {
|
|
221
|
+
const next = nextYamlValue(lines, index, indent)
|
|
222
|
+
containerIndents.push(indent)
|
|
223
|
+
containerKinds.push(Array.isArray(next) ? 'array' : 'object')
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { kind: malformed ? 'malformed' : 'valid', metadata }
|
|
227
|
+
}
|
|
228
|
+
|
|
191
229
|
function relationMap(value) {
|
|
192
230
|
if (!isPlainObject(value)) return {}
|
|
193
231
|
const relations = {}
|
|
194
232
|
for (const [key, items] of Object.entries(value)) {
|
|
195
|
-
|
|
233
|
+
// Preserve the original graph command's shorthand while emitting one
|
|
234
|
+
// canonical shape: a scalar Markdown relation is a one-target list.
|
|
235
|
+
relations[key] = Array.isArray(items) ? asStringArray(items) : typeof items === 'string' ? asStringArray([items]) : items
|
|
196
236
|
}
|
|
197
237
|
return relations
|
|
198
238
|
}
|
|
@@ -315,7 +355,6 @@ export function walkDocuments(dir, root, acc = [], isIgnored = gitIgnoreFilter(r
|
|
|
315
355
|
// sidecar itself is metadata, never a source asset.
|
|
316
356
|
if (ent.name.endsWith('.kg.json')) continue
|
|
317
357
|
if (GENERATED_FILES.has(ent.name)) continue
|
|
318
|
-
if (rel === 'index.html') continue
|
|
319
358
|
const ext = path.extname(ent.name).toLowerCase()
|
|
320
359
|
// Opt-in requires a VISIBLE sidecar. A git-ignored one is machine-local:
|
|
321
360
|
// it would enrol a node that exists in no tracked file, and hand it an
|
|
@@ -548,7 +587,9 @@ export function generatedFrontmatter({ id, repoName, rel, title, summary, domain
|
|
|
548
587
|
].join('\n')
|
|
549
588
|
}
|
|
550
589
|
|
|
551
|
-
|
|
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) {
|
|
552
593
|
const inferredLifecycle = inferLifecycle(file.rel)
|
|
553
594
|
const inferredDomain = inferDomain(repoName, file.rel)
|
|
554
595
|
const inferredStatus = inferStatus(inferredLifecycle, file.rel)
|
|
@@ -556,13 +597,21 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
556
597
|
let title = titleCase(path.basename(file.rel))
|
|
557
598
|
let summary = ''
|
|
558
599
|
let metadata = {}
|
|
600
|
+
let classification = 'classified'
|
|
601
|
+
let classificationReason = null
|
|
559
602
|
let sidecar = null
|
|
560
603
|
let hasSidecar = false
|
|
561
604
|
let atelier = { section: coverage.sections.get(file.rel) || null, status: null, kind: null }
|
|
562
605
|
|
|
563
606
|
if (file.ext === '.md') {
|
|
564
|
-
const raw = fs.readFileSync(file.abs, 'utf8')
|
|
565
|
-
|
|
607
|
+
const raw = typeof sourceText === 'string' ? sourceText : fs.readFileSync(file.abs, 'utf8')
|
|
608
|
+
const frontmatter = markdownFrontmatterState(raw)
|
|
609
|
+
metadata = frontmatter.metadata
|
|
610
|
+
if (frontmatter.kind !== 'valid' || !isPlainObject(metadata.kg)) {
|
|
611
|
+
classification = 'unclassified'
|
|
612
|
+
classificationReason = frontmatter.kind === 'valid' ? 'missing-kg-block' : `${frontmatter.kind}-frontmatter`
|
|
613
|
+
metadata = {}
|
|
614
|
+
}
|
|
566
615
|
title = asString(metadata.title, extractMarkdownTitle(raw, file.rel))
|
|
567
616
|
summary = asString(metadata.summary, extractMarkdownSummary(raw))
|
|
568
617
|
} else if (file.ext === '.html') {
|
|
@@ -594,7 +643,7 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
594
643
|
const domain = asString(kg.domain, inferredDomain)
|
|
595
644
|
const lifecycle = asString(kg.lifecycle, inferredLifecycle)
|
|
596
645
|
const status = asString(kg.status, inferredStatus)
|
|
597
|
-
const audience = asString(kg.audience, '')
|
|
646
|
+
const audience = classification === 'unclassified' ? 'private' : asString(kg.audience, '')
|
|
598
647
|
const frontmatterHasLegacyVisibility = Object.hasOwn(kg, 'visibility')
|
|
599
648
|
const type = asString(kg.type, file.ext === '.md' ? 'document' : file.ext.slice(1))
|
|
600
649
|
const id = asString(kg.id, fallbackId(repoName, file.rel, file.ext))
|
|
@@ -615,6 +664,8 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
615
664
|
lifecycle,
|
|
616
665
|
status,
|
|
617
666
|
audience,
|
|
667
|
+
classification,
|
|
668
|
+
...(classificationReason ? { classificationReason } : {}),
|
|
618
669
|
...(frontmatterHasLegacyVisibility ? { frontmatterHasLegacyVisibility: true } : {}),
|
|
619
670
|
...(file.ext === '.md' ? { markdownHasKgBlock: hasKgBlock, markdownHasKgId: hasExplicitKgId } : {}),
|
|
620
671
|
...(file.ext !== '.md'
|
|
@@ -645,27 +696,461 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
|
|
|
645
696
|
}
|
|
646
697
|
}
|
|
647
698
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
+
}
|
|
666
746
|
}
|
|
747
|
+
offset = next
|
|
667
748
|
}
|
|
668
|
-
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)
|
|
669
1154
|
}
|
|
670
1155
|
|
|
671
1156
|
export function declaredRelationEdges(nodes) {
|
|
@@ -694,6 +1179,20 @@ export function uniqueEdges(edges) {
|
|
|
694
1179
|
export function graphDiagnostics(nodes) {
|
|
695
1180
|
const diagnostics = []
|
|
696
1181
|
for (const node of nodes) {
|
|
1182
|
+
if (node.classification === 'unclassified') {
|
|
1183
|
+
diagnostics.push({
|
|
1184
|
+
severity: 'warning',
|
|
1185
|
+
type: 'unclassified-content',
|
|
1186
|
+
code: 'unclassified-content',
|
|
1187
|
+
node: node.id,
|
|
1188
|
+
repo: node.repo,
|
|
1189
|
+
path: node.path,
|
|
1190
|
+
audience: node.audience,
|
|
1191
|
+
repoAccess: node.repoAccess,
|
|
1192
|
+
reason: node.classificationReason,
|
|
1193
|
+
message: `${node.repo}/${node.path}: Markdown classification metadata is ${node.classificationReason}; enrolled as private unclassified content`,
|
|
1194
|
+
})
|
|
1195
|
+
}
|
|
697
1196
|
const audienceRank = AUDIENCE_READ_RANK[node.audience]
|
|
698
1197
|
const readBoundary = node.repoAccess?.readBoundary
|
|
699
1198
|
const readBoundaryRank = AUDIENCE_READ_RANK[readBoundary]
|
|
@@ -730,14 +1229,16 @@ export function activeOrphanSidecars(repoName, repoRoot, files, isIgnored = gitI
|
|
|
730
1229
|
return orphans
|
|
731
1230
|
}
|
|
732
1231
|
|
|
733
|
-
export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
|
|
1232
|
+
export function validateKnowledgeGraph(nodes, edges, orphanSidecars = [], { externalRelationPrefixes = [], externalRelationIds = [] } = {}) {
|
|
734
1233
|
const errors = []
|
|
735
1234
|
const seenIds = new Map()
|
|
1235
|
+
const allowedExternalPrefixes = new Set(externalRelationPrefixes)
|
|
1236
|
+
const allowedExternalIds = new Set(externalRelationIds)
|
|
736
1237
|
|
|
737
1238
|
for (const node of nodes) {
|
|
738
1239
|
if (!node.id) errors.push(`${node.repo}/${node.path}: missing kg.id`)
|
|
739
|
-
if (node.extension === 'md' && node.markdownHasKgBlock && !node.markdownHasKgId) {
|
|
740
|
-
errors.push(`${node.repo}/${node.path}:
|
|
1240
|
+
if (node.extension === 'md' && node.classification !== 'unclassified' && node.markdownHasKgBlock && !node.markdownHasKgId) {
|
|
1241
|
+
errors.push(`${node.repo}/${node.path}: kg.id is required in a Markdown kg block`)
|
|
741
1242
|
}
|
|
742
1243
|
if (node.extension !== 'md' && !node.hasSidecar) {
|
|
743
1244
|
errors.push(`${node.repo}/${node.path}: missing non-Markdown sidecar ${node.sidecar || `${node.path}.kg.json`}`)
|
|
@@ -753,7 +1254,7 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
|
|
|
753
1254
|
seenIds.set(node.id, `${node.repo}/${node.path}`)
|
|
754
1255
|
}
|
|
755
1256
|
if (node.frontmatterHasLegacyVisibility) {
|
|
756
|
-
errors.push(`${node.repo}/${node.path}:
|
|
1257
|
+
errors.push(`${node.repo}/${node.path}: kg.visibility is invalid for graph classification; use kg.audience`)
|
|
757
1258
|
}
|
|
758
1259
|
if (!VALID_AUDIENCES.has(node.audience)) {
|
|
759
1260
|
errors.push(`${node.repo}/${node.path}: invalid or missing kg.audience "${node.audience ?? ''}"`)
|
|
@@ -776,7 +1277,8 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
|
|
|
776
1277
|
|
|
777
1278
|
for (const edge of edges) {
|
|
778
1279
|
if (!edge.declared) continue
|
|
779
|
-
|
|
1280
|
+
const externalPrefix = String(edge.target).split(':')[0]
|
|
1281
|
+
if (!seenIds.has(edge.target) && !allowedExternalIds.has(edge.target) && !allowedExternalPrefixes.has(externalPrefix)) {
|
|
780
1282
|
const sourcePath = seenIds.get(edge.source) || edge.source
|
|
781
1283
|
errors.push(`${sourcePath}: declared ${edge.type} target "${edge.target}" was not found`)
|
|
782
1284
|
}
|
|
@@ -789,21 +1291,85 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
|
|
|
789
1291
|
return errors
|
|
790
1292
|
}
|
|
791
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
|
+
|
|
792
1345
|
export function buildKnowledgeGraph({
|
|
793
1346
|
workspaceRoot,
|
|
794
1347
|
repoAccessConfig,
|
|
795
1348
|
repoRoots = null,
|
|
1349
|
+
repoEntries = null,
|
|
796
1350
|
repoAccessConfigPath = 'repo-access config',
|
|
797
1351
|
externalRepos = [],
|
|
1352
|
+
externalRelationPrefixes = [],
|
|
1353
|
+
externalRelationIds = [],
|
|
1354
|
+
isLinkTargetEligible = undefined,
|
|
1355
|
+
isAssetEligible = undefined,
|
|
1356
|
+
fileCache = null,
|
|
1357
|
+
observedDigest = null,
|
|
798
1358
|
} = {}) {
|
|
799
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')
|
|
800
1362
|
const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
|
|
801
1363
|
const external = new Set(externalRepos)
|
|
802
|
-
const
|
|
1364
|
+
const discoveredEntries = repoEntries
|
|
1365
|
+
? repoEntries.map((entry) => ({ name: entry.name, path: path.resolve(entry.path) })).sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
1366
|
+
: (repoRoots ? repoRoots.map((repoRoot) => path.resolve(repoRoot)) : listRepos(resolvedWorkspaceRoot))
|
|
1367
|
+
.sort()
|
|
1368
|
+
.map((repoRoot) => ({ name: path.basename(repoRoot), path: repoRoot }))
|
|
803
1369
|
// External repos are acknowledged but never walked: no document census, no
|
|
804
1370
|
// sidecar demands, no projection.
|
|
805
|
-
const roots =
|
|
806
|
-
const repoNames = roots.map((
|
|
1371
|
+
const roots = discoveredEntries.filter((entry) => !external.has(entry.name))
|
|
1372
|
+
const repoNames = roots.map((entry) => entry.name)
|
|
807
1373
|
const accessConfig = repoAccessConfig ?? {
|
|
808
1374
|
schema: REPO_ACCESS_SCHEMA,
|
|
809
1375
|
defaultReadBoundary: 'team',
|
|
@@ -824,9 +1390,14 @@ export function buildKnowledgeGraph({
|
|
|
824
1390
|
const workspaceOrphanSidecars = []
|
|
825
1391
|
const workspaceIgnoredSidecars = []
|
|
826
1392
|
const repoGraphs = []
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1393
|
+
const census = []
|
|
1394
|
+
const scanned = new Map()
|
|
1395
|
+
const nextCache = fileCache ? new Map() : null
|
|
1396
|
+
const reuse = { reused: 0, derived: 0, read: 0 }
|
|
1397
|
+
|
|
1398
|
+
for (const entry of roots) {
|
|
1399
|
+
const repoRoot = entry.path
|
|
1400
|
+
const repoName = entry.name
|
|
830
1401
|
// One batched ignore lookup per repo, shared by every walk below.
|
|
831
1402
|
const isIgnored = gitIgnoreFilter(repoRoot)
|
|
832
1403
|
const files = walkDocuments(repoRoot, repoRoot, [], isIgnored)
|
|
@@ -837,12 +1408,32 @@ export function buildKnowledgeGraph({
|
|
|
837
1408
|
const nodesByPath = new Map()
|
|
838
1409
|
|
|
839
1410
|
for (const file of files) {
|
|
840
|
-
|
|
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
|
+
}
|
|
841
1422
|
nodes.push(node)
|
|
842
1423
|
nodesByPath.set(file.rel, node)
|
|
843
1424
|
}
|
|
844
1425
|
|
|
845
|
-
|
|
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)])
|
|
846
1437
|
const graph = {
|
|
847
1438
|
schema: KNOWLEDGE_GRAPH_SCHEMA,
|
|
848
1439
|
repo: repoName,
|
|
@@ -857,14 +1448,18 @@ export function buildKnowledgeGraph({
|
|
|
857
1448
|
workspaceNodes.push(...graph.nodes)
|
|
858
1449
|
workspaceEdges.push(...graph.edges)
|
|
859
1450
|
}
|
|
1451
|
+
workspaceEdges.push(...resolved.links.filter((link) => !repoLocalLink(link)).map(linkEdge))
|
|
860
1452
|
|
|
861
|
-
const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars
|
|
1453
|
+
const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars, {
|
|
1454
|
+
externalRelationPrefixes,
|
|
1455
|
+
externalRelationIds,
|
|
1456
|
+
})
|
|
862
1457
|
const workspaceGraph = {
|
|
863
1458
|
schema: KNOWLEDGE_GRAPH_SCHEMA,
|
|
864
1459
|
workspace: path.basename(resolvedWorkspaceRoot),
|
|
865
1460
|
diagnostics: graphDiagnostics(workspaceNodes),
|
|
866
1461
|
nodes: workspaceNodes.sort((a, b) => `${a.repo}/${a.path}`.localeCompare(`${b.repo}/${b.path}`)),
|
|
867
|
-
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}`)),
|
|
868
1463
|
}
|
|
869
1464
|
workspaceGraph.nodeCount = workspaceGraph.nodes.length
|
|
870
1465
|
workspaceGraph.edgeCount = workspaceGraph.edges.length
|
|
@@ -876,6 +1471,11 @@ export function buildKnowledgeGraph({
|
|
|
876
1471
|
workspaceGraph,
|
|
877
1472
|
orphanSidecars: workspaceOrphanSidecars,
|
|
878
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,
|
|
879
1479
|
}
|
|
880
1480
|
}
|
|
881
1481
|
|