diff --git a/AGENTS.md b/AGENTS.md index e82f363..f0485e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ - NEVER EXPOSE SECRETS in conversations. We "develop in public" and make most LLM conversations available to the world. +- We use two agent personas: + - Kim, the coder + - Jimbo, the project manager + - Review the [product vision](documentation/notes/product-vision.md) - We are using Kato to record LLM conversations. diff --git a/deno.json b/deno.json index 0b42491..8e6223b 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { - "version": "0.1.3", + "version": "0.2.2", "tasks": { "dev:root": "deno run --allow-read --allow-write --allow-env --allow-net src/main.ts", "bump:version": "deno run --allow-read --allow-write scripts/bump-version.ts", diff --git a/documentation/notes/release-notes.v0.2.2.md b/documentation/notes/release-notes.v0.2.2.md new file mode 100644 index 0000000..835ecb3 --- /dev/null +++ b/documentation/notes/release-notes.v0.2.2.md @@ -0,0 +1,61 @@ +--- +id: 75f2f1407bc849918424fff216c910fd +title: 'release notes v0.2.2' +desc: '' +updated: 1779952466744 +created: 1779952466744 +--- + +## Summary + +`v0.2.2` is a runtime correctness and publication-prep release for Weave. It adds the shared artifact-resolution service, uses that service for source-backed config and ResourcePage loading, makes effective config target-aware, improves custom/current-only ResourcePage behavior, and strengthens the test/fixture pipeline used by GitHub CI and Codecov. + +This release also starts the append-onlyish inventory cleanup. The first inventory slices add an RDF-aware append planner and use it to preserve carried Knop support facts without dropping source registries, extraction sources, reference catalogs, or unknown support details. + +## Highlights + +- Shared artifact resolution now handles governed working, target-local, target-located, latest-state, exact-state, fallback, digest, and observed-resolution evidence flows through a common runtime service. +- Config-source discovery now resolves mesh-local, Knop-local, and Knop-inheritable source attachments through the shared resolver, including recursive source chains, cycle/dedupe handling, and traceable source ordering. +- Effective config is now provided per mesh or target scope, so recursive and multi-target `weave`, `version`, and `generate` operations can apply Knop-local and inherited config to the right target instead of flattening everything into one global config. +- ResourcePage generation now supports per-page presentation config and exact/fallback ResourcePageSource resolution. Custom ResourcePage definitions can be current-only, so `_knop/_page/page.ttl` does not require a `_history001` shape when the effective policy says current-only. +- The fixture/test pipeline now includes immutable fixture snapshot caching, better Deno JUnit normalization for Codecov test analytics, and release-runbook guidance for versioned release notes. +- Append-onlyish inventory groundwork now includes an RDF-aware append planner plus planner-backed preservation for carried Knop support artifacts and `knop add-reference` source/reference coexistence. + +## Breaking Or Changed Behavior + +- This is still pre-1.0 software, and no compatibility shims are promised for stale pre-v1 fixture or inventory shapes. +- Mesh-inheritable config has been removed as a distinct runtime layer. Mesh config remains mesh-local config; Knop-local and Knop-inheritable behavior is handled through Knop-scoped source attachments and per-target effective config resolution. +- Source-backed config authority is attached from metadata. Mesh-local `sfcfg:hasConfigSource` belongs on `<_mesh>` in `_mesh/_meta/meta.ttl`; Knop-local and Knop-inheritable source attachments belong in current Knop metadata. Resolved `_config/*.ttl` files are config payloads, not bootstrap authority by filesystem location alone. +- ResourcePageSource resolution now fails closed for malformed, unsafe, or unsupported source specs instead of silently falling through to unrelated fallback content. +- ResourcePageDefinition current-only behavior means a Knop page-definition artifact may be represented by `_knop/_page/page.ttl` without a versioned `_history001` tree. +- Fixture helpers now read git-backed fixture snapshots from resolved commit SHAs rather than mutable refs, so local branch movement should not change fixture content under an existing cache key. + +## Artifacts + +- Git tag: `v0.2.2` +- GitHub Release: `v0.2.2` +- Native binary archives for Linux x64, Windows x64, macOS x64, and macOS arm64 +- Matching `.sha256` checksum files +- npm packages for `@semantic-flow/weave` and supported platform packages + +## Validation + +- Source quality gate for release preparation: `deno task ci`, covering fmt check, lint, type check, coverage test run, and LCOV generation. +- PR-level GitHub Actions `ci`, CodeQL, and CodeRabbit checks passed for the release-candidate branch before release prep. Codecov patch status remained useful coverage telemetry rather than the source quality gate for this release. +- Release workflow rehearsal should use the `Release Manual` workflow with `npm_publish_mode: dry-run` and `github_release_mode: draft` before publishing npm packages or the GitHub Release. +- Native binary smoke path: each built executable should run `weave --version`, and npm smoke install should verify the wrapper package resolves the matching platform package. + +## Known Limitations + +- Full append-onlyish inventory behavior is not complete. The release includes the shared append planner and first support-artifact consumers, but broad MeshInventory/KnopInventory writer migration and the full inventory-versus-metadata progression split remain follow-up work. +- ResourcePageDefinition source regions still have a first-slice source-loading surface. Delegating direct `targetLocalRelativePath` regions fully through the shared resolver and adding `targetLocatedFile` support should land with explicit page-definition behavior tests. +- Generic config-source management commands do not exist yet; source-backed config can be authored in RDF, but there is no dedicated CLI for adding or auditing config-source attachments. +- Historical ResourcePage regeneration remains policy/design follow-up. Current generation is target-aware, but rebuild/repair/retraction modes are still intentionally separate future work. +- Remote working/source URL resolution remains behind explicit operational policy and digest expectations; broad remote fetch workflows are not part of this release. + +## Next + +- Continue append-onlyish inventory work: migrate remaining inventory writers to append/no-op/fail-closed semantics, keep mutable current/latest/next progression in metadata, and add rerun idempotence tests for release automation. +- Finish the ResourcePageDefinition resolver follow-up for direct `targetLocalRelativePath`, `targetLocatedFile`, digest, and fallback behavior. +- Use the improved GitHub test analytics on main to tune Codecov patch thresholds and identify real coverage gaps without letting coverage telemetry block unrelated correctness releases. +- Prepare the next publication dogfood pass for SFLO/URPX once this release is available as a packaged CLI. diff --git a/documentation/notes/wd.codebase-overview.md b/documentation/notes/wd.codebase-overview.md index 442664f..0b39f1d 100644 --- a/documentation/notes/wd.codebase-overview.md +++ b/documentation/notes/wd.codebase-overview.md @@ -39,7 +39,8 @@ Core owns portable Semantic Flow behavior: request/result types, RDF/Turtle help Runtime owns local execution against a workspace: filesystem reads/writes, git-aware source resolution, effective config loading, structured logging, progress reporting, and command-scoped staging. -- `src/runtime/config` loads and resolves effective runtime config from RDF. +- `src/runtime/config` loads and resolves effective runtime config from RDF, including mesh-local `sfcfg:hasConfigSource` discovery through shared artifact resolution. +- `src/runtime/artifact_resolution` resolves `sflo:ArtifactResolutionSpec`-shaped runtime requests into requested/observed coordinates plus optional bytes/text under local path policy; config-source discovery, artifact-backed ResourcePageSource loading, and extraction-source selected-state evidence use it. - `src/runtime/logging` provides structured operational and audit logging. - `src/runtime/operational/local_path_policy.ts` controls workspace-local path safety and allowed repo-adjacent access. - `src/runtime/weave/weave.ts` is the public runtime façade for validate/version/generate/weave. diff --git a/documentation/notes/wd.general-guidance.md b/documentation/notes/wd.general-guidance.md index e55c51c..e4f619f 100644 --- a/documentation/notes/wd.general-guidance.md +++ b/documentation/notes/wd.general-guidance.md @@ -51,6 +51,8 @@ Before starting substantial code changes, a task note should be written and refi Before a task is closed, [[wd.decision-log]] can be updated with important decisions made. +The backlog is kept in [[wd.todo]] and should be updated as tasks are completed. + ## Behavior specs Semantic Flow behavior specs live in the Semantic Flow Framework notes as `sf.spec.*`. These specs describe externally visible Semantic Flow operation behavior and are meant to guide implementation and drive higher-level tests across implementations. diff --git a/documentation/notes/wd.release-runbook.md b/documentation/notes/wd.release-runbook.md index 8d3592e..df3960b 100644 --- a/documentation/notes/wd.release-runbook.md +++ b/documentation/notes/wd.release-runbook.md @@ -2,7 +2,7 @@ id: 1u8rkf5ka5mmgschlel7lym title: Release Runbook desc: '' -updated: 1778685955558 +updated: 1779952454402 created: 1778685955558 --- @@ -28,7 +28,7 @@ The normal release path is the `Release Manual` GitHub Actions workflow, run fro 2. Bump or verify the release version: ```bash -VERSION=0.1.1 +VERSION=0.2.2 deno task bump:version -- --version "$VERSION" ``` diff --git a/documentation/notes/wd.runtime.md b/documentation/notes/wd.runtime.md index 23b792d..e2aca37 100644 --- a/documentation/notes/wd.runtime.md +++ b/documentation/notes/wd.runtime.md @@ -26,6 +26,23 @@ If an adjacent workspace source is denied and a mesh config exists, the runtime `weave mesh create` is the exception: it also accepts `--workspace` because it may need to create the initial mesh config before any mesh-carried workspace relationship exists. +## Config-Source Bootstrap + +Weave discovers initial source-backed config attachments from current metadata, not by scanning `_config` directories. + +Mesh-local config sources are attached to the active mesh subject, normally in `_mesh/_meta/meta.ttl`: + +```ttl +<_mesh> sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "_mesh/_config/config.ttl" +] . +``` + +Knop-local and Knop-inheritable config sources are attached to the current Knop subject in that Knop's metadata. For `alice`, that means `alice/_knop/_meta/meta.ttl` with subject ``. + +The resolved `_mesh/_config/*.ttl` and `/_knop/_config/*.ttl` files are config payloads/artifacts. They may contain ordinary policy bindings and may chain to additional config sources after the first source has been authorized, but they are not the initial bootstrap authority just because they are under `_config`. Inline `sfcfg:hasConfig` and `sfcfg:hasInheritableConfig` payloads are still deferred in the Weave runtime; use `sfcfg:hasConfigSource` / `sfcfg:hasInheritableConfigSource` with explicit `sflo:ArtifactResolutionSpec` coordinates. + ## Runtime Logs The Weave CLI writes runtime logs under `${XDG_STATE_HOME:-~/.local/state}/weave/meshes//logs/` by default. `WEAVE_LOG_DIR` remains an explicit override for CI, replay scripts, and other workflows that want a job-local log directory. diff --git a/documentation/notes/wd.testing.md b/documentation/notes/wd.testing.md index 2f5309e..e2a96f8 100644 --- a/documentation/notes/wd.testing.md +++ b/documentation/notes/wd.testing.md @@ -102,11 +102,15 @@ Preferred placement: Temp workspaces created with `createTestTmpDir()` are registered with the test harness and cleaned after each test when tests are run through `deno task test` or `deno task test:coverage`. Set `WEAVE_KEEP_TEST_TMP=1` when running those tasks to preserve temp workspaces for debugging a failing test. +Curated Git fixture refs should be materialized through the shared fixture snapshot cache when possible. The cache stores immutable per-repo/per-commit snapshots under `/tmp/semantic-flow-fixture-snapshots`, keyed by resolved commit SHA, then copies from that snapshot into each test's private temp workspace. This keeps tests isolated while avoiding repeated per-file `git show` extraction. + Live Semantic Flow fixture ladder regeneration is a separate cross-repo workflow; see [[wd.testing.fixture-ladder-regeneration]]. ### Codecov Test Analytics -GitHub Actions publishes JUnit XML to Codecov Test Analytics from the normal CI workflow. `deno task test:coverage` writes Deno's raw JUnit report to `/tmp/semantic-flow-coverage/junit.xml`, normalizes it for Codecov at `/tmp/semantic-flow-coverage/codecov-junit.xml`, and writes raw coverage data in the same temp directory. `deno task coverage:lcov` writes LCOV to `/tmp/semantic-flow-coverage/lcov.info`. +GitHub Actions publishes JUnit XML to Codecov Test Analytics from the normal CI workflow. Treat Codecov as the canonical broad timing view for slowest suites and tests; add local timing instrumentation only when it answers a narrower question, such as setup time versus command execution inside one slow e2e suite. + +`deno task test:coverage` writes Deno's raw JUnit report to `/tmp/semantic-flow-coverage/junit.xml`, normalizes it for Codecov at `/tmp/semantic-flow-coverage/codecov-junit.xml`, and writes raw coverage data in the same temp directory. `deno task coverage:lcov` writes LCOV to `/tmp/semantic-flow-coverage/lcov.info`. Coverage-producing tasks create `/tmp/semantic-flow-coverage` if it does not exist, so a reboot-cleared `/tmp` does not require manual setup. diff --git a/documentation/notes/wd.todo.md b/documentation/notes/wd.todo.md index 9b16e61..a3c648d 100644 --- a/documentation/notes/wd.todo.md +++ b/documentation/notes/wd.todo.md @@ -2,22 +2,124 @@ id: 5d7q7j0ra3tybq1dn6e1zha title: Todo desc: '' -updated: 1779383363506 +updated: 1779947902489 created: 1774046031081 --- ## Backlog -- [ ] feed the release notes as a supplemental reference into the corresponding historical states, but so far references only works on RDF. -- [ ] After [[wa.completed.2026.2026-04-04_0952-rdf-parsing]] and before or during [[wa.task.2026.2026-04-06_1905-markdown-payload-publishing]], decide whether Weave should add operation-scoped parsed RDF read models for mesh-awareness in publishing and later daemon handlers; defer any daemon-wide in-memory mesh cache until real daemon job flow, locking, and invalidation needs are specified. -- [ ] If `_mesh/_meta/meta.ttl` grows beyond the current carried shape, tighten `src/runtime/mesh/metadata.ts` to require `sflo:meshBase` on the expected mesh subject such as `<_mesh>` rather than accepting that triple from any subject in the document. -- [ ] Decide whether Weave should keep Turtle as the canonical on-disk RDF support-artifact format while later allowing multi-serialization RDF ingest/export at operation boundaries; if explored, scope parser/serializer selection by media type or extension, artifact metadata for serialization choice, hardcoded `.ttl` path assumptions, per-history-state serialization policy, and the runtime checks that still assume `.ttl` payloads in paths such as `src/runtime/integrate/integrate.ts` and `src/runtime/extract/extract.ts`. -- [ ] Implement append-onlyish inventory writes from [[wa.task.2026.2026-05-17-append-onlyish-inventory]]: normal inventory operations should append new settled facts, no-op existing facts, and fail closed on conflicting facts; current/progression pointers belong in metadata or explicit repair/regeneration/retraction modes, not graph-preserving rewrites. -- [ ] Add a carried fixture or focused test for forcing a new payload release state when the source bytes are unchanged, so named-release sequencing can publish a new HistoricalState without relying on content changes. This was deferred from the Fantasy Rules `v0.0.2` slice after the SHACL source gained real version metadata changes. +Groomed on 2026-05-27 from all `wa.task.*` notes in the Weave archive. Updated after per-target effective config and `ResourcePageSource` exact/fallback resolution landed. + +### Current Work And Next Pick + +- [x] Finish in-progress [[wa.completed.2026.2026-05-27_2215-resourcepage-source-resolution-semantics]]: exact `targetHistoricalState`, one-level fallback, no fallback for malformed/unsafe/unsupported specs, and focused resolver/page-definition tests. +- [x] Finish the current-only `ResourcePageDefinition` slice from [[wa.task.2026.2026-05-05-optional-history-and-slim-support-artifacts-by-default]]: `_knop/_page` can remain unversioned, custom page generation reads `_knop/_page/page.ttl` when `_history001` is absent, and omitted page-definition histories are not linked. +- [ ] Take [[wa.task.2026.2026-05-17-append-onlyish-inventory]] as the next larger inventory-correctness task now that the current history-policy slice is bounded. + +### P0: Current Config And Resolution Follow-Ups + +- [x] Implement [[wa.completed.2026.2026-05-27-2031-per-target-effective-config-resolution]] before applying Knop-local/inherited config to recursive or multi-target version/generate/weave operations. +- [x] Finish [[wa.completed.2026.2026-05-27_2215-resourcepage-source-resolution-semantics]] as the first broader page-source resolver cleanup consumer from [[wa.completed.2026.2026-05-24_1748-shared-artifact-resolution-runtime-service]] and [[wa.task.2026.2026-04-08_1545-resource-page-definition-and-sources]]. +- [x] Implement the current-only `ResourcePageDefinition` support-artifact slice from [[wa.task.2026.2026-05-05-optional-history-and-slim-support-artifacts-by-default]] after ResourcePageSource exact/fallback semantics landed. +- [ ] Keep path and URL trust policy aligned with [[wa.task.2026.2026-04-11_1723-operational-config-for-runtime-resolution]], [[wa.task.2026.2026-05-20_2152-workingAccessUrl]], and the config-source resolver. Do not let portable mesh config silently grant broader host trust. +- [x] Document config-source bootstrap authoring now that tests exercise it correctly: mesh-local config-source attachments belong in `_mesh/_meta/meta.ttl`, Knop-local and inheritable attachments belong in current Knop metadata, and the resolved `_config/*.ttl` files are config payloads rather than bootstrap authority. + +### P1: Publication, History, And Runtime Correctness + +- [ ] Remove the remaining first-payload planner blockers from [[wa.task.2026.2026-05-04-refactor-planFirstPayloadWeave]]: support multi-pending first-payload weave in one transaction, support current-mode extracted-term weave, and replace fixture-shaped errors with condition-specific diagnostics. The SFLO and URPX docs still warn about the current failure mode, so this is real backlog, not just cleanup. +- [ ] Implement append-onlyish inventory writes from [[wa.task.2026.2026-05-17-append-onlyish-inventory]]: normal inventory operations append new settled facts, no-op existing facts, and fail closed on conflicts; current/latest/next progression belongs in metadata or explicit repair/regeneration/retraction modes. +- [d] Finish broader slim/current-only support-artifact behavior from [[wa.task.2026.2026-05-05-optional-history-and-slim-support-artifacts-by-default]]: the current-only `ResourcePageDefinition` slice has landed; remaining inventory/meta split and durable history-toggle coherence stay with [[wa.task.2026.2026-05-17-append-onlyish-inventory]] and follow-up history-policy work. +- [ ] Keep the durable history-toggle path coherent across config, CLI overrides, and weave/version/generate behavior. A changed working file should be able to update current/latest state without always writing a new history state when the effective history policy says current-only or slim. +- [ ] Finish branch-published working-source portability from [[wa.task.2026.2026-05-19_2349-branch-based-workingfile-fix]]: add path-leakage validation with all failing files listed, update durable runtime docs only if needed, and rerun the scratch SFLO `gh-pages` publication to confirm no host-local or sibling-worktree paths leak. +- [ ] Define and implement publication-base-relative link policy from [[wa.task.2026.2026-05-26_1321-relative-links]] for generated ResourcePages and rendered Markdown links. This should become a real task note before code work; the current note is only a prompt. - [ ] Add user-visible `generate`/`validate` findings for ResourcePage publication anomalies, especially current RDF artifact pages that have ResourcePage generation enabled but no latest HistoricalState to render from. -- [ ] Add a publication option to suppress working-file locator metadata on ResourcePages, while still deriving semantic panels from settled historical states. +- [ ] Add a publication option to suppress working-file locator metadata on ResourcePages while still deriving semantic panels from settled historical states. + +### P2: Fixtures, Renderer, And Developer Experience + +- [ ] Add the manifest completeness check from [[wa.task.2026.2026-05-16_1625-manifest-completeness-check]] so fixture branch diffs cannot drift beyond Accord manifest expectations or deliberate `ignorePaths`. +- [ ] Add the latest-state conformance fixture from [[wa.task.2026.2026-05-19_1536-latest-state-conformance]] once Accord can express the needed rendered-text assertions. +- [ ] Revisit custom ResourcePage follow-up work in [[wa.task.2026.2026-05-23-2157-resourcepage-followup]] after Knop config inheritance settles: the next useful slice is probably a narrow custom page fixture that keeps the shared Semantic Site presentation while opting into authored Markdown plus selected generated panels. +- [ ] Continue test/performance work from [[wa.task.2026.2026-05-26_2237-testing-optimization]] and [[wa.task.2026.2026-05-17-weave-performance-optimization]]: add `WEAVE_TEST_TIMING=1`, record baselines, cache fixture reads by resolved commit, add tiny-mesh builders for tests that do not need the real fixture ladders, and add validation progress output behind a quiet/default/verbose policy. +- [ ] Implement the first RDF parse/render boundary slice from [[wa.task.2026.2026-05-28-0030-rdf-and-turtle-cleanup]]: add shared Weave Turtle context/fact helpers, let `inventory_append_planner` accept requested facts/quads instead of parser-dependent Turtle snippets, and keep compact SFLO/config rendering separate from byte-preserved carried blocks. +- [ ] Split the largest files under [[wa.task.2026.2026-05-23_0040-further-refactoring]] in behavior-preserving slices: `src/runtime/weave/pages.ts`, `scripts/fixture-ladder.ts`, `src/core/weave/weave_test.ts`, `tests/integration/weave_test.ts`, and `tests/scripts/fixture_ladder_test.ts` are still large enough to slow future work. +- [ ] Decide renderer strategy before broadening Markdown behavior: either keep the current small renderer and close [[wa.task.2026.2026-05-25-markdown-it]] as superseded, or switch deliberately to a Deno-friendly Markdown library. Tie this to [[wa.task.2026.2026-04-13_1715-page-renderer-refresh-and-html-regeneration]] and [[wa.task.2026.2026-05-24_2353-autolinking]] rather than making another ad hoc parser pass. +- [ ] Define autolinking behavior from [[wa.task.2026.2026-05-24_2353-autolinking]]: term links in Turtle/prose, Dendron wikilinks in Markdown-derived pages, and any publication-base rewrite rules should be scoped together. +- [ ] Add a carried fixture or focused test for forcing a new payload release state when source bytes are unchanged, so named-release sequencing can publish a new HistoricalState without relying on content changes. +- [ ] Decide whether Weave should migrate acceptance fixtures from git-branch-backed before/after states to explicit folder-backed snapshots. Upside: simpler local/CI reads and easier task-specific fixture authoring. Downside: duplicated fixture trees, weaker branch provenance, and updates to manifests, notes, and helpers that currently address fixture refs by branch name. + +### P3: Later Features And Architecture + +- [ ] Keep manifest-driven integrate from [[wa.task.2026.2026-05-18_1846-integrate-manifest]] parked until the one-target integrate/source-binding contract and publication dogfood are stable. +- [ ] Keep guarded branch-published rebuild from [[wa.task.2026.2026-05-14_1105-guarded-branch-published-rebuild]] parked until incremental branch publishing has more mileage; rebuild should stay loud, dry-runnable, and deletion-plan-driven. +- [ ] Keep deploy profiles from [[wa.task.2026.2026-05-16-deploy-profile]] parked until the manual SFLO/URPX publication command sequence has stopped moving. +- [ ] Keep remote current-byte resolution from [[wa.task.2026.2026-05-20_2152-workingAccessUrl]] behind explicit operational policy, bounded fetch behavior, digest verification, and clear locator precedence. +- [ ] Decide whether Weave still needs a user-facing fingerprint verification command from [[wa.task.2026.2026-05-04-fingerprint-verification]]. The digest substrate now exists through artifact-resolution vocabulary; the missing piece is the operator-facing verify surface. +- [ ] Decide whether operation-scoped parsed RDF read models should graduate into an Oxigraph-backed graph store. Use [[wa.task.2026.2026-05-27_1314-oxigraph]] only after a concrete config/source/inventory query workload proves the value; do not start by introducing Oxigraph as a general cache. +- [ ] Feed release notes as supplemental references into corresponding historical states once non-RDF reference support exists. +- [ ] If `_mesh/_meta/meta.ttl` grows beyond the current carried shape, tighten `src/runtime/mesh/metadata.ts` to require `sflo:meshBase` on the expected mesh subject such as `<_mesh>` rather than accepting that triple from any subject in the document. +- [ ] Decide whether Weave should keep Turtle as the canonical on-disk RDF support-artifact format while later allowing multi-serialization RDF ingest/export at operation boundaries. - [ ] Add an optional `WEAVE_TEST_TMP_ROOT` override for `createTestTmpDir()` only if stable grouping of preserved test temp workspaces becomes useful again. The current helper already defaults to platform temp space outside the repository. -- [ ] Remove the remaining `ResourcePageDefinition` history dependency: `_knop/_page` should be able to stay current-only when history policy says current-only, and ResourcePage generation should make a best-effort current rendering when `_knop/_page/_history001` is absent. `config` remains the explicit versioned support-artifact exception. -- when an artifact gets created or (in the case of a payload artifact, integrated) it's possible to have "trackHistory" turned off (usually in config but potentially in CLI); support the situation where a changed WorkingFile, on weave, only updates a "latestState" and doesn't write to history -- [ ] Decide whether Weave should migrate acceptance fixtures from git-branch-backed before/after states in `mesh-alice-bio` to explicit folder-backed snapshots. Upside: simpler local/CI fixture reads and easier task-specific fixture authoring. Downside: more duplicated checked-out fixture trees, loss of branch-based carried-slice provenance, and required updates to conformance manifests, notes, and helpers that currently address fixture refs by branch name. + +## Human Decision Before Task-Note Edits + +- [ ] [[wa.task.2026.2026-05-24_2304-honor-mesh-config]] appears broadly landed in code and user docs, including mesh config loading, history/presentation policy parsing, all-panels/no-panels defaults, command override precedence, and CLI documentation. Do not bulk-check this note until Knop config inheritance settles; then either mark the landed checklist items or replace the note with a short residual follow-up. +- [ ] [[wa.task.2026.2026-05-22_1128-referencelink-clarification]] also appears partly or mostly landed: `ReferenceSource` and `hasReferenceSource` exist in ontology/code/tests, but the note spans SFLO, framework specs, fixture regeneration, and terminology cleanup. It needs a cross-repo audit before checkbox edits. +- [ ] [[wa.task.2026.2026-04-08_1545-resource-page-definition-and-sources]] is now a legacy umbrella note. The first-pass `_knop/_page` behavior landed, while remaining work belongs to source-mode/fallback/import-boundary tasks. Decide whether to split/close the note or keep it as a historical ledger. +- [ ] [[wa.task.2026.2026-04-13_1715-page-renderer-refresh-and-html-regeneration]], [[wa.task.2026.2026-05-24_2353-autolinking]], and [[wa.task.2026.2026-05-25-markdown-it]] overlap. Choose one renderer/autolink direction before editing individual task notes. +- [ ] [[wa.task.2026.2026-05-04-fingerprint-verification]] should either be rewritten around the current digest vocabulary and a user-facing verify command, or marked superseded by artifact-resolution digest verification. +- [ ] [[wa.task.2026.2026-05-27_1314-oxigraph]] is an architectural sketch, not an implementation task. Convert it into a concrete spike with entry/exit criteria or leave it parked. +- [ ] [[wa.task.2026.2026-04-14_0018-configurable-test-tmp]] is mostly superseded by the current platform-temp helper and Codecov output directory work. Only edit the note if we decide the optional `WEAVE_TEST_TMP_ROOT` override is worth implementing. + +## Task Note Audit Index + +### Active + +- [[wa.task.2026.2026-05-04-refactor-planFirstPayloadWeave]]: active; docs still warn about the "settled first-payload-weave mesh inventory shape" failure, so the multi-pending first-payload and current-mode extracted-term fixes remain real. +- [[wa.task.2026.2026-05-17-append-onlyish-inventory]]: active; important correctness cleanup for reruns and release workflows. +- [[wa.task.2026.2026-05-05-optional-history-and-slim-support-artifacts-by-default]]: partially superseded by config synthesis, but the current-only `ResourcePageDefinition` and history-policy runtime behavior remain active. +- [[wa.task.2026.2026-05-19_2349-branch-based-workingfile-fix]]: mostly implemented, with validation/docs/SFLO replay residue. +- [[wa.task.2026.2026-05-26_1321-relative-links]]: active but underspecified; needs expansion before implementation. +- [[wa.task.2026.2026-05-16_1625-manifest-completeness-check]]: active fixture-infrastructure work; scenario indexes exist, but manifest completeness checking remains. +- [[wa.task.2026.2026-05-19_1536-latest-state-conformance]]: active conformance fixture backlog. +- [[wa.task.2026.2026-05-23-2157-resourcepage-followup]]: active product/fixture follow-up after the shared ResourcePage pipeline. +- [[wa.task.2026.2026-05-26_2237-testing-optimization]]: active; Codecov work landed, timing/caching/tiny-fixture work remains. +- [[wa.task.2026.2026-05-17-weave-performance-optimization]]: active but narrowed; read/candidate caching landed, parsed RDF reuse and validation progress remain. +- [[wa.task.2026.2026-05-28-0030-rdf-and-turtle-cleanup]]: active; first slice should reduce Turtle snippet/context coupling around inventory append planning without introducing Oxigraph or broad generated-TTL churn. +- [[wa.task.2026.2026-05-23_0040-further-refactoring]]: active maintenance backlog; still justified by current file sizes. +- [[wa.task.2026.2026-05-24_1648-ArtifactResolutionTarget-subclass-cleanup]]: active cleanup ledger after Knop config inheritance and import/source-registry work. + +### Needs Revision Or Human Decision + +- [[wa.task.2026.2026-04-08_1545-resource-page-definition-and-sources]]: needs revision; first-pass page definition behavior appears landed, with source-mode/fallback/import-boundary leftovers better tracked under artifact-resolution work. +- [[wa.task.2026.2026-04-11_1723-operational-config-for-runtime-resolution]]: mostly completed; only docs and remote-policy follow-through remain. +- [[wa.task.2026.2026-04-13_0910-weave-shape-generalization-for-later-carried-states]]: still useful as technical-debt context, but should stay pull-driven by concrete fixture failures. +- [[wa.task.2026.2026-04-13_1715-page-renderer-refresh-and-html-regeneration]]: partially completed by current Markdown rendering and ResourcePage pipeline work; needs reconciliation with Markdown-it and autolinking tasks. +- [[wa.task.2026.2026-04-14_0018-configurable-test-tmp]]: mostly superseded; platform temp and cleanup exist, optional env override remains parked. +- [[wa.task.2026.2026-05-04-fingerprint-verification]]: needs rewrite or supersession decision. +- [[wa.task.2026.2026-05-04-split-extraction-from-page-selection]]: still directionally active but too broad; should be revised after ReferenceLink and page-selection vocabulary settles. +- [[wa.task.2026.2026-05-06-grand-config-synthesis]]: umbrella mostly executed; residual items are default-segment hints, historical ResourcePage regeneration policy, path/URL trust alignment, tests, and docs. +- [[wa.task.2026.2026-05-22_1128-referencelink-clarification]]: needs cross-repo audit before note edits. +- [[wa.task.2026.2026-05-24_2304-honor-mesh-config]]: needs task-note cleanup after Knop config inheritance work. +- [[wa.task.2026.2026-05-24_2353-autolinking]]: needs a real implementation plan. +- [[wa.task.2026.2026-05-25-markdown-it]]: needs a keep-small-renderer versus adopt-library decision. +- [[wa.task.2026.2026-05-27_1314-oxigraph]]: needs conversion into a concrete spike or parking-lot item. + +### Parked Future Work + +- [[wa.task.2026.2026-05-14_1105-guarded-branch-published-rebuild]]: still valid, but not current. +- [[wa.task.2026.2026-05-16-deploy-profile]]: still valid after manual publication dogfood stabilizes. +- [[wa.task.2026.2026-05-18_1846-integrate-manifest]]: still valid, but after one-target integrate/source-binding behavior is settled. +- [[wa.task.2026.2026-05-20_2152-workingAccessUrl]]: valid future remote-resolution work, gated by operational policy and digest checks. + +### Appears Completed Or Deferred + +- [[wa.completed.2026.2026-05-24_1748-shared-artifact-resolution-runtime-service]]: completed first resolver slice; broader consumer migration remains tracked by [[wa.task.2026.2026-05-24_1648-ArtifactResolutionTarget-subclass-cleanup]] and the current Knop config task. +- [[wa.completed.2026.2026-05-27_1246-config-source-discovery-and-resolution]]: completed mesh-local config-source discovery and resolution; Knop-local and inherited config moved to [[wa.task.2026.2026-05-27_1914-knop-config-source-discovery-and-inheritance]]. +- [[wa.task.2026.2026-05-27_1914-knop-config-source-discovery-and-inheritance]]: core Knop-local and inherited config-source runtime slice is implemented; remaining work is tracked as smaller follow-ups for inline config, multi-target/per-target effective config, and docs. +- [[wa.task.2026.2026-05-22_2253-resourcepage-config-and-templating]]: appears completed except for future low-impact panel presentation modes. +- [[wa.task.2026.2026-05-22_2308-fixture-helper-generalization]]: completed. +- [[wa.task.2026.2026-05-23_2230-custom-resourcepage-shared-shell-fixture]]: completed, with Carol-specific work deferred to [[wa.completed.2026.2026-05-25_0849-carol]]. +- [[wa.completed.2026.2026-05-25_0849-carol]]: completed; the fixture branches `a.26-carol` and `a.27-carol-woven` exist and the note checklist was corrected during this grooming pass. +- [[wa.completed.2026.2026-05-27_1347-drop-MeshInheritableConfig]]: completed. diff --git a/documentation/notes/wd.vision.md b/documentation/notes/wd.vision.md new file mode 100644 index 0000000..ed1117e --- /dev/null +++ b/documentation/notes/wd.vision.md @@ -0,0 +1,13 @@ +--- +id: aqnk3ferc6mewpj9qpd5ola +title: Weave Vision +desc: '' +updated: 1779954261344 +created: 1779954054498 +--- + +Weave will be an versatile implementation of the Semantic Flow Framework with a RDF-native core and three prongs: + +- an agile CLI for local use +- an API +- a web application that consumes the API \ No newline at end of file diff --git a/documentation/notes/wu.cli-reference.md b/documentation/notes/wu.cli-reference.md index 91cd7c5..31bbc51 100644 --- a/documentation/notes/wu.cli-reference.md +++ b/documentation/notes/wu.cli-reference.md @@ -24,6 +24,34 @@ Detailed notes are available for [[wu.cli-reference.weave]], [[wu.cli-reference. When `_mesh/_config/config.ttl` exists, existing-mesh commands compile it as mesh-local config above Weave defaults and below command-scoped overrides. Durable mesh policy belongs there; flags such as `--history-tracking-policy` and `--include-semantic-flow-metadata` are one-run overrides. +Config-source bootstrap is authored in metadata, not in the resolved config payload itself. Mesh-local `sfcfg:hasConfigSource` attachments belong on `<_mesh>` in `_mesh/_meta/meta.ttl`; Knop-local `sfcfg:hasConfigSource` and inheritable `sfcfg:hasInheritableConfigSource` attachments belong on the current Knop subject in that Knop's `_knop/_meta/meta.ttl`. The target can be a conventional `_config/*.ttl` file, but that file is the config content being resolved, not the initial authority that makes itself visible. + +```ttl +@prefix sflo: . +@prefix sfcfg: . + +<_mesh> sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "_mesh/_config/config.ttl" +] . +``` + +For Knop-scoped config, attach the source to the Knop subject in current Knop metadata: + +```ttl + sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "alice/_knop/_config/config.ttl" +] . + + sfcfg:hasInheritableConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "alice/_knop/_config/children.ttl" +] . +``` + +`ConfigSource` reuses the `sflo:ArtifactResolutionSpec` coordinate vocabulary, so use `sflo:targetLocalRelativePath`. + `--target ` limits `weave`, `weave validate`, `weave version`, and `weave generate` to specific designator paths. If no `--target` flags are provided, those commands operate on all applicable weave candidates in the mesh. diff --git a/scripts/normalize-deno-junit.ts b/scripts/normalize-deno-junit.ts index 2969054..cba9bd2 100644 --- a/scripts/normalize-deno-junit.ts +++ b/scripts/normalize-deno-junit.ts @@ -11,12 +11,15 @@ export interface NormalizeDenoJunitOptions { } interface TestcaseRecord { - classname: string; - name: string; - time: string; + attributes: Map; body: string; } +interface TestsuiteRecord { + attributes: Map; + testcases: TestcaseRecord[]; +} + if (import.meta.main) { await normalizeDenoJunitFile({ inputPath: coverageJunitPath, @@ -42,59 +45,150 @@ export function normalizeDenoJunitXml( const rootAttributes = parseAttributes( /]*)>/.exec(input)?.[1] ?? "", ); - const testcases = extractTestcases(input); + const suites = extractTestsuites(input); + const testcases = suites.flatMap((suite) => suite.testcases); const tests = testcases.length; - const failures = - testcases.filter((testcase) => / / /', - ``, - ` `, + ``, ]; - for (const testcase of testcases) { + for (const suite of suites) { + const suiteFailures = countTestcases(suite.testcases, /`, + ` `, ); - const body = testcase.body.trim(); - if (body.length > 0) { - lines.push(indent(body, " ")); + + for (const testcase of suite.testcases) { + const outputTestcaseAttributes = new Map(testcase.attributes); + outputTestcaseAttributes.set( + "classname", + testcase.attributes.get("classname") ?? suite.attributes.get("name") ?? + "deno", + ); + outputTestcaseAttributes.set( + "name", + testcase.attributes.get("name") ?? "unnamed test", + ); + outputTestcaseAttributes.set( + "time", + testcase.attributes.get("time") ?? "0", + ); + + lines.push( + ` `, + ); + const body = testcase.body.trim(); + if (body.length > 0) { + lines.push(indent(body, " ")); + } + lines.push(" "); } - lines.push(" "); + + lines.push(" "); } - lines.push(" ", "", ""); + lines.push("", ""); return lines.join("\n"); } +function extractTestsuites(input: string): TestsuiteRecord[] { + const suites: TestsuiteRecord[] = []; + const testsuitePattern = /]*)>([\s\S]*?)<\/testsuite>/g; + for (const match of input.matchAll(testsuitePattern)) { + suites.push({ + attributes: parseAttributes(match[1]), + testcases: extractTestcases(match[2]), + }); + } + + if (suites.length > 0) { + return suites; + } + + return [{ + attributes: new Map([["name", "deno"]]), + testcases: extractTestcases(input), + }]; +} + function extractTestcases(input: string): TestcaseRecord[] { const testcases: TestcaseRecord[] = []; const testcasePattern = /]*)>([\s\S]*?)<\/testcase>/g; for (const match of input.matchAll(testcasePattern)) { - const attributes = parseAttributes(match[1]); testcases.push({ - classname: attributes.get("classname") ?? "deno", - name: attributes.get("name") ?? "unnamed test", - time: attributes.get("time") ?? "0", + attributes: parseAttributes(match[1]), body: match[2], }); } return testcases; } +function countTestcases( + testcases: readonly TestcaseRecord[], + pattern: RegExp, +): number { + return testcases.filter((testcase) => pattern.test(testcase.body)).length; +} + function parseAttributes(input: string): Map { const attributes = new Map(); const attributePattern = /([A-Za-z_:][A-Za-z0-9_.:-]*)="([^"]*)"/g; @@ -106,12 +200,33 @@ function parseAttributes(input: string): Map { function sumTestcaseTime(testcases: readonly TestcaseRecord[]): string { const total = testcases.reduce((sum, testcase) => { - const value = Number(testcase.time); + const value = Number(testcase.attributes.get("time") ?? "0"); return Number.isFinite(value) ? sum + value : sum; }, 0); return total.toFixed(3); } +function renderAttributes( + attributes: ReadonlyMap, + preferredOrder: readonly string[], +): string { + const seen = new Set(); + const orderedNames = [ + ...preferredOrder, + ...[...attributes.keys()].filter((name) => !preferredOrder.includes(name)), + ].filter((name) => { + if (seen.has(name)) { + return false; + } + seen.add(name); + return attributes.has(name); + }); + + return orderedNames.map((name) => + `${name}="${escapeAttribute(attributes.get(name) ?? "")}"` + ).join(" "); +} + function escapeAttribute(value: string): string { return value .replaceAll("&", "&") diff --git a/src/core/knop/add_reference.ts b/src/core/knop/add_reference.ts index de7c295..3444c1b 100644 --- a/src/core/knop/add_reference.ts +++ b/src/core/knop/add_reference.ts @@ -10,6 +10,8 @@ import { SFLO_NAMESPACE, SFLO_TURTLE_PREFIX_DECLARATION, } from "../rdf/namespaces.ts"; +import { WeaveInputError } from "../weave/errors.ts"; +import { renderKnopInventoryWithPreservedSupportArtifacts } from "../weave/knop_support_renderers.ts"; const RDF_TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const XSD_NON_NEGATIVE_INTEGER_IRI = @@ -24,12 +26,9 @@ const SFLO_HAS_ARTIFACT_HISTORY_IRI = `${SFLO_NAMESPACE}hasArtifactHistory`; const SFLO_HAS_HISTORICAL_STATE_IRI = `${SFLO_NAMESPACE}hasHistoricalState`; const SFLO_HAS_KNOP_INVENTORY_IRI = `${SFLO_NAMESPACE}hasKnopInventory`; const SFLO_HAS_KNOP_METADATA_IRI = `${SFLO_NAMESPACE}hasKnopMetadata`; -const SFLO_HAS_EXTRACTION_SOURCE_IRI = `${SFLO_NAMESPACE}hasExtractionSource`; const SFLO_LOCATED_FILE_FOR_MANIFESTATION_IRI = `${SFLO_NAMESPACE}locatedFileForManifestation`; const SFLO_HAS_MANIFESTATION_IRI = `${SFLO_NAMESPACE}hasManifestation`; -const SFLO_HAS_KNOP_SOURCE_REGISTRY_IRI = - `${SFLO_NAMESPACE}hasKnopSourceRegistry`; const SFLO_HAS_REFERENCE_CATALOG_IRI = `${SFLO_NAMESPACE}hasReferenceCatalog`; const SFLO_HAS_RESOURCE_PAGE_IRI = `${SFLO_NAMESPACE}hasResourcePage`; const SFLO_HAS_WORKING_KNOP_INVENTORY_FILE_IRI = @@ -41,7 +40,6 @@ const SFLO_HISTORY_ORDINAL_IRI = `${SFLO_NAMESPACE}historyOrdinal`; const SFLO_KNOP_IRI = `${SFLO_NAMESPACE}Knop`; const SFLO_KNOP_INVENTORY_IRI = `${SFLO_NAMESPACE}KnopInventory`; const SFLO_KNOP_METADATA_IRI = `${SFLO_NAMESPACE}KnopMetadata`; -const SFLO_KNOP_SOURCE_REGISTRY_IRI = `${SFLO_NAMESPACE}KnopSourceRegistry`; const SFLO_LATEST_HISTORICAL_STATE_IRI = `${SFLO_NAMESPACE}latestHistoricalState`; const SFLO_LOCATED_FILE_FOR_STATE_IRI = `${SFLO_NAMESPACE}locatedFileForState`; @@ -97,13 +95,6 @@ export class KnopAddReferenceInputError extends Error { type KnopInventoryShape = "unwoven" | "currentOnlyWoven" | "woven"; -interface CurrentKnopSourceFacts { - sourceRegistryPath?: string; - sourcesFilePath?: string; - extractionSourcePath?: string; - extractionSourceBlock?: string; -} - export function planKnopAddReference( request: ResolvedKnopAddReferenceRequest, ): KnopAddReferencePlan { @@ -306,255 +297,19 @@ function renderUpdatedKnopInventoryTurtle( ) : renderUnwovenKnopInventoryWithReferenceCatalog(meshBase, knopPath); - return renderKnopInventoryWithPreservedSourceFacts({ - meshBase, - currentKnopInventoryTurtle, - renderedKnopInventoryTurtle: rendered, - knopPath, - }); -} - -function renderKnopInventoryWithPreservedSourceFacts(options: { - meshBase: string; - currentKnopInventoryTurtle: string; - renderedKnopInventoryTurtle: string; - knopPath: string; -}): string { - const sourceFacts = resolveCurrentKnopSourceFacts(options); - if ( - sourceFacts.sourceRegistryPath === undefined && - sourceFacts.extractionSourcePath === undefined - ) { - return options.renderedKnopInventoryTurtle; - } - - let blocks = splitTurtleBlocks(options.renderedKnopInventoryTurtle); - const knopBlockIndex = findSubjectBlockIndex(blocks, options.knopPath); - if (knopBlockIndex === -1) { - throw new KnopAddReferenceInputError( - `rendered knop inventory is missing the ${options.knopPath} block`, - ); - } - - blocks = replaceSubjectBlock( - blocks, - options.knopPath, - renderKnopBlockWithSourceFacts(blocks[knopBlockIndex]!, sourceFacts), - ); - if ( - sourceFacts.sourceRegistryPath !== undefined && - sourceFacts.sourcesFilePath !== undefined - ) { - blocks = upsertSubjectBlockAfter( - blocks, - `${options.knopPath}/_inventory`, - sourceFacts.sourceRegistryPath, - renderSubjectPredicateBlock( - sourceFacts.sourceRegistryPath, - "sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument", - [ - `sflo:hasWorkingLocatedFile <${sourceFacts.sourcesFilePath}>`, - ], - ), - ); - blocks = upsertSubjectBlockAfter( - blocks, - sourceFacts.sourceRegistryPath, - sourceFacts.sourcesFilePath, - renderLocatedFileBlock(sourceFacts.sourcesFilePath), - ); - } - if ( - sourceFacts.extractionSourcePath !== undefined && - sourceFacts.extractionSourceBlock !== undefined - ) { - blocks = upsertSubjectBlockAfter( - blocks, - sourceFacts.sourceRegistryPath ?? `${options.knopPath}/_inventory`, - sourceFacts.extractionSourcePath, - sourceFacts.extractionSourceBlock, - ); - } - - return `${blocks.join("\n\n")}\n`; -} - -function resolveCurrentKnopSourceFacts(options: { - meshBase: string; - currentKnopInventoryTurtle: string; - knopPath: string; -}): CurrentKnopSourceFacts { - const errorMessage = - `Could not resolve carried source facts from current KnopInventory for ${options.knopPath}.`; - const quads = parseKnopInventoryQuads( - options.meshBase, - options.currentKnopInventoryTurtle, - errorMessage, - ); - const knopIri = new URL(options.knopPath, options.meshBase).href; - const sourceRegistryIri = requireOptionalNamedNodeObject( - quads, - knopIri, - SFLO_HAS_KNOP_SOURCE_REGISTRY_IRI, - errorMessage, - ); - const extractionSourceIri = requireOptionalNamedNodeObject( - quads, - knopIri, - SFLO_HAS_EXTRACTION_SOURCE_IRI, - errorMessage, - ); - const sourceRegistryPath = sourceRegistryIri === undefined - ? undefined - : toMeshPath(options.meshBase, sourceRegistryIri, errorMessage); - const extractionSourcePath = extractionSourceIri === undefined - ? undefined - : toMeshPath(options.meshBase, extractionSourceIri, errorMessage); - const sourcesFilePath = sourceRegistryIri === undefined - ? undefined - : requireOptionalNamedNodeObject( - quads, - sourceRegistryIri, - SFLO_HAS_WORKING_LOCATED_FILE_IRI, - errorMessage, - ); - const sourcesFileRelativePath = sourcesFilePath === undefined - ? undefined - : toMeshPath(options.meshBase, sourcesFilePath, errorMessage); - const extractionSourceBlock = extractionSourcePath === undefined - ? undefined - : splitTurtleBlocks(options.currentKnopInventoryTurtle).find((block) => - getSubjectPathFromBlock(block) === extractionSourcePath - ); - - if ( - sourceRegistryPath !== undefined && - !hasNamedNodeFact( - quads, - options.meshBase, - sourceRegistryPath, - RDF_TYPE_IRI, - SFLO_KNOP_SOURCE_REGISTRY_IRI, - ) - ) { - throw new KnopAddReferenceInputError(errorMessage); - } - if ( - sourceRegistryPath !== undefined && sourcesFileRelativePath === undefined - ) { - throw new KnopAddReferenceInputError(errorMessage); - } - - return { - ...(sourceRegistryPath !== undefined ? { sourceRegistryPath } : {}), - ...(sourcesFileRelativePath !== undefined - ? { sourcesFilePath: sourcesFileRelativePath } - : {}), - ...(extractionSourcePath !== undefined ? { extractionSourcePath } : {}), - ...(extractionSourceBlock !== undefined ? { extractionSourceBlock } : {}), - }; -} - -function renderKnopBlockWithSourceFacts( - block: string, - sourceFacts: CurrentKnopSourceFacts, -): string { - const carriedLines = [ - ...(sourceFacts.sourceRegistryPath === undefined ? [] : [ - ` sflo:hasKnopSourceRegistry <${sourceFacts.sourceRegistryPath}> ;`, - ]), - ...(sourceFacts.extractionSourcePath === undefined ? [] : [ - ` sflo:hasExtractionSource <${sourceFacts.extractionSourcePath}> ;`, - ]), - ].filter((line) => !block.includes(line)); - if (carriedLines.length === 0) { - return block; - } - - const workingInventoryLine = " sflo:hasWorkingKnopInventoryFile "; - if (!block.includes(workingInventoryLine)) { - throw new KnopAddReferenceInputError( - "could not preserve source facts because the Knop block is missing hasWorkingKnopInventoryFile", - ); - } - - return block.replace( - workingInventoryLine, - `${carriedLines.join("\n")}\n${workingInventoryLine}`, - ); -} - -function splitTurtleBlocks(turtle: string): string[] { - return turtle.trimEnd().split("\n\n"); -} - -function replaceSubjectBlock( - blocks: readonly string[], - subjectPath: string, - replacementBlock: string, -): string[] { - const index = findSubjectBlockIndex(blocks, subjectPath); - if (index === -1) { - throw new KnopAddReferenceInputError( - `rendered knop inventory did not contain subject block <${subjectPath}>`, - ); - } - - const nextBlocks = [...blocks]; - nextBlocks[index] = replacementBlock; - return nextBlocks; -} - -function upsertSubjectBlockAfter( - blocks: readonly string[], - anchorSubjectPath: string, - subjectPath: string, - block: string, -): string[] { - const existingIndex = findSubjectBlockIndex(blocks, subjectPath); - if (existingIndex !== -1) { - const nextBlocks = [...blocks]; - nextBlocks[existingIndex] = block; - return nextBlocks; - } - - const anchorIndex = findSubjectBlockIndex(blocks, anchorSubjectPath); - if (anchorIndex === -1) { - throw new KnopAddReferenceInputError( - `rendered knop inventory did not contain anchor subject block <${anchorSubjectPath}>`, - ); + try { + return renderKnopInventoryWithPreservedSupportArtifacts({ + meshBase, + currentKnopInventoryTurtle, + renderedKnopInventoryTurtle: rendered, + knopPath, + }); + } catch (error) { + if (error instanceof WeaveInputError) { + throw new KnopAddReferenceInputError(error.message); + } + throw error; } - - const nextBlocks = [...blocks]; - nextBlocks.splice(anchorIndex + 1, 0, block); - return nextBlocks; -} - -function findSubjectBlockIndex( - blocks: readonly string[], - subjectPath: string, -): number { - return blocks.findIndex((block) => - getSubjectPathFromBlock(block) === subjectPath - ); -} - -function getSubjectPathFromBlock(block: string): string | undefined { - const match = block.match(/^<([^>]*)>/); - return match?.[1]; -} - -function renderSubjectPredicateBlock( - subjectPath: string, - typeList: string, - predicates: readonly string[], -): string { - return `<${subjectPath}> a ${typeList} ; - ${predicates.join(" ;\n ")} .`; -} - -function renderLocatedFileBlock(path: string): string { - return `<${path}> a sflo:LocatedFile, sflo:RdfDocument .`; } function classifyCurrentKnopInventoryShape( @@ -1306,37 +1061,3 @@ function parseKnopInventoryQuads( throw new KnopAddReferenceInputError(errorMessage); } } - -function requireOptionalNamedNodeObject( - quads: readonly Quad[], - subjectIri: string, - predicateIri: string, - errorMessage: string, -): string | undefined { - const values = quads.flatMap((quad) => - quad.subject.termType === "NamedNode" && - quad.subject.value === subjectIri && - quad.predicate.value === predicateIri && - quad.object.termType === "NamedNode" - ? [quad.object.value] - : [] - ); - - if (values.length > 1) { - throw new KnopAddReferenceInputError(errorMessage); - } - - return values[0]; -} - -function toMeshPath( - meshBase: string, - iri: string, - errorMessage: string, -): string { - if (!iri.startsWith(meshBase)) { - throw new KnopAddReferenceInputError(errorMessage); - } - - return iri.slice(meshBase.length); -} diff --git a/src/core/knop/add_reference_test.ts b/src/core/knop/add_reference_test.ts index 2c65987..8c7d5c6 100644 --- a/src/core/knop/add_reference_test.ts +++ b/src/core/knop/add_reference_test.ts @@ -1,4 +1,10 @@ -import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { + assert, + assertEquals, + assertStringIncludes, + assertThrows, +} from "@std/assert"; +import { Parser, type Quad } from "n3"; import { readMeshAliceBioBranchFile } from "../../../tests/support/mesh_alice_bio_fixture.ts"; import { KnopAddReferenceInputError, @@ -300,21 +306,79 @@ Deno.test("planKnopAddReference preserves extracted source registry facts", () = }); const updatedInventory = plan.updatedFiles[0]?.contents ?? ""; + assert( + hasNamedNodeFact( + updatedInventory, + "bob/_knop", + "https://semantic-flow.github.io/sflo/ontology/hasKnopSourceRegistry", + "bob/_knop/_sources", + ), + ); + assert( + hasNamedNodeFact( + updatedInventory, + "bob/_knop", + "https://semantic-flow.github.io/sflo/ontology/hasExtractionSource", + "bob/_knop/_sources#extraction-source", + ), + ); assertStringIncludes( updatedInventory, - "sflo:hasKnopSourceRegistry ;", + " a sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument ;", ); assertStringIncludes( updatedInventory, - "sflo:hasExtractionSource ;", + " a sflo:LocatedFile, sflo:RdfDocument .", ); +}); + +Deno.test("planKnopAddReference preserves unknown source registry facts byte-for-byte", () => { + const plan = planKnopAddReference({ + meshBase: "https://semantic-flow.github.io/mesh-alice-bio/", + designatorPath: "bob", + referenceTargetDesignatorPath: "alice/data", + referenceRole: "canonical", + currentKnopInventoryTurtle: extractedKnopInventory + .replace( + "@prefix sflo: .", + `@prefix ex: . +@prefix sflo: .`, + ) + .replace( + " sflo:hasWorkingLocatedFile .", + ` ex:opaqueSourceFact "keep exactly" ; + sflo:hasWorkingLocatedFile .`, + ), + }); + const updatedInventory = plan.updatedFiles[0]?.contents ?? ""; + assertStringIncludes( updatedInventory, - " a sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument ;", + "@prefix ex: .", ); assertStringIncludes( updatedInventory, - " a sflo:LocatedFile, sflo:RdfDocument .", + ` ex:opaqueSourceFact "keep exactly" ; + sflo:hasWorkingLocatedFile .`, + ); +}); + +Deno.test("planKnopAddReference fails closed on conflicting source registry facts", () => { + assertThrows( + () => + planKnopAddReference({ + meshBase: "https://semantic-flow.github.io/mesh-alice-bio/", + designatorPath: "bob", + referenceTargetDesignatorPath: "alice/data", + referenceRole: "canonical", + currentKnopInventoryTurtle: extractedKnopInventory.replace( + " sflo:hasKnopSourceRegistry ;", + ` sflo:hasKnopSourceRegistry ; + sflo:hasKnopSourceRegistry ;`, + ), + }), + KnopAddReferenceInputError, + "Could not resolve Knop source registry", ); }); @@ -340,9 +404,13 @@ Deno.test("planKnopAddReference supports current-only woven extracted KnopInvent updatedInventory, " a sflo:ReferenceCatalog, sflo:DigitalArtifact, sflo:RdfDocument ;", ); - assertStringIncludes( - updatedInventory, - "sflo:hasKnopSourceRegistry ;", + assert( + hasNamedNodeFact( + updatedInventory, + "bob/_knop", + "https://semantic-flow.github.io/sflo/ontology/hasKnopSourceRegistry", + "bob/_knop/_sources", + ), ); assertEquals(updatedInventory.includes("sflo:hasArtifactHistory"), false); }); @@ -462,3 +530,22 @@ function withRdfPrefix(turtle: string): string { @prefix sflo: .`, ); } + +function hasNamedNodeFact( + turtle: string, + subjectValue: string, + predicateIri: string, + objectValue: string, +): boolean { + const meshBase = "https://semantic-flow.github.io/mesh-alice-bio/"; + const subjectIri = new URL(subjectValue, meshBase).href; + const objectIri = new URL(objectValue, meshBase).href; + + return new Parser({ baseIRI: meshBase }).parse(turtle).some((quad: Quad) => + quad.subject.termType === "NamedNode" && + quad.subject.value === subjectIri && + quad.predicate.value === predicateIri && + quad.object.termType === "NamedNode" && + quad.object.value === objectIri + ); +} diff --git a/src/core/weave/inventory_append_planner.ts b/src/core/weave/inventory_append_planner.ts new file mode 100644 index 0000000..131fc88 --- /dev/null +++ b/src/core/weave/inventory_append_planner.ts @@ -0,0 +1,354 @@ +import { Parser, type Quad, type Term } from "n3"; +import { XSD_NAMESPACE } from "../rdf/namespaces.ts"; +import { escapeTurtleString } from "../rdf/turtle.ts"; +import { WeaveInputError } from "./errors.ts"; + +const XSD_STRING_IRI = `${XSD_NAMESPACE}string`; + +export interface InventoryAppendPlannerInput { + readonly baseIri: string; + readonly currentInventoryTurtle: string; + readonly requestedSettledFactsTurtle: string; + readonly singleValuedSettledPredicates?: readonly string[]; + readonly currentInventoryLabel?: string; + readonly requestedFactsLabel?: string; +} + +export interface InventoryFactSummary { + readonly key: string; + readonly subject: string; + readonly predicate: string; + readonly object: string; + readonly turtle: string; +} + +export interface InventoryFactConflict { + readonly predicate: string; + readonly requested: InventoryFactSummary; + readonly existing: readonly InventoryFactSummary[]; + readonly message: string; +} + +export type InventoryAppendPlan = + | { + readonly kind: "unchanged"; + readonly outputTurtle: string; + readonly alreadyPresent: readonly InventoryFactSummary[]; + readonly missing: readonly []; + readonly conflicts: readonly []; + readonly appendTurtle: ""; + } + | { + readonly kind: "append"; + readonly outputTurtle: string; + readonly alreadyPresent: readonly InventoryFactSummary[]; + readonly missing: readonly InventoryFactSummary[]; + readonly conflicts: readonly []; + readonly appendTurtle: string; + } + | { + readonly kind: "conflict"; + readonly alreadyPresent: readonly InventoryFactSummary[]; + readonly missing: readonly InventoryFactSummary[]; + readonly conflicts: readonly InventoryFactConflict[]; + }; + +interface RequestedFact { + readonly quad: Quad; + readonly summary: InventoryFactSummary; +} + +export function planInventoryAppend( + input: InventoryAppendPlannerInput, +): InventoryAppendPlan { + const currentLabel = input.currentInventoryLabel ?? "current inventory"; + const requestedLabel = input.requestedFactsLabel ?? "requested inventory"; + const currentQuads = parseTurtle( + input.baseIri, + input.currentInventoryTurtle, + currentLabel, + ); + const requestedFacts = parseRequestedFacts( + input.baseIri, + input.requestedSettledFactsTurtle, + requestedLabel, + ); + const singleValuedPredicates = new Set( + input.singleValuedSettledPredicates ?? [], + ); + const currentKeys = new Set(currentQuads.map(toQuadKey)); + const currentBySubjectPredicate = groupBySubjectPredicate(currentQuads); + const requestedBySubjectPredicate = new Map(); + const alreadyPresent: InventoryFactSummary[] = []; + const missing: InventoryFactSummary[] = []; + const conflicts: InventoryFactConflict[] = []; + + for (const requestedFact of requestedFacts) { + const requestedQuad = requestedFact.quad; + const subjectPredicateKey = toSubjectPredicateKey(requestedQuad); + const sameSlotFacts = currentBySubjectPredicate.get( + subjectPredicateKey, + ) ?? []; + const hasConflict = singleValuedPredicates.has( + requestedQuad.predicate.value, + ); + const priorRequestedFacts = hasConflict + ? requestedBySubjectPredicate.get(subjectPredicateKey) ?? [] + : []; + const conflictingFacts = hasConflict + ? [ + ...sameSlotFacts.filter((quad) => + !rdfTermsEqual(quad.object, requestedQuad.object) + ).map(toFactSummary), + ...priorRequestedFacts.filter((fact) => + !rdfTermsEqual(fact.quad.object, requestedQuad.object) + ).map((fact) => fact.summary), + ] + : []; + + if (hasConflict) { + requestedBySubjectPredicate.set(subjectPredicateKey, [ + ...priorRequestedFacts, + requestedFact, + ]); + } + + if (conflictingFacts.length > 0) { + conflicts.push({ + predicate: requestedQuad.predicate.value, + requested: requestedFact.summary, + existing: conflictingFacts, + message: formatConflictMessage( + requestedFact.summary, + conflictingFacts, + ), + }); + continue; + } + + if (currentKeys.has(requestedFact.summary.key)) { + alreadyPresent.push(requestedFact.summary); + } else { + missing.push(requestedFact.summary); + } + } + + if (conflicts.length > 0) { + return { + kind: "conflict", + alreadyPresent, + missing, + conflicts, + }; + } + + if (missing.length === 0) { + return { + kind: "unchanged", + outputTurtle: input.currentInventoryTurtle, + alreadyPresent, + missing: [], + conflicts: [], + appendTurtle: "", + }; + } + + const appendTurtle = `${missing.map((fact) => fact.turtle).join("\n")}\n`; + return { + kind: "append", + outputTurtle: appendToCurrentTurtle( + input.currentInventoryTurtle, + appendTurtle, + ), + alreadyPresent, + missing, + conflicts: [], + appendTurtle, + }; +} + +function parseTurtle(baseIri: string, turtle: string, label: string): Quad[] { + try { + return new Parser({ baseIRI: baseIri }).parse(turtle); + } catch { + throw new WeaveInputError(`Could not parse ${label} Turtle.`); + } +} + +function parseRequestedFacts( + baseIri: string, + turtle: string, + label: string, +): RequestedFact[] { + const seenKeys = new Set(); + const facts: RequestedFact[] = []; + + for (const quad of parseTurtle(baseIri, turtle, label)) { + assertAppendableRequestedFact(quad, label); + const summary = toFactSummary(quad); + if (seenKeys.has(summary.key)) { + continue; + } + seenKeys.add(summary.key); + facts.push({ quad, summary }); + } + + return facts; +} + +function assertAppendableRequestedFact(quad: Quad, label: string): void { + if (quad.graph.termType !== "DefaultGraph") { + throw new WeaveInputError( + `Could not plan ${label} append because requested inventory facts must be in the default graph.`, + ); + } + if (quad.subject.termType !== "NamedNode") { + throw new WeaveInputError( + `Could not plan ${label} append because requested inventory fact subjects must be named nodes.`, + ); + } + if (quad.predicate.termType !== "NamedNode") { + throw new WeaveInputError( + `Could not plan ${label} append because requested inventory fact predicates must be named nodes.`, + ); + } + if ( + quad.object.termType !== "NamedNode" && + quad.object.termType !== "Literal" + ) { + throw new WeaveInputError( + `Could not plan ${label} append because requested inventory fact objects must be named nodes or literals.`, + ); + } +} + +function groupBySubjectPredicate( + quads: readonly Quad[], +): ReadonlyMap { + const groups = new Map(); + + for (const quad of quads) { + const key = toSubjectPredicateKey(quad); + const group = groups.get(key); + if (group) { + group.push(quad); + } else { + groups.set(key, [quad]); + } + } + + return groups; +} + +function toSubjectPredicateKey(quad: Quad): string { + return [ + toTermKey(quad.graph), + toTermKey(quad.subject), + toTermKey(quad.predicate), + ].join("|"); +} + +function toQuadKey(quad: Quad): string { + return [ + toTermKey(quad.graph), + toTermKey(quad.subject), + toTermKey(quad.predicate), + toTermKey(quad.object), + ].join("|"); +} + +function rdfTermsEqual(left: Term, right: Term): boolean { + return toTermKey(left) === toTermKey(right); +} + +function toTermKey(term: Term): string { + if (term.termType === "Literal") { + return [ + term.termType, + term.value, + term.language, + term.datatype.value, + ].join(":"); + } + + return `${term.termType}:${term.value}`; +} + +function toFactSummary(quad: Quad): InventoryFactSummary { + const subject = formatTermForMessage(quad.subject); + const predicate = formatTermForMessage(quad.predicate); + const object = formatTermForMessage(quad.object); + return { + key: toQuadKey(quad), + subject, + predicate, + object, + turtle: `${renderTurtleTerm(quad.subject)} ${ + renderTurtleTerm(quad.predicate) + } ${renderTurtleTerm(quad.object)} .`, + }; +} + +function renderTurtleTerm(term: Term): string { + switch (term.termType) { + case "NamedNode": + return `<${term.value}>`; + case "Literal": + return renderTurtleLiteral(term); + default: + throw new WeaveInputError( + "Could not render requested inventory append fact.", + ); + } +} + +function renderTurtleLiteral(term: Term & { termType: "Literal" }): string { + const value = `"${escapeTurtleString(term.value)}"`; + if (term.language.length > 0) { + return `${value}@${term.language}`; + } + if (term.datatype.value === XSD_STRING_IRI) { + return value; + } + return `${value}^^<${term.datatype.value}>`; +} + +function formatTermForMessage(term: Term): string { + switch (term.termType) { + case "NamedNode": + return `<${term.value}>`; + case "BlankNode": + return `_:${term.value}`; + case "Literal": + return renderTurtleLiteral(term); + case "DefaultGraph": + return "default graph"; + default: + return `${term.termType}:${term.value}`; + } +} + +function formatConflictMessage( + requested: InventoryFactSummary, + existing: readonly InventoryFactSummary[], +): string { + return `Requested settled inventory fact ${requested.turtle} conflicts with existing fact ${ + existing.map((fact) => fact.turtle).join(" ") + }`; +} + +function appendToCurrentTurtle( + currentTurtle: string, + appendTurtle: string, +): string { + if (currentTurtle.length === 0) { + return appendTurtle; + } + if (currentTurtle.endsWith("\n\n")) { + return `${currentTurtle}${appendTurtle}`; + } + if (currentTurtle.endsWith("\n")) { + return `${currentTurtle}\n${appendTurtle}`; + } + return `${currentTurtle}\n\n${appendTurtle}`; +} diff --git a/src/core/weave/inventory_append_planner_test.ts b/src/core/weave/inventory_append_planner_test.ts new file mode 100644 index 0000000..2d7206c --- /dev/null +++ b/src/core/weave/inventory_append_planner_test.ts @@ -0,0 +1,199 @@ +import { + assert, + assertEquals, + assertFalse, + assertStringIncludes, +} from "@std/assert"; +import { Parser, type Quad } from "n3"; +import { RDF_NAMESPACE, SFLO_NAMESPACE } from "../rdf/namespaces.ts"; +import { planInventoryAppend } from "./inventory_append_planner.ts"; + +const meshBase = "https://semantic-flow.github.io/mesh-alice-bio/"; +const sfloHasPayloadArtifact = `${SFLO_NAMESPACE}hasPayloadArtifact`; +const sfloHasHistoricalState = `${SFLO_NAMESPACE}hasHistoricalState`; + +Deno.test("planInventoryAppend treats semantic duplicate facts as a no-op", () => { + const currentInventoryTurtle = `@base <${meshBase}> . +@prefix rdf: <${RDF_NAMESPACE}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + rdf:type sflo:Knop . +`; + const plan = planInventoryAppend({ + baseIri: meshBase, + currentInventoryTurtle, + requestedSettledFactsTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + a sflo:Knop . +`, + }); + + assert(plan.kind === "unchanged"); + assertEquals(plan.outputTurtle, currentInventoryTurtle); + assertEquals(plan.alreadyPresent.length, 1); + assertEquals(plan.missing.length, 0); +}); + +Deno.test("planInventoryAppend appends only missing requested facts", () => { + const currentInventoryTurtle = `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + a sflo:Knop . +`; + const plan = planInventoryAppend({ + baseIri: meshBase, + currentInventoryTurtle, + requestedSettledFactsTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + a sflo:Knop ; + sflo:hasPayloadArtifact . +`, + }); + + assert(plan.kind === "append"); + assertEquals(plan.alreadyPresent.length, 1); + assertEquals(plan.missing.length, 1); + assert(plan.outputTurtle.startsWith(currentInventoryTurtle)); + assertFalse(plan.appendTurtle.includes("sflo:Knop")); + assert( + hasNamedNodeFact( + plan.outputTurtle, + `${meshBase}alice/_knop`, + sfloHasPayloadArtifact, + `${meshBase}alice/data`, + ), + ); +}); + +Deno.test("planInventoryAppend fails closed on single-valued predicate conflicts", () => { + const plan = planInventoryAppend({ + baseIri: meshBase, + currentInventoryTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + sflo:hasPayloadArtifact . +`, + requestedSettledFactsTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + sflo:hasPayloadArtifact . +`, + singleValuedSettledPredicates: [sfloHasPayloadArtifact], + }); + + assert(plan.kind === "conflict"); + assertEquals(plan.conflicts.length, 1); + assertEquals("outputTurtle" in plan, false); + assertStringIncludes(plan.conflicts[0]!.message, "alice/data-v1"); + assertStringIncludes(plan.conflicts[0]!.message, "alice/data-v2"); +}); + +Deno.test("planInventoryAppend fails closed on divergent requested single-valued facts", () => { + const plan = planInventoryAppend({ + baseIri: meshBase, + currentInventoryTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + a sflo:Knop . +`, + requestedSettledFactsTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + sflo:hasPayloadArtifact , . +`, + singleValuedSettledPredicates: [sfloHasPayloadArtifact], + }); + + assert(plan.kind === "conflict"); + assertEquals(plan.conflicts.length, 1); + assertStringIncludes(plan.conflicts[0]!.message, "alice/data-v1"); + assertStringIncludes(plan.conflicts[0]!.message, "alice/data-v2"); +}); + +Deno.test("planInventoryAppend allows repeated requested multi-valued predicates", () => { + const plan = planInventoryAppend({ + baseIri: meshBase, + currentInventoryTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + a sflo:PayloadArtifact . +`, + requestedSettledFactsTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + sflo:hasHistoricalState , . +`, + singleValuedSettledPredicates: [sfloHasPayloadArtifact], + }); + + assert(plan.kind === "append"); + assertEquals(plan.conflicts.length, 0); + assertEquals(plan.missing.length, 2); + assert( + hasNamedNodeFact( + plan.outputTurtle, + `${meshBase}alice/_history001`, + sfloHasHistoricalState, + `${meshBase}alice/_history001/_s0001`, + ), + ); + assert( + hasNamedNodeFact( + plan.outputTurtle, + `${meshBase}alice/_history001`, + sfloHasHistoricalState, + `${meshBase}alice/_history001/_s0002`, + ), + ); +}); + +Deno.test("planInventoryAppend preserves unknown existing triples byte-for-byte", () => { + const currentInventoryTurtle = `@base <${meshBase}> . +@prefix ex: . +@prefix sflo: <${SFLO_NAMESPACE}> . + +# hand-curated local inventory note + ex:curatedNote "keep this byte-for-byte" . +`; + const plan = planInventoryAppend({ + baseIri: meshBase, + currentInventoryTurtle, + requestedSettledFactsTurtle: `@base <${meshBase}> . +@prefix sflo: <${SFLO_NAMESPACE}> . + + sflo:hasPayloadArtifact . +`, + }); + + assert(plan.kind === "append"); + assert(plan.outputTurtle.startsWith(currentInventoryTurtle)); + assertStringIncludes( + plan.outputTurtle, + '# hand-curated local inventory note\n ex:curatedNote "keep this byte-for-byte" .', + ); + assert( + hasNamedNodeFact( + plan.outputTurtle, + `${meshBase}alice/_knop`, + sfloHasPayloadArtifact, + `${meshBase}alice/data`, + ), + ); +}); + +function hasNamedNodeFact( + turtle: string, + subjectIri: string, + predicateIri: string, + objectIri: string, +): boolean { + return new Parser({ baseIRI: meshBase }).parse(turtle).some((quad: Quad) => + quad.subject.termType === "NamedNode" && + quad.subject.value === subjectIri && + quad.predicate.value === predicateIri && + quad.object.termType === "NamedNode" && + quad.object.value === objectIri + ); +} diff --git a/src/core/weave/knop_inventory_renderers.ts b/src/core/weave/knop_inventory_renderers.ts index a0f366c..95e6d83 100644 --- a/src/core/weave/knop_inventory_renderers.ts +++ b/src/core/weave/knop_inventory_renderers.ts @@ -349,6 +349,53 @@ export function renderCurrentOnlyReferenceCatalogWovenKnopInventoryTurtle( return `${finalBlocks.join("\n\n")}\n`; } +export function renderCurrentOnlyPageDefinitionWovenKnopInventoryTurtle( + _meshBase: string, + currentKnopInventoryTurtle: string, + designatorPath: string, + workingLocalRelativePath: string, +): string { + const pageDefinitionPath = `${toKnopPath(designatorPath)}/_page`; + const pageDefinitionPagePath = `${pageDefinitionPath}/index.html`; + const blocks = splitTurtleBlocks(currentKnopInventoryTurtle); + const currentPageDefinitionBlockIndex = findSubjectBlockIndex( + blocks, + pageDefinitionPath, + ); + if (currentPageDefinitionBlockIndex === -1) { + throw new WeaveInputError( + `Current KnopInventory did not contain ResourcePageDefinition block <${pageDefinitionPath}>.`, + ); + } + + const currentPageDefinitionBlock = blocks[currentPageDefinitionBlockIndex]!; + if ( + !currentPageDefinitionBlock.includes(`<${workingLocalRelativePath}>`) && + !currentPageDefinitionBlock.includes(`"${workingLocalRelativePath}"`) + ) { + throw new WeaveInputError( + `Current ResourcePageDefinition block did not carry the expected working file for ${designatorPath}.`, + ); + } + + const blocksWithPage = replaceSubjectBlock( + blocks, + pageDefinitionPath, + appendPredicateToSubjectBlock( + currentPageDefinitionBlock, + `sflo:hasResourcePage <${pageDefinitionPagePath}>`, + ), + ); + const finalBlocks = upsertSubjectBlockAfter( + blocksWithPage, + pageDefinitionPath, + pageDefinitionPagePath, + renderResourcePageLocatedFileBlock(pageDefinitionPagePath), + ); + + return `${finalBlocks.join("\n\n")}\n`; +} + export function renderSubsequentPageDefinitionWovenKnopInventoryTurtle( meshBase: string, designatorPath: string, diff --git a/src/core/weave/knop_support_renderers.ts b/src/core/weave/knop_support_renderers.ts index f3d6aa6..39216ae 100644 --- a/src/core/weave/knop_support_renderers.ts +++ b/src/core/weave/knop_support_renderers.ts @@ -1,5 +1,12 @@ -import { SFLO_NAMESPACE } from "../rdf/namespaces.ts"; +import { Parser, type Quad, type Term } from "n3"; +import { + SFCFG_NAMESPACE, + SFLO_NAMESPACE, + XSD_NAMESPACE, +} from "../rdf/namespaces.ts"; +import { escapeTurtleString } from "../rdf/turtle.ts"; import { WeaveInputError } from "./errors.ts"; +import { planInventoryAppend } from "./inventory_append_planner.ts"; import { hasNamedNodeFact, parseWeaveShapeQuads, @@ -12,10 +19,7 @@ import { collectSubjectSubtreeBlocks, findSubjectBlockIndex, getSubjectPathFromBlock, - renderSubjectPredicateBlock, - replaceSubjectBlock, splitTurtleBlocks, - upsertSubjectBlockAfter, } from "./turtle_blocks.ts"; const RDF_TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; @@ -28,6 +32,29 @@ const SFLO_HAS_WORKING_LOCATED_FILE_IRI = const SFLO_KNOP_SOURCE_REGISTRY_IRI = `${SFLO_NAMESPACE}KnopSourceRegistry`; const SFLO_REFERENCE_CATALOG_IRI = `${SFLO_NAMESPACE}ReferenceCatalog`; const SFLO_WORKING_FILE_PATH_IRI = `${SFLO_NAMESPACE}workingLocalRelativePath`; +const SFLO_CURRENT_ARTIFACT_HISTORY_IRI = + `${SFLO_NAMESPACE}currentArtifactHistory`; +const SFLO_LATEST_HISTORICAL_STATE_IRI = + `${SFLO_NAMESPACE}latestHistoricalState`; +const SFLO_NEXT_HISTORY_ORDINAL_IRI = `${SFLO_NAMESPACE}nextHistoryOrdinal`; +const SFLO_NEXT_STATE_ORDINAL_IRI = `${SFLO_NAMESPACE}nextStateOrdinal`; +const SFCFG_HAS_NEXT_STATE_SEGMENT_HINT_IRI = + `${SFCFG_NAMESPACE}hasNextStateSegmentHint`; +const XSD_STRING_IRI = `${XSD_NAMESPACE}string`; +const MUTABLE_PROGRESSION_PREDICATES = new Set([ + SFLO_CURRENT_ARTIFACT_HISTORY_IRI, + SFLO_LATEST_HISTORICAL_STATE_IRI, + SFLO_NEXT_HISTORY_ORDINAL_IRI, + SFLO_NEXT_STATE_ORDINAL_IRI, + SFCFG_HAS_NEXT_STATE_SEGMENT_HINT_IRI, +]); +const SUPPORT_SINGLE_VALUED_PREDICATES = [ + SFLO_HAS_EXTRACTION_SOURCE_IRI, + SFLO_HAS_KNOP_SOURCE_REGISTRY_IRI, + SFLO_HAS_REFERENCE_CATALOG_IRI, + SFLO_HAS_WORKING_LOCATED_FILE_IRI, + SFLO_WORKING_FILE_PATH_IRI, +]; interface CurrentKnopSourceRegistry { sourceRegistryPath: string; @@ -37,6 +64,21 @@ interface CurrentKnopSourceRegistry { interface CurrentKnopReferenceCatalog { referenceCatalogPath: string; + referencesFilePath: string; + referencesFilePredicateIri: string; +} + +interface RequestedSupportFact { + key: string; + turtle: string; +} + +interface CarriedSupportBlock { + subjectPath: string; + block: string; + quads: readonly Quad[]; + requestedFacts: readonly RequestedSupportFact[]; + hasMutableProgressionFacts: boolean; } export function renderKnopInventoryWithPreservedSupportArtifacts(options: { @@ -51,73 +93,46 @@ export function renderKnopInventoryWithPreservedSupportArtifacts(options: { return options.renderedKnopInventoryTurtle; } - let blocks = splitTurtleBlocks(options.renderedKnopInventoryTurtle); - const currentBlocks = splitTurtleBlocks(options.currentKnopInventoryTurtle); - const knopBlockIndex = findSubjectBlockIndex(blocks, options.knopPath); - if (knopBlockIndex === -1) { - throw new WeaveInputError( - `Rendered KnopInventory did not contain Knop block <${options.knopPath}> while preserving carried Knop support artifacts.`, - ); + const carriedSupport = collectCarriedSupportFactsAndBlocks( + options, + sourceRegistry, + referenceCatalog, + ); + const requestedFactsBody = carriedSupport.facts + .map((fact) => fact.turtle) + .join("\n"); + if (requestedFactsBody.length === 0) { + return options.renderedKnopInventoryTurtle; } + const requestedFactsTurtle = + `${supportFactParseDirectives()}\n\n${requestedFactsBody}`; - blocks = replaceSubjectBlock( - blocks, - options.knopPath, - renderKnopBlockWithCarriedSupportFacts( - blocks[knopBlockIndex]!, - sourceRegistry, - referenceCatalog, - ), - ); - let insertionSubjectPath = `${options.knopPath}/_inventory`; + const plan = planInventoryAppend({ + baseIri: options.meshBase, + currentInventoryTurtle: options.renderedKnopInventoryTurtle, + requestedSettledFactsTurtle: requestedFactsTurtle, + singleValuedSettledPredicates: SUPPORT_SINGLE_VALUED_PREDICATES, + currentInventoryLabel: "rendered KnopInventory", + requestedFactsLabel: "carried Knop support facts", + }); - if (sourceRegistry !== undefined) { - blocks = upsertSubjectBlockAfter( - blocks, - insertionSubjectPath, - sourceRegistry.sourceRegistryPath, - renderSubjectPredicateBlock( - sourceRegistry.sourceRegistryPath, - "sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument", - [ - `sflo:hasWorkingLocatedFile <${sourceRegistry.sourcesFilePath}>`, - ], - ), - ); - blocks = upsertSubjectBlockAfter( - blocks, - sourceRegistry.sourceRegistryPath, - sourceRegistry.sourcesFilePath, - renderLocatedFileBlock(sourceRegistry.sourcesFilePath), + if (plan.kind === "conflict") { + throw new WeaveInputError( + `Could not preserve carried Knop support facts for ${options.knopPath}: ${ + plan.conflicts.map((conflict) => conflict.message).join(" ") + }`, ); - insertionSubjectPath = sourceRegistry.sourcesFilePath; } - if ( - referenceCatalog !== undefined && - findSubjectBlockIndex(blocks, referenceCatalog.referenceCatalogPath) === -1 - ) { - for ( - const block of collectSubjectSubtreeBlocks( - currentBlocks, - referenceCatalog.referenceCatalogPath, - ) - ) { - const subjectPath = getSubjectPathFromBlock(block); - if (!subjectPath) { - continue; - } - blocks = upsertSubjectBlockAfter( - blocks, - insertionSubjectPath, - subjectPath, - block, - ); - insertionSubjectPath = subjectPath; - } + if (plan.kind === "unchanged") { + return options.renderedKnopInventoryTurtle; } - return `${blocks.join("\n\n")}\n`; + return appendMissingCarriedSupportFacts( + options, + plan.missing.map((fact) => fact.key), + carriedSupport, + ); } function resolveCurrentKnopSourceRegistry(options: { @@ -234,42 +249,471 @@ function resolveCurrentKnopReferenceCatalog(options: { throw new WeaveInputError(errorMessage); } - return { referenceCatalogPath }; + return { + referenceCatalogPath, + referencesFilePath, + referencesFilePredicateIri: referencesLocatedFilePath === undefined + ? SFLO_WORKING_FILE_PATH_IRI + : SFLO_HAS_WORKING_LOCATED_FILE_IRI, + }; } -function renderKnopBlockWithCarriedSupportFacts( - block: string, +function collectCarriedSupportFactsAndBlocks( + options: { + meshBase: string; + currentKnopInventoryTurtle: string; + knopPath: string; + }, sourceRegistry: CurrentKnopSourceRegistry | undefined, referenceCatalog: CurrentKnopReferenceCatalog | undefined, +): { + facts: readonly RequestedSupportFact[]; + blocks: readonly CarriedSupportBlock[]; +} { + const currentDirectives = extractTurtleDirectives( + options.currentKnopInventoryTurtle, + ); + const currentBlocks = splitTurtleBlocks(options.currentKnopInventoryTurtle); + const requestedFacts: RequestedSupportFact[] = []; + const carriedBlocks: CarriedSupportBlock[] = []; + const carriedBlockSubjectPaths = new Set(); + + for ( + const fact of renderKnopSupportLinkFacts( + options.knopPath, + sourceRegistry, + referenceCatalog, + ) + ) { + requestedFacts.push(...parseRequestedSupportFacts(options.meshBase, fact)); + } + + const addBlock = (subjectPath: string | undefined) => { + if ( + subjectPath === undefined || carriedBlockSubjectPaths.has(subjectPath) + ) { + return; + } + const blockIndex = findSubjectBlockIndex(currentBlocks, subjectPath); + if (blockIndex === -1) { + return; + } + + const block = currentBlocks[blockIndex]!; + const quads = parseBlockQuads( + options.meshBase, + currentDirectives, + block, + `Could not parse carried Knop support block <${subjectPath}>.`, + ); + const hasMutableProgressionFacts = quads.some((quad) => + MUTABLE_PROGRESSION_PREDICATES.has(quad.predicate.value) + ); + const requestedBlockFacts = quads + .filter((quad) => + !MUTABLE_PROGRESSION_PREDICATES.has(quad.predicate.value) + ) + .map((quad: Quad) => toRequestedSupportFact(quad, options.meshBase)); + carriedBlockSubjectPaths.add(subjectPath); + carriedBlocks.push({ + subjectPath, + block, + quads, + requestedFacts: requestedBlockFacts, + hasMutableProgressionFacts, + }); + requestedFacts.push(...requestedBlockFacts); + }; + + if (sourceRegistry !== undefined) { + addBlock(sourceRegistry.sourceRegistryPath); + addBlock(sourceRegistry.sourcesFilePath); + addBlock(sourceRegistry.extractionSourcePath); + if (!carriedBlockSubjectPaths.has(sourceRegistry.sourceRegistryPath)) { + requestedFacts.push( + ...parseRequestedSupportFacts( + options.meshBase, + renderSourceRegistryFallbackFacts(sourceRegistry), + ), + ); + } + if (!carriedBlockSubjectPaths.has(sourceRegistry.sourcesFilePath)) { + requestedFacts.push( + ...parseRequestedSupportFacts( + options.meshBase, + renderLocatedFileFallbackFact(sourceRegistry.sourcesFilePath), + ), + ); + } + } + + if (referenceCatalog !== undefined) { + for ( + const block of collectSubjectSubtreeBlocks( + currentBlocks, + referenceCatalog.referenceCatalogPath, + ) + ) { + addBlock(getSubjectPathFromBlock(block)); + } + if (!carriedBlockSubjectPaths.has(referenceCatalog.referenceCatalogPath)) { + requestedFacts.push( + ...parseRequestedSupportFacts( + options.meshBase, + renderReferenceCatalogFallbackFacts(referenceCatalog), + ), + ); + } + if (!carriedBlockSubjectPaths.has(referenceCatalog.referencesFilePath)) { + requestedFacts.push( + ...parseRequestedSupportFacts( + options.meshBase, + renderLocatedFileFallbackFact(referenceCatalog.referencesFilePath), + ), + ); + } + } + + return { + facts: deduplicateRequestedFacts(requestedFacts), + blocks: carriedBlocks, + }; +} + +function appendMissingCarriedSupportFacts( + options: { + meshBase: string; + currentKnopInventoryTurtle: string; + renderedKnopInventoryTurtle: string; + }, + missingFactKeys: readonly string[], + carriedSupport: { + facts: readonly RequestedSupportFact[]; + blocks: readonly CarriedSupportBlock[]; + }, ): string { - const carriedLines = [ + const missingKeys = new Set(missingFactKeys); + const coveredByBlockKeys = new Set(); + const renderedSubjectIris = new Set( + parseWeaveShapeQuads( + options.meshBase, + options.renderedKnopInventoryTurtle, + "Could not parse rendered KnopInventory while preserving carried support facts.", + ).flatMap((quad) => + quad.subject.termType === "NamedNode" ? [quad.subject.value] : [] + ), + ); + + const appendBlocks: string[] = []; + for (const block of carriedSupport.blocks) { + const blockHasMissingFacts = block.requestedFacts.some((fact) => + missingKeys.has(fact.key) + ); + const blockSubjectIri = toAbsoluteIri(options.meshBase, block.subjectPath); + if ( + blockHasMissingFacts && + !block.hasMutableProgressionFacts && + !renderedSubjectIris.has(blockSubjectIri) + ) { + appendBlocks.push(block.block); + for (const fact of block.requestedFacts) { + coveredByBlockKeys.add(fact.key); + } + } + } + + const appendFacts = carriedSupport.facts + .filter((fact) => + missingKeys.has(fact.key) && !coveredByBlockKeys.has(fact.key) + ) + .map((fact) => fact.turtle); + const appendChunks = [ + ...(appendBlocks.length === 0 ? [] : [ + ...missingPrefixDeclarations( + options.currentKnopInventoryTurtle, + options.renderedKnopInventoryTurtle, + ), + ]), + ...appendFacts, + ...appendBlocks, + ]; + + if (appendChunks.length === 0) { + return options.renderedKnopInventoryTurtle; + } + + return appendTurtleChunks(options.renderedKnopInventoryTurtle, appendChunks); +} + +function renderKnopSupportLinkFacts( + knopPath: string, + sourceRegistry: CurrentKnopSourceRegistry | undefined, + referenceCatalog: CurrentKnopReferenceCatalog | undefined, +): string[] { + return [ ...(sourceRegistry === undefined ? [] : [ - ` sflo:hasKnopSourceRegistry <${sourceRegistry.sourceRegistryPath}> ;`, + renderNamedNodeFact( + knopPath, + SFLO_HAS_KNOP_SOURCE_REGISTRY_IRI, + sourceRegistry.sourceRegistryPath, + ), ...(sourceRegistry.extractionSourcePath === undefined ? [] : [ - ` sflo:hasExtractionSource <${sourceRegistry.extractionSourcePath}> ;`, + renderNamedNodeFact( + knopPath, + SFLO_HAS_EXTRACTION_SOURCE_IRI, + sourceRegistry.extractionSourcePath, + ), ]), ]), ...(referenceCatalog === undefined ? [] : [ - ` sflo:hasReferenceCatalog <${referenceCatalog.referenceCatalogPath}> ;`, + renderNamedNodeFact( + knopPath, + SFLO_HAS_REFERENCE_CATALOG_IRI, + referenceCatalog.referenceCatalogPath, + ), ]), - ].filter((line) => !block.includes(line)); - if (carriedLines.length === 0) { - return block; + ]; +} + +function renderSourceRegistryFallbackFacts( + sourceRegistry: CurrentKnopSourceRegistry, +): string { + return [ + renderNamedNodeFact( + sourceRegistry.sourceRegistryPath, + RDF_TYPE_IRI, + SFLO_KNOP_SOURCE_REGISTRY_IRI, + ), + renderNamedNodeFact( + sourceRegistry.sourceRegistryPath, + RDF_TYPE_IRI, + `${SFLO_NAMESPACE}DigitalArtifact`, + ), + renderNamedNodeFact( + sourceRegistry.sourceRegistryPath, + RDF_TYPE_IRI, + `${SFLO_NAMESPACE}RdfDocument`, + ), + renderNamedNodeFact( + sourceRegistry.sourceRegistryPath, + SFLO_HAS_WORKING_LOCATED_FILE_IRI, + sourceRegistry.sourcesFilePath, + ), + ].join("\n"); +} + +function renderReferenceCatalogFallbackFacts( + referenceCatalog: CurrentKnopReferenceCatalog, +): string { + return [ + renderNamedNodeFact( + referenceCatalog.referenceCatalogPath, + RDF_TYPE_IRI, + SFLO_REFERENCE_CATALOG_IRI, + ), + renderNamedNodeFact( + referenceCatalog.referenceCatalogPath, + RDF_TYPE_IRI, + `${SFLO_NAMESPACE}DigitalArtifact`, + ), + renderNamedNodeFact( + referenceCatalog.referenceCatalogPath, + RDF_TYPE_IRI, + `${SFLO_NAMESPACE}RdfDocument`, + ), + referenceCatalog.referencesFilePredicateIri === SFLO_WORKING_FILE_PATH_IRI + ? renderLiteralFact( + referenceCatalog.referenceCatalogPath, + SFLO_WORKING_FILE_PATH_IRI, + referenceCatalog.referencesFilePath, + ) + : renderNamedNodeFact( + referenceCatalog.referenceCatalogPath, + SFLO_HAS_WORKING_LOCATED_FILE_IRI, + referenceCatalog.referencesFilePath, + ), + ].join("\n"); +} + +function renderLocatedFileFallbackFact(path: string): string { + return [ + renderNamedNodeFact(path, RDF_TYPE_IRI, `${SFLO_NAMESPACE}LocatedFile`), + renderNamedNodeFact(path, RDF_TYPE_IRI, `${SFLO_NAMESPACE}RdfDocument`), + ].join("\n"); +} + +function renderNamedNodeFact( + subjectPath: string, + predicateIri: string, + objectPathOrIri: string, +): string { + return `${renderNamedNodeTerm(subjectPath, "subject")} ${ + renderNamedNodeTerm(predicateIri, "predicate") + } ${renderNamedNodeTerm(objectPathOrIri, "object")} .`; +} + +function renderLiteralFact( + subjectPath: string, + predicateIri: string, + value: string, +): string { + return `${renderNamedNodeTerm(subjectPath, "subject")} ${ + renderNamedNodeTerm(predicateIri, "predicate") + } "${escapeTurtleString(value)}" .`; +} + +function parseRequestedSupportFacts( + meshBase: string, + turtle: string, +): RequestedSupportFact[] { + return new Parser({ baseIRI: meshBase }).parse( + `${supportFactParseDirectives()}\n\n${turtle}`, + ) + .filter((quad: Quad) => + !MUTABLE_PROGRESSION_PREDICATES.has(quad.predicate.value) + ) + .map((quad: Quad) => toRequestedSupportFact(quad, meshBase)); +} + +function supportFactParseDirectives(): string { + return `@prefix sflo: <${SFLO_NAMESPACE}> .`; +} + +function parseBlockQuads( + meshBase: string, + directives: string, + block: string, + errorMessage: string, +): Quad[] { + try { + return new Parser({ baseIRI: meshBase }).parse(`${directives}\n\n${block}`); + } catch { + throw new WeaveInputError(errorMessage); } +} - const workingInventoryLine = " sflo:hasWorkingKnopInventoryFile "; - if (!block.includes(workingInventoryLine)) { - throw new WeaveInputError( - "Could not find hasWorkingKnopInventoryFile while preserving carried Knop support artifacts.", - ); +function toRequestedSupportFact( + quad: Quad, + meshBase: string, +): RequestedSupportFact { + return { + key: toQuadKey(quad), + turtle: `${renderTurtleTerm(quad.subject, meshBase, "subject")} ${ + renderTurtleTerm(quad.predicate, meshBase, "predicate") + } ${renderTurtleTerm(quad.object, meshBase, "object")} .`, + }; +} + +function deduplicateRequestedFacts( + facts: readonly RequestedSupportFact[], +): RequestedSupportFact[] { + const seen = new Set(); + const deduped: RequestedSupportFact[] = []; + for (const fact of facts) { + if (seen.has(fact.key)) { + continue; + } + seen.add(fact.key); + deduped.push(fact); + } + return deduped; +} + +function toQuadKey(quad: Quad): string { + return [ + toTermKey(quad.graph), + toTermKey(quad.subject), + toTermKey(quad.predicate), + toTermKey(quad.object), + ].join("|"); +} + +function toTermKey(term: Term): string { + if (term.termType === "Literal") { + return [ + term.termType, + term.value, + term.language, + term.datatype.value, + ].join(":"); } - return block.replace( - workingInventoryLine, - `${carriedLines.join("\n")}\n${workingInventoryLine}`, + return `${term.termType}:${term.value}`; +} + +function renderTurtleTerm( + term: Term, + meshBase: string, + position: "subject" | "predicate" | "object", +): string { + switch (term.termType) { + case "NamedNode": + return renderNamedNodeTerm(term.value, position, meshBase); + case "Literal": + return renderTurtleLiteral(term); + default: + throw new WeaveInputError( + "Could not render carried Knop support fact.", + ); + } +} + +function renderNamedNodeTerm( + value: string, + position: "subject" | "predicate" | "object", + meshBase?: string, +): string { + if (position === "predicate" && value === RDF_TYPE_IRI) { + return "a"; + } + if (value.startsWith(SFLO_NAMESPACE)) { + return `sflo:${value.slice(SFLO_NAMESPACE.length)}`; + } + if (meshBase !== undefined && value.startsWith(meshBase)) { + return `<${value.slice(meshBase.length)}>`; + } + return `<${value}>`; +} + +function renderTurtleLiteral(term: Term & { termType: "Literal" }): string { + const value = `"${escapeTurtleString(term.value)}"`; + if (term.language.length > 0) { + return `${value}@${term.language}`; + } + if (term.datatype.value === XSD_STRING_IRI) { + return value; + } + return `${value}^^<${term.datatype.value}>`; +} + +function extractTurtleDirectives(turtle: string): string { + return turtle.split("\n") + .filter((line) => line.startsWith("@base ") || line.startsWith("@prefix ")) + .join("\n"); +} + +function missingPrefixDeclarations( + currentTurtle: string, + renderedTurtle: string, +): string[] { + const renderedPrefixes = new Set( + renderedTurtle.split("\n").filter((line) => line.startsWith("@prefix ")), + ); + return currentTurtle.split("\n").filter((line) => + line.startsWith("@prefix ") && !renderedPrefixes.has(line) ); } -function renderLocatedFileBlock(path: string): string { - return `<${path}> a sflo:LocatedFile, sflo:RdfDocument .`; +function appendTurtleChunks(turtle: string, chunks: readonly string[]): string { + const appendTurtle = `${chunks.join("\n\n")}\n`; + if (turtle.length === 0) { + return appendTurtle; + } + if (turtle.endsWith("\n\n")) { + return `${turtle}${appendTurtle}`; + } + if (turtle.endsWith("\n")) { + return `${turtle}\n${appendTurtle}`; + } + return `${turtle}\n\n${appendTurtle}`; } diff --git a/src/core/weave/knop_support_renderers_test.ts b/src/core/weave/knop_support_renderers_test.ts new file mode 100644 index 0000000..a88140f --- /dev/null +++ b/src/core/weave/knop_support_renderers_test.ts @@ -0,0 +1,284 @@ +import { + assert, + assertEquals, + assertFalse, + assertStringIncludes, + assertThrows, +} from "@std/assert"; +import { Parser, type Quad } from "n3"; +import { + RDF_NAMESPACE, + SFLO_NAMESPACE, + XSD_NAMESPACE, +} from "../rdf/namespaces.ts"; +import { WeaveInputError } from "./errors.ts"; +import { renderKnopInventoryWithPreservedSupportArtifacts } from "./knop_support_renderers.ts"; + +const meshBase = "https://semantic-flow.github.io/mesh-alice-bio/"; +const knopPath = "alice/_knop"; + +Deno.test("renderKnopInventoryWithPreservedSupportArtifacts no-ops semantically equivalent carried support facts", () => { + const currentKnopInventoryTurtle = inventoryWithSupportFacts({ + sourceRegistry: true, + referenceCatalog: true, + sourceRegistryUsesRdfType: true, + }); + const renderedKnopInventoryTurtle = inventoryWithSupportFacts({ + sourceRegistry: true, + referenceCatalog: true, + }); + + assertEquals( + renderKnopInventoryWithPreservedSupportArtifacts({ + meshBase, + currentKnopInventoryTurtle, + renderedKnopInventoryTurtle, + knopPath, + }), + renderedKnopInventoryTurtle, + ); +}); + +Deno.test("renderKnopInventoryWithPreservedSupportArtifacts appends missing carried support facts", () => { + const output = renderKnopInventoryWithPreservedSupportArtifacts({ + meshBase, + currentKnopInventoryTurtle: inventoryWithSupportFacts({ + sourceRegistry: true, + referenceCatalog: true, + }), + renderedKnopInventoryTurtle: inventoryWithSupportFacts({}), + knopPath, + }); + + assert( + hasNamedNodeFact( + output, + `${meshBase}${knopPath}`, + `${SFLO_NAMESPACE}hasKnopSourceRegistry`, + `${meshBase}${knopPath}/_sources`, + ), + ); + assert( + hasNamedNodeFact( + output, + `${meshBase}${knopPath}`, + `${SFLO_NAMESPACE}hasReferenceCatalog`, + `${meshBase}${knopPath}/_references`, + ), + ); + assert( + hasNamedNodeFact( + output, + `${meshBase}${knopPath}/_sources`, + `${SFLO_NAMESPACE}hasWorkingLocatedFile`, + `${meshBase}${knopPath}/_sources/sources.ttl`, + ), + ); +}); + +Deno.test("renderKnopInventoryWithPreservedSupportArtifacts preserves unknown carried support facts byte-for-byte", () => { + const currentKnopInventoryTurtle = inventoryWithSupportFacts({ + sourceRegistry: true, + sourceRegistryExtra: ' ex:opaqueSourceFact "keep exactly" ;', + extraPrefixes: "@prefix ex: .\n", + }); + const output = renderKnopInventoryWithPreservedSupportArtifacts({ + meshBase, + currentKnopInventoryTurtle, + renderedKnopInventoryTurtle: inventoryWithSupportFacts({}), + knopPath, + }); + + assertStringIncludes( + output, + ' ex:opaqueSourceFact "keep exactly" ;\n sflo:hasWorkingLocatedFile .', + ); + assertStringIncludes(output, "@prefix ex: ."); + assert(parseQuads(output).length > 0); +}); + +Deno.test("renderKnopInventoryWithPreservedSupportArtifacts carries reference catalog when source registry is already rendered", () => { + const output = renderKnopInventoryWithPreservedSupportArtifacts({ + meshBase, + currentKnopInventoryTurtle: inventoryWithSupportFacts({ + sourceRegistry: true, + referenceCatalog: true, + }), + renderedKnopInventoryTurtle: inventoryWithSupportFacts({ + sourceRegistry: true, + }), + knopPath, + }); + + assert( + hasNamedNodeFact( + output, + `${meshBase}${knopPath}`, + `${SFLO_NAMESPACE}hasKnopSourceRegistry`, + `${meshBase}${knopPath}/_sources`, + ), + ); + assert( + hasNamedNodeFact( + output, + `${meshBase}${knopPath}`, + `${SFLO_NAMESPACE}hasReferenceCatalog`, + `${meshBase}${knopPath}/_references`, + ), + ); +}); + +Deno.test("renderKnopInventoryWithPreservedSupportArtifacts carries source registry when reference catalog is already rendered", () => { + const output = renderKnopInventoryWithPreservedSupportArtifacts({ + meshBase, + currentKnopInventoryTurtle: inventoryWithSupportFacts({ + sourceRegistry: true, + referenceCatalog: true, + }), + renderedKnopInventoryTurtle: inventoryWithSupportFacts({ + referenceCatalog: true, + }), + knopPath, + }); + + assert( + hasNamedNodeFact( + output, + `${meshBase}${knopPath}`, + `${SFLO_NAMESPACE}hasKnopSourceRegistry`, + `${meshBase}${knopPath}/_sources`, + ), + ); + assert( + hasNamedNodeFact( + output, + `${meshBase}${knopPath}`, + `${SFLO_NAMESPACE}hasReferenceCatalog`, + `${meshBase}${knopPath}/_references`, + ), + ); +}); + +Deno.test("renderKnopInventoryWithPreservedSupportArtifacts fails closed on conflicting carried support facts", () => { + const error = assertThrows( + () => + renderKnopInventoryWithPreservedSupportArtifacts({ + meshBase, + currentKnopInventoryTurtle: inventoryWithSupportFacts({ + sourceRegistry: true, + }), + renderedKnopInventoryTurtle: inventoryWithSupportFacts({ + sourceRegistry: true, + sourceRegistryPath: "alice/_knop/_other-sources", + }), + knopPath, + }), + WeaveInputError, + "conflicts", + ); + + assertStringIncludes(error.message, "alice/_knop/_sources"); + assertStringIncludes(error.message, "alice/_knop/_other-sources"); +}); + +Deno.test("renderKnopInventoryWithPreservedSupportArtifacts does not carry mutable progression facts from support blocks", () => { + const output = renderKnopInventoryWithPreservedSupportArtifacts({ + meshBase, + currentKnopInventoryTurtle: inventoryWithSupportFacts({ + referenceCatalog: true, + referenceCatalogProgression: true, + }), + renderedKnopInventoryTurtle: inventoryWithSupportFacts({}), + knopPath, + }); + + const quads = new Parser({ baseIRI: meshBase }).parse(output); + assertFalse(hasPredicate(quads, `${SFLO_NAMESPACE}currentArtifactHistory`)); + assertFalse(hasPredicate(quads, `${SFLO_NAMESPACE}nextHistoryOrdinal`)); +}); + +function inventoryWithSupportFacts(options: { + sourceRegistry?: boolean; + sourceRegistryPath?: string; + sourceRegistryUsesRdfType?: boolean; + sourceRegistryExtra?: string; + referenceCatalog?: boolean; + referenceCatalogProgression?: boolean; + extraPrefixes?: string; +}): string { + const sourceRegistryPath = options.sourceRegistryPath ?? + `${knopPath}/_sources`; + const referenceCatalogPath = `${knopPath}/_references`; + const sourceRegistryLine = options.sourceRegistry + ? ` sflo:hasKnopSourceRegistry <${sourceRegistryPath}> ; + sflo:hasExtractionSource <${sourceRegistryPath}#extraction-source> ; +` + : ""; + const referenceCatalogLine = options.referenceCatalog + ? ` sflo:hasReferenceCatalog <${referenceCatalogPath}> ; +` + : ""; + const sourceRegistryTypePredicate = options.sourceRegistryUsesRdfType + ? "rdf:type" + : "a"; + const sourceRegistryBlock = options.sourceRegistry + ? ` +<${sourceRegistryPath}> ${sourceRegistryTypePredicate} sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument ; +${options.sourceRegistryExtra ?? ""}${ + options.sourceRegistryExtra ? "\n" : "" + } sflo:hasWorkingLocatedFile <${sourceRegistryPath}/sources.ttl> . + +<${sourceRegistryPath}/sources.ttl> a sflo:LocatedFile, sflo:RdfDocument . +` + : ""; + const referenceCatalogProgression = options.referenceCatalogProgression + ? ` ; + sflo:currentArtifactHistory <${referenceCatalogPath}/_history001> ; + sflo:nextHistoryOrdinal "2"^^xsd:nonNegativeInteger` + : ""; + const referenceCatalogBlock = options.referenceCatalog + ? ` +<${referenceCatalogPath}> a sflo:ReferenceCatalog, sflo:DigitalArtifact, sflo:RdfDocument${referenceCatalogProgression} ; + sflo:hasWorkingLocatedFile <${referenceCatalogPath}/references.ttl> . + +<${referenceCatalogPath}/references.ttl> a sflo:LocatedFile, sflo:RdfDocument . +` + : ""; + + return `@base <${meshBase}> . +@prefix rdf: <${RDF_NAMESPACE}> . +@prefix sflo: <${SFLO_NAMESPACE}> . +@prefix xsd: <${XSD_NAMESPACE}> . +${options.extraPrefixes ?? ""} +<${knopPath}> a sflo:Knop ; +${sourceRegistryLine}${referenceCatalogLine} sflo:hasWorkingKnopInventoryFile <${knopPath}/_inventory/inventory.ttl> . + +<${knopPath}/_inventory> a sflo:KnopInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_inventory/inventory.ttl> . + +<${knopPath}/_inventory/inventory.ttl> a sflo:LocatedFile, sflo:RdfDocument . +${sourceRegistryBlock}${referenceCatalogBlock}`; +} + +function hasNamedNodeFact( + turtle: string, + subjectIri: string, + predicateIri: string, + objectIri: string, +): boolean { + return parseQuads(turtle).some((quad) => + quad.subject.termType === "NamedNode" && + quad.subject.value === subjectIri && + quad.predicate.value === predicateIri && + quad.object.termType === "NamedNode" && + quad.object.value === objectIri + ); +} + +function hasPredicate(quads: readonly Quad[], predicateIri: string): boolean { + return quads.some((quad) => quad.predicate.value === predicateIri); +} + +function parseQuads(turtle: string): Quad[] { + return new Parser({ baseIRI: meshBase }).parse(turtle); +} diff --git a/src/core/weave/mod.ts b/src/core/weave/mod.ts index f72e388..7dbb169 100644 --- a/src/core/weave/mod.ts +++ b/src/core/weave/mod.ts @@ -1 +1,2 @@ export * from "./weave.ts"; +export * from "./inventory_append_planner.ts"; diff --git a/src/core/weave/resource_page_builders.ts b/src/core/weave/resource_page_builders.ts index a49adac..6af2c2c 100644 --- a/src/core/weave/resource_page_builders.ts +++ b/src/core/weave/resource_page_builders.ts @@ -355,6 +355,22 @@ export function buildSubsequentPageDefinitionWeavePages( ]; } +export function buildCurrentOnlyPageDefinitionWeavePages( + designatorPath: string, +): readonly ResourcePageModel[] { + const knopPath = toKnopPath(designatorPath); + const designatorPagePath = toDesignatorResourcePagePath(designatorPath); + const displayDesignatorPath = formatDesignatorPathForDisplay(designatorPath); + + return [ + identifierPage(designatorPagePath, designatorPath), + simplePage( + `${knopPath}/_page/index.html`, + `Resource page for the ${displayDesignatorPath} ResourcePageDefinition artifact.`, + ), + ]; +} + export function buildSecondPayloadWeavePages( designatorPath: string, payloadLayout: PayloadVersionLayout, diff --git a/src/core/weave/resource_page_models.ts b/src/core/weave/resource_page_models.ts index 9b08a43..369badd 100644 --- a/src/core/weave/resource_page_models.ts +++ b/src/core/weave/resource_page_models.ts @@ -19,6 +19,7 @@ export interface IdentifierResourcePageModel { export interface SimpleResourcePageModel { kind: "simple"; path: string; + ownerDesignatorPath?: string; description: string; childIdentifiers?: readonly ResourcePageChildIdentifierModel[]; historyGroups?: readonly ResourcePageHistoryGroupModel[]; diff --git a/src/core/weave/slice_classification.ts b/src/core/weave/slice_classification.ts index 170995e..b51ab6a 100644 --- a/src/core/weave/slice_classification.ts +++ b/src/core/weave/slice_classification.ts @@ -167,6 +167,33 @@ export function detectPendingWeaveSlice( SFLO_HAS_RESOURCE_PAGE_DEFINITION_IRI, pageDefinitionPath, ); + const pageDefinitionHistoryPath = resolveOptionalNamedNodePath( + quads, + meshBase, + pageDefinitionPath, + SFLO_CURRENT_ARTIFACT_HISTORY_IRI, + errorMessage, + ); + const pageDefinitionArtifactHistoryPaths = resolveNamedNodeObjectPaths( + quads, + meshBase, + pageDefinitionPath, + SFLO_HAS_ARTIFACT_HISTORY_IRI, + errorMessage, + ); + const pageDefinitionHasDeclaredArtifactHistory = [ + ...(pageDefinitionHistoryPath ? [pageDefinitionHistoryPath] : []), + ...pageDefinitionArtifactHistoryPaths, + ].some((historyPath) => + isDeclaredArtifactHistory(quads, meshBase, historyPath) + ); + const pageDefinitionHasResourcePage = hasNamedNodeFact( + quads, + meshBase, + pageDefinitionPath, + SFLO_HAS_RESOURCE_PAGE_IRI, + `${pageDefinitionPath}/index.html`, + ); const knopInventoryHasHistory = hasNamedNodeFact( quads, meshBase, @@ -203,7 +230,11 @@ export function detectPendingWeaveSlice( return "firstExtractedKnopWeave"; } - if (pageDefinitionRelationship && knopInventoryHasHistory) { + if ( + pageDefinitionRelationship && + knopInventoryHasHistory && + (pageDefinitionHasDeclaredArtifactHistory || !pageDefinitionHasResourcePage) + ) { return "pageDefinitionWeave"; } diff --git a/src/core/weave/weave.ts b/src/core/weave/weave.ts index 88d631f..e386d7e 100644 --- a/src/core/weave/weave.ts +++ b/src/core/weave/weave.ts @@ -43,6 +43,7 @@ import { renderMeshMetadataWithMeshInventoryProgression, } from "./mesh_inventory_renderers.ts"; import { + renderCurrentOnlyPageDefinitionWovenKnopInventoryTurtle, renderCurrentOnlyReferenceCatalogWovenKnopInventoryTurtle, renderFirstExtractedKnopWovenKnopInventoryTurtle, renderFirstKnopWovenKnopInventoryTurtle, @@ -67,6 +68,7 @@ import { resolvePageDefinitionWeaveProgression, } from "./progression_resolvers.ts"; import { + buildCurrentOnlyPageDefinitionWeavePages, buildCurrentOnlyReferenceCatalogWeavePages, buildFirstExtractedKnopWeavePages, buildFirstKnopWeavePages, @@ -292,6 +294,7 @@ export function planWeave(input: PlanWeaveInput): WeavePlan { meshBase, input.currentMeshInventoryTurtle, candidate, + input.supportHistoryPolicies, ); case "secondPayloadWeave": return planSecondPayloadWeave( @@ -1066,9 +1069,46 @@ function planPageDefinitionWeave( meshBase: string, _meshInventoryTurtle: string, candidate: WeaveableKnopCandidate, + supportHistoryPolicies?: WeaveSupportHistoryPolicies, ): WeavePlan { const pageDefinitionArtifact = candidate.resourcePageDefinitionArtifact!; const designatorPath = candidate.designatorPath; + const versionPageDefinition = pageDefinitionArtifact + .currentArtifactHistoryExists || + shouldMaterializeSupportHistory( + supportHistoryPolicies?.resourcePageDefinition ?? "versioned", + ); + if (!versionPageDefinition) { + if ( + pageDefinitionArtifact.currentArtifactHistoryPath !== undefined || + pageDefinitionArtifact.latestHistoricalStatePath !== undefined + ) { + throw new WeaveInputError( + `ResourcePageDefinition current-only weave cannot use a partial explicit history for ${designatorPath}.`, + ); + } + const renderedKnopInventory = + renderCurrentOnlyPageDefinitionWovenKnopInventoryTurtle( + meshBase, + candidate.currentKnopInventoryTurtle, + designatorPath, + pageDefinitionArtifact.workingLocalRelativePath, + ); + + return { + meshBase, + wovenDesignatorPaths: [designatorPath], + createdFiles: [], + updatedFiles: [ + { + path: `${toKnopPath(designatorPath)}/_inventory/inventory.ttl`, + contents: renderedKnopInventory, + }, + ], + createdPages: buildCurrentOnlyPageDefinitionWeavePages(designatorPath), + }; + } + const progression = resolvePageDefinitionWeaveProgression( designatorPath, pageDefinitionArtifact, diff --git a/src/core/weave/weave_test.ts b/src/core/weave/weave_test.ts index 04d1537..5b2e7e8 100644 --- a/src/core/weave/weave_test.ts +++ b/src/core/weave/weave_test.ts @@ -3364,6 +3364,65 @@ Deno.test("planWeave renders the first page-definition weave slice", async () => ]); }); +Deno.test("planWeave supports current-only first page-definition weave", async () => { + const meshBase = "https://semantic-flow.github.io/mesh-alice-bio/"; + const pageDefinitionTurtle = await readMeshAliceBioBranchFile( + "14-alice-page-customized", + "alice/_knop/_page/page.ttl", + ); + const plan = planWeave({ + request: { + targets: [{ designatorPath: "alice" }], + }, + meshBase, + currentMeshInventoryTurtle: await readMeshAliceBioBranchFile( + "14-alice-page-customized", + "_mesh/_inventory/inventory.ttl", + ), + weaveableKnops: [{ + designatorPath: "alice", + currentKnopMetadataTurtle: await readMeshAliceBioBranchFile( + "14-alice-page-customized", + "alice/_knop/_meta/meta.ttl", + ), + currentKnopInventoryTurtle: await readMeshAliceBioBranchFile( + "14-alice-page-customized", + "alice/_knop/_inventory/inventory.ttl", + ), + resourcePageDefinitionArtifact: { + artifactPath: "alice/_knop/_page", + workingLocalRelativePath: "alice/_knop/_page/page.ttl", + currentPageDefinitionTurtle: pageDefinitionTurtle, + currentArtifactHistoryExists: false, + assetBundlePath: "alice/_knop/_assets", + }, + }], + supportHistoryPolicies: { + resourcePageDefinition: "currentOnly", + }, + }); + + assertEquals(plan.wovenDesignatorPaths, ["alice"]); + assertEquals(plan.createdFiles, []); + assertEquals(plan.updatedFiles.map((file) => file.path), [ + "alice/_knop/_inventory/inventory.ttl", + ]); + assertEquals(plan.createdPages.map((page) => page.path), [ + "alice/index.html", + "alice/_knop/_page/index.html", + ]); + const updatedKnopInventory = plan.updatedFiles[0]?.contents ?? ""; + assertStringIncludes( + updatedKnopInventory, + "sflo:hasResourcePage ", + ); + assertFalse(updatedKnopInventory.includes("alice/_knop/_page/_history001")); + assertEquals( + detectPendingWeaveSlice(meshBase, "alice", updatedKnopInventory), + undefined, + ); +}); + Deno.test("planWeave generalizes the first page-definition weave slice for earlier KnopInventory states", async () => { const meshBase = "https://semantic-flow.github.io/mesh-alice-bio/"; const currentKnopInventoryTurtle = ( diff --git a/src/runtime/artifact_resolution/models.ts b/src/runtime/artifact_resolution/models.ts new file mode 100644 index 0000000..54958f3 --- /dev/null +++ b/src/runtime/artifact_resolution/models.ts @@ -0,0 +1,52 @@ +import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; + +export type ArtifactResolutionMode = "working" | "latestState"; +export type ArtifactResolutionContentMode = "none" | "bytes" | "text"; + +export interface ArtifactResolutionRequest { + sourceTerm?: string; + sourceIri?: string; + sourceDescription?: string; + targetArtifactIri?: string; + targetArtifactHistoryIri?: string; + targetHistoricalStateIri?: string; + targetManifestationIri?: string; + targetLocatedFileIri?: string; + targetLocalRelativePath?: string; + targetAccessUrl?: string; + targetRepositorySourceTerm?: string; + repositorySourceFloatingLocatorTerm?: string; + fallbackArtifactResolutionSpecTerm?: string; + mode?: ArtifactResolutionMode; + expectedContentDigest?: string; +} + +export interface ArtifactResolutionObservedCoordinates { + historicalStateIri?: string; + manifestationIri?: string; + locatedFileIri?: string; + localRelativePath?: string; + contentDigest?: string; +} + +export interface ArtifactResolutionContent { + bytes: Uint8Array; + text?: string; +} + +export interface ArtifactResolutionResult { + requested: ArtifactResolutionRequest; + observed: ArtifactResolutionObservedCoordinates; + content?: ArtifactResolutionContent; +} + +export interface ArtifactResolutionContext { + meshRoot: string; + meshBase: string; + localPathPolicy: OperationalLocalPathPolicy; + overlay?: ReadonlyMap; +} + +export interface ArtifactResolutionOptions { + contentMode?: ArtifactResolutionContentMode; +} diff --git a/src/runtime/artifact_resolution/resolver.ts b/src/runtime/artifact_resolution/resolver.ts new file mode 100644 index 0000000..6de5095 --- /dev/null +++ b/src/runtime/artifact_resolution/resolver.ts @@ -0,0 +1,1669 @@ +import { join } from "@std/path"; +import * as pathPosix from "@std/path/posix"; +import { Parser, type Quad, type Term } from "n3"; +import { + appendMeshPath, + normalizeSafeDesignatorPath, + toKnopPath, +} from "../../core/designator_segments.ts"; +import { RDF_NAMESPACE, SFLO_NAMESPACE } from "../../core/rdf/namespaces.ts"; +import { + type RepositorySourceFloatingLocatorState, + resolvePayloadArtifactInventoryState, + resolveReferenceCatalogInventoryState, + resolveResourcePageDefinitionInventoryState, +} from "../mesh/inventory.ts"; +import { + LocalPathAccessError, + type OperationalLocalPathPolicy, + resolveAllowedLocalPath, + resolveRepositorySourceFloatingLocalPath, +} from "../operational/local_path_policy.ts"; +import type { + ArtifactResolutionContentMode, + ArtifactResolutionContext, + ArtifactResolutionMode, + ArtifactResolutionObservedCoordinates, + ArtifactResolutionOptions, + ArtifactResolutionRequest, + ArtifactResolutionResult, +} from "./models.ts"; +export type { + ArtifactResolutionContent, + ArtifactResolutionContentMode, + ArtifactResolutionContext, + ArtifactResolutionMode, + ArtifactResolutionObservedCoordinates, + ArtifactResolutionOptions, + ArtifactResolutionRequest, + ArtifactResolutionResult, +} from "./models.ts"; + +const RDF_TYPE_IRI = `${RDF_NAMESPACE}type`; +const SFLO_TARGET_ARTIFACT_IRI = `${SFLO_NAMESPACE}targetArtifact`; +const SFLO_TARGET_ARTIFACT_HISTORY_IRI = + `${SFLO_NAMESPACE}targetArtifactHistory`; +const SFLO_TARGET_HISTORICAL_STATE_IRI = + `${SFLO_NAMESPACE}targetHistoricalState`; +const SFLO_TARGET_MANIFESTATION_IRI = `${SFLO_NAMESPACE}targetManifestation`; +const SFLO_TARGET_LOCATED_FILE_IRI = `${SFLO_NAMESPACE}targetLocatedFile`; +const SFLO_TARGET_LOCAL_RELATIVE_PATH_IRI = + `${SFLO_NAMESPACE}targetLocalRelativePath`; +const SFLO_TARGET_ACCESS_URL_IRI = `${SFLO_NAMESPACE}targetAccessUrl`; +const SFLO_TARGET_REPOSITORY_SOURCE_IRI = + `${SFLO_NAMESPACE}targetRepositorySource`; +const SFLO_HAS_REPOSITORY_SOURCE_FLOATING_LOCATOR_IRI = + `${SFLO_NAMESPACE}hasRepositorySourceFloatingLocator`; +const SFLO_HAS_ARTIFACT_RESOLUTION_MODE_IRI = + `${SFLO_NAMESPACE}hasArtifactResolutionMode`; +const SFLO_ARTIFACT_RESOLUTION_MODE_WORKING_IRI = + `${SFLO_NAMESPACE}artifactResolutionMode_working`; +const SFLO_ARTIFACT_RESOLUTION_MODE_LATEST_STATE_IRI = + `${SFLO_NAMESPACE}artifactResolutionMode_latestState`; +const SFLO_HAS_FALLBACK_ARTIFACT_RESOLUTION_SPEC_IRI = + `${SFLO_NAMESPACE}hasFallbackArtifactResolutionSpec`; +const SFLO_EXPECTS_CONTENT_DIGEST_IRI = `${SFLO_NAMESPACE}expectsContentDigest`; +const SFLO_CURRENT_ARTIFACT_HISTORY_IRI = + `${SFLO_NAMESPACE}currentArtifactHistory`; +const SFLO_HAS_ARTIFACT_HISTORY_IRI = `${SFLO_NAMESPACE}hasArtifactHistory`; +const SFLO_HAS_HISTORICAL_STATE_IRI = `${SFLO_NAMESPACE}hasHistoricalState`; +const SFLO_ARTIFACT_HISTORY_IRI = `${SFLO_NAMESPACE}ArtifactHistory`; +const SFLO_HISTORICAL_STATE_IRI = `${SFLO_NAMESPACE}HistoricalState`; +const SFLO_LATEST_HISTORICAL_STATE_IRI = + `${SFLO_NAMESPACE}latestHistoricalState`; +const SFLO_LOCATED_FILE_FOR_STATE_IRI = `${SFLO_NAMESPACE}locatedFileForState`; + +const PAGE_DEFINITION_ARTIFACT_SUFFIX = "/_knop/_page"; +const ROOT_PAGE_DEFINITION_ARTIFACT_PATH = "_knop/_page"; +const ROOT_REFERENCE_CATALOG_PATH = "_knop/_references"; +const REFERENCE_CATALOG_SUFFIX = "/_knop/_references"; + +interface ArtifactResolutionSpecParseOptions { + sourceDescription?: string; +} + +interface ArtifactResolutionSpecQuadsOptions + extends ArtifactResolutionSpecParseOptions, ArtifactResolutionOptions {} + +export type ArtifactResolutionFailureKind = + | "validation" + | "unsupported" + | "unsafe" + | "unavailable" + | "digestMismatch" + | "decodeFailure"; + +interface ArtifactResolutionErrorOptions extends ErrorOptions { + kind?: ArtifactResolutionFailureKind; +} + +interface TargetArtifactDescriptor { + kind: "payload" | "referenceCatalog" | "resourcePageDefinition"; + designatorPath: string; + artifactPath: string; +} + +interface TargetArtifactInventory { + meshBase: string; + target: TargetArtifactDescriptor; + turtle: string; + quads: readonly Quad[]; +} + +export class ArtifactResolutionError extends Error { + readonly kind: ArtifactResolutionFailureKind; + + constructor(message: string, options: ArtifactResolutionErrorOptions = {}) { + super(message, { cause: options.cause }); + this.name = "ArtifactResolutionError"; + this.kind = options.kind ?? "validation"; + } +} + +export function parseArtifactResolutionSpecTurtle( + turtle: string, + subjectIri: string, + baseIri: string, + options: ArtifactResolutionSpecParseOptions = {}, +): ArtifactResolutionRequest { + let quads: Quad[]; + try { + quads = new Parser({ baseIRI: baseIri }).parse(turtle); + } catch (error) { + throw new ArtifactResolutionError( + `Could not parse ArtifactResolutionSpec Turtle${ + options.sourceDescription ? ` from ${options.sourceDescription}` : "" + }.`, + { cause: error }, + ); + } + return parseArtifactResolutionSpecQuads(quads, subjectIri, options); +} + +export function parseArtifactResolutionSpecQuads( + quads: readonly Quad[], + subject: Term | string, + options: ArtifactResolutionSpecParseOptions = {}, +): ArtifactResolutionRequest { + const subjectKey = typeof subject === "string" + ? termKeyFromSubjectString(subject) + : toTermKey(subject); + const sourceIri = subjectKey.startsWith("NamedNode:") + ? subjectKey.slice("NamedNode:".length) + : undefined; + const sourceDescription = options.sourceDescription ?? + (sourceIri ?? subjectKey); + + const targetArtifactIri = optionalUniqueNamedNodeObject( + quads, + subjectKey, + SFLO_TARGET_ARTIFACT_IRI, + sourceDescription, + ); + const targetArtifactHistoryIri = optionalUniqueNamedNodeObject( + quads, + subjectKey, + SFLO_TARGET_ARTIFACT_HISTORY_IRI, + sourceDescription, + ); + const targetHistoricalStateIri = optionalUniqueNamedNodeObject( + quads, + subjectKey, + SFLO_TARGET_HISTORICAL_STATE_IRI, + sourceDescription, + ); + const targetManifestationIri = optionalUniqueNamedNodeObject( + quads, + subjectKey, + SFLO_TARGET_MANIFESTATION_IRI, + sourceDescription, + ); + const targetLocatedFileIri = optionalUniqueNamedNodeObject( + quads, + subjectKey, + SFLO_TARGET_LOCATED_FILE_IRI, + sourceDescription, + ); + const targetLocalRelativePath = optionalUniqueLiteralObject( + quads, + subjectKey, + SFLO_TARGET_LOCAL_RELATIVE_PATH_IRI, + sourceDescription, + ); + const targetAccessUrl = optionalUniqueLiteralObject( + quads, + subjectKey, + SFLO_TARGET_ACCESS_URL_IRI, + sourceDescription, + ); + const targetRepositorySourceTerm = optionalUniqueResourceObject( + quads, + subjectKey, + SFLO_TARGET_REPOSITORY_SOURCE_IRI, + sourceDescription, + ); + const repositorySourceFloatingLocatorTerm = optionalUniqueResourceObject( + quads, + subjectKey, + SFLO_HAS_REPOSITORY_SOURCE_FLOATING_LOCATOR_IRI, + sourceDescription, + ); + const fallbackArtifactResolutionSpecTerm = optionalUniqueResourceObject( + quads, + subjectKey, + SFLO_HAS_FALLBACK_ARTIFACT_RESOLUTION_SPEC_IRI, + sourceDescription, + ); + const modeIri = optionalUniqueNamedNodeObject( + quads, + subjectKey, + SFLO_HAS_ARTIFACT_RESOLUTION_MODE_IRI, + sourceDescription, + ); + const expectedContentDigest = optionalUniqueLiteralObject( + quads, + subjectKey, + SFLO_EXPECTS_CONTENT_DIGEST_IRI, + sourceDescription, + ); + + return compactRequest({ + sourceTerm: subjectKey, + ...(sourceIri ? { sourceIri } : {}), + sourceDescription, + ...(targetArtifactIri ? { targetArtifactIri } : {}), + ...(targetArtifactHistoryIri ? { targetArtifactHistoryIri } : {}), + ...(targetHistoricalStateIri ? { targetHistoricalStateIri } : {}), + ...(targetManifestationIri ? { targetManifestationIri } : {}), + ...(targetLocatedFileIri ? { targetLocatedFileIri } : {}), + ...(targetLocalRelativePath ? { targetLocalRelativePath } : {}), + ...(targetAccessUrl ? { targetAccessUrl } : {}), + ...(targetRepositorySourceTerm ? { targetRepositorySourceTerm } : {}), + ...(repositorySourceFloatingLocatorTerm + ? { repositorySourceFloatingLocatorTerm } + : {}), + ...(fallbackArtifactResolutionSpecTerm + ? { fallbackArtifactResolutionSpecTerm } + : {}), + ...(modeIri + ? { mode: parseResolutionMode(modeIri, sourceDescription) } + : {}), + ...(expectedContentDigest ? { expectedContentDigest } : {}), + }); +} + +export async function resolveArtifactResolutionSpecQuads( + context: ArtifactResolutionContext, + quads: readonly Quad[], + subject: Term | string, + options: ArtifactResolutionSpecQuadsOptions = {}, +): Promise { + const request = parseArtifactResolutionSpecQuads(quads, subject, options); + return await resolveParsedArtifactResolutionSpec( + context, + quads, + request, + options, + ); +} + +async function resolveParsedArtifactResolutionSpec( + context: ArtifactResolutionContext, + quads: readonly Quad[], + request: ArtifactResolutionRequest, + options: ArtifactResolutionOptions, +): Promise { + const primaryRequest = withoutFallbackSpec(request); + try { + return await resolveArtifactResolutionRequest( + context, + primaryRequest, + options, + ); + } catch (error) { + if ( + request.fallbackArtifactResolutionSpecTerm === undefined || + !isFallbackEligiblePrimary(primaryRequest, error) + ) { + throw error; + } + + const primaryError = error as ArtifactResolutionError; + const fallbackTerm = request.fallbackArtifactResolutionSpecTerm; + const fallbackDescription = `${describeRequest(primaryRequest)} fallback ${ + describeTermKey(fallbackTerm) + }`; + const fallbackRequest = parseArtifactResolutionSpecQuads( + quads, + fallbackTerm, + { sourceDescription: fallbackDescription }, + ); + if (fallbackRequest.fallbackArtifactResolutionSpecTerm !== undefined) { + throw new ArtifactResolutionError( + `${fallbackDescription} declares a nested hasFallbackArtifactResolutionSpec, which this resolver slice does not support.`, + { kind: "unsupported", cause: primaryError }, + ); + } + + try { + return await resolveArtifactResolutionRequest( + context, + fallbackRequest, + options, + ); + } catch (fallbackError) { + if (fallbackError instanceof ArtifactResolutionError) { + throw new ArtifactResolutionError( + `${ + describeRequest(primaryRequest) + } failed before fallback: ${primaryError.message} Fallback ${ + describeRequest(fallbackRequest) + } also failed: ${fallbackError.message}`, + { kind: fallbackError.kind, cause: fallbackError }, + ); + } + throw fallbackError; + } + } +} + +export async function resolveArtifactResolutionRequest( + context: ArtifactResolutionContext, + request: ArtifactResolutionRequest, + options: ArtifactResolutionOptions = {}, +): Promise { + const normalizedRequest = compactRequest(request); + const contentMode = options.contentMode ?? "bytes"; + + rejectUnsupportedRequestForms(normalizedRequest); + rejectContradictoryRequest(normalizedRequest); + + if (normalizedRequest.targetLocalRelativePath !== undefined) { + return await resolveDirectTargetLocalRelativePath( + context, + normalizedRequest, + contentMode, + ); + } + + if (normalizedRequest.targetLocatedFileIri !== undefined) { + return await resolveExactLocatedFile( + context, + normalizedRequest, + contentMode, + ); + } + + if (normalizedRequest.targetArtifactIri !== undefined) { + if (normalizedRequest.targetHistoricalStateIri !== undefined) { + return await resolveExactHistoricalState( + context, + normalizedRequest, + contentMode, + ); + } + if ( + normalizedRequest.mode === "latestState" || + normalizedRequest.targetArtifactHistoryIri !== undefined + ) { + return await resolveTargetArtifactLatestState( + context, + normalizedRequest, + contentMode, + ); + } + return await resolveTargetArtifactWorking( + context, + normalizedRequest, + contentMode, + ); + } + + if (normalizedRequest.targetHistoricalStateIri !== undefined) { + throw new ArtifactResolutionError( + `${ + describeRequest(normalizedRequest) + } declares targetHistoricalState without targetArtifact; this resolver slice requires the target artifact for ownership checks.`, + ); + } + + throw new ArtifactResolutionError( + `${ + describeRequest(normalizedRequest) + } does not declare a supported target locator.`, + ); +} + +function rejectUnsupportedRequestForms( + request: ArtifactResolutionRequest, +): void { + if (request.targetAccessUrl !== undefined) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares targetAccessUrl, which ordinary artifact resolution does not fetch.`, + { kind: "unsupported" }, + ); + } + if (request.targetRepositorySourceTerm !== undefined) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares targetRepositorySource, which this resolver slice does not fetch or map to local checkouts.`, + { kind: "unsupported" }, + ); + } + if (request.repositorySourceFloatingLocatorTerm !== undefined) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares hasRepositorySourceFloatingLocator directly on the resolution spec, which this resolver slice does not support yet.`, + { kind: "unsupported" }, + ); + } + if (request.fallbackArtifactResolutionSpecTerm !== undefined) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares hasFallbackArtifactResolutionSpec, which this resolver slice does not support yet.`, + { kind: "unsupported" }, + ); + } + if (request.targetManifestationIri !== undefined) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares targetManifestation, which this resolver slice does not support yet.`, + { kind: "unsupported" }, + ); + } +} + +function rejectContradictoryRequest(request: ArtifactResolutionRequest): void { + if ( + request.targetLocalRelativePath !== undefined && + (request.targetArtifactIri !== undefined || + request.targetArtifactHistoryIri !== undefined || + request.targetHistoricalStateIri !== undefined || + request.targetLocatedFileIri !== undefined) + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares targetLocalRelativePath with another target locator; this resolver requires one independent locator.`, + ); + } + if ( + request.targetLocatedFileIri !== undefined && + (request.targetArtifactIri !== undefined || + request.targetArtifactHistoryIri !== undefined || + request.targetHistoricalStateIri !== undefined) + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares targetLocatedFile with another target locator; this resolver requires one independent locator.`, + ); + } + if ( + request.mode === "working" && + (request.targetArtifactHistoryIri !== undefined || + request.targetHistoricalStateIri !== undefined) + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } requests working resolution with exact or history coordinates.`, + ); + } + if ( + request.mode === "latestState" && + request.targetHistoricalStateIri !== undefined + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } requests latest-state mode with an exact targetHistoricalState.`, + ); + } +} + +async function resolveDirectTargetLocalRelativePath( + context: ArtifactResolutionContext, + request: ArtifactResolutionRequest, + contentMode: ArtifactResolutionContentMode, +): Promise { + const localRelativePath = normalizeLocalRelativePath( + request.targetLocalRelativePath!, + SFLO_TARGET_LOCAL_RELATIVE_PATH_IRI, + request, + ); + const absolutePath = resolvePolicyPath( + context.localPathPolicy, + "targetLocalRelativePath", + localRelativePath, + request, + ); + + return await resultForResolvedPath( + context, + request, + { + localRelativePath, + }, + absolutePath, + contentMode, + ); +} + +async function resolveExactLocatedFile( + context: ArtifactResolutionContext, + request: ArtifactResolutionRequest, + contentMode: ArtifactResolutionContentMode, +): Promise { + const localRelativePath = requireMeshPathFromIri( + context.meshBase, + request.targetLocatedFileIri!, + `${describeRequest(request)} targetLocatedFile must be an in-mesh IRI.`, + ); + const absolutePath = resolvePolicyPath( + context.localPathPolicy, + "workingLocalRelativePath", + localRelativePath, + request, + ); + + return await resultForResolvedPath( + context, + request, + { + locatedFileIri: request.targetLocatedFileIri, + localRelativePath, + }, + absolutePath, + contentMode, + ); +} + +async function resolveTargetArtifactWorking( + context: ArtifactResolutionContext, + request: ArtifactResolutionRequest, + contentMode: ArtifactResolutionContentMode, +): Promise { + const inventory = await loadTargetArtifactInventory(context, request); + const working = resolveTargetArtifactWorkingSource(inventory, request); + const absolutePath = working.repositorySourceFloatingLocator + ? await resolveRepositorySourceFloatingPath( + context.localPathPolicy, + working.repositorySourceFloatingLocator, + request, + ) + : resolvePolicyPath( + context.localPathPolicy, + "workingLocalRelativePath", + working.workingLocalRelativePath, + request, + ); + + return await resultForResolvedPath( + context, + request, + { + ...(working.workingLocatedFilePath + ? { + locatedFileIri: + new URL(working.workingLocatedFilePath, context.meshBase).href, + } + : {}), + localRelativePath: working.workingLocalRelativePath, + }, + absolutePath, + contentMode, + ); +} + +async function resolveTargetArtifactLatestState( + context: ArtifactResolutionContext, + request: ArtifactResolutionRequest, + contentMode: ArtifactResolutionContentMode, +): Promise { + const inventory = await loadTargetArtifactInventory(context, request); + if (inventory.target.kind !== "payload") { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } requests latest-state resolution for ${inventory.target.artifactPath}; this resolver slice supports latest-state targetArtifact only for payload artifacts.`, + { kind: "unsupported" }, + ); + } + + const latest = resolvePayloadLatestState( + context.meshBase, + inventory, + request, + ); + const absolutePath = resolvePolicyPath( + context.localPathPolicy, + "workingLocalRelativePath", + latest.snapshotPath, + request, + ); + + return await resultForResolvedPath( + context, + request, + { + historicalStateIri: latest.stateIri, + ...(latest.locatedFileWasAsserted + ? { locatedFileIri: latest.locatedFileIri } + : {}), + localRelativePath: latest.snapshotPath, + }, + absolutePath, + contentMode, + ); +} + +async function resolveExactHistoricalState( + context: ArtifactResolutionContext, + request: ArtifactResolutionRequest, + contentMode: ArtifactResolutionContentMode, +): Promise { + const inventory = await loadTargetArtifactInventory(context, request); + if (inventory.target.kind !== "payload") { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } requests exact state resolution for ${inventory.target.artifactPath}; this resolver slice supports targetHistoricalState only for payload artifacts.`, + { kind: "unsupported" }, + ); + } + + const exact = resolvePayloadExactState(context.meshBase, inventory, request); + const absolutePath = resolvePolicyPath( + context.localPathPolicy, + "workingLocalRelativePath", + exact.snapshotPath, + request, + ); + + return await resultForResolvedPath( + context, + request, + { + historicalStateIri: exact.stateIri, + ...(exact.locatedFileWasAsserted + ? { locatedFileIri: exact.locatedFileIri } + : {}), + localRelativePath: exact.snapshotPath, + }, + absolutePath, + contentMode, + ); +} + +async function resultForResolvedPath( + context: ArtifactResolutionContext, + request: ArtifactResolutionRequest, + observed: ArtifactResolutionObservedCoordinates, + absolutePath: string, + contentMode: ArtifactResolutionContentMode, +): Promise { + if (contentMode === "none") { + if (request.expectedContentDigest !== undefined) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares expectsContentDigest, but contentMode=none cannot verify it.`, + ); + } + return { + requested: request, + observed, + }; + } + + const bytes = await readResolvedBytes(context, absolutePath, request); + const contentDigest = await sha256Digest(bytes); + verifyExpectedDigest(request, contentDigest); + return { + requested: request, + observed: { + ...observed, + contentDigest, + }, + content: { + bytes, + ...(contentMode === "text" ? { text: decodeUtf8(bytes, request) } : {}), + }, + }; +} + +async function readResolvedBytes( + context: ArtifactResolutionContext, + absolutePath: string, + request: ArtifactResolutionRequest, +): Promise { + const stagedContents = context.overlay?.get(absolutePath); + if (stagedContents !== undefined) { + return new TextEncoder().encode(stagedContents); + } + + try { + return await Deno.readFile(absolutePath); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } resolved to a missing local file: ${absolutePath}`, + { kind: "unavailable" }, + ); + } + throw error; + } +} + +function decodeUtf8( + bytes: Uint8Array, + request: ArtifactResolutionRequest, +): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new ArtifactResolutionError( + `${describeRequest(request)} resolved bytes are not valid UTF-8 text.`, + { cause: error, kind: "decodeFailure" }, + ); + } +} + +function resolveInventoryState( + resolve: () => T, + parseErrorMessage: string, + missingWorkingFileMessage: string, +): T { + try { + return resolve(); + } catch (error) { + if (error instanceof Error) { + if (error.message === missingWorkingFileMessage) { + throw new ArtifactResolutionError(error.message, { + kind: "unavailable", + cause: error, + }); + } + if (error.message === parseErrorMessage) { + throw new ArtifactResolutionError(error.message, { + kind: "validation", + cause: error, + }); + } + } + throw error; + } +} + +function resolveTargetArtifactWorkingSource( + inventory: TargetArtifactInventory, + request: ArtifactResolutionRequest, +): { + workingLocalRelativePath: string; + workingLocatedFilePath?: string; + repositorySourceFloatingLocator?: RepositorySourceFloatingLocatorState; +} { + const parseErrorMessage = `${ + describeRequest(request) + } could not parse the target artifact inventory.`; + const missingWorkingFileMessage = `${ + describeRequest(request) + } target artifact is missing a current working file.`; + + if (inventory.target.kind === "payload") { + const state = resolveInventoryState( + () => + resolvePayloadArtifactInventoryState( + inventory.meshBase, + inventory.turtle, + inventory.target.designatorPath, + { + parseErrorMessage, + missingWorkingFileMessage, + }, + ), + parseErrorMessage, + missingWorkingFileMessage, + ); + if (!state) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } target payload artifact is not registered in its owning Knop inventory: ${inventory.target.artifactPath}`, + ); + } + return { + workingLocalRelativePath: state.workingLocalRelativePath, + ...(state.workingLocatedFilePath + ? { workingLocatedFilePath: state.workingLocatedFilePath } + : {}), + ...(state.repositorySourceFloatingLocator + ? { + repositorySourceFloatingLocator: + state.repositorySourceFloatingLocator, + } + : {}), + }; + } + + if (inventory.target.kind === "referenceCatalog") { + const state = resolveInventoryState( + () => + resolveReferenceCatalogInventoryState( + inventory.meshBase, + inventory.turtle, + inventory.target.designatorPath, + { + parseErrorMessage, + missingWorkingFileMessage, + }, + ), + parseErrorMessage, + missingWorkingFileMessage, + ); + if (!state) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } target ReferenceCatalog is not registered in its owning Knop inventory: ${inventory.target.artifactPath}`, + ); + } + return { + workingLocalRelativePath: state.workingLocalRelativePath, + }; + } + + const state = resolveInventoryState( + () => + resolveResourcePageDefinitionInventoryState( + inventory.meshBase, + inventory.turtle, + inventory.target.designatorPath, + { + parseErrorMessage, + missingWorkingFileMessage, + }, + ), + parseErrorMessage, + missingWorkingFileMessage, + ); + if (!state || state.artifactPath !== inventory.target.artifactPath) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } target ResourcePageDefinition is not registered in its owning Knop inventory: ${inventory.target.artifactPath}`, + ); + } + + return { + workingLocalRelativePath: state.workingLocalRelativePath, + }; +} + +function resolvePayloadLatestState( + meshBase: string, + inventory: TargetArtifactInventory, + request: ArtifactResolutionRequest, +): { + stateIri: string; + statePath: string; + snapshotPath: string; + locatedFileIri: string; + locatedFileWasAsserted: boolean; +} { + const targetArtifactIri = request.targetArtifactIri!; + const requestedHistoryIri = request.targetArtifactHistoryIri; + const historyIri = requestedHistoryIri ?? + requiredNamedNodeObject( + inventory.quads, + targetArtifactIri, + SFLO_CURRENT_ARTIFACT_HISTORY_IRI, + `${ + describeRequest(request) + } requests latest-state resolution for ${inventory.target.artifactPath}, but that artifact has no currentArtifactHistory.`, + "unavailable", + ); + const historyPath = requireMeshPathFromIri( + meshBase, + historyIri, + `${ + describeRequest(request) + } requests latest-state resolution outside the mesh: ${historyIri}.`, + ); + assertHistoryBelongsToArtifact( + inventory.quads, + targetArtifactIri, + historyIri, + request, + ); + assertTypedHistory(inventory.quads, historyIri, historyPath, request); + + const stateIri = requiredNamedNodeObject( + inventory.quads, + historyIri, + SFLO_LATEST_HISTORICAL_STATE_IRI, + `${ + describeRequest(request) + } requests latest-state resolution for ${inventory.target.artifactPath}, but ${historyPath} has no latestHistoricalState.`, + "unavailable", + ); + const statePath = requireMeshPathFromIri( + meshBase, + stateIri, + `${ + describeRequest(request) + } resolved latestHistoricalState outside the mesh: ${stateIri}.`, + ); + assertTypedHistoricalState(inventory.quads, stateIri, statePath, request); + return resolvePayloadStateSnapshot( + meshBase, + inventory, + request, + historyPath, + stateIri, + ); +} + +function resolvePayloadExactState( + meshBase: string, + inventory: TargetArtifactInventory, + request: ArtifactResolutionRequest, +): { + stateIri: string; + statePath: string; + snapshotPath: string; + locatedFileIri: string; + locatedFileWasAsserted: boolean; +} { + const targetArtifactIri = request.targetArtifactIri!; + const stateIri = request.targetHistoricalStateIri!; + const statePath = requireMeshPathFromIri( + meshBase, + stateIri, + `${describeRequest(request)} targetHistoricalState must be an in-mesh IRI.`, + ); + const historyPath = request.targetArtifactHistoryIri !== undefined + ? requireMeshPathFromIri( + meshBase, + request.targetArtifactHistoryIri, + `${ + describeRequest(request) + } targetArtifactHistory must be an in-mesh IRI.`, + ) + : pathPosix.dirname(statePath); + const historyIri = new URL(historyPath, meshBase).href; + + if (!statePath.startsWith(`${historyPath}/`)) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } targetHistoricalState ${statePath} is outside requested history ${historyPath}.`, + ); + } + assertHistoryBelongsToArtifact( + inventory.quads, + targetArtifactIri, + historyIri, + request, + ); + assertTypedHistory(inventory.quads, historyIri, historyPath, request); + assertStateBelongsToHistory( + inventory.quads, + historyIri, + stateIri, + statePath, + request, + ); + assertTypedHistoricalState(inventory.quads, stateIri, statePath, request); + + return resolvePayloadStateSnapshot( + meshBase, + inventory, + request, + historyPath, + stateIri, + ); +} + +function resolvePayloadStateSnapshot( + meshBase: string, + inventory: TargetArtifactInventory, + request: ArtifactResolutionRequest, + historyPath: string, + stateIri: string, +): { + stateIri: string; + statePath: string; + snapshotPath: string; + locatedFileIri: string; + locatedFileWasAsserted: boolean; +} { + const statePath = requireMeshPathFromIri( + meshBase, + stateIri, + `${describeRequest(request)} resolved state outside the mesh: ${stateIri}.`, + ); + if (!statePath.startsWith(`${historyPath}/`)) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } resolved state ${statePath} outside requested history ${historyPath}.`, + ); + } + + const locatedFileIri = optionalNamedNodeObject( + inventory.quads, + stateIri, + SFLO_LOCATED_FILE_FOR_STATE_IRI, + `${ + describeRequest(request) + } has multiple locatedFileForState values for ${statePath}.`, + ); + if (locatedFileIri !== undefined) { + const snapshotPath = requireMeshPathFromIri( + meshBase, + locatedFileIri, + `${ + describeRequest(request) + } resolved located file outside the mesh: ${locatedFileIri}.`, + ); + return { + stateIri, + statePath, + snapshotPath, + locatedFileIri, + locatedFileWasAsserted: true, + }; + } + + const working = resolveTargetArtifactWorkingSource(inventory, request); + const snapshotPath = toPayloadHistoricalSnapshotPath( + statePath, + working.workingLocalRelativePath, + ); + return { + stateIri, + statePath, + snapshotPath, + locatedFileIri: new URL(snapshotPath, meshBase).href, + locatedFileWasAsserted: false, + }; +} + +async function loadTargetArtifactInventory( + context: ArtifactResolutionContext, + request: ArtifactResolutionRequest, +): Promise { + const artifactPath = requireMeshPathFromIri( + context.meshBase, + request.targetArtifactIri!, + `${ + describeRequest(request) + } targetArtifact must be an in-mesh governed artifact.`, + ); + const target = describeSupportedTargetArtifact(artifactPath, request); + const inventoryPath = join( + context.meshRoot, + toKnopPath(target.designatorPath), + "_inventory/inventory.ttl", + ); + const turtle = await readTextFile(context.overlay, inventoryPath, request); + let quads: Quad[]; + try { + quads = new Parser({ baseIRI: context.meshBase }).parse(turtle); + } catch (error) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } could not parse target Knop inventory for ${target.artifactPath}.`, + { cause: error }, + ); + } + + return { meshBase: context.meshBase, target, turtle, quads }; +} + +function describeSupportedTargetArtifact( + artifactPath: string, + request: ArtifactResolutionRequest, +): TargetArtifactDescriptor { + if ( + artifactPath === ROOT_REFERENCE_CATALOG_PATH || + artifactPath.endsWith(REFERENCE_CATALOG_SUFFIX) + ) { + return { + kind: "referenceCatalog", + designatorPath: artifactPath === ROOT_REFERENCE_CATALOG_PATH + ? "" + : artifactPath.slice(0, -REFERENCE_CATALOG_SUFFIX.length), + artifactPath, + }; + } + + if ( + artifactPath === ROOT_PAGE_DEFINITION_ARTIFACT_PATH || + artifactPath.endsWith(PAGE_DEFINITION_ARTIFACT_SUFFIX) + ) { + return { + kind: "resourcePageDefinition", + designatorPath: artifactPath === ROOT_PAGE_DEFINITION_ARTIFACT_PATH + ? "" + : artifactPath.slice(0, -PAGE_DEFINITION_ARTIFACT_SUFFIX.length), + artifactPath, + }; + } + + try { + return { + kind: "payload", + designatorPath: normalizeSafeDesignatorPath( + artifactPath, + "targetArtifact", + (message) => new ArtifactResolutionError(message), + { allowRoot: true }, + ), + artifactPath, + }; + } catch (error) { + if (error instanceof ArtifactResolutionError) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } targets unsupported artifact ${artifactPath}; this resolver slice supports payload artifacts, ReferenceCatalog artifacts, and ResourcePageDefinition artifacts.`, + { kind: "unsupported", cause: error }, + ); + } + throw error; + } +} + +async function readTextFile( + overlay: ReadonlyMap | undefined, + path: string, + request: ArtifactResolutionRequest, +): Promise { + const staged = overlay?.get(path); + if (staged !== undefined) { + return staged; + } + try { + return await Deno.readTextFile(path); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } target artifact inventory is missing: ${path}`, + { kind: "unavailable" }, + ); + } + throw error; + } +} + +function resolvePolicyPath( + policy: OperationalLocalPathPolicy, + locatorKind: "workingLocalRelativePath" | "targetLocalRelativePath", + localRelativePath: string, + request: ArtifactResolutionRequest, +): string { + try { + return resolveAllowedLocalPath(policy, locatorKind, localRelativePath); + } catch (error) { + if (error instanceof LocalPathAccessError) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } resolved ${locatorKind} outside the allowed local-path boundary: ${localRelativePath}`, + { kind: "unsafe", cause: error }, + ); + } + throw error; + } +} + +async function resolveRepositorySourceFloatingPath( + policy: OperationalLocalPathPolicy, + locator: RepositorySourceFloatingLocatorState, + request: ArtifactResolutionRequest, +): Promise { + try { + return await resolveRepositorySourceFloatingLocalPath(policy, locator); + } catch (error) { + if (error instanceof LocalPathAccessError) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } repository floating source did not match an allowed local checkout: ${locator.repositoryUrl} ${locator.repositoryPathFromRoot}`, + { kind: "unsafe", cause: error }, + ); + } + throw error; + } +} + +function assertHistoryBelongsToArtifact( + quads: readonly Quad[], + targetArtifactIri: string, + historyIri: string, + request: ArtifactResolutionRequest, +): void { + if ( + !hasNamedNodeObject( + quads, + targetArtifactIri, + SFLO_HAS_ARTIFACT_HISTORY_IRI, + historyIri, + ) && + !hasNamedNodeObject( + quads, + targetArtifactIri, + SFLO_CURRENT_ARTIFACT_HISTORY_IRI, + historyIri, + ) + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } targetArtifactHistory is not a history of targetArtifact.`, + ); + } +} + +function assertTypedHistory( + quads: readonly Quad[], + historyIri: string, + historyPath: string, + request: ArtifactResolutionRequest, +): void { + if ( + !hasNamedNodeObject( + quads, + historyIri, + RDF_TYPE_IRI, + SFLO_ARTIFACT_HISTORY_IRI, + ) + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } target history is not declared as an ArtifactHistory: ${historyPath}`, + ); + } +} + +function assertStateBelongsToHistory( + quads: readonly Quad[], + historyIri: string, + stateIri: string, + statePath: string, + request: ArtifactResolutionRequest, +): void { + if ( + !hasNamedNodeObject( + quads, + historyIri, + SFLO_HAS_HISTORICAL_STATE_IRI, + stateIri, + ) && + !hasNamedNodeObject( + quads, + historyIri, + SFLO_LATEST_HISTORICAL_STATE_IRI, + stateIri, + ) + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } targetHistoricalState is not declared in targetArtifactHistory: ${statePath}`, + ); + } +} + +function assertTypedHistoricalState( + quads: readonly Quad[], + stateIri: string, + statePath: string, + request: ArtifactResolutionRequest, +): void { + if ( + !hasNamedNodeObject( + quads, + stateIri, + RDF_TYPE_IRI, + SFLO_HISTORICAL_STATE_IRI, + ) + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } targetHistoricalState is not declared as a HistoricalState: ${statePath}`, + ); + } +} + +function hasNamedNodeObject( + quads: readonly Quad[], + subjectIri: string, + predicateIri: string, + objectIri: string, +): boolean { + return quads.some((quad) => + quad.subject.termType === "NamedNode" && + quad.subject.value === subjectIri && + quad.predicate.value === predicateIri && + quad.object.termType === "NamedNode" && + quad.object.value === objectIri + ); +} + +function requiredNamedNodeObject( + quads: readonly Quad[], + subjectIri: string, + predicateIri: string, + errorMessage: string, + kind: ArtifactResolutionFailureKind = "validation", +): string { + const value = optionalNamedNodeObject( + quads, + subjectIri, + predicateIri, + errorMessage, + ); + if (value === undefined) { + throw new ArtifactResolutionError(errorMessage, { kind }); + } + return value; +} + +function optionalNamedNodeObject( + quads: readonly Quad[], + subjectIri: string, + predicateIri: string, + errorMessage: string, +): string | undefined { + const values = new Set(); + for (const quad of quads) { + if ( + quad.subject.termType === "NamedNode" && + quad.subject.value === subjectIri && + quad.predicate.value === predicateIri && + quad.object.termType === "NamedNode" + ) { + values.add(quad.object.value); + } + } + if (values.size === 0) { + return undefined; + } + if (values.size !== 1) { + throw new ArtifactResolutionError(errorMessage); + } + return values.values().next().value!; +} + +function requireMeshPathFromIri( + meshBase: string, + iri: string, + errorMessage: string, +): string { + if (!iri.startsWith(meshBase)) { + throw new ArtifactResolutionError(errorMessage); + } + const meshPath = iri.slice(meshBase.length); + if (meshPath.includes("#") || meshPath.includes("?")) { + throw new ArtifactResolutionError(errorMessage); + } + return meshPath; +} + +function normalizeLocalRelativePath( + value: string, + predicateIri: string, + request: ArtifactResolutionRequest, +): string { + const trimmed = value.trim(); + if ( + trimmed.length === 0 || + trimmed.includes("\\") || + trimmed.includes("?") || + trimmed.includes("#") || + pathPosix.isAbsolute(trimmed) || + /^[A-Za-z]:/.test(trimmed) + ) { + throw new ArtifactResolutionError( + `${describeRequest(request)} has invalid ${predicateIri} value: ${value}`, + ); + } + const normalized = pathPosix.normalize(trimmed); + if (normalized === ".") { + throw new ArtifactResolutionError( + `${describeRequest(request)} has invalid ${predicateIri} value: ${value}`, + ); + } + return normalized; +} + +function parseResolutionMode( + modeIri: string, + sourceDescription: string, +): ArtifactResolutionMode { + switch (modeIri) { + case SFLO_ARTIFACT_RESOLUTION_MODE_WORKING_IRI: + return "working"; + case SFLO_ARTIFACT_RESOLUTION_MODE_LATEST_STATE_IRI: + return "latestState"; + default: + throw new ArtifactResolutionError( + `Unsupported ArtifactResolutionMode in ${sourceDescription}: ${modeIri}`, + { kind: "unsupported" }, + ); + } +} + +function optionalUniqueNamedNodeObject( + quads: readonly Quad[], + subjectKey: string, + predicateIri: string, + sourceDescription: string, +): string | undefined { + const values = collectObjectTerms(quads, subjectKey, predicateIri) + .filter((term) => term.startsWith("NamedNode:")) + .map((term) => term.slice("NamedNode:".length)); + return optionalUnique(values, predicateIri, sourceDescription); +} + +function optionalUniqueLiteralObject( + quads: readonly Quad[], + subjectKey: string, + predicateIri: string, + sourceDescription: string, +): string | undefined { + const values = collectObjectTerms(quads, subjectKey, predicateIri) + .filter((term) => term.startsWith("Literal:")) + .map((term) => term.slice("Literal:".length)); + return optionalUnique(values, predicateIri, sourceDescription); +} + +function optionalUniqueResourceObject( + quads: readonly Quad[], + subjectKey: string, + predicateIri: string, + sourceDescription: string, +): string | undefined { + const values = collectObjectTerms(quads, subjectKey, predicateIri) + .filter((term) => + term.startsWith("NamedNode:") || term.startsWith("BlankNode:") + ); + return optionalUnique(values, predicateIri, sourceDescription); +} + +function optionalUnique( + values: readonly string[], + predicateIri: string, + sourceDescription: string, +): string | undefined { + const uniqueValues = new Set(values); + if (uniqueValues.size === 0) { + return undefined; + } + if (uniqueValues.size !== 1) { + throw new ArtifactResolutionError( + `Expected at most one ${predicateIri} value in ${sourceDescription}.`, + ); + } + return uniqueValues.values().next().value!; +} + +function collectObjectTerms( + quads: readonly Quad[], + subjectKey: string, + predicateIri: string, +): readonly string[] { + const values = new Set(); + for (const quad of quads) { + if ( + toTermKey(quad.subject) !== subjectKey || + quad.predicate.value !== predicateIri + ) { + continue; + } + if (quad.object.termType === "Literal") { + values.add(`Literal:${quad.object.value}`); + continue; + } + values.add(toTermKey(quad.object)); + } + return [...values]; +} + +function termKeyFromSubjectString(subject: string): string { + return subject.startsWith("NamedNode:") || subject.startsWith("BlankNode:") + ? subject + : `NamedNode:${subject}`; +} + +function toTermKey(term: Term): string { + return `${term.termType}:${term.value}`; +} + +function compactRequest( + request: ArtifactResolutionRequest, +): ArtifactResolutionRequest { + return { + ...(request.sourceTerm ? { sourceTerm: request.sourceTerm } : {}), + ...(request.sourceIri ? { sourceIri: request.sourceIri } : {}), + ...(request.sourceDescription + ? { sourceDescription: request.sourceDescription } + : {}), + ...(request.targetArtifactIri + ? { targetArtifactIri: request.targetArtifactIri } + : {}), + ...(request.targetArtifactHistoryIri + ? { targetArtifactHistoryIri: request.targetArtifactHistoryIri } + : {}), + ...(request.targetHistoricalStateIri + ? { targetHistoricalStateIri: request.targetHistoricalStateIri } + : {}), + ...(request.targetManifestationIri + ? { targetManifestationIri: request.targetManifestationIri } + : {}), + ...(request.targetLocatedFileIri + ? { targetLocatedFileIri: request.targetLocatedFileIri } + : {}), + ...(request.targetLocalRelativePath + ? { targetLocalRelativePath: request.targetLocalRelativePath } + : {}), + ...(request.targetAccessUrl + ? { targetAccessUrl: request.targetAccessUrl } + : {}), + ...(request.targetRepositorySourceTerm + ? { targetRepositorySourceTerm: request.targetRepositorySourceTerm } + : {}), + ...(request.repositorySourceFloatingLocatorTerm + ? { + repositorySourceFloatingLocatorTerm: + request.repositorySourceFloatingLocatorTerm, + } + : {}), + ...(request.fallbackArtifactResolutionSpecTerm + ? { + fallbackArtifactResolutionSpecTerm: + request.fallbackArtifactResolutionSpecTerm, + } + : {}), + ...(request.mode ? { mode: request.mode } : {}), + ...(request.expectedContentDigest + ? { expectedContentDigest: request.expectedContentDigest } + : {}), + }; +} + +function withoutFallbackSpec( + request: ArtifactResolutionRequest, +): ArtifactResolutionRequest { + const { fallbackArtifactResolutionSpecTerm: _fallback, ...rest } = request; + return compactRequest(rest); +} + +function isFallbackEligiblePrimary( + request: ArtifactResolutionRequest, + error: unknown, +): error is ArtifactResolutionError { + return error instanceof ArtifactResolutionError && + error.kind === "unavailable" && + request.targetArtifactIri !== undefined && + (request.targetHistoricalStateIri !== undefined || + request.targetArtifactHistoryIri !== undefined || + request.mode === "latestState"); +} + +function describeRequest(request: ArtifactResolutionRequest): string { + return request.sourceDescription ?? request.sourceIri ?? request.sourceTerm ?? + "ArtifactResolutionSpec"; +} + +function describeTermKey(termKey: string): string { + if (termKey.startsWith("NamedNode:")) { + return `<${termKey.slice("NamedNode:".length)}>`; + } + if (termKey.startsWith("BlankNode:")) { + return `_:${termKey.slice("BlankNode:".length)}`; + } + return termKey; +} + +function toPayloadHistoricalSnapshotPath( + statePath: string, + workingLocalRelativePath: string, +): string { + const fileName = toFileName(workingLocalRelativePath); + const manifestationSegment = toDefaultManifestationSegment(fileName); + return appendMeshPath( + appendMeshPath(statePath, manifestationSegment), + fileName, + ); +} + +function toFileName(path: string): string { + const segments = path.split("/"); + return segments[segments.length - 1]!; +} + +function toDefaultManifestationSegment(fileName: string): string { + const extensionIndex = fileName.lastIndexOf("."); + return extensionIndex > 0 && extensionIndex < fileName.length - 1 + ? fileName.slice(extensionIndex + 1) + : fileName.replaceAll(".", "-"); +} + +async function sha256Digest(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer, + ); + const hex = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return `sha256:${hex}`; +} + +function verifyExpectedDigest( + request: ArtifactResolutionRequest, + observedDigest: string, +): void { + if (request.expectedContentDigest === undefined) { + return; + } + if (!/^sha256:[0-9a-fA-F]{64}$/.test(request.expectedContentDigest)) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } declares unsupported expectsContentDigest value: ${request.expectedContentDigest}`, + { kind: "validation" }, + ); + } + if ( + request.expectedContentDigest.toLowerCase() !== observedDigest.toLowerCase() + ) { + throw new ArtifactResolutionError( + `${ + describeRequest(request) + } digest mismatch: expected ${request.expectedContentDigest}, observed ${observedDigest}.`, + { kind: "digestMismatch" }, + ); + } +} diff --git a/src/runtime/artifact_resolution/resolver_test.ts b/src/runtime/artifact_resolution/resolver_test.ts new file mode 100644 index 0000000..b8cc723 --- /dev/null +++ b/src/runtime/artifact_resolution/resolver_test.ts @@ -0,0 +1,448 @@ +import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; +import { join } from "@std/path"; +import { Parser } from "n3"; +import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; +import { loadOperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; +import { + type ArtifactResolutionContext, + ArtifactResolutionError, + parseArtifactResolutionSpecQuads, + resolveArtifactResolutionRequest, + resolveArtifactResolutionSpecQuads, +} from "./resolver.ts"; + +const MESH_BASE = "https://example.org/mesh/"; +const SFLO = "https://semantic-flow.github.io/sflo/ontology/"; + +Deno.test("parseArtifactResolutionSpecQuads normalizes supported RDF coordinates", () => { + const quads = new Parser({ baseIRI: MESH_BASE }).parse(` +@prefix sflo: <${SFLO}> . + +<#source> a sflo:ArtifactResolutionSpec ; + sflo:targetLocalRelativePath "config/source.ttl" ; + sflo:expectsContentDigest "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" . +`); + + const request = parseArtifactResolutionSpecQuads( + quads, + new URL("#source", MESH_BASE).href, + ); + + assertEquals(request.sourceIri, "https://example.org/mesh/#source"); + assertEquals(request.targetLocalRelativePath, "config/source.ttl"); + assertEquals( + request.expectedContentDigest, + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); +}); + +Deno.test("resolveArtifactResolutionRequest reads direct targetLocalRelativePath text and verifies digest", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await Deno.mkdir(join(meshRoot, "config"), { recursive: true }); + const path = join(meshRoot, "config/source.ttl"); + const text = "@prefix : <#> .\n"; + await Deno.writeTextFile(path, text); + + const result = await resolveArtifactResolutionRequest(context, { + sourceDescription: "direct config source", + targetLocalRelativePath: "config/source.ttl", + expectedContentDigest: await sha256Digest(text), + }, { contentMode: "text" }); + + assertEquals(result.observed.localRelativePath, "config/source.ttl"); + assertEquals(result.observed.contentDigest, await sha256Digest(text)); + assertEquals(result.content?.text, text); +}); + +Deno.test("resolveArtifactResolutionRequest allows workspace-bounded targetLocalRelativePath through policy", async () => { + const tempRoot = await Deno.makeTempDir({ + prefix: "weave-artifact-resolution-workspace-", + }); + const repoRoot = join(tempRoot, "repo"); + const meshRoot = join(repoRoot, "docs"); + await Deno.mkdir(join(meshRoot, "_mesh/_config"), { recursive: true }); + await Deno.mkdir(join(repoRoot, "shared"), { recursive: true }); + await Deno.writeTextFile( + join(meshRoot, "_mesh/_config/config.ttl"), + `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:workspaceRootRelativeToMeshRoot "../" ; + sfcfg:hasMeshWorkspacePathRule [ + a sfcfg:MeshWorkspacePathRule ; + sfcfg:workspacePathPrefix "../shared/" ; + sfcfg:appliesToLocalPathLocatorKind + ] . +`, + ); + await Deno.writeTextFile(join(repoRoot, "shared/config.ttl"), "shared\n"); + + const policy = await loadOperationalLocalPathPolicy(meshRoot); + const result = await resolveArtifactResolutionRequest({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy: policy, + }, { + sourceDescription: "workspace config source", + targetLocalRelativePath: "../shared/config.ttl", + }, { contentMode: "text" }); + + assertEquals(result.content?.text, "shared\n"); + assertEquals(result.observed.localRelativePath, "../shared/config.ttl"); +}); + +Deno.test("resolveArtifactResolutionRequest rejects denied targetLocalRelativePath escapes", async () => { + const { context } = await createResolverTestContext(); + + await assertRejects( + () => + resolveArtifactResolutionRequest(context, { + sourceDescription: "escaping config source", + targetLocalRelativePath: "../outside.ttl", + }, { contentMode: "text" }), + ArtifactResolutionError, + "outside the allowed local-path boundary", + ); +}); + +Deno.test("resolveArtifactResolutionRequest rejects targetAccessUrl without fetching", async () => { + const { context } = await createResolverTestContext(); + + await assertRejects( + () => + resolveArtifactResolutionRequest(context, { + sourceDescription: "remote import provenance", + targetAccessUrl: "https://example.org/source.ttl", + }, { contentMode: "text" }), + ArtifactResolutionError, + "does not fetch", + ); +}); + +Deno.test("resolveArtifactResolutionRequest rejects digest mismatches", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await Deno.writeTextFile(join(meshRoot, "source.ttl"), "actual\n"); + + await assertRejects( + () => + resolveArtifactResolutionRequest(context, { + sourceDescription: "digest-pinned source", + targetLocalRelativePath: "source.ttl", + expectedContentDigest: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, { contentMode: "text" }), + ArtifactResolutionError, + "digest mismatch", + ); +}); + +Deno.test("resolveArtifactResolutionRequest resolves governed payload working bytes", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await materializePayloadArtifact(meshRoot); + await Deno.writeTextFile(join(meshRoot, "alice/source.ttl"), "working\n"); + + const result = await resolveArtifactResolutionRequest(context, { + sourceDescription: "working payload source", + targetArtifactIri: new URL("alice", MESH_BASE).href, + mode: "working", + }, { contentMode: "text" }); + + assertEquals(result.observed.localRelativePath, "alice/source.ttl"); + assertEquals(result.content?.text, "working\n"); +}); + +Deno.test("resolveArtifactResolutionRequest resolves governed payload latest state", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await materializePayloadArtifact(meshRoot); + await Deno.writeTextFile( + join(meshRoot, "alice/_history001/_s0001/ttl/source.ttl"), + "settled\n", + ); + + const result = await resolveArtifactResolutionRequest(context, { + sourceDescription: "latest payload source", + targetArtifactIri: new URL("alice", MESH_BASE).href, + mode: "latestState", + }, { contentMode: "text" }); + + assertEquals( + result.observed.historicalStateIri, + new URL("alice/_history001/_s0001", MESH_BASE).href, + ); + assertEquals( + result.observed.locatedFileIri, + new URL("alice/_history001/_s0001/ttl/source.ttl", MESH_BASE).href, + ); + assertEquals(result.content?.text, "settled\n"); +}); + +Deno.test("resolveArtifactResolutionRequest rejects latest payload state without HistoricalState type", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await materializePayloadArtifact(meshRoot); + const inventoryPath = join( + meshRoot, + "alice/_knop/_inventory/inventory.ttl", + ); + await Deno.writeTextFile( + inventoryPath, + (await Deno.readTextFile(inventoryPath)).replace( + " a sflo:HistoricalState ;", + " a sflo:LocatedFile ;", + ), + ); + + await assertRejects( + () => + resolveArtifactResolutionRequest(context, { + sourceDescription: "latest payload source", + targetArtifactIri: new URL("alice", MESH_BASE).href, + mode: "latestState", + }, { contentMode: "text" }), + ArtifactResolutionError, + "targetHistoricalState is not declared as a HistoricalState", + ); +}); + +Deno.test("resolveArtifactResolutionRequest resolves exact payload historical state", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await materializePayloadArtifact(meshRoot); + await Deno.writeTextFile( + join(meshRoot, "alice/_history001/_s0001/ttl/source.ttl"), + "exact\n", + ); + + const result = await resolveArtifactResolutionRequest(context, { + sourceDescription: "exact payload source", + targetArtifactIri: new URL("alice", MESH_BASE).href, + targetHistoricalStateIri: new URL( + "alice/_history001/_s0001", + MESH_BASE, + ).href, + }, { contentMode: "text" }); + + assertEquals( + result.observed.historicalStateIri, + new URL("alice/_history001/_s0001", MESH_BASE).href, + ); + assertEquals(result.content?.text, "exact\n"); +}); + +Deno.test("resolveArtifactResolutionRequest rejects state/history ownership mismatches", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await materializePayloadArtifact(meshRoot); + + await assertRejects( + () => + resolveArtifactResolutionRequest(context, { + sourceDescription: "wrong history source", + targetArtifactIri: new URL("alice", MESH_BASE).href, + targetArtifactHistoryIri: new URL("alice/_history999", MESH_BASE).href, + targetHistoricalStateIri: new URL( + "alice/_history999/_s0001", + MESH_BASE, + ).href, + }, { contentMode: "text" }), + ArtifactResolutionError, + "not a history of targetArtifact", + ); +}); + +Deno.test("resolveArtifactResolutionSpecQuads falls back after unavailable exact state bytes", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await materializePayloadArtifact(meshRoot); + await Deno.writeTextFile(join(meshRoot, "fallback.md"), "fallback\n"); + const quads = new Parser({ baseIRI: MESH_BASE }).parse(` +@prefix sflo: <${SFLO}> . + +<#source> a sflo:ArtifactResolutionSpec ; + sflo:targetArtifact ; + sflo:targetHistoricalState ; + sflo:hasFallbackArtifactResolutionSpec <#fallback> . + +<#fallback> a sflo:ArtifactResolutionSpec ; + sflo:targetLocalRelativePath "fallback.md" . +`); + + const result = await resolveArtifactResolutionSpecQuads( + context, + quads, + new URL("#source", MESH_BASE).href, + { contentMode: "text" }, + ); + + assertEquals(result.observed.localRelativePath, "fallback.md"); + assertEquals(result.content?.text, "fallback\n"); +}); + +Deno.test("resolveArtifactResolutionSpecQuads does not fall back after digest mismatch", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await materializePayloadArtifact(meshRoot); + await Deno.writeTextFile( + join(meshRoot, "alice/_history001/_s0001/ttl/source.ttl"), + "primary\n", + ); + await Deno.writeTextFile(join(meshRoot, "fallback.md"), "fallback\n"); + const quads = new Parser({ baseIRI: MESH_BASE }).parse(` +@prefix sflo: <${SFLO}> . + +<#source> a sflo:ArtifactResolutionSpec ; + sflo:targetArtifact ; + sflo:targetHistoricalState ; + sflo:expectsContentDigest "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ; + sflo:hasFallbackArtifactResolutionSpec <#fallback> . + +<#fallback> a sflo:ArtifactResolutionSpec ; + sflo:targetLocalRelativePath "fallback.md" . +`); + + await assertRejects( + () => + resolveArtifactResolutionSpecQuads( + context, + quads, + new URL("#source", MESH_BASE).href, + { contentMode: "text" }, + ), + ArtifactResolutionError, + "digest mismatch", + ); +}); + +Deno.test("resolveArtifactResolutionSpecQuads reports primary and fallback failures", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await materializePayloadArtifact(meshRoot); + const quads = new Parser({ baseIRI: MESH_BASE }).parse(` +@prefix sflo: <${SFLO}> . + +<#source> a sflo:ArtifactResolutionSpec ; + sflo:targetArtifact ; + sflo:targetHistoricalState ; + sflo:hasFallbackArtifactResolutionSpec <#fallback> . + +<#fallback> a sflo:ArtifactResolutionSpec ; + sflo:targetLocalRelativePath "missing.md" . +`); + + const error = await assertRejects( + () => + resolveArtifactResolutionSpecQuads( + context, + quads, + new URL("#source", MESH_BASE).href, + { contentMode: "text" }, + ), + ArtifactResolutionError, + ); + + assertStringIncludes(error.message, "failed before fallback"); + assertStringIncludes(error.message, "Fallback"); + assertStringIncludes(error.message, "missing.md"); +}); + +Deno.test("resolveArtifactResolutionRequest resolves exact in-mesh located files", async () => { + const { context, meshRoot } = await createResolverTestContext(); + await Deno.mkdir(join(meshRoot, "_mesh/_config"), { recursive: true }); + await Deno.writeTextFile(join(meshRoot, "_mesh/_config/config.ttl"), "cfg\n"); + + const result = await resolveArtifactResolutionRequest(context, { + sourceDescription: "located file source", + targetLocatedFileIri: new URL("_mesh/_config/config.ttl", MESH_BASE).href, + }, { contentMode: "text" }); + + assertEquals(result.observed.localRelativePath, "_mesh/_config/config.ttl"); + assertEquals( + result.observed.locatedFileIri, + new URL("_mesh/_config/config.ttl", MESH_BASE).href, + ); + assertEquals(result.content?.text, "cfg\n"); +}); + +Deno.test("parseArtifactResolutionSpecQuads rejects duplicate singleton coordinates", () => { + const quads = new Parser({ baseIRI: MESH_BASE }).parse(` +@prefix sflo: <${SFLO}> . + +<#source> sflo:targetLocalRelativePath "a.ttl", "b.ttl" . +`); + + const error = assertRejectsSync(() => + parseArtifactResolutionSpecQuads( + quads, + new URL("#source", MESH_BASE).href, + ) + ); + + assertStringIncludes(error.message, "Expected at most one"); +}); + +async function createResolverTestContext(): Promise<{ + meshRoot: string; + context: ArtifactResolutionContext; +}> { + const meshRoot = await Deno.makeTempDir({ + prefix: "weave-artifact-resolution-", + }); + const policy: OperationalLocalPathPolicy = { + meshRoot, + workspaceRoot: meshRoot, + rules: [], + }; + return { + meshRoot, + context: { + meshRoot, + meshBase: MESH_BASE, + localPathPolicy: policy, + }, + }; +} + +async function materializePayloadArtifact(meshRoot: string): Promise { + await Deno.mkdir(join(meshRoot, "alice/_knop/_inventory"), { + recursive: true, + }); + await Deno.mkdir(join(meshRoot, "alice"), { recursive: true }); + await Deno.mkdir(join(meshRoot, "alice/_history001/_s0001/ttl"), { + recursive: true, + }); + await Deno.writeTextFile( + join(meshRoot, "alice/_knop/_inventory/inventory.ttl"), + `@base <${MESH_BASE}> . +@prefix sflo: <${SFLO}> . + + a sflo:Knop ; + sflo:hasPayloadArtifact . + + a sflo:PayloadArtifact, sflo:RdfDocument ; + sflo:workingLocalRelativePath "alice/source.ttl" ; + sflo:currentArtifactHistory ; + sflo:hasArtifactHistory . + + a sflo:ArtifactHistory ; + sflo:latestHistoricalState . + + a sflo:HistoricalState ; + sflo:locatedFileForState . +`, + ); +} + +async function sha256Digest(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const hex = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return `sha256:${hex}`; +} + +function assertRejectsSync(callback: () => unknown): Error { + try { + callback(); + } catch (error) { + if (error instanceof Error) { + return error; + } + throw error; + } + throw new Error("Expected callback to throw"); +} diff --git a/src/runtime/config/config_sources.ts b/src/runtime/config/config_sources.ts new file mode 100644 index 0000000..19d65b0 --- /dev/null +++ b/src/runtime/config/config_sources.ts @@ -0,0 +1,792 @@ +import { resolve, toFileUrl } from "@std/path"; +import { Parser, type Quad, type Term } from "n3"; +import { toKnopPath } from "../../core/designator_segments.ts"; +import { + RDF_NAMESPACE, + SFCFG_NAMESPACE, + SFLO_NAMESPACE, +} from "../../core/rdf/namespaces.ts"; +import { + type ArtifactResolutionRequest, + type ArtifactResolutionResult, + parseArtifactResolutionSpecQuads, + resolveArtifactResolutionRequest, +} from "../artifact_resolution/resolver.ts"; +import { + type LocalPathLocatorKind, + type OperationalLocalPathPolicy, + resolveAllowedLocalPath, +} from "../operational/local_path_policy.ts"; +import { + type ProjectedInheritedConfigSource, + resolveKnopInheritedConfigSources, +} from "./inheritance.ts"; + +const RDF_TYPE_IRI = `${RDF_NAMESPACE}type`; +const SFCFG_CONFIG_SOURCE_IRI = `${SFCFG_NAMESPACE}ConfigSource`; +const SFCFG_HAS_CONFIG_SOURCE_IRI = `${SFCFG_NAMESPACE}hasConfigSource`; +const SFCFG_HAS_INHERITABLE_CONFIG_SOURCE_IRI = + `${SFCFG_NAMESPACE}hasInheritableConfigSource`; +const SFLO_SEMANTIC_MESH_IRI = `${SFLO_NAMESPACE}SemanticMesh`; +const SFLO_KNOP_IRI = `${SFLO_NAMESPACE}Knop`; + +export interface MeshLocalConfigInput { + turtle: string; + source: string; +} + +export type ConfigSourceLayerRole = + | "meshLocal" + | "knopInherited" + | "knopLocal"; + +export interface LayeredConfigInput extends MeshLocalConfigInput { + layerRole: ConfigSourceLayerRole; + sourceOrder: number; + authorityScopeKey?: string; +} + +export interface KnopConfigScopeInput { + scopeKey: string; + turtle: string; + source?: string; +} + +export interface ConfigSourceResolutionTraceEntry { + kind: "configSource"; + status: "accepted" | "skipped"; + declaredInSource: string; + attachmentSubject: string; + attachmentProperty: + | typeof SFCFG_HAS_CONFIG_SOURCE_IRI + | typeof SFCFG_HAS_INHERITABLE_CONFIG_SOURCE_IRI; + sourceTerm: string; + sourceIri?: string; + requested: ArtifactResolutionRequest; + resolvedLocalRelativePath?: string; + resolvedLocatedFileIri?: string; + resolvedHistoricalStateIri?: string; + contentDigest?: string; + layerRole: ConfigSourceLayerRole; + sourceOrder?: number; + authoredScopeKey?: string; + offeredByScopeKey?: string; + projectedToScopeKey?: string; + projection?: "ancestorInherited" | "selfInclusiveOffer"; + reason?: string; +} + +export interface DiscoverMeshLocalConfigSourcesOptions { + meshRoot: string; + meshBase: string; + localPathPolicy: OperationalLocalPathPolicy; + seedDocuments: readonly MeshLocalConfigInput[]; +} + +export interface DiscoverKnopConfigSourcesOptions { + meshRoot: string; + meshBase: string; + localPathPolicy: OperationalLocalPathPolicy; + knopScopePath: readonly KnopConfigScopeInput[]; +} + +interface ParsedConfigSourceDocument extends MeshLocalConfigInput { + quads: readonly Quad[]; +} + +interface ConfigSourceAttachment { + document: ParsedConfigSourceDocument; + subjectKey: string; + attachmentProperty: + | typeof SFCFG_HAS_CONFIG_SOURCE_IRI + | typeof SFCFG_HAS_INHERITABLE_CONFIG_SOURCE_IRI; + sourceTerm: string; + sourceIri?: string; + object: Term; + authoredScopeKey?: string; + offeredByScopeKey?: string; + projectedToScopeKey?: string; + projection?: "ancestorInherited" | "selfInclusiveOffer"; +} + +export class ConfigSourceDiscoveryError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ConfigSourceDiscoveryError"; + } +} + +export async function discoverMeshLocalConfigSources( + options: DiscoverMeshLocalConfigSourcesOptions, +): Promise<{ + configInputs: readonly MeshLocalConfigInput[]; + resolutionTrace: readonly ConfigSourceResolutionTraceEntry[]; +}> { + const activeMeshIri = new URL( + "_mesh", + ensureDirectoryIri(options.meshBase), + ).href; + const activeMeshSubjectKey = `NamedNode:${activeMeshIri}`; + const context = { + meshRoot: options.meshRoot, + meshBase: options.meshBase, + localPathPolicy: options.localPathPolicy, + }; + const configInputs: MeshLocalConfigInput[] = []; + const resolutionTrace: ConfigSourceResolutionTraceEntry[] = []; + const seenAttachments = new Set(); + const seenResolvedSources = new Set(); + + async function discoverFromDocument( + document: ParsedConfigSourceDocument, + stack: readonly string[], + ): Promise { + const attachments = collectMeshLocalConfigSourceAttachments( + document, + activeMeshSubjectKey, + activeMeshIri, + ); + for (const attachment of attachments) { + await resolveAttachment(attachment, stack); + } + } + + async function resolveAttachment( + attachment: ConfigSourceAttachment, + stack: readonly string[], + ): Promise { + const attachmentKey = + `${attachment.document.source} ${attachment.sourceTerm}`; + if (seenAttachments.has(attachmentKey)) { + return; + } + seenAttachments.add(attachmentKey); + + const sourceDescription = + `${attachment.document.source} ${SFCFG_HAS_CONFIG_SOURCE_IRI} ${attachment.sourceTerm}`; + const request = parseArtifactResolutionSpecQuads( + attachment.document.quads, + attachment.object, + { sourceDescription }, + ); + const result = await resolveArtifactResolutionRequest( + context, + request, + { contentMode: "text" }, + ); + const resolvedIdentity = resolvedConfigSourceIdentity(result); + + if (stack.includes(resolvedIdentity)) { + throw new ConfigSourceDiscoveryError( + `Cyclic mesh-local config-source reference detected: ${ + [...stack, resolvedIdentity].join(" -> ") + }`, + ); + } + + if (seenResolvedSources.has(resolvedIdentity)) { + resolutionTrace.push(traceEntryForAttachment( + attachment, + result, + "skipped", + "meshLocal", + undefined, + "duplicate resolved config source", + )); + return; + } + seenResolvedSources.add(resolvedIdentity); + + const text = result.content?.text; + if (text === undefined) { + throw new ConfigSourceDiscoveryError( + `Resolved config source did not provide Turtle text: ${sourceDescription}`, + ); + } + + const source = sourceLabelForResult(result, options.localPathPolicy); + configInputs.push({ turtle: text, source }); + resolutionTrace.push(traceEntryForAttachment( + attachment, + result, + "accepted", + )); + + await discoverFromDocument( + parseConfigSourceDocument({ turtle: text, source }), + [...stack, resolvedIdentity], + ); + } + + for (const document of options.seedDocuments.map(parseConfigSourceDocument)) { + await discoverFromDocument(document, []); + } + + return { configInputs, resolutionTrace }; +} + +export async function discoverKnopConfigSources( + options: DiscoverKnopConfigSourcesOptions, +): Promise<{ + configInputs: readonly LayeredConfigInput[]; + resolutionTrace: readonly ConfigSourceResolutionTraceEntry[]; +}> { + if (options.knopScopePath.length === 0) { + return { configInputs: [], resolutionTrace: [] }; + } + + const context = { + meshRoot: options.meshRoot, + meshBase: options.meshBase, + localPathPolicy: options.localPathPolicy, + }; + const parsedScopes = options.knopScopePath.map((scope) => { + const source = scope.source ?? + `${toKnopPath(scope.scopeKey)}/_meta/meta.ttl`; + return { + scopeKey: scope.scopeKey, + subjectKey: `NamedNode:${ + new URL( + toKnopPath(scope.scopeKey), + ensureDirectoryIri(options.meshBase), + ) + .href + }`, + document: parseConfigSourceDocument({ + turtle: scope.turtle, + source, + }), + }; + }); + const scopes = parsedScopes.map((scope) => { + const attachments = collectKnopConfigSourceAttachments( + scope.document, + scope.subjectKey, + scope.scopeKey, + ); + return { + ...scope, + localSources: attachments.localSources, + inheritableSources: attachments.inheritableSources, + }; + }); + const targetScope = scopes.at(-1)!; + const targetScopeKey = targetScope.scopeKey; + const projectedInheritedSources = resolveKnopInheritedConfigSources( + scopes.map((scope) => ({ + scopeKey: scope.scopeKey, + inheritableSources: scope.inheritableSources, + })), + ); + + const inherited = await resolveLayeredConfigSourceAttachments({ + context, + localPathPolicy: options.localPathPolicy, + layerRole: "knopInherited", + initialAttachments: orderProjectedInheritedSourcesForPrecedence( + projectedInheritedSources, + ).map((projection) => + toProjectedConfigSourceAttachment(projection, targetScopeKey) + ), + }); + const local = await resolveLayeredConfigSourceAttachments({ + context, + localPathPolicy: options.localPathPolicy, + layerRole: "knopLocal", + initialAttachments: targetScope.localSources, + }); + + return { + configInputs: [ + ...inherited.configInputs, + ...local.configInputs, + ], + resolutionTrace: [ + ...inherited.resolutionTrace, + ...local.resolutionTrace, + ], + }; +} + +function collectMeshLocalConfigSourceAttachments( + document: ParsedConfigSourceDocument, + activeMeshSubjectKey: string, + activeMeshIri: string, +): readonly ConfigSourceAttachment[] { + const attachments = new Map(); + + for (const quad of document.quads) { + if ( + quad.predicate.value !== SFCFG_HAS_CONFIG_SOURCE_IRI && + quad.predicate.value !== SFCFG_HAS_INHERITABLE_CONFIG_SOURCE_IRI + ) { + continue; + } + + const subjectKey = toTermKey(quad.subject); + if (quad.predicate.value === SFCFG_HAS_INHERITABLE_CONFIG_SOURCE_IRI) { + throw new ConfigSourceDiscoveryError( + `${SFCFG_HAS_INHERITABLE_CONFIG_SOURCE_IRI} in ${document.source} is supported only by Knop metadata discovery, not mesh-local config-source discovery.`, + ); + } + + if (subjectKey !== activeMeshSubjectKey) { + if ( + hasNamedNodeObject( + document.quads, + subjectKey, + RDF_TYPE_IRI, + SFLO_KNOP_IRI, + ) + ) { + throw new ConfigSourceDiscoveryError( + `${SFCFG_HAS_CONFIG_SOURCE_IRI} on sflo:Knop in ${document.source} is supported only by Knop metadata discovery, not mesh-local config-source discovery.`, + ); + } + throw new ConfigSourceDiscoveryError( + `${SFCFG_HAS_CONFIG_SOURCE_IRI} in ${document.source} is supported only on the active SemanticMesh <${activeMeshIri}> in this implementation slice; found ${ + describeTerm(quad.subject) + }.`, + ); + } + if ( + !hasNamedNodeObject( + document.quads, + subjectKey, + RDF_TYPE_IRI, + SFLO_SEMANTIC_MESH_IRI, + ) + ) { + throw new ConfigSourceDiscoveryError( + `${SFCFG_HAS_CONFIG_SOURCE_IRI} in ${document.source} requires the active mesh attachment subject to be typed ${SFLO_SEMANTIC_MESH_IRI}.`, + ); + } + + if (quad.object.termType === "Literal") { + throw new ConfigSourceDiscoveryError( + `${SFCFG_HAS_CONFIG_SOURCE_IRI} values in ${document.source} must be named or blank nodes.`, + ); + } + + const sourceTerm = toTermKey(quad.object); + requireTermHasType( + document.quads, + sourceTerm, + SFCFG_CONFIG_SOURCE_IRI, + document.source, + ); + attachments.set(sourceTerm, { + document, + subjectKey, + attachmentProperty: SFCFG_HAS_CONFIG_SOURCE_IRI, + sourceTerm, + ...(quad.object.termType === "NamedNode" + ? { sourceIri: quad.object.value } + : {}), + object: quad.object, + }); + } + + return [...attachments.values()].sort((left, right) => + left.sourceTerm.localeCompare(right.sourceTerm) + ); +} + +function collectKnopConfigSourceAttachments( + document: ParsedConfigSourceDocument, + activeKnopSubjectKey: string, + activeScopeKey: string, +): { + localSources: readonly ConfigSourceAttachment[]; + inheritableSources: readonly ConfigSourceAttachment[]; +} { + const localSources = new Map(); + const inheritableSources = new Map(); + + for (const quad of document.quads) { + if ( + quad.predicate.value !== SFCFG_HAS_CONFIG_SOURCE_IRI && + quad.predicate.value !== SFCFG_HAS_INHERITABLE_CONFIG_SOURCE_IRI + ) { + continue; + } + + const subjectKey = toTermKey(quad.subject); + if (subjectKey !== activeKnopSubjectKey) { + throw new ConfigSourceDiscoveryError( + `${quad.predicate.value} in ${document.source} is supported only on the active Knop ${activeKnopSubjectKey}; found ${ + describeTerm(quad.subject) + }.`, + ); + } + requireTermHasType( + document.quads, + subjectKey, + SFLO_KNOP_IRI, + document.source, + ); + if (quad.object.termType === "Literal") { + throw new ConfigSourceDiscoveryError( + `${quad.predicate.value} values in ${document.source} must be named or blank nodes.`, + ); + } + + const sourceTerm = toTermKey(quad.object); + requireTermHasType( + document.quads, + sourceTerm, + SFCFG_CONFIG_SOURCE_IRI, + document.source, + ); + const attachment: ConfigSourceAttachment = { + document, + subjectKey, + attachmentProperty: quad.predicate.value as + | typeof SFCFG_HAS_CONFIG_SOURCE_IRI + | typeof SFCFG_HAS_INHERITABLE_CONFIG_SOURCE_IRI, + sourceTerm, + ...(quad.object.termType === "NamedNode" + ? { sourceIri: quad.object.value } + : {}), + object: quad.object, + authoredScopeKey: activeScopeKey, + offeredByScopeKey: activeScopeKey, + }; + const attachmentMap = quad.predicate.value === SFCFG_HAS_CONFIG_SOURCE_IRI + ? localSources + : inheritableSources; + attachmentMap.set(sourceTerm, attachment); + } + + return { + localSources: sortAttachments(localSources.values()), + inheritableSources: sortAttachments(inheritableSources.values()), + }; +} + +function sortAttachments( + attachments: Iterable, +): readonly ConfigSourceAttachment[] { + return [...attachments].sort((left, right) => + left.sourceTerm.localeCompare(right.sourceTerm) + ); +} + +function toProjectedConfigSourceAttachment( + projection: ProjectedInheritedConfigSource, + targetScopeKey: string, +): ConfigSourceAttachment { + return { + ...projection.source, + offeredByScopeKey: projection.offeredByScopeKey, + projectedToScopeKey: targetScopeKey, + projection: projection.projection, + }; +} + +function orderProjectedInheritedSourcesForPrecedence( + projections: readonly ProjectedInheritedConfigSource< + ConfigSourceAttachment + >[], +): readonly ProjectedInheritedConfigSource[] { + const ordered: ProjectedInheritedConfigSource[] = []; + for (let index = projections.length - 1; index >= 0;) { + const offeredByScopeKey = projections[index]!.offeredByScopeKey; + let groupStart = index; + while ( + groupStart > 0 && + projections[groupStart - 1]!.offeredByScopeKey === offeredByScopeKey + ) { + groupStart -= 1; + } + ordered.push(...projections.slice(groupStart, index + 1)); + index = groupStart - 1; + } + return ordered; +} + +async function resolveLayeredConfigSourceAttachments( + input: { + context: { + meshRoot: string; + meshBase: string; + localPathPolicy: OperationalLocalPathPolicy; + }; + localPathPolicy: OperationalLocalPathPolicy; + layerRole: "knopInherited" | "knopLocal"; + initialAttachments: readonly ConfigSourceAttachment[]; + }, +): Promise<{ + configInputs: readonly LayeredConfigInput[]; + resolutionTrace: readonly ConfigSourceResolutionTraceEntry[]; +}> { + const configInputs: LayeredConfigInput[] = []; + const resolutionTrace: ConfigSourceResolutionTraceEntry[] = []; + const seenAttachments = new Set(); + const seenResolvedSources = new Set(); + let nextSourceOrder = 0; + + async function discoverFromAttachment( + attachment: ConfigSourceAttachment, + stack: readonly string[], + ): Promise { + const attachmentKey = + `${attachment.document.source} ${attachment.attachmentProperty} ${attachment.sourceTerm}`; + if (seenAttachments.has(attachmentKey)) { + return; + } + seenAttachments.add(attachmentKey); + + const sourceDescription = + `${attachment.document.source} ${attachment.attachmentProperty} ${attachment.sourceTerm}`; + const request = parseArtifactResolutionSpecQuads( + attachment.document.quads, + attachment.object, + { sourceDescription }, + ); + const result = await resolveArtifactResolutionRequest( + input.context, + request, + { contentMode: "text" }, + ); + const resolvedIdentity = resolvedConfigSourceIdentity(result); + + if (stack.includes(resolvedIdentity)) { + throw new ConfigSourceDiscoveryError( + `Cyclic ${input.layerRole} config-source reference detected: ${ + [...stack, resolvedIdentity].join(" -> ") + }`, + ); + } + + if (seenResolvedSources.has(resolvedIdentity)) { + resolutionTrace.push(traceEntryForAttachment( + attachment, + result, + "skipped", + input.layerRole, + undefined, + "duplicate resolved config source", + )); + return; + } + seenResolvedSources.add(resolvedIdentity); + + const text = result.content?.text; + if (text === undefined) { + throw new ConfigSourceDiscoveryError( + `Resolved config source did not provide Turtle text: ${sourceDescription}`, + ); + } + + const sourceOrder = nextSourceOrder; + nextSourceOrder += 1; + const source = sourceLabelForResult(result, input.localPathPolicy); + configInputs.push({ + turtle: text, + source, + layerRole: input.layerRole, + sourceOrder, + ...(attachment.projectedToScopeKey !== undefined + ? { authorityScopeKey: attachment.projectedToScopeKey } + : attachment.authoredScopeKey !== undefined + ? { authorityScopeKey: attachment.authoredScopeKey } + : {}), + }); + resolutionTrace.push(traceEntryForAttachment( + attachment, + result, + "accepted", + input.layerRole, + sourceOrder, + )); + + const parsed = parseConfigSourceDocument({ turtle: text, source }); + const recursiveAttachments = collectKnopConfigSourceAttachments( + parsed, + attachment.subjectKey, + attachment.authoredScopeKey ?? "", + ).localSources.map((recursiveAttachment) => ({ + ...recursiveAttachment, + authoredScopeKey: attachment.authoredScopeKey, + offeredByScopeKey: attachment.offeredByScopeKey, + projectedToScopeKey: attachment.projectedToScopeKey, + projection: attachment.projection, + })); + for (const recursiveAttachment of recursiveAttachments) { + await discoverFromAttachment(recursiveAttachment, [ + ...stack, + resolvedIdentity, + ]); + } + } + + for (const attachment of input.initialAttachments) { + await discoverFromAttachment(attachment, []); + } + + return { configInputs, resolutionTrace }; +} + +function parseConfigSourceDocument( + document: MeshLocalConfigInput, +): ParsedConfigSourceDocument { + try { + return { + ...document, + quads: new Parser({ baseIRI: toParserBaseIri(document.source) }).parse( + document.turtle, + ), + }; + } catch (error) { + throw new ConfigSourceDiscoveryError( + `Could not parse config source discovery input: ${document.source}`, + { cause: error }, + ); + } +} + +function traceEntryForAttachment( + attachment: ConfigSourceAttachment, + result: ArtifactResolutionResult, + status: ConfigSourceResolutionTraceEntry["status"], + layerRole: ConfigSourceLayerRole = "meshLocal", + sourceOrder?: number, + reason?: string, +): ConfigSourceResolutionTraceEntry { + return { + kind: "configSource", + status, + declaredInSource: attachment.document.source, + attachmentSubject: attachment.subjectKey, + attachmentProperty: attachment.attachmentProperty, + sourceTerm: attachment.sourceTerm, + ...(attachment.sourceIri ? { sourceIri: attachment.sourceIri } : {}), + requested: result.requested, + ...(result.observed.localRelativePath + ? { resolvedLocalRelativePath: result.observed.localRelativePath } + : {}), + ...(result.observed.locatedFileIri + ? { resolvedLocatedFileIri: result.observed.locatedFileIri } + : {}), + ...(result.observed.historicalStateIri + ? { resolvedHistoricalStateIri: result.observed.historicalStateIri } + : {}), + ...(result.observed.contentDigest + ? { contentDigest: result.observed.contentDigest } + : {}), + layerRole, + ...(sourceOrder !== undefined ? { sourceOrder } : {}), + ...(attachment.authoredScopeKey !== undefined + ? { authoredScopeKey: attachment.authoredScopeKey } + : {}), + ...(attachment.offeredByScopeKey !== undefined + ? { offeredByScopeKey: attachment.offeredByScopeKey } + : {}), + ...(attachment.projectedToScopeKey !== undefined + ? { projectedToScopeKey: attachment.projectedToScopeKey } + : {}), + ...(attachment.projection ? { projection: attachment.projection } : {}), + ...(reason ? { reason } : {}), + }; +} + +function resolvedConfigSourceIdentity( + result: ArtifactResolutionResult, +): string { + if (result.observed.localRelativePath) { + return `local:${result.observed.localRelativePath}`; + } + if (result.observed.locatedFileIri) { + return `located:${result.observed.locatedFileIri}`; + } + if (result.observed.historicalStateIri) { + return `historical:${result.observed.historicalStateIri}`; + } + if (result.requested.sourceIri) { + return `source:${result.requested.sourceIri}`; + } + return `request:${JSON.stringify(result.requested)}`; +} + +function sourceLabelForResult( + result: ArtifactResolutionResult, + localPathPolicy: OperationalLocalPathPolicy, +): string { + if (result.observed.localRelativePath) { + const locatorKind = locatorKindForRequest(result.requested); + try { + return resolveAllowedLocalPath( + localPathPolicy, + locatorKind, + result.observed.localRelativePath, + ); + } catch { + return result.observed.localRelativePath; + } + } + + return result.observed.locatedFileIri ?? + result.observed.historicalStateIri ?? + result.requested.sourceIri ?? + result.requested.sourceTerm ?? + "resolved config source"; +} + +function locatorKindForRequest( + request: ArtifactResolutionRequest, +): LocalPathLocatorKind { + return request.targetLocalRelativePath === undefined + ? "workingLocalRelativePath" + : "targetLocalRelativePath"; +} + +function requireTermHasType( + quads: readonly Quad[], + termKey: string, + typeIri: string, + source: string, +): void { + if (!hasNamedNodeObject(quads, termKey, RDF_TYPE_IRI, typeIri)) { + throw new ConfigSourceDiscoveryError( + `Expected ${termKey} to be typed ${typeIri} in ${source}`, + ); + } +} + +function hasNamedNodeObject( + quads: readonly Quad[], + subjectKey: string, + predicateIri: string, + objectIri: string, +): boolean { + return quads.some((quad) => + toTermKey(quad.subject) === subjectKey && + quad.predicate.value === predicateIri && + quad.object.termType === "NamedNode" && + quad.object.value === objectIri + ); +} + +function describeTerm(term: Term): string { + if (term.termType === "NamedNode") { + return `<${term.value}>`; + } + return `${term.termType}:${term.value}`; +} + +function toTermKey(term: Term): string { + return `${term.termType}:${term.value}`; +} + +function ensureDirectoryIri(iri: string): string { + return iri.endsWith("/") ? iri : `${iri}/`; +} + +function toParserBaseIri(source: string): string { + try { + return new URL(source).href; + } catch { + return toFileUrl(resolve(source)).href; + } +} diff --git a/src/runtime/config/effective_config.ts b/src/runtime/config/effective_config.ts index 7fa8558..8c6faab 100644 --- a/src/runtime/config/effective_config.ts +++ b/src/runtime/config/effective_config.ts @@ -6,6 +6,15 @@ import { SFCFG_NAMESPACE, SFLO_NAMESPACE, } from "../../core/rdf/namespaces.ts"; +import { + type ConfigSourceResolutionTraceEntry, + discoverKnopConfigSources, + discoverMeshLocalConfigSources, + type KnopConfigScopeInput, + type LayeredConfigInput, + type MeshLocalConfigInput, +} from "./config_sources.ts"; +import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; const RDF_TYPE_IRI = `${RDF_NAMESPACE}type`; const APPLICATION_CONFIG_IRI = `${SFCFG_NAMESPACE}ApplicationConfig`; @@ -623,6 +632,8 @@ export interface EffectiveConfigSources { applicationSource: string; configResolutionSource: string; meshConfigSource?: string; + meshConfigSources?: readonly string[]; + knopConfigSources?: readonly string[]; commandOverrideSource?: string; } @@ -639,9 +650,15 @@ export interface CompileWeaveEffectiveConfigOptions { defaultsRoot?: string | URL; meshConfigTurtle?: string; meshConfigSource?: string; + meshConfigInputs?: readonly MeshLocalConfigInput[]; + meshRoot?: string; + meshMetadataTurtle?: string; + meshMetadataSource?: string; meshBase?: string; meshInventoryTurtle?: string; + localPathPolicy?: OperationalLocalPathPolicy; governedArtifactIris?: readonly string[]; + knopConfigScopePath?: readonly KnopConfigScopeInput[]; commandOverrides?: CompileWeaveEffectiveConfigCommandOverrides; } @@ -673,10 +690,13 @@ export interface ArtifactPolicyTargetQuery { interface PolicyTargetValidationContext { governedArtifactIris?: ReadonlySet; + meshBase?: string; + knopAuthorityScopeKey?: string; } interface CompiledPolicyBinding { source: string; + sourceOrder: number; layerRole: ConfigLayerRole; layerOrder: number; bindingTerm: string; @@ -689,10 +709,23 @@ interface ScopedSettingLayer { quads: readonly Quad[]; configSubject: string; source: string; + sourceOrder: number; layerRole: ConfigLayerRole; layerOrder: number; } +interface ParsedLayeredConfigInput { + quads: readonly Quad[]; + configSubject?: string; + source: string; + sourceOrder: number; + authorityScopeKey?: string; + layerRole: Extract< + ConfigLayerRole, + "meshLocal" | "knopInherited" | "knopLocal" + >; +} + export interface PolicyResolutionTraceEntry { slot: PolicySlot; artifactIri?: string; @@ -700,10 +733,15 @@ export interface PolicyResolutionTraceEntry { selectedValue: string; selectedLayerRole: ConfigLayerRole; selectedSource: string; + selectedSourceOrder: number; selectedTargetKind: PolicyTarget["kind"]; candidateCount: number; } +export type EffectiveConfigResolutionTraceEntry = + | PolicyResolutionTraceEntry + | ConfigSourceResolutionTraceEntry; + export class EffectiveConfigError extends Error { constructor(message: string) { super(message); @@ -719,7 +757,7 @@ export class EffectiveConfig { readonly resourcePageRegenerationConfigPolicy: ResourcePageRegenerationConfigPolicy; readonly scopedSettings: EffectiveScopedSettings; - readonly resolutionTrace: PolicyResolutionTraceEntry[] = []; + readonly resolutionTrace: EffectiveConfigResolutionTraceEntry[]; readonly #policyBindings: readonly CompiledPolicyBinding[]; readonly #presentationProfiles: ReadonlyMap< ResourcePagePresentationIdentity, @@ -739,6 +777,7 @@ export class EffectiveConfig { namingPolicies: DefaultNamingPolicies; scopedSettings: EffectiveScopedSettings; configResolution: DefaultConfigResolutionProfile; + resolutionTrace?: readonly ConfigSourceResolutionTraceEntry[]; }, ) { this.sources = input.sources; @@ -749,6 +788,7 @@ export class EffectiveConfig { this.namingPolicies = input.namingPolicies; this.scopedSettings = input.scopedSettings; this.configResolution = input.configResolution; + this.resolutionTrace = [...(input.resolutionTrace ?? [])]; this.resourcePagePresentation = this .resourcePagePresentationPolicyForTarget({ artifactRoles: [] }); } @@ -879,16 +919,23 @@ export class EffectiveConfig { const priorityWinners = specificityWinners.filter((binding) => binding.priority === highestPriority ); + const earliestSourceOrder = Math.min( + ...priorityWinners.map((binding) => binding.sourceOrder), + ); + const sourceOrderWinners = priorityWinners.filter((binding) => + binding.sourceOrder === earliestSourceOrder + ); const values = new Set( - priorityWinners.map((binding) => + sourceOrderWinners.map((binding) => policyValueForSlot(binding.values, slot)! ), ); if (values.size !== 1) { const conflictingValues = [...values].sort(); - const conflictingBindings = priorityWinners.map((binding) => ({ + const conflictingBindings = sourceOrderWinners.map((binding) => ({ source: binding.source, + sourceOrder: binding.sourceOrder, layerRole: binding.layerRole, layerOrder: binding.layerOrder, bindingTerm: binding.bindingTerm, @@ -897,13 +944,13 @@ export class EffectiveConfig { value: policyValueForSlot(binding.values, slot), })); throw new EffectiveConfigError( - `Conflicting ${slot} policy bindings at the same layer, specificity, and priority; values=${ + `Conflicting ${slot} policy bindings at the same layer, specificity, priority, and source order; values=${ JSON.stringify(conflictingValues) }; bindings=${JSON.stringify(conflictingBindings)}`, ); } - const selected = priorityWinners[0]!; + const selected = sourceOrderWinners[0]!; const selectedValue = values.values().next().value!; this.resolutionTrace.push({ slot, @@ -912,6 +959,7 @@ export class EffectiveConfig { selectedValue, selectedLayerRole: selected.layerRole, selectedSource: selected.source, + selectedSourceOrder: selected.sourceOrder, selectedTargetKind: selected.target.kind, candidateCount: candidates.length, }); @@ -936,22 +984,113 @@ export async function loadWeaveEffectiveConfig( options.defaultsRoot, "config-resolution.ttl", ); + const seedMeshConfigInputs = options.meshConfigInputs ?? + (options.meshConfigTurtle + ? [{ + turtle: options.meshConfigTurtle, + source: options.meshConfigSource ?? "mesh-config.ttl", + }] + : []); + let resolvedMeshConfigInputs: readonly MeshLocalConfigInput[] = []; + let resolvedKnopConfigInputs: readonly LayeredConfigInput[] = []; + let configSourceResolutionTrace: readonly ConfigSourceResolutionTraceEntry[] = + []; + + if ( + (seedMeshConfigInputs.length > 0 || options.meshMetadataTurtle) && + options.meshRoot && + options.meshBase && + options.localPathPolicy + ) { + try { + const discovery = await discoverMeshLocalConfigSources({ + meshRoot: options.meshRoot, + meshBase: options.meshBase, + localPathPolicy: options.localPathPolicy, + seedDocuments: [ + ...seedMeshConfigInputs, + ...(options.meshMetadataTurtle + ? [{ + turtle: options.meshMetadataTurtle, + source: options.meshMetadataSource ?? "_mesh/_meta/meta.ttl", + }] + : []), + ], + }); + resolvedMeshConfigInputs = discovery.configInputs; + configSourceResolutionTrace = discovery.resolutionTrace; + } catch (error) { + if (error instanceof EffectiveConfigError) { + throw error; + } + throw new EffectiveConfigError( + error instanceof Error + ? error.message + : `Could not discover mesh-local config sources: ${String(error)}`, + ); + } + } + + if ( + options.knopConfigScopePath && + options.knopConfigScopePath.length > 0 && + options.meshRoot && + options.meshBase && + options.localPathPolicy + ) { + try { + const discovery = await discoverKnopConfigSources({ + meshRoot: options.meshRoot, + meshBase: options.meshBase, + localPathPolicy: options.localPathPolicy, + knopScopePath: options.knopConfigScopePath, + }); + resolvedKnopConfigInputs = discovery.configInputs; + configSourceResolutionTrace = [ + ...configSourceResolutionTrace, + ...discovery.resolutionTrace, + ]; + } catch (error) { + if (error instanceof EffectiveConfigError) { + throw error; + } + throw new EffectiveConfigError( + error instanceof Error + ? error.message + : `Could not discover Knop config sources: ${String(error)}`, + ); + } + } + + const meshConfigInputs = [ + ...seedMeshConfigInputs, + ...resolvedMeshConfigInputs, + ]; + const meshConfigSources = meshConfigInputs.map((input) => input.source); + const firstMeshConfigSource = meshConfigSources[0]; + const knopConfigSources = resolvedKnopConfigInputs.map((input) => + input.source + ); return compileWeaveEffectiveConfig({ applicationTurtle: await Deno.readTextFile(applicationSource), configResolutionTurtle: await Deno.readTextFile(configResolutionSource), - meshConfigTurtle: options.meshConfigTurtle, + meshConfigInputs, meshBase: options.meshBase, meshInventoryTurtle: options.meshInventoryTurtle, + knopConfigInputs: resolvedKnopConfigInputs, governedArtifactIris: options.governedArtifactIris, commandOverrides: options.commandOverrides, sources: { applicationSource: formatSource(applicationSource), configResolutionSource: formatSource(configResolutionSource), - ...(options.meshConfigSource - ? { meshConfigSource: options.meshConfigSource } + ...(firstMeshConfigSource + ? { meshConfigSource: firstMeshConfigSource } : {}), + ...(meshConfigSources.length > 0 ? { meshConfigSources } : {}), + ...(knopConfigSources.length > 0 ? { knopConfigSources } : {}), }, + configSourceResolutionTrace, }); } @@ -974,27 +1113,54 @@ export function compileWeaveEffectiveConfig(input: { applicationTurtle: string; configResolutionTurtle: string; meshConfigTurtle?: string; + meshConfigInputs?: readonly MeshLocalConfigInput[]; + knopConfigInputs?: readonly LayeredConfigInput[]; meshBase?: string; meshInventoryTurtle?: string; governedArtifactIris?: readonly string[]; commandOverrides?: CompileWeaveEffectiveConfigCommandOverrides; sources?: EffectiveConfigSources; + configSourceResolutionTrace?: readonly ConfigSourceResolutionTraceEntry[]; }): EffectiveConfig { + const meshConfigInputs = input.meshConfigInputs ?? + (input.meshConfigTurtle + ? [{ + turtle: input.meshConfigTurtle, + source: input.sources?.meshConfigSource ?? "mesh-config.ttl", + }] + : []); + const knopConfigInputs = input.knopConfigInputs ?? []; const sources = input.sources ?? { applicationSource: "application.ttl", configResolutionSource: "config-resolution.ttl", - ...(input.meshConfigTurtle ? { meshConfigSource: "mesh-config.ttl" } : {}), + ...(meshConfigInputs.length > 0 + ? { meshConfigSource: meshConfigInputs[0]!.source } + : {}), + ...(meshConfigInputs.length > 0 + ? { + meshConfigSources: meshConfigInputs.map((document) => document.source), + } + : {}), + ...(knopConfigInputs.length > 0 + ? { + knopConfigSources: knopConfigInputs.map((document) => document.source), + } + : {}), }; const applicationQuads = parseTurtle( input.applicationTurtle, sources.applicationSource, ); - const meshQuads = input.meshConfigTurtle - ? parseTurtle( - input.meshConfigTurtle, - sources.meshConfigSource ?? "mesh-config.ttl", - ) - : []; + const meshDocuments = meshConfigInputs.map((document, index) => + parseLayeredConfigInput({ + ...document, + layerRole: "meshLocal", + sourceOrder: index, + }) + ); + const knopDocuments = knopConfigInputs.map(parseLayeredConfigInput); + const configDocuments = [...meshDocuments, ...knopDocuments]; + const configQuads = configDocuments.flatMap((document) => document.quads); const configResolutionQuads = parseTurtle( input.configResolutionTurtle, sources.configResolutionSource, @@ -1017,39 +1183,39 @@ export function compileWeaveEffectiveConfig(input: { HAS_CONFIG_RESOLUTION_CONFIG_IRI, sources.applicationSource, ); - const meshSubject = resolveOptionalMeshConfigSubject( - meshQuads, - sources.meshConfigSource ?? "mesh-config.ttl", - ); - rejectPortableMeshResolverConfig( - meshQuads, - meshSubject, - sources.meshConfigSource ?? "mesh-config.ttl", - ); rejectRetiredDirectPolicyPredicates( applicationQuads, sources.applicationSource, ); - rejectRetiredDirectPolicyPredicates( - meshQuads, - sources.meshConfigSource ?? "mesh-config.ttl", - ); + for (const document of configDocuments) { + rejectPortableMeshResolverConfig( + document.quads, + document.configSubject, + document.source, + ); + rejectRetiredDirectPolicyPredicates(document.quads, document.source); + } - const allQuads = [...applicationQuads, ...meshQuads]; + const allQuads = [...applicationQuads, ...configQuads]; const scopedSettingLayers: ScopedSettingLayer[] = [{ quads: applicationQuads, configSubject: applicationSubject, source: sources.applicationSource, + sourceOrder: 0, layerRole: "weaveDefaults", layerOrder: requireLayerOrder(layerOrderByRole, "weaveDefaults"), }]; - if (meshSubject) { + for (const document of configDocuments) { + if (!document.configSubject) { + continue; + } scopedSettingLayers.push({ - quads: meshQuads, - configSubject: meshSubject, - source: sources.meshConfigSource ?? "mesh-config.ttl", - layerRole: "meshLocal", - layerOrder: requireLayerOrder(layerOrderByRole, "meshLocal"), + quads: document.quads, + configSubject: document.configSubject, + source: document.source, + sourceOrder: document.sourceOrder, + layerRole: document.layerRole, + layerOrder: requireLayerOrder(layerOrderByRole, document.layerRole), }); } const targetValidation = createPolicyTargetValidationContext({ @@ -1063,20 +1229,29 @@ export function compileWeaveEffectiveConfig(input: { quads: applicationQuads, configSubject: applicationSubject, source: sources.applicationSource, + sourceOrder: 0, layerRole: "weaveDefaults", layerOrderByRole, targetValidation, }), - ...(meshSubject - ? parsePolicyBindings({ - quads: meshQuads, - configSubject: meshSubject, - source: sources.meshConfigSource ?? "mesh-config.ttl", - layerRole: "meshLocal", - layerOrderByRole, - targetValidation, - }) - : []), + ...configDocuments.flatMap((document) => + document.configSubject + ? parsePolicyBindings({ + quads: document.quads, + configSubject: document.configSubject, + source: document.source, + sourceOrder: document.sourceOrder, + layerRole: document.layerRole, + layerOrderByRole, + targetValidation: { + ...targetValidation, + ...(document.authorityScopeKey !== undefined + ? { knopAuthorityScopeKey: document.authorityScopeKey } + : {}), + }, + }) + : [] + ), ...compileCommandOverrideBindings( input.commandOverrides, layerOrderByRole, @@ -1118,16 +1293,31 @@ export function compileWeaveEffectiveConfig(input: { ), }, scopedSettings: { - mesh: parseMeshScopedSettings( - meshQuads, - meshSubject, - sources.meshConfigSource ?? "mesh-config.ttl", + mesh: resolveMeshScopedSettings( + scopedSettingLayers.filter((layer) => layer.layerRole === "meshLocal"), ), }, configResolution, + resolutionTrace: input.configSourceResolutionTrace, }); } +function parseLayeredConfigInput( + document: LayeredConfigInput, +): ParsedLayeredConfigInput { + const quads = parseTurtle(document.turtle, document.source); + return { + quads, + source: document.source, + sourceOrder: document.sourceOrder, + layerRole: document.layerRole, + ...(document.authorityScopeKey !== undefined + ? { authorityScopeKey: document.authorityScopeKey } + : {}), + configSubject: resolveOptionalMeshConfigSubject(quads, document.source), + }; +} + function resolveOptionalMeshConfigSubject( meshQuads: readonly Quad[], source: string, @@ -1168,39 +1358,28 @@ function rejectPortableMeshResolverConfig( } } -function parseMeshScopedSettings( - meshQuads: readonly Quad[], - meshSubject: string | undefined, - source: string, +function resolveMeshScopedSettings( + meshLayers: readonly ScopedSettingLayer[], ): MeshScopedSettings { - if (!meshSubject) { - return {}; - } - - const workspaceRootRelativeToMeshRoot = requireOptionalSingleStringLiteral( - meshQuads, - meshSubject, - WORKSPACE_ROOT_RELATIVE_TO_MESH_ROOT_IRI, - source, - ); - const publicationProfile = requireOptionalSingleNamedValue( - meshQuads, - meshSubject, + const workspaceRootRelativeToMeshRoot = + resolveOptionalLayeredStringScopedSetting( + meshLayers, + WORKSPACE_ROOT_RELATIVE_TO_MESH_ROOT_IRI, + "workspace root relative to mesh root", + validateWorkspaceRootRelativeToMeshRoot, + ); + const publicationProfile = resolveOptionalLayeredNamedScopedSetting( + meshLayers, HAS_PUBLICATION_PROFILE_IRI, PUBLICATION_PROFILE_VALUES, - source, + "mesh publication profile", ); - const settings: MeshScopedSettings = {}; if (publicationProfile !== undefined) { settings.publicationProfile = publicationProfile; } if (workspaceRootRelativeToMeshRoot !== undefined) { - settings.workspaceRootRelativeToMeshRoot = - validateWorkspaceRootRelativeToMeshRoot( - workspaceRootRelativeToMeshRoot, - source, - ); + settings.workspaceRootRelativeToMeshRoot = workspaceRootRelativeToMeshRoot; } return settings; } @@ -1266,15 +1445,24 @@ function resolveLayeredNamedScopedSetting( const winners = candidates.filter((candidate) => candidate.layerOrder === highestLayerOrder ); - const resolvedValues = new Set(winners.map((candidate) => candidate.value)); + const earliestSourceOrder = Math.min( + ...winners.map((candidate) => candidate.sourceOrder), + ); + const sourceOrderWinners = winners.filter((candidate) => + candidate.sourceOrder === earliestSourceOrder + ); + const resolvedValues = new Set( + sourceOrderWinners.map((candidate) => candidate.value), + ); if (resolvedValues.size !== 1) { throw new EffectiveConfigError( `Conflicting layered ${settingName} values for ${predicateIri}; values=${ JSON.stringify([...resolvedValues].sort()) }; bindings=${ JSON.stringify( - winners.map((winner) => ({ + sourceOrderWinners.map((winner) => ({ source: winner.source, + sourceOrder: winner.sourceOrder, layerRole: winner.layerRole, layerOrder: winner.layerOrder, value: winner.value, @@ -1284,7 +1472,101 @@ function resolveLayeredNamedScopedSetting( ); } - return winners[0]!.value; + return sourceOrderWinners[0]!.value; +} + +function resolveOptionalLayeredNamedScopedSetting( + layers: readonly ScopedSettingLayer[], + predicateIri: string, + values: Record, + settingName: string, +): T | undefined { + return resolveOptionalLayeredScopedSetting( + layers, + predicateIri, + settingName, + (layer) => + requireOptionalSingleNamedValue( + layer.quads, + layer.configSubject, + predicateIri, + values, + layer.source, + ), + ); +} + +function resolveOptionalLayeredStringScopedSetting( + layers: readonly ScopedSettingLayer[], + predicateIri: string, + settingName: string, + transform: (value: string, source: string) => string, +): string | undefined { + return resolveOptionalLayeredScopedSetting( + layers, + predicateIri, + settingName, + (layer) => { + const value = requireOptionalSingleStringLiteral( + layer.quads, + layer.configSubject, + predicateIri, + layer.source, + ); + return value === undefined ? undefined : transform(value, layer.source); + }, + ); +} + +function resolveOptionalLayeredScopedSetting( + layers: readonly ScopedSettingLayer[], + predicateIri: string, + settingName: string, + readValue: (layer: ScopedSettingLayer) => T | undefined, +): T | undefined { + const candidates = layers.flatMap((layer) => { + const value = readValue(layer); + return value === undefined ? [] : [{ ...layer, value }]; + }); + + if (candidates.length === 0) { + return undefined; + } + + const highestLayerOrder = Math.max( + ...candidates.map((candidate) => candidate.layerOrder), + ); + const winners = candidates.filter((candidate) => + candidate.layerOrder === highestLayerOrder + ); + const earliestSourceOrder = Math.min( + ...winners.map((candidate) => candidate.sourceOrder), + ); + const sourceOrderWinners = winners.filter((candidate) => + candidate.sourceOrder === earliestSourceOrder + ); + const resolvedValues = new Set( + sourceOrderWinners.map((candidate) => candidate.value), + ); + if (resolvedValues.size !== 1) { + throw new EffectiveConfigError( + `Conflicting layered ${settingName} values for ${predicateIri}; values=${ + JSON.stringify([...resolvedValues].sort()) + }; bindings=${ + JSON.stringify( + sourceOrderWinners.map((winner) => ({ + source: winner.source, + sourceOrder: winner.sourceOrder, + layerRole: winner.layerRole, + layerOrder: winner.layerOrder, + value: winner.value, + })), + ) + }`, + ); + } + + return sourceOrderWinners[0]!.value; } function compileCommandOverrideBindings( @@ -1303,6 +1585,7 @@ function compileCommandOverrideBindings( if (commandOverrides.historyTrackingPolicy) { bindings.push({ source, + sourceOrder: 0, layerRole: "commandOverride", layerOrder, bindingTerm: "command:historyTrackingPolicy", @@ -1314,6 +1597,7 @@ function compileCommandOverrideBindings( if (commandOverrides.resourcePagePresentation) { bindings.push({ source, + sourceOrder: 0, layerRole: "commandOverride", layerOrder, bindingTerm: "command:resourcePagePresentation", @@ -1342,7 +1626,10 @@ function createPolicyTargetValidationContext(input: { return {}; } if (input.governedArtifactIris !== undefined) { - return { governedArtifactIris: new Set(input.governedArtifactIris) }; + return { + governedArtifactIris: new Set(input.governedArtifactIris), + ...(input.meshBase !== undefined ? { meshBase: input.meshBase } : {}), + }; } if ( input.meshBase !== undefined && input.meshInventoryTurtle !== undefined @@ -1352,6 +1639,7 @@ function createPolicyTargetValidationContext(input: { input.meshBase, input.meshInventoryTurtle, ), + meshBase: input.meshBase, }; } return {}; @@ -1400,6 +1688,7 @@ function parsePolicyBindings(input: { quads: readonly Quad[]; configSubject: string; source: string; + sourceOrder: number; layerRole: ConfigLayerRole; layerOrderByRole: ReadonlyMap; targetValidation: PolicyTargetValidationContext; @@ -1412,6 +1701,7 @@ function parsePolicyBindings(input: { ).map((bindingTerm) => parsePolicyBinding(input.quads, bindingTerm, { source: input.source, + sourceOrder: input.sourceOrder, layerRole: input.layerRole, layerOrder, targetValidation: input.targetValidation, @@ -1424,6 +1714,7 @@ function parsePolicyBinding( bindingTerm: string, sourceLayer: { source: string; + sourceOrder: number; layerRole: ConfigLayerRole; layerOrder: number; targetValidation: PolicyTargetValidationContext; @@ -1467,6 +1758,7 @@ function parsePolicyBinding( return { source: sourceLayer.source, + sourceOrder: sourceLayer.sourceOrder, layerRole: sourceLayer.layerRole, layerOrder: sourceLayer.layerOrder, bindingTerm, @@ -1606,9 +1898,46 @@ function parsePolicyTarget( `Exact artifact policy target in ${source} is not governed by the active mesh scope: ${artifactIri}`, ); } + if ( + validation.knopAuthorityScopeKey !== undefined && + validation.meshBase !== undefined && + !artifactIriIsWithinKnopAuthorityScope( + artifactIri, + validation.meshBase, + validation.knopAuthorityScopeKey, + ) + ) { + throw new EffectiveConfigError( + `Exact artifact policy target in ${source} is outside the active Knop config authority scope <${validation.knopAuthorityScopeKey}>: ${artifactIri}`, + ); + } return { kind: "exactArtifact", artifactIri }; } +function artifactIriIsWithinKnopAuthorityScope( + artifactIri: string, + meshBase: string, + scopeKey: string, +): boolean { + if (!artifactIri.startsWith(meshBase)) { + return false; + } + const artifactPath = artifactIri.slice(meshBase.length); + if (artifactPath.startsWith("_mesh/") || artifactPath === "_mesh") { + return false; + } + if (scopeKey.length === 0) { + return artifactPath.length === 0 || + artifactPath === "_history" || + artifactPath.startsWith("_history/") || + /^_history\d+($|\/)/.test(artifactPath) || + artifactPath === "_knop" || + artifactPath.startsWith("_knop/"); + } + return artifactPath === scopeKey || + artifactPath.startsWith(`${scopeKey}/`); +} + function rejectPolicyTargetSelector( quads: readonly Quad[], targetTerm: string, diff --git a/src/runtime/config/effective_config_test.ts b/src/runtime/config/effective_config_test.ts index e4c0e66..c8e8a0c 100644 --- a/src/runtime/config/effective_config_test.ts +++ b/src/runtime/config/effective_config_test.ts @@ -1,10 +1,14 @@ -import { assertEquals, assertThrows } from "@std/assert"; +import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { dirname, join } from "@std/path"; +import { toKnopPath } from "../../core/designator_segments.ts"; +import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; import { ALL_PANELS_RESOURCE_PAGE_PRESENTATION_PROFILE, compileWeaveEffectiveConfig, DEFAULT_RESOURCE_PAGE_PRESENTATION_PROFILE, EffectiveConfigError, loadWeaveDefaultEffectiveConfig, + loadWeaveEffectiveConfig, NO_PANELS_RESOURCE_PAGE_PRESENTATION_PROFILE, parseWeaveDefaultEffectiveConfig, WEAVE_DEFAULTS_NAMESPACE, @@ -296,6 +300,678 @@ Deno.test("compileWeaveEffectiveConfig applies command overrides above mesh conf ); }); +Deno.test("loadWeaveEffectiveConfig resolves mesh-local config sources as mesh-local policy", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source.ttl", + currentOnlyPayloadPolicyConfigTurtle(), + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: meshConfigAttachingLocalSource( + "_mesh/_config/source.ttl", + ), + meshConfigSource: "_mesh/_config/config.ttl", + commandOverrides: { + resourcePagePresentation: "semanticSiteAllPanels", + }, + }); + + assertEquals( + config.historyTrackingPolicyForArtifactRole("payload"), + "currentOnly", + ); + assertEquals( + config.resourcePagePresentation.identity, + "semanticSiteAllPanels", + ); + assertEquals(config.sources.meshConfigSources?.length, 2); + const sourceTrace = config.resolutionTrace.find((entry) => + "kind" in entry && entry.kind === "configSource" + ); + assert(sourceTrace && "status" in sourceTrace); + assertEquals(sourceTrace.status, "accepted"); + assertEquals( + sourceTrace.resolvedLocalRelativePath, + "_mesh/_config/source.ttl", + ); +}); + +Deno.test("loadWeaveEffectiveConfig chains mesh-local config sources", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source-a.ttl", + `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPublicationProfile sfcfg:publicationProfile_githubPages . + +<_mesh> a sflo:SemanticMesh ; + sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "_mesh/_config/source-b.ttl" + ] . +`, + ); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source-b.ttl", + `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPublicationProfile sfcfg:publicationProfile_none . +`, + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: meshConfigAttachingLocalSource( + "_mesh/_config/source-a.ttl", + ), + meshConfigSource: "_mesh/_config/config.ttl", + }); + + assertEquals( + config.scopedSettings.mesh.publicationProfile, + "githubPages", + ); + assertEquals(config.sources.meshConfigSources?.length, 3); +}); + +Deno.test("loadWeaveEffectiveConfig lets earlier chained policy sources beat later sources", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source-a.ttl", + `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +${currentOnlyPayloadPolicyConfigTurtle().trimEnd()} + +<_mesh> a sflo:SemanticMesh ; + sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "_mesh/_config/source-b.ttl" + ] . +`, + ); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source-b.ttl", + `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPolicyBinding <#payload-versioned> . + +<#payload-versioned> a sfcfg:PolicyBinding ; + sfcfg:bindsPolicy <#versioned> ; + sfcfg:appliesToPolicyTarget <#payload> . + +<#versioned> a sfcfg:PolicyDefinition ; + sfcfg:hasHistoryTrackingPolicy sfcfg:historyTrackingPolicy_versioned . + +<#payload> a sfcfg:ArtifactRolePolicyTarget ; + sfcfg:hasArtifactRole sfcfg:artifactRole_payload . +`, + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: meshConfigAttachingLocalSource( + "_mesh/_config/source-a.ttl", + ), + meshConfigSource: "_mesh/_config/config.ttl", + }); + + assertEquals( + config.historyTrackingPolicyForArtifactRole("payload"), + "currentOnly", + ); +}); + +Deno.test("loadWeaveEffectiveConfig discovers mesh-local config sources from mesh metadata", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source.ttl", + `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPublicationProfile sfcfg:publicationProfile_githubPages . +`, + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshMetadataTurtle: meshMetadataAttachingLocalSource( + "_mesh/_config/source.ttl", + ), + meshMetadataSource: "_mesh/_meta/meta.ttl", + }); + + assertEquals( + config.scopedSettings.mesh.publicationProfile, + "githubPages", + ); + assertEquals(config.sources.meshConfigSources?.length, 1); +}); + +Deno.test("loadWeaveEffectiveConfig rejects unsupported config-source attachment subjects", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +<> a sfcfg:MeshConfig . + +<#stray> sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "_mesh/_config/source.ttl" +] . +`, + }), + EffectiveConfigError, + "supported only on the active SemanticMesh", + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +<> a sfcfg:MeshConfig . + + a sflo:Knop ; + sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "_mesh/_config/source.ttl" + ] . +`, + }), + EffectiveConfigError, + "supported only by Knop metadata discovery", + ); +}); + +Deno.test("loadWeaveEffectiveConfig rejects unsupported inheritable config sources", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +<> a sfcfg:MeshConfig . + +<_mesh> a sflo:SemanticMesh ; + sfcfg:hasInheritableConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "_mesh/_config/source.ttl" + ] . +`, + }), + EffectiveConfigError, + "hasInheritableConfigSource", + ); +}); + +Deno.test("loadWeaveEffectiveConfig rejects cyclic mesh-local config sources", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source-a.ttl", + configSourceForwarderTurtle("_mesh/_config/source-b.ttl"), + ); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source-b.ttl", + configSourceForwarderTurtle("_mesh/_config/source-a.ttl"), + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: meshConfigAttachingLocalSource( + "_mesh/_config/source-a.ttl", + ), + }), + EffectiveConfigError, + "Cyclic mesh-local config-source reference", + ); +}); + +Deno.test("loadWeaveEffectiveConfig rejects unsafe config-source coordinates", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "_mesh/_config/source.ttl", + currentOnlyPayloadPolicyConfigTurtle(), + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: meshConfigAttachingLocalSource( + "_mesh/_config/source.ttl", + 'sflo:expectsContentDigest "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', + ), + }), + EffectiveConfigError, + "digest mismatch", + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: meshConfigAttachingSourceSpec( + 'sflo:targetAccessUrl "https://example.invalid/config.ttl"', + ), + }), + EffectiveConfigError, + "does not fetch", + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + meshConfigTurtle: meshConfigAttachingLocalSource("../source.ttl"), + }), + EffectiveConfigError, + "outside the allowed local-path boundary", + ); +}); + +Deno.test("loadWeaveEffectiveConfig resolves target Knop-local config sources", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "alice/_knop/_config/local.ttl", + payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_currentOnly"), + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [{ + scopeKey: "alice", + turtle: knopMetadataAttachingLocalSource( + "alice", + "hasConfigSource", + "alice/_knop/_config/local.ttl", + ), + }], + }); + + assertEquals( + config.historyTrackingPolicyForArtifactRole("payload"), + "currentOnly", + ); + assertEquals(config.sources.knopConfigSources?.length, 1); + const sourceTrace = config.resolutionTrace.find((entry) => + "kind" in entry && entry.kind === "configSource" && + entry.layerRole === "knopLocal" + ); + assert(sourceTrace && "status" in sourceTrace); + assertEquals(sourceTrace.status, "accepted"); + assertEquals(sourceTrace.authoredScopeKey, "alice"); + assertEquals( + sourceTrace.resolvedLocalRelativePath, + "alice/_knop/_config/local.ttl", + ); +}); + +Deno.test("loadWeaveEffectiveConfig projects ancestor inheritable Knop config sources", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "alice/_knop/_config/inherited.ttl", + payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_currentOnly"), + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [ + { + scopeKey: "alice", + turtle: knopMetadataAttachingLocalSource( + "alice", + "hasInheritableConfigSource", + "alice/_knop/_config/inherited.ttl", + ), + }, + { + scopeKey: "alice/data", + turtle: bareKnopMetadata("alice/data"), + }, + ], + }); + + assertEquals( + config.historyTrackingPolicyForArtifactRole("payload"), + "currentOnly", + ); + const sourceTrace = config.resolutionTrace.find((entry) => + "kind" in entry && entry.kind === "configSource" && + entry.layerRole === "knopInherited" + ); + assert(sourceTrace && "status" in sourceTrace); + assertEquals(sourceTrace.offeredByScopeKey, "alice"); + assertEquals(sourceTrace.projectedToScopeKey, "alice/data"); + assertEquals(sourceTrace.projection, "ancestorInherited"); +}); + +Deno.test("loadWeaveEffectiveConfig keeps descendant-only inherited Knop config off the authoring Knop", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "alice/_knop/_config/inherited.ttl", + payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_currentOnly"), + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [{ + scopeKey: "alice", + turtle: knopMetadataAttachingLocalSource( + "alice", + "hasInheritableConfigSource", + "alice/_knop/_config/inherited.ttl", + ), + }], + }); + + assertEquals( + config.historyTrackingPolicyForArtifactRole("payload"), + "versioned", + ); + assertEquals(config.sources.knopConfigSources, undefined); +}); + +Deno.test("loadWeaveEffectiveConfig lets nearer inherited Knop config beat farther inherited config", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "alice/_knop/_config/far.ttl", + payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_currentOnly"), + ); + await writeMeshConfigSource( + meshRoot, + "alice/data/_knop/_config/near.ttl", + payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_required"), + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [ + { + scopeKey: "alice", + turtle: knopMetadataAttachingLocalSource( + "alice", + "hasInheritableConfigSource", + "alice/_knop/_config/far.ttl", + ), + }, + { + scopeKey: "alice/data", + turtle: knopMetadataAttachingLocalSource( + "alice/data", + "hasInheritableConfigSource", + "alice/data/_knop/_config/near.ttl", + ), + }, + { + scopeKey: "alice/data/summary", + turtle: bareKnopMetadata("alice/data/summary"), + }, + ], + }); + + assertEquals( + config.historyTrackingPolicyForArtifactRole("payload"), + "required", + ); +}); + +Deno.test("loadWeaveEffectiveConfig lets target Knop-local config beat inherited Knop config", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "alice/_knop/_config/inherited.ttl", + payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_currentOnly"), + ); + await writeMeshConfigSource( + meshRoot, + "alice/data/_knop/_config/local.ttl", + payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_required"), + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [ + { + scopeKey: "alice", + turtle: knopMetadataAttachingLocalSource( + "alice", + "hasInheritableConfigSource", + "alice/_knop/_config/inherited.ttl", + ), + }, + { + scopeKey: "alice/data", + turtle: knopMetadataAttachingLocalSource( + "alice/data", + "hasConfigSource", + "alice/data/_knop/_config/local.ttl", + ), + }, + ], + }); + + assertEquals( + config.historyTrackingPolicyForArtifactRole("payload"), + "required", + ); +}); + +Deno.test("loadWeaveEffectiveConfig lets command overrides beat Knop-local config", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "alice/_knop/_config/local.ttl", + payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_currentOnly"), + ); + + const config = await loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [{ + scopeKey: "alice", + turtle: knopMetadataAttachingLocalSource( + "alice", + "hasConfigSource", + "alice/_knop/_config/local.ttl", + ), + }], + commandOverrides: { + historyTrackingPolicy: "versioned", + }, + }); + + assertEquals( + config.historyTrackingPolicyForArtifactRole("payload"), + "versioned", + ); +}); + +Deno.test("loadWeaveEffectiveConfig rejects unsafe Knop config-source coordinates", async () => { + const { meshRoot, localPathPolicy } = await createConfigSourceTestContext(); + await writeMeshConfigSource( + meshRoot, + "alice/_knop/_config/local.ttl", + currentOnlyPayloadPolicyConfigTurtle(), + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [{ + scopeKey: "alice", + turtle: knopMetadataAttachingSourceSpec( + "alice", + "hasConfigSource", + 'sflo:targetAccessUrl "https://example.invalid/config.ttl"', + ), + }], + }), + EffectiveConfigError, + "does not fetch", + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [{ + scopeKey: "alice", + turtle: knopMetadataAttachingLocalSource( + "alice", + "hasConfigSource", + "../config.ttl", + ), + }], + }), + EffectiveConfigError, + "outside the allowed local-path boundary", + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [{ + scopeKey: "alice", + turtle: knopMetadataAttachingSourceSpec( + "alice", + "hasConfigSource", + [ + 'sflo:targetLocalRelativePath "alice/_knop/_config/local.ttl"', + 'sflo:expectsContentDigest "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"', + ].join(" ;\n "), + ), + }], + }), + EffectiveConfigError, + "digest mismatch", + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [{ + scopeKey: "alice", + turtle: `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + + a sflo:Knop ; + sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath "alice/_knop/_config/local.ttl" + ] . +`, + }], + }), + EffectiveConfigError, + "active Knop", + ); + + await assertRejects( + () => + loadWeaveEffectiveConfig({ + meshRoot, + meshBase: MESH_BASE, + localPathPolicy, + knopConfigScopePath: [{ + scopeKey: "alice", + turtle: `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + + a sflo:Knop ; + sfcfg:hasConfigSource [ + sflo:targetLocalRelativePath "alice/_knop/_config/local.ttl" + ] . +`, + }], + }), + EffectiveConfigError, + "ConfigSource", + ); +}); + Deno.test("compileWeaveEffectiveConfig uses priority only for same-specificity conflicts", () => { const config = compileWeaveEffectiveConfig({ applicationTurtle: VALID_APPLICATION_WITH_PRESENTATION_TURTLE, @@ -434,6 +1110,29 @@ Deno.test("compileWeaveEffectiveConfig rejects exact artifact targets outside th ); }); +Deno.test("compileWeaveEffectiveConfig rejects Knop-local exact artifact targets outside the Knop authority scope", () => { + assertThrows( + () => + compileWeaveEffectiveConfig({ + applicationTurtle: VALID_APPLICATION_WITH_PRESENTATION_TURTLE, + configResolutionTurtle: DEFAULT_CONFIG_RESOLUTION_TURTLE, + meshBase: MESH_BASE, + meshInventoryTurtle: `${GOVERNED_ARTIFACTS_INVENTORY_TURTLE.trimEnd()} +<_mesh/_inventory> a sflo:MeshInventory, sflo:DigitalArtifact . +`, + knopConfigInputs: [{ + turtle: exactTargetMeshConfigTurtle("<_mesh/_inventory>"), + source: "alice/_knop/_config/config.ttl", + layerRole: "knopLocal", + sourceOrder: 0, + authorityScopeKey: "alice", + }], + }), + EffectiveConfigError, + "outside the active Knop config authority scope", + ); +}); + Deno.test("compileWeaveEffectiveConfig rejects malformed exact artifact targets", () => { assertThrows( () => @@ -788,6 +1487,10 @@ const VALID_CONFIG_RESOLUTION_TURTLE = ] . `; +const DEFAULT_CONFIG_RESOLUTION_TURTLE = Deno.readTextFileSync( + new URL("../../../defaults/config-resolution.ttl", import.meta.url), +); + function exactTargetMeshConfigTurtle(artifactObject: string): string { return `@base <${MESH_BASE}> . @prefix sfcfg: . @@ -806,3 +1509,144 @@ function exactTargetMeshConfigTurtle(artifactObject: string): string { sfcfg:targetsArtifact ${artifactObject} . `; } + +async function createConfigSourceTestContext(): Promise<{ + meshRoot: string; + localPathPolicy: OperationalLocalPathPolicy; +}> { + const meshRoot = await Deno.makeTempDir({ + prefix: "weave-config-source-", + }); + return { + meshRoot, + localPathPolicy: { + meshRoot, + workspaceRoot: meshRoot, + rules: [], + }, + }; +} + +async function writeMeshConfigSource( + meshRoot: string, + relativePath: string, + turtle: string, +): Promise { + const path = join(meshRoot, relativePath); + await Deno.mkdir(dirname(path), { recursive: true }); + await Deno.writeTextFile(path, turtle); +} + +function meshConfigAttachingLocalSource( + relativePath: string, + extraSpec = "", +): string { + return meshConfigAttachingSourceSpec( + [ + `sflo:targetLocalRelativePath ${JSON.stringify(relativePath)}`, + extraSpec, + ].filter((line) => line.length > 0).join(" ;\n "), + ); +} + +function meshMetadataAttachingLocalSource(relativePath: string): string { + return `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +<_mesh> a sflo:SemanticMesh ; + sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath ${JSON.stringify(relativePath)} + ] . +`; +} + +function meshConfigAttachingSourceSpec(sourceSpec: string): string { + return `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +<> a sfcfg:MeshConfig . + +<_mesh> a sflo:SemanticMesh ; + sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + ${sourceSpec} + ] . +`; +} + +function configSourceForwarderTurtle(relativePath: string): string { + return `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +<> a sfcfg:MeshConfig . + +<_mesh> a sflo:SemanticMesh ; + sfcfg:hasConfigSource [ + a sfcfg:ConfigSource ; + sflo:targetLocalRelativePath ${JSON.stringify(relativePath)} + ] . +`; +} + +function currentOnlyPayloadPolicyConfigTurtle(): string { + return payloadHistoryPolicyConfigTurtle("historyTrackingPolicy_currentOnly"); +} + +function payloadHistoryPolicyConfigTurtle(policyLocalName: string): string { + return `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPolicyBinding <#payload-current-only> . + +<#payload-current-only> a sfcfg:PolicyBinding ; + sfcfg:bindsPolicy <#payload-history-policy> ; + sfcfg:appliesToPolicyTarget <#payload> . + +<#payload-history-policy> a sfcfg:PolicyDefinition ; + sfcfg:hasHistoryTrackingPolicy sfcfg:${policyLocalName} . + +<#payload> a sfcfg:ArtifactRolePolicyTarget ; + sfcfg:hasArtifactRole sfcfg:artifactRole_payload . +`; +} + +function bareKnopMetadata(scopeKey: string): string { + return `@base <${MESH_BASE}> . +@prefix sflo: . + +<${toKnopPath(scopeKey)}> a sflo:Knop . +`; +} + +function knopMetadataAttachingLocalSource( + scopeKey: string, + predicateLocalName: "hasConfigSource" | "hasInheritableConfigSource", + relativePath: string, +): string { + return knopMetadataAttachingSourceSpec( + scopeKey, + predicateLocalName, + `sflo:targetLocalRelativePath ${JSON.stringify(relativePath)}`, + ); +} + +function knopMetadataAttachingSourceSpec( + scopeKey: string, + predicateLocalName: "hasConfigSource" | "hasInheritableConfigSource", + sourceSpec: string, +): string { + return `@base <${MESH_BASE}> . +@prefix sfcfg: . +@prefix sflo: . + +<${toKnopPath(scopeKey)}> a sflo:Knop ; + sfcfg:${predicateLocalName} [ + a sfcfg:ConfigSource ; + ${sourceSpec} + ] . +`; +} diff --git a/src/runtime/mesh/inventory.ts b/src/runtime/mesh/inventory.ts index 26e7e03..602e394 100644 --- a/src/runtime/mesh/inventory.ts +++ b/src/runtime/mesh/inventory.ts @@ -5,6 +5,10 @@ import { toReferenceCatalogPath, } from "../../core/designator_segments.ts"; import { SFLO_NAMESPACE } from "../../core/rdf/namespaces.ts"; +import type { + ArtifactResolutionObservedCoordinates, + ArtifactResolutionRequest, +} from "../artifact_resolution/models.ts"; const RDF_TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const SFLO_ARTIFACT_RESOLUTION_MODE_WORKING_IRI = @@ -109,6 +113,8 @@ export interface ExtractionSourceInventoryState { sourceArtifactPath: string; requestedTargetStatePath?: string; artifactResolutionModeIri?: string; + resolutionRequest: ArtifactResolutionRequest; + resolutionObservation?: SourceRegistryResolutionObservationState; observedSourceStatePath?: string; observedSourceManifestationPath?: string; observedSourceLocatedFilePath?: string; @@ -130,6 +136,8 @@ export interface IntegrationSourceInventoryState { expectedContentDigest?: string; repositorySource?: RepositorySourceLocatorState; repositorySourceFloatingLocator?: RepositorySourceFloatingLocatorState; + resolutionRequest: ArtifactResolutionRequest; + resolutionObservation?: SourceRegistryResolutionObservationState; observedSourceLocalRelativePath?: string; observedSourceDigest?: string; observedAt?: string; @@ -142,11 +150,18 @@ export interface ImportSourceInventoryState { targetLocalRelativePath?: string; artifactResolutionModeIri?: string; expectedContentDigest?: string; + resolutionRequest: ArtifactResolutionRequest; + resolutionObservation?: SourceRegistryResolutionObservationState; observedSourceLocalRelativePath?: string; observedSourceDigest?: string; observedAt?: string; } +export interface SourceRegistryResolutionObservationState { + observed: ArtifactResolutionObservedCoordinates; + observedAt?: string; +} + export interface ResourcePageDefinitionInventoryState { artifactPath: string; workingLocalRelativePath: string; @@ -424,15 +439,29 @@ export function resolveExtractionSourceInventoryState( ) { throw new Error(messages.unsupportedResolutionModeMessage); } + const resolutionObservation = resolveSourceRegistryResolutionObservationState( + sourceRegistryQuads, + extractionSourceIri, + messages.parseErrorMessage, + ); return { sourceArtifactPath, ...(requestedTargetStatePath ? { requestedTargetStatePath } : {}), ...(artifactResolutionModeIri ? { artifactResolutionModeIri } : {}), - ...resolveExtractionSourceEvidenceState( - sourceRegistryQuads, + resolutionRequest: toSourceRegistryResolutionRequest( meshBase, extractionSourceIri, + { + sourceArtifactPath, + targetHistoricalStatePath: requestedTargetStatePath, + artifactResolutionModeIri, + }, + ), + ...(resolutionObservation ? { resolutionObservation } : {}), + ...toLegacyObservedSourceEvidence( + meshBase, + resolutionObservation, messages.parseErrorMessage, ), }; @@ -557,6 +586,12 @@ export function listIntegrationSourceInventoryStates( sourceBindingIri, messages.parseErrorMessage, ); + const resolutionObservation = + resolveSourceRegistryResolutionObservationState( + quads, + sourceBindingIri, + messages.parseErrorMessage, + ); return { sourceBindingIri, @@ -568,9 +603,20 @@ export function listIntegrationSourceInventoryStates( ...(repositorySourceFloatingLocator ? { repositorySourceFloatingLocator } : {}), - ...resolveIntegrationSourceEvidenceState( - quads, + resolutionRequest: toSourceRegistryResolutionRequest( + meshBase, sourceBindingIri, + { + sourceArtifactPath, + targetLocalRelativePath, + artifactResolutionModeIri, + expectedContentDigest, + }, + ), + ...(resolutionObservation ? { resolutionObservation } : {}), + ...toLegacyObservedSourceEvidence( + meshBase, + resolutionObservation, messages.parseErrorMessage, ), }; @@ -654,6 +700,12 @@ export function listImportSourceInventoryStates( SFLO_EXPECTS_CONTENT_DIGEST_IRI, messages.parseErrorMessage, ); + const resolutionObservation = + resolveSourceRegistryResolutionObservationState( + quads, + sourceBindingIri, + messages.parseErrorMessage, + ); return { sourceBindingIri, @@ -662,138 +714,106 @@ export function listImportSourceInventoryStates( ...(targetLocalRelativePath ? { targetLocalRelativePath } : {}), ...(artifactResolutionModeIri ? { artifactResolutionModeIri } : {}), ...(expectedContentDigest ? { expectedContentDigest } : {}), - ...resolveImportSourceEvidenceState( - quads, + resolutionRequest: toSourceRegistryResolutionRequest( + meshBase, sourceBindingIri, + { + sourceArtifactPath, + targetAccessUrl, + targetLocalRelativePath, + artifactResolutionModeIri, + expectedContentDigest, + }, + ), + ...(resolutionObservation ? { resolutionObservation } : {}), + ...toLegacyObservedSourceEvidence( + meshBase, + resolutionObservation, messages.parseErrorMessage, ), }; }); } -function resolveImportSourceEvidenceState( +function resolveObservedArtifactResolutionSpecKey( quads: readonly Quad[], - importSourceIri: string, + observationIri: string, errorMessage: string, -): Pick< - ImportSourceInventoryState, - "observedSourceLocalRelativePath" | "observedSourceDigest" | "observedAt" -> { - const observationIri = resolveOptionalUniqueNamedNodeIri( - quads, - importSourceIri, - SFLO_HAS_RESOLUTION_OBSERVATION_IRI, - errorMessage, - ); - if (observationIri === undefined) { - return {}; - } - - const observedSpecKey = resolveObservedArtifactResolutionSpecKey( - quads, - observationIri, - errorMessage, - ); - const observedSourceLocalRelativePath = - resolveOptionalUniqueLiteralWorkingLocalRelativePath( - quads, - observedSpecKey, - SFLO_TARGET_LOCAL_RELATIVE_PATH_IRI, - errorMessage, - ); - const observedSourceDigest = resolveOptionalUniqueLiteral( - quads, - observationIri, - SFLO_OBSERVED_CONTENT_DIGEST_IRI, - errorMessage, - ); - const observedAt = resolveOptionalUniqueLiteral( +): string { + const observedSpecKey = resolveOptionalUniqueObjectTermKey( quads, observationIri, - SFLO_OBSERVED_AT_IRI, + SFLO_OBSERVED_ARTIFACT_RESOLUTION_SPEC_IRI, errorMessage, ); + if (observedSpecKey === undefined) { + throw new Error(errorMessage); + } + return observedSpecKey; +} +function toSourceRegistryResolutionRequest( + meshBase: string, + sourceIri: string, + options: { + sourceArtifactPath: string; + targetHistoricalStatePath?: string; + targetAccessUrl?: string; + targetLocalRelativePath?: string; + artifactResolutionModeIri?: string; + expectedContentDigest?: string; + }, +): ArtifactResolutionRequest { + const mode = toArtifactResolutionMode(options.artifactResolutionModeIri); return { - ...(observedSourceLocalRelativePath - ? { observedSourceLocalRelativePath } + sourceIri, + targetArtifactIri: toMeshIri(meshBase, options.sourceArtifactPath), + ...(options.targetHistoricalStatePath + ? { + targetHistoricalStateIri: toMeshIri( + meshBase, + options.targetHistoricalStatePath, + ), + } + : {}), + ...(options.targetAccessUrl + ? { targetAccessUrl: options.targetAccessUrl } + : {}), + ...(options.targetLocalRelativePath + ? { targetLocalRelativePath: options.targetLocalRelativePath } + : {}), + ...(mode ? { mode } : {}), + ...(options.expectedContentDigest + ? { expectedContentDigest: options.expectedContentDigest } : {}), - ...(observedSourceDigest ? { observedSourceDigest } : {}), - ...(observedAt ? { observedAt } : {}), }; } -function resolveIntegrationSourceEvidenceState( - quads: readonly Quad[], - integrationSourceIri: string, - errorMessage: string, -): Pick< - IntegrationSourceInventoryState, - "observedSourceLocalRelativePath" | "observedSourceDigest" | "observedAt" -> { - const observationIri = resolveOptionalUniqueNamedNodeIri( - quads, - integrationSourceIri, - SFLO_HAS_RESOLUTION_OBSERVATION_IRI, - errorMessage, - ); - if (observationIri === undefined) { - return {}; +function toArtifactResolutionMode( + modeIri: string | undefined, +): "working" | "latestState" | undefined { + if (modeIri === SFLO_ARTIFACT_RESOLUTION_MODE_WORKING_IRI) { + return "working"; } - - const observedSpecKey = resolveObservedArtifactResolutionSpecKey( - quads, - observationIri, - errorMessage, - ); - const observedSourceLocalRelativePath = - resolveOptionalUniqueLiteralWorkingLocalRelativePath( - quads, - observedSpecKey, - SFLO_TARGET_LOCAL_RELATIVE_PATH_IRI, - errorMessage, - ); - const observedSourceDigest = resolveOptionalUniqueLiteral( - quads, - observationIri, - SFLO_OBSERVED_CONTENT_DIGEST_IRI, - errorMessage, - ); - const observedAt = resolveOptionalUniqueLiteral( - quads, - observationIri, - SFLO_OBSERVED_AT_IRI, - errorMessage, - ); - - return { - ...(observedSourceLocalRelativePath - ? { observedSourceLocalRelativePath } - : {}), - ...(observedSourceDigest ? { observedSourceDigest } : {}), - ...(observedAt ? { observedAt } : {}), - }; + if (modeIri === SFLO_ARTIFACT_RESOLUTION_MODE_LATEST_STATE_IRI) { + return "latestState"; + } + return undefined; } -function resolveExtractionSourceEvidenceState( +function resolveSourceRegistryResolutionObservationState( quads: readonly Quad[], - meshBase: string, - extractionSourceIri: string, + sourceIri: string, errorMessage: string, -): Omit< - ExtractionSourceInventoryState, - | "sourceArtifactPath" - | "requestedTargetStatePath" - | "artifactResolutionModeIri" -> { +): SourceRegistryResolutionObservationState | undefined { const observationIri = resolveOptionalUniqueNamedNodeIri( quads, - extractionSourceIri, + sourceIri, SFLO_HAS_RESOLUTION_OBSERVATION_IRI, errorMessage, ); if (observationIri === undefined) { - return {}; + return undefined; } const observedSpecKey = resolveObservedArtifactResolutionSpecKey( @@ -801,34 +821,32 @@ function resolveExtractionSourceEvidenceState( observationIri, errorMessage, ); - const observedSourceStatePath = resolveOptionalUniqueNamedNodePath( + const historicalStateIri = resolveOptionalUniqueNamedNodeIri( quads, - meshBase, observedSpecKey, SFLO_TARGET_HISTORICAL_STATE_IRI, errorMessage, ); - const observedSourceManifestationPath = resolveOptionalUniqueNamedNodePath( + const manifestationIri = resolveOptionalUniqueNamedNodeIri( quads, - meshBase, observedSpecKey, SFLO_TARGET_MANIFESTATION_IRI, errorMessage, ); - const observedSourceLocatedFilePath = resolveOptionalUniqueNamedNodePath( + const locatedFileIri = resolveOptionalUniqueNamedNodeIri( quads, - meshBase, observedSpecKey, SFLO_TARGET_LOCATED_FILE_IRI, errorMessage, ); - const observedSourceLocalRelativePath = resolveOptionalUniqueLiteral( - quads, - observedSpecKey, - SFLO_TARGET_LOCAL_RELATIVE_PATH_IRI, - errorMessage, - ); - const observedSourceDigest = resolveOptionalUniqueLiteral( + const localRelativePath = + resolveOptionalUniqueLiteralWorkingLocalRelativePath( + quads, + observedSpecKey, + SFLO_TARGET_LOCAL_RELATIVE_PATH_IRI, + errorMessage, + ); + const contentDigest = resolveOptionalUniqueLiteral( quads, observationIri, SFLO_OBSERVED_CONTENT_DIGEST_IRI, @@ -842,34 +860,71 @@ function resolveExtractionSourceEvidenceState( ); return { - ...(observedSourceStatePath ? { observedSourceStatePath } : {}), - ...(observedSourceManifestationPath - ? { observedSourceManifestationPath } - : {}), - ...(observedSourceLocatedFilePath ? { observedSourceLocatedFilePath } : {}), - ...(observedSourceLocalRelativePath - ? { observedSourceLocalRelativePath } - : {}), - ...(observedSourceDigest ? { observedSourceDigest } : {}), + observed: { + ...(historicalStateIri ? { historicalStateIri } : {}), + ...(manifestationIri ? { manifestationIri } : {}), + ...(locatedFileIri ? { locatedFileIri } : {}), + ...(localRelativePath ? { localRelativePath } : {}), + ...(contentDigest ? { contentDigest } : {}), + }, ...(observedAt ? { observedAt } : {}), }; } -function resolveObservedArtifactResolutionSpecKey( - quads: readonly Quad[], - observationIri: string, +function toLegacyObservedSourceEvidence( + meshBase: string, + observation: SourceRegistryResolutionObservationState | undefined, errorMessage: string, -): string { - const observedSpecKey = resolveOptionalUniqueObjectTermKey( - quads, - observationIri, - SFLO_OBSERVED_ARTIFACT_RESOLUTION_SPEC_IRI, - errorMessage, - ); - if (observedSpecKey === undefined) { - throw new Error(errorMessage); +): { + observedSourceStatePath?: string; + observedSourceManifestationPath?: string; + observedSourceLocatedFilePath?: string; + observedSourceLocalRelativePath?: string; + observedSourceDigest?: string; + observedAt?: string; +} { + if (observation === undefined) { + return {}; } - return observedSpecKey; + + return { + ...(observation.observed.historicalStateIri + ? { + observedSourceStatePath: requireMeshPath( + meshBase, + observation.observed.historicalStateIri, + errorMessage, + ), + } + : {}), + ...(observation.observed.manifestationIri + ? { + observedSourceManifestationPath: requireMeshPath( + meshBase, + observation.observed.manifestationIri, + errorMessage, + ), + } + : {}), + ...(observation.observed.locatedFileIri + ? { + observedSourceLocatedFilePath: requireMeshPath( + meshBase, + observation.observed.locatedFileIri, + errorMessage, + ), + } + : {}), + ...(observation.observed.localRelativePath + ? { + observedSourceLocalRelativePath: observation.observed.localRelativePath, + } + : {}), + ...(observation.observed.contentDigest + ? { observedSourceDigest: observation.observed.contentDigest } + : {}), + ...(observation.observedAt ? { observedAt: observation.observedAt } : {}), + }; } export function resolveResourcePageDefinitionInventoryState( @@ -1423,7 +1478,7 @@ function resolveNamedNodePaths( function resolveOptionalUniqueNamedNodeIri( quads: readonly Quad[], - subjectIri: string, + subjectIriOrKey: string, predicateIri: string, errorMessage: string, ): string | undefined { @@ -1431,8 +1486,7 @@ function resolveOptionalUniqueNamedNodeIri( for (const quad of quads) { if ( - quad.subject.termType !== "NamedNode" || - quad.subject.value !== subjectIri || + !matchesSubject(quad.subject, subjectIriOrKey) || quad.predicate.value !== predicateIri || quad.object.termType !== "NamedNode" ) { diff --git a/src/runtime/mesh/inventory_test.ts b/src/runtime/mesh/inventory_test.ts index b5bac49..5b59e75 100644 --- a/src/runtime/mesh/inventory_test.ts +++ b/src/runtime/mesh/inventory_test.ts @@ -264,6 +264,26 @@ Deno.test("resolveExtractionSourceInventoryState returns source registry observe { sourceArtifactPath: "alice/data", requestedTargetStatePath: "alice/data/_history001/_s0002", + resolutionRequest: { + sourceIri: + "https://semantic-flow.github.io/mesh-alice-bio/bob/_knop/_sources#extraction-source", + targetArtifactIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data", + targetHistoricalStateIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data/_history001/_s0002", + }, + resolutionObservation: { + observed: { + historicalStateIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data/_history001/_s0002", + manifestationIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data/_history001/_s0002/ttl", + locatedFileIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data/_history001/_s0002/ttl/alice-data.ttl", + localRelativePath: "../alice-data.ttl", + contentDigest: "sha256:abc123", + }, + }, observedSourceStatePath: "alice/data/_history001/_s0002", observedSourceManifestationPath: "alice/data/_history001/_s0002/ttl", observedSourceLocatedFilePath: @@ -333,6 +353,13 @@ Deno.test("resolveExtractionSourceInventoryState reads source registry extractio sourceArtifactPath: "alice/data", artifactResolutionModeIri: "https://semantic-flow.github.io/sflo/ontology/artifactResolutionMode_working", + resolutionRequest: { + sourceIri: + "https://semantic-flow.github.io/mesh-alice-bio/bob/_knop/_sources#extraction-source", + targetArtifactIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data", + mode: "working", + }, }, ); }); @@ -370,6 +397,14 @@ Deno.test("listIntegrationSourceInventoryStates reads IntegrationSource bindings targetLocalRelativePath: "../source/alice-data.ttl", artifactResolutionModeIri: "https://semantic-flow.github.io/sflo/ontology/artifactResolutionMode_working", + resolutionRequest: { + sourceIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data/_knop/_sources#payload-source", + targetArtifactIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data", + targetLocalRelativePath: "../source/alice-data.ttl", + mode: "working", + }, }], ); }); @@ -431,6 +466,20 @@ Deno.test("listIntegrationSourceInventoryStates reads repository-backed observat repositoryPath: "alice-data.ttl", contentDigest: "sha256:abc123", }, + resolutionRequest: { + sourceIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data/_knop/_sources#payload-source", + targetArtifactIri: + "https://semantic-flow.github.io/mesh-alice-bio/alice/data", + targetLocalRelativePath: "../source/alice-data.ttl", + mode: "working", + expectedContentDigest: "sha256:abc123", + }, + resolutionObservation: { + observed: { + contentDigest: "sha256:abc123", + }, + }, observedSourceDigest: "sha256:abc123", }], ); @@ -480,6 +529,22 @@ Deno.test("listImportSourceInventoryStates reads URL import source observations" artifactResolutionModeIri: "https://semantic-flow.github.io/sflo/ontology/artifactResolutionMode_working", expectedContentDigest: "sha256:abc123", + resolutionRequest: { + sourceIri: + "https://semantic-flow.github.io/mesh-alice-bio/bob/page-main/_knop/_sources#payload-source", + targetArtifactIri: + "https://semantic-flow.github.io/mesh-alice-bio/bob/page-main", + targetAccessUrl: "https://example.com/bob.md", + mode: "working", + expectedContentDigest: "sha256:abc123", + }, + resolutionObservation: { + observed: { + localRelativePath: "bob-page-main.md", + contentDigest: "sha256:abc123", + }, + observedAt: "2026-05-24T20:00:00.000Z", + }, observedSourceLocalRelativePath: "bob-page-main.md", observedSourceDigest: "sha256:abc123", observedAt: "2026-05-24T20:00:00.000Z", diff --git a/src/runtime/weave/artifact_loaders.ts b/src/runtime/weave/artifact_loaders.ts index 3aabebb..245fdad 100644 --- a/src/runtime/weave/artifact_loaders.ts +++ b/src/runtime/weave/artifact_loaders.ts @@ -9,12 +9,15 @@ import type { } from "../../core/weave/candidates.ts"; import { resolveExtractionSourceInventoryState, - resolveHistoricalStateLocatedFilePath, resolveKnopSourceRegistryInventoryState, resolvePayloadArtifactInventoryState, resolveReferenceCatalogInventoryState, resolveResourcePageDefinitionInventoryState, } from "../mesh/inventory.ts"; +import { + ArtifactResolutionError, + resolveArtifactResolutionRequest, +} from "../artifact_resolution/resolver.ts"; import { LocalPathAccessError, type OperationalLocalPathPolicy, @@ -260,42 +263,15 @@ export async function loadReferenceTargetSourcePayloadArtifact( `Extracted weave source for ${designatorPath} is missing an exact or latest source state.`, ); } - const selectedHistoricalSnapshotPath = resolveHistoricalStateLocatedFilePath( + const selectedSource = await resolveSelectedExtractionSource( + workspaceRoot, + localPathPolicy, meshBase, - sourceKnopInventoryTurtle, + designatorPath, + extractionSource.sourceArtifactPath, selectedHistoricalStatePath, - `Could not parse the source Knop inventory while resolving the extracted source payload snapshot for ${designatorPath}.`, - ) ?? (sourcePayloadArtifact.latestHistoricalStatePath === - selectedHistoricalStatePath && - sourcePayloadArtifact.latestHistoricalSnapshotPath - ? sourcePayloadArtifact.latestHistoricalSnapshotPath - : toPayloadHistoricalSnapshotPath( - selectedHistoricalStatePath, - sourcePayloadArtifact.workingLocalRelativePath, - )); - let selectedHistoricalSnapshotTurtle: string | undefined; - try { - selectedHistoricalSnapshotTurtle = await readTextFileWithOverlay( - resolveAllowedLocalPath( - localPathPolicy, - "workingLocalRelativePath", - selectedHistoricalSnapshotPath, - ), - overlay, - ); - } catch (error) { - if (error instanceof LocalPathAccessError) { - throw new WeaveRuntimeError( - `Extracted source payload snapshot for ${designatorPath} is outside the allowed local-path boundary: ${selectedHistoricalSnapshotPath}`, - ); - } - if (error instanceof Deno.errors.NotFound) { - throw new WeaveRuntimeError( - `Workspace is missing the extracted source payload snapshot for ${designatorPath}: ${selectedHistoricalSnapshotPath}`, - ); - } - throw error; - } + overlay, + ); return { designatorPath: sourceDesignatorPath, @@ -314,22 +290,104 @@ export async function loadReferenceTargetSourcePayloadArtifact( currentSourceRegistryTurtle: sourceRegistryArtifact.turtle, } : {}), - latestHistoricalSnapshotPath: selectedHistoricalSnapshotPath, - latestHistoricalSnapshotTurtle: selectedHistoricalSnapshotTurtle, - latestHistoricalStatePath: selectedHistoricalStatePath, - sourceEvidence: { - sourceStatePath: selectedHistoricalStatePath, - sourceManifestationPath: dirname(selectedHistoricalSnapshotPath) - .replaceAll( - "\\", - "/", - ), - sourceLocatedFilePath: selectedHistoricalSnapshotPath, - sourceDigest: await sha256Digest(selectedHistoricalSnapshotTurtle), - }, + latestHistoricalSnapshotPath: selectedSource.snapshotPath, + latestHistoricalSnapshotTurtle: selectedSource.snapshotTurtle, + latestHistoricalStatePath: selectedSource.sourceEvidence.sourceStatePath, + sourceEvidence: selectedSource.sourceEvidence, }; } +async function resolveSelectedExtractionSource( + workspaceRoot: string, + localPathPolicy: OperationalLocalPathPolicy, + meshBase: string, + designatorPath: string, + sourceArtifactPath: string, + selectedHistoricalStatePath: string, + overlay?: ReadonlyMap, +): Promise<{ + snapshotPath: string; + snapshotTurtle: string; + sourceEvidence: { + sourceStatePath: string; + sourceManifestationPath: string; + sourceLocatedFilePath: string; + sourceDigest: string; + }; +}> { + try { + const result = await resolveArtifactResolutionRequest( + { + meshRoot: workspaceRoot, + meshBase, + localPathPolicy, + ...(overlay ? { overlay } : {}), + }, + { + sourceDescription: + `ExtractionSource selected source for ${designatorPath}`, + targetArtifactIri: new URL(sourceArtifactPath, meshBase).href, + targetHistoricalStateIri: new URL( + selectedHistoricalStatePath, + meshBase, + ).href, + }, + { contentMode: "text" }, + ); + const snapshotPath = result.observed.localRelativePath; + const snapshotTurtle = result.content?.text; + const sourceDigest = result.observed.contentDigest; + const sourceStatePath = result.observed.historicalStateIri === undefined + ? undefined + : requireObservedMeshPath( + meshBase, + result.observed.historicalStateIri, + `ExtractionSource selected source for ${designatorPath} resolved a source state outside the mesh.`, + ); + if ( + snapshotPath === undefined || + snapshotTurtle === undefined || + sourceDigest === undefined || + sourceStatePath === undefined + ) { + throw new WeaveRuntimeError( + `ExtractionSource selected source for ${designatorPath} did not resolve to text content with observed state, local path, and digest.`, + ); + } + + return { + snapshotPath, + snapshotTurtle, + sourceEvidence: { + sourceStatePath, + sourceManifestationPath: dirname(snapshotPath).replaceAll("\\", "/"), + sourceLocatedFilePath: snapshotPath, + sourceDigest, + }, + }; + } catch (error) { + if (error instanceof ArtifactResolutionError) { + throw new WeaveRuntimeError(error.message); + } + throw error; + } +} + +function requireObservedMeshPath( + meshBase: string, + iri: string, + errorMessage: string, +): string { + if (!iri.startsWith(meshBase)) { + throw new WeaveRuntimeError(errorMessage); + } + const meshPath = iri.slice(meshBase.length); + if (meshPath.includes("#") || meshPath.includes("?")) { + throw new WeaveRuntimeError(errorMessage); + } + return meshPath; +} + export async function loadKnopSourceRegistryArtifact( localPathPolicy: OperationalLocalPathPolicy, meshBase: string, @@ -497,12 +555,3 @@ function toDefaultManifestationSegment(fileName: string): string { ? fileName.slice(extensionIndex + 1) : fileName.replaceAll(".", "-"); } - -async function sha256Digest(contents: string): Promise { - const bytes = new TextEncoder().encode(contents); - const digest = await crypto.subtle.digest("SHA-256", bytes); - const hex = [...new Uint8Array(digest)] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); - return `sha256:${hex}`; -} diff --git a/src/runtime/weave/execution_config.ts b/src/runtime/weave/execution_config.ts index cf15000..3d2f0ec 100644 --- a/src/runtime/weave/execution_config.ts +++ b/src/runtime/weave/execution_config.ts @@ -1,21 +1,181 @@ +import { join } from "@std/path"; +import { toKnopPath } from "../../core/designator_segments.ts"; +import type { ResourcePageGenerationConfig } from "../../core/weave/resource_page_policy.ts"; import type { + ArtifactRole, EffectiveConfig, HistoryTrackingPolicy, + ResourcePagePresentationProfile, } from "../config/effective_config.ts"; import { loadWeaveEffectiveConfig } from "../config/effective_config.ts"; +import type { KnopConfigScopeInput } from "../config/config_sources.ts"; +import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; +import type { RuntimeTiming } from "../timing.ts"; import type { WeaveNamingPolicies, WeaveResourcePageGenerationPolicies, WeaveSupportHistoryPolicies, } from "../../core/weave/weave.ts"; +import type { MeshState } from "./mesh_state.ts"; + +const ROOT_KNOP_PROVIDER_SCOPE_KEY = "/"; +const MESH_CONFIG_PROVIDER_SCOPE_KEY = "MESH_CONFIG"; + +export interface EffectiveConfigProvider { + configForMeshScope(): Promise; + configForTarget(designatorPath: string): Promise; + resourcePagePresentationForMeshScope(): Promise< + ResourcePagePresentationProfile + >; + resourcePagePresentationForTarget( + designatorPath: string, + ): Promise; +} + +export function createEffectiveConfigProviderForExecution( + options: { + meshRoot: string; + meshState: MeshState; + localPathPolicy: OperationalLocalPathPolicy; + targetMetadataTurtleByDesignatorPath?: ReadonlyMap; + historyTrackingPolicyOverride?: HistoryTrackingPolicy; + includeSemanticFlowMetadata?: boolean; + timing?: RuntimeTiming; + phasePrefix?: string; + }, +): EffectiveConfigProvider { + const targetConfigByScopeKey = new Map>(); + let meshConfig: Promise | undefined; + let cacheHits = 0; + let cacheMisses = 0; + + const phase = (name: string) => + options.phasePrefix ? `${options.phasePrefix}.${name}` : name; + const setTimingFields = () => { + options.timing?.setField("effectiveConfigCacheHits", cacheHits); + options.timing?.setField("effectiveConfigCacheMisses", cacheMisses); + options.timing?.setField( + "effectiveConfigCacheEntries", + targetConfigByScopeKey.size + (meshConfig ? 1 : 0), + ); + }; + const loadConfig = async ( + phaseName: string, + operation: () => Promise, + ) => + options.timing + ? await options.timing.time(phase(phaseName), operation) + : await operation(); + + async function configForMeshScope(): Promise { + if (meshConfig) { + cacheHits += 1; + setTimingFields(); + return await meshConfig; + } + + cacheMisses += 1; + meshConfig = loadConfig( + "loadEffectiveConfig.mesh", + () => + loadEffectiveConfigForExecution({ + meshConfigTurtle: options.meshState.currentMeshConfigTurtle, + meshConfigSource: options.meshState.currentMeshConfigTurtle + ? "_mesh/_config/config.ttl" + : undefined, + meshRoot: options.meshRoot, + meshBase: options.meshState.meshBase, + meshMetadataTurtle: options.meshState.currentMeshMetadataTurtle, + meshMetadataSource: "_mesh/_meta/meta.ttl", + meshInventoryTurtle: options.meshState.currentMeshInventoryTurtle, + localPathPolicy: options.localPathPolicy, + historyTrackingPolicyOverride: options.historyTrackingPolicyOverride, + includeSemanticFlowMetadata: options.includeSemanticFlowMetadata, + }), + ); + setTimingFields(); + return await meshConfig; + } + + async function configForTarget( + designatorPath: string, + ): Promise { + const scopeKey = toProviderScopeKey(designatorPath); + const existing = targetConfigByScopeKey.get(scopeKey); + if (existing) { + cacheHits += 1; + setTimingFields(); + return await existing; + } + + cacheMisses += 1; + const normalizedDesignatorPath = fromProviderScopeKey(scopeKey); + const config = loadConfig("loadEffectiveConfig.target", async () => { + const knopConfigScopePath = await loadKnopConfigScopePathForTarget({ + workspaceRoot: options.meshRoot, + designatorPath: normalizedDesignatorPath, + targetMetadataTurtle: options.targetMetadataTurtleByDesignatorPath + ?.get(normalizedDesignatorPath), + }); + return await loadEffectiveConfigForExecution({ + meshConfigTurtle: options.meshState.currentMeshConfigTurtle, + meshConfigSource: options.meshState.currentMeshConfigTurtle + ? "_mesh/_config/config.ttl" + : undefined, + meshRoot: options.meshRoot, + meshBase: options.meshState.meshBase, + meshMetadataTurtle: options.meshState.currentMeshMetadataTurtle, + meshMetadataSource: "_mesh/_meta/meta.ttl", + meshInventoryTurtle: options.meshState.currentMeshInventoryTurtle, + localPathPolicy: options.localPathPolicy, + knopConfigScopePath, + historyTrackingPolicyOverride: options.historyTrackingPolicyOverride, + includeSemanticFlowMetadata: options.includeSemanticFlowMetadata, + }); + }); + targetConfigByScopeKey.set(scopeKey, config); + setTimingFields(); + return await config; + } + + return { + configForMeshScope, + configForTarget, + async resourcePagePresentationForMeshScope() { + return (await configForMeshScope()).resourcePagePresentation; + }, + async resourcePagePresentationForTarget(designatorPath: string) { + return (await configForTarget(designatorPath)).resourcePagePresentation; + }, + }; +} + +function toProviderScopeKey(designatorPath: string): string { + return designatorPath.length === 0 + ? ROOT_KNOP_PROVIDER_SCOPE_KEY + : designatorPath; +} + +function fromProviderScopeKey(scopeKey: string): string { + return scopeKey === ROOT_KNOP_PROVIDER_SCOPE_KEY ? "" : scopeKey; +} + +export function meshConfigProviderScopeKey(): string { + return MESH_CONFIG_PROVIDER_SCOPE_KEY; +} export async function loadEffectiveConfigForExecution( options: { meshConfigTurtle?: string; meshConfigSource?: string; + meshRoot?: string; meshBase?: string; + meshMetadataTurtle?: string; + meshMetadataSource?: string; meshInventoryTurtle?: string; + localPathPolicy?: OperationalLocalPathPolicy; governedArtifactIris?: readonly string[]; + knopConfigScopePath?: readonly KnopConfigScopeInput[]; historyTrackingPolicyOverride?: HistoryTrackingPolicy; includeSemanticFlowMetadata?: boolean; } = {}, @@ -23,9 +183,14 @@ export async function loadEffectiveConfigForExecution( return await loadWeaveEffectiveConfig({ meshConfigTurtle: options.meshConfigTurtle, meshConfigSource: options.meshConfigSource, + meshRoot: options.meshRoot, meshBase: options.meshBase, + meshMetadataTurtle: options.meshMetadataTurtle, + meshMetadataSource: options.meshMetadataSource, meshInventoryTurtle: options.meshInventoryTurtle, + localPathPolicy: options.localPathPolicy, governedArtifactIris: options.governedArtifactIris, + knopConfigScopePath: options.knopConfigScopePath, commandOverrides: { ...(options.historyTrackingPolicyOverride ? { historyTrackingPolicy: options.historyTrackingPolicyOverride } @@ -37,6 +202,59 @@ export async function loadEffectiveConfigForExecution( }); } +export async function loadKnopConfigScopePathForTarget( + options: { + workspaceRoot: string; + designatorPath: string; + targetMetadataTurtle?: string; + }, +): Promise { + const scopePath: KnopConfigScopeInput[] = []; + for ( + const scopeKey of ancestorScopeKeysForDesignatorPath( + options.designatorPath, + ) + ) { + const source = `${toKnopPath(scopeKey)}/_meta/meta.ttl`; + const turtle = scopeKey === options.designatorPath && + options.targetMetadataTurtle !== undefined + ? options.targetMetadataTurtle + : await readOptionalTextFile(join(options.workspaceRoot, source)); + if (turtle === undefined) { + continue; + } + scopePath.push({ scopeKey, turtle, source }); + } + + return scopePath.length > 0 ? scopePath : undefined; +} + +function ancestorScopeKeysForDesignatorPath( + designatorPath: string, +): readonly string[] { + if (designatorPath === "") { + return [""]; + } + + const segments = designatorPath.split("/"); + const scopeKeys = [""]; + for (let index = 0; index < segments.length; index += 1) { + scopeKeys.push(segments.slice(0, index + 1).join("/")); + } + return scopeKeys; +} + +async function readOptionalTextFile(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return undefined; + } + throw error; + } +} + export function supportHistoryPoliciesFromEffectiveConfig( effectiveConfig: EffectiveConfig, ): WeaveSupportHistoryPolicies { @@ -64,6 +282,35 @@ export function supportHistoryPoliciesFromEffectiveConfig( }; } +export function supportHistoryPoliciesFromScopedEffectiveConfigs( + meshEffectiveConfig: EffectiveConfig, + targetEffectiveConfig: EffectiveConfig, +): WeaveSupportHistoryPolicies { + return { + meshMetadata: meshEffectiveConfig.historyTrackingPolicyForArtifactRole( + "meshMetadata", + ), + meshInventory: meshEffectiveConfig.historyTrackingPolicyForArtifactRole( + "meshInventory", + ), + config: meshEffectiveConfig.historyTrackingPolicyForArtifactRole("config"), + knopMetadata: targetEffectiveConfig.historyTrackingPolicyForArtifactRole( + "knopMetadata", + ), + knopInventory: targetEffectiveConfig.historyTrackingPolicyForArtifactRole( + "knopInventory", + ), + referenceCatalog: targetEffectiveConfig + .historyTrackingPolicyForArtifactRole( + "referenceCatalog", + ), + resourcePageDefinition: targetEffectiveConfig + .historyTrackingPolicyForArtifactRole( + "resourcePageDefinition", + ), + }; +} + export function namingPoliciesFromEffectiveConfig( effectiveConfig: EffectiveConfig, ): WeaveNamingPolicies { @@ -107,3 +354,68 @@ export function resourcePageGenerationPoliciesFromEffectiveConfig( ), }; } + +export function resourcePageGenerationConfigFromScopedEffectiveConfigs( + meshEffectiveConfig: EffectiveConfig, + targetEffectiveConfig: EffectiveConfig, +): ResourcePageGenerationConfig { + const configForRole = (artifactRole: ArtifactRole) => + meshOwnedArtifactRoles.has(artifactRole) + ? meshEffectiveConfig + : targetEffectiveConfig; + + return { + resourcePageGenerationPolicyForArtifactRole(artifactRole) { + return configForRole(artifactRole) + .resourcePageGenerationPolicyForArtifactRole(artifactRole); + }, + resourcePageGenerationPolicyForArtifactTarget(target) { + return configForRole(target.artifactRole) + .resourcePageGenerationPolicyForArtifactTarget(target); + }, + }; +} + +export function resourcePageGenerationPoliciesFromScopedEffectiveConfigs( + meshEffectiveConfig: EffectiveConfig, + targetEffectiveConfig: EffectiveConfig, +): WeaveResourcePageGenerationPolicies { + return { + payload: targetEffectiveConfig.resourcePageGenerationPolicyForArtifactRole( + "payload", + ), + meshInventory: meshEffectiveConfig + .resourcePageGenerationPolicyForArtifactRole( + "meshInventory", + ), + knopInventory: targetEffectiveConfig + .resourcePageGenerationPolicyForArtifactRole( + "knopInventory", + ), + meshMetadata: meshEffectiveConfig + .resourcePageGenerationPolicyForArtifactRole( + "meshMetadata", + ), + knopMetadata: targetEffectiveConfig + .resourcePageGenerationPolicyForArtifactRole( + "knopMetadata", + ), + config: meshEffectiveConfig.resourcePageGenerationPolicyForArtifactRole( + "config", + ), + referenceCatalog: targetEffectiveConfig + .resourcePageGenerationPolicyForArtifactRole( + "referenceCatalog", + ), + resourcePageDefinition: targetEffectiveConfig + .resourcePageGenerationPolicyForArtifactRole( + "resourcePageDefinition", + ), + }; +} + +const meshOwnedArtifactRoles = new Set([ + "meshInventory", + "meshMetadata", + "config", +]); diff --git a/src/runtime/weave/page_contexts.ts b/src/runtime/weave/page_contexts.ts index 14a16c0..06c4153 100644 --- a/src/runtime/weave/page_contexts.ts +++ b/src/runtime/weave/page_contexts.ts @@ -91,12 +91,16 @@ export interface GenerateDesignatorContext { >; } +export type EffectiveConfigForDesignator = + | EffectiveConfig + | ((designatorPath: string) => Promise); + export async function loadBestEffortGenerateDesignatorContexts( workspaceRoot: string, localPathPolicy: OperationalLocalPathPolicy, meshState: MeshState, designatorPaths: readonly string[], - effectiveConfig: EffectiveConfig, + effectiveConfig: EffectiveConfigForDesignator, hasExplicitGenerateTargets: boolean, timing?: RuntimeTiming, phasePrefix = "loadBestEffortGenerateDesignatorContexts", @@ -149,7 +153,7 @@ export async function loadGenerateDesignatorContexts( localPathPolicy: OperationalLocalPathPolicy, meshState: MeshState, designatorPaths: readonly string[], - effectiveConfig: EffectiveConfig, + effectiveConfig: EffectiveConfigForDesignator, hasExplicitGenerateTargets: boolean, timing?: RuntimeTiming, phasePrefix = "loadGenerateDesignatorContexts", @@ -158,6 +162,12 @@ export async function loadGenerateDesignatorContexts( const contexts: GenerateDesignatorContext[] = []; for (const designatorPath of designatorPaths) { + const targetEffectiveConfig = await timeOptional( + timing, + phase("loadEffectiveConfig"), + () => + resolveEffectiveConfigForDesignator(effectiveConfig, designatorPath), + ); const knopInventoryPath = join( workspaceRoot, `${toKnopPath(designatorPath)}/_inventory/inventory.ttl`, @@ -315,7 +325,7 @@ export async function loadGenerateDesignatorContexts( inventoryTurtle: currentKnopInventoryTurtle, parseErrorMessage: `Could not parse the current Knop inventory while collecting ResourcePages for ${designatorPath}.`, - config: effectiveConfig, + config: targetEffectiveConfig, explicitRequest: hasExplicitGenerateTargets, }), ); @@ -464,6 +474,15 @@ export async function loadGenerateDesignatorContexts( return contexts; } +async function resolveEffectiveConfigForDesignator( + effectiveConfig: EffectiveConfigForDesignator, + designatorPath: string, +): Promise { + return typeof effectiveConfig === "function" + ? await effectiveConfig(designatorPath) + : effectiveConfig; +} + export function listRuntimeGeneratedResourcePagePaths( input: ListGeneratedResourcePagePathsInput, ): readonly string[] { diff --git a/src/runtime/weave/page_definition.ts b/src/runtime/weave/page_definition.ts index 5d1f3c1..14ebab2 100644 --- a/src/runtime/weave/page_definition.ts +++ b/src/runtime/weave/page_definition.ts @@ -1,18 +1,13 @@ import { join } from "@std/path"; import * as pathPosix from "@std/path/posix"; import { Parser, type Quad } from "n3"; -import { - formatDesignatorPathForDisplay, - normalizeSafeDesignatorPath, - toKnopPath, -} from "../../core/designator_segments.ts"; +import { formatDesignatorPathForDisplay } from "../../core/designator_segments.ts"; import type { ResourcePageDefinitionWorkingArtifact } from "../../core/weave/candidates.ts"; +import type { ResourcePageDefinitionInventoryState } from "../mesh/inventory.ts"; import { - resolvePayloadArtifactInventoryState, - resolveReferenceCatalogInventoryState, - resolveResourcePageDefinitionInventoryState, - type ResourcePageDefinitionInventoryState, -} from "../mesh/inventory.ts"; + ArtifactResolutionError, + resolveArtifactResolutionSpecQuads, +} from "../artifact_resolution/resolver.ts"; import { LocalPathAccessError, type OperationalLocalPathPolicy, @@ -39,43 +34,6 @@ const SFLO_HAS_ARTIFACT_RESOLUTION_FALLBACK_POLICY_IRI = const SFLO_TARGET_MESH_PATH_IRI = `${SFLO_NAMESPACE}targetLocalRelativePath`; const SFLO_TARGET_ACCESS_URL_IRI = `${SFLO_NAMESPACE}targetAccessUrl`; const SFLO_REGION_KEY_IRI = `${SFLO_NAMESPACE}regionKey`; -const SFLO_ARTIFACT_RESOLUTION_MODE_WORKING_IRI = - `${SFLO_NAMESPACE}artifactResolutionMode_working`; -const SFLO_ARTIFACT_RESOLUTION_MODE_LATEST_STATE_IRI = - `${SFLO_NAMESPACE}artifactResolutionMode_latestState`; -const RDF_TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; -const SFLO_ARTIFACT_HISTORY_IRI = `${SFLO_NAMESPACE}ArtifactHistory`; -const SFLO_CURRENT_ARTIFACT_HISTORY_IRI = - `${SFLO_NAMESPACE}currentArtifactHistory`; -const SFLO_HAS_ARTIFACT_HISTORY_IRI = `${SFLO_NAMESPACE}hasArtifactHistory`; -const SFLO_HAS_PAYLOAD_ARTIFACT_IRI = `${SFLO_NAMESPACE}hasPayloadArtifact`; -const SFLO_LATEST_HISTORICAL_STATE_IRI = - `${SFLO_NAMESPACE}latestHistoricalState`; -const SFLO_LOCATED_FILE_FOR_STATE_IRI = `${SFLO_NAMESPACE}locatedFileForState`; -const PAGE_DEFINITION_ARTIFACT_SUFFIX = "/_knop/_page"; -const ROOT_PAGE_DEFINITION_ARTIFACT_PATH = "_knop/_page"; -const ROOT_REFERENCE_CATALOG_PATH = "_knop/_references"; -const REFERENCE_CATALOG_SUFFIX = "/_knop/_references"; - -interface ArtifactSourceTarget { - kind: "payload" | "referenceCatalog" | "resourcePageDefinition"; - designatorPath: string; - artifactPath: string; -} - -interface ArtifactSourceCurrentState extends ArtifactSourceTarget { - workingLocalRelativePath: string; -} - -interface ArtifactSourceInventory { - target: ArtifactSourceTarget; - targetKnopInventoryTurtle: string; -} - -interface ArtifactSourceLatestState { - statePath: string; - snapshotPath: string; -} export interface CustomIdentifierRegionModel { key: string; @@ -169,7 +127,7 @@ export async function loadActiveCustomIdentifierPage( designatorPath: string, artifact: ResourcePageDefinitionWorkingArtifact | undefined, ): Promise { - if (!artifact || !artifact.currentArtifactHistoryExists) { + if (!artifact) { return undefined; } @@ -339,73 +297,15 @@ export async function loadActiveCustomIdentifierPage( formatDesignatorPathForDisplay(designatorPath) } must declare exactly one target locator.`, ); - const resolutionMode = requireOptionalSingleNamedNodeTarget( - artifactResolutionModes, - `ResourcePageDefinition region ${key} for ${ - formatDesignatorPathForDisplay(designatorPath) - } declares multiple artifact resolution modes.`, - ); - const requestedTargetHistory = requireOptionalSingleNamedNodeTarget( - requestedTargetHistories, - `ResourcePageDefinition region ${key} for ${ - formatDesignatorPathForDisplay(designatorPath) - } declares multiple requested target histories.`, - ); - - if ( - requestedTargetStates.length > 0 || - artifactResolutionFallbackSpecs.length > 0 - ) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${key} for ${ - formatDesignatorPathForDisplay(designatorPath) - } requests exact/fallback artifact resolution that this first artifact-backed page-source slice does not support yet.`, - ); - } - if (resolutionMode === SFLO_ARTIFACT_RESOLUTION_MODE_WORKING_IRI) { - if (requestedTargetHistory !== undefined) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${key} for ${ - formatDesignatorPathForDisplay(designatorPath) - } requests Working artifact resolution with a requested target history, which is contradictory.`, - ); - } - return await loadCurrentArtifactSourceRegion( - meshRoot, - localPathPolicy, - meshBase, - designatorPath, - key, - targetArtifactIri, - ); - } - if ( - resolutionMode === SFLO_ARTIFACT_RESOLUTION_MODE_LATEST_STATE_IRI || - requestedTargetHistory !== undefined - ) { - return await loadLatestStateArtifactSourceRegion( - meshRoot, - meshBase, - designatorPath, - key, - targetArtifactIri, - requestedTargetHistory, - ); - } - if (resolutionMode !== undefined) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${key} for ${ - formatDesignatorPathForDisplay(designatorPath) - } requests an unsupported artifact resolution mode: ${resolutionMode}.`, - ); - } - return await loadCurrentArtifactSourceRegion( + return await loadArtifactSourceRegionFromSpec( meshRoot, localPathPolicy, meshBase, designatorPath, key, + quads, + sourceSubject, targetArtifactIri, ); }), @@ -600,20 +500,6 @@ function requireSingleNamedNodeTarget( return values[0]!; } -function requireOptionalSingleNamedNodeTarget( - values: readonly string[], - errorMessage: string, -): string | undefined { - if (values.length === 0) { - return undefined; - } - if (values.length !== 1) { - throw new ResourcePageDefinitionResolutionError(errorMessage); - } - - return values[0]!; -} - function countDeclaredTargetLocators(lengths: readonly number[]): number { return lengths.filter((length) => length > 0).length; } @@ -651,477 +537,47 @@ function normalizeTargetLocalRelativePath( return normalized; } -async function loadCurrentArtifactSourceRegion( +async function loadArtifactSourceRegionFromSpec( meshRoot: string, localPathPolicy: OperationalLocalPathPolicy, meshBase: string, ownerDesignatorPath: string, regionKey: string, + quads: readonly Quad[], + sourceSubject: string, targetArtifactIri: string, ): Promise { - const { target, targetKnopInventoryTurtle } = - await loadArtifactSourceInventory( - meshRoot, - meshBase, - ownerDesignatorPath, - regionKey, - targetArtifactIri, - ); - const currentState = resolveArtifactSourceCurrentState( - meshBase, - targetKnopInventoryTurtle, - target, - ownerDesignatorPath, - regionKey, - ); - - return { - key: regionKey, - sourcePath: currentState.workingLocalRelativePath, - markdown: await readAllowedSourceText( - localPathPolicy, - "workingLocalRelativePath", - currentState.workingLocalRelativePath, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } points at governed artifact ${currentState.artifactPath}, whose current working bytes are outside the allowed local-path boundary: ${currentState.workingLocalRelativePath}`, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } points at governed artifact ${currentState.artifactPath}, whose current working file is missing: ${currentState.workingLocalRelativePath}`, - ), - }; -} - -async function loadLatestStateArtifactSourceRegion( - meshRoot: string, - meshBase: string, - ownerDesignatorPath: string, - regionKey: string, - targetArtifactIri: string, - requestedTargetHistoryIri: string | undefined, -): Promise { - const { target, targetKnopInventoryTurtle } = - await loadArtifactSourceInventory( - meshRoot, - meshBase, - ownerDesignatorPath, - regionKey, - targetArtifactIri, - ); - const latestState = resolveArtifactSourceLatestState( - meshBase, - targetKnopInventoryTurtle, - target, - ownerDesignatorPath, - regionKey, - requestedTargetHistoryIri, - ); - - return { - key: regionKey, - sourcePath: latestState.snapshotPath, - markdown: await readMeshSourceText( - meshRoot, - latestState.snapshotPath, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } points at governed artifact ${target.artifactPath}, whose latest settled source file is missing: ${latestState.snapshotPath}`, - ), - }; -} - -async function loadArtifactSourceInventory( - meshRoot: string, - meshBase: string, - ownerDesignatorPath: string, - regionKey: string, - targetArtifactIri: string, -): Promise { - const targetArtifactPath = requireMeshPathFromTargetArtifact( - meshBase, - targetArtifactIri, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } must target an in-mesh governed artifact.`, - ); - const targetKind = describeSupportedTargetArtifact( - targetArtifactPath, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } targets unsupported artifact ${targetArtifactPath}; this first slice supports payload artifacts, ReferenceCatalog artifacts, and ResourcePageDefinition artifacts only.`, - ); - const targetKnopInventoryPath = join( - meshRoot, - `${toKnopPath(targetKind.designatorPath)}/_inventory/inventory.ttl`, - ); + const sourceDescription = `ResourcePageDefinition region ${regionKey} for ${ + formatDesignatorPathForDisplay(ownerDesignatorPath) + } page source targeting ${targetArtifactIri}`; - let targetKnopInventoryTurtle: string; try { - targetKnopInventoryTurtle = await Deno.readTextFile( - targetKnopInventoryPath, - ); - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } targets governed artifact ${targetArtifactPath}, but its owning Knop inventory is missing: ${ - pathPosix.join( - toKnopPath(targetKind.designatorPath), - "_inventory/inventory.ttl", - ) - }`, - ); - } - throw error; - } - - return { - target: targetKind, - targetKnopInventoryTurtle, - }; -} - -function resolveArtifactSourceCurrentState( - meshBase: string, - targetKnopInventoryTurtle: string, - targetKind: ArtifactSourceTarget, - ownerDesignatorPath: string, - regionKey: string, -): ArtifactSourceCurrentState { - const workingLocalRelativePath = - resolveArtifactSourceWorkingLocalRelativePath( - meshBase, - targetKnopInventoryTurtle, - targetKind, - ownerDesignatorPath, - regionKey, - ); - - return { - ...targetKind, - workingLocalRelativePath, - }; -} - -function resolveArtifactSourceLatestState( - meshBase: string, - targetKnopInventoryTurtle: string, - target: ArtifactSourceTarget, - ownerDesignatorPath: string, - regionKey: string, - requestedTargetHistoryIri: string | undefined, -): ArtifactSourceLatestState { - if (target.kind !== "payload") { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } requests latest-state resolution for ${target.artifactPath}, but this implementation slice supports latest-state page sources for payload artifacts only.`, - ); - } - - const parseErrorMessage = - `Could not parse the current Knop inventory while resolving latest-state page-source artifact ${target.artifactPath} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - }.`; - const quads = parseTargetKnopInventoryQuads( - meshBase, - targetKnopInventoryTurtle, - parseErrorMessage, - ); - const targetArtifactIri = new URL(target.artifactPath, meshBase).href; - const targetKnopIri = new URL(toKnopPath(target.designatorPath), meshBase) - .href; - - if ( - !hasNamedNodeObject( - quads, - targetKnopIri, - SFLO_HAS_PAYLOAD_ARTIFACT_IRI, - targetArtifactIri, - ) - ) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } targets ${target.artifactPath}, but that payload artifact is not registered in its owning Knop inventory.`, - ); - } - - const historyIri = requestedTargetHistoryIri ?? - resolveRequiredNamedNodeObject( - quads, - targetArtifactIri, - SFLO_CURRENT_ARTIFACT_HISTORY_IRI, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } requests latest-state resolution for ${target.artifactPath}, but that artifact has no currentArtifactHistory.`, - ); - const historyPath = requireMeshPathFromTargetArtifact( - meshBase, - historyIri, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } requests latest-state resolution outside the mesh: ${historyIri}.`, - ); - - if ( - !hasNamedNodeObject( - quads, - targetArtifactIri, - SFLO_HAS_ARTIFACT_HISTORY_IRI, - historyIri, - ) && - !hasNamedNodeObject( + const result = await resolveArtifactResolutionSpecQuads( + { meshRoot, meshBase, localPathPolicy }, quads, - targetArtifactIri, - SFLO_CURRENT_ARTIFACT_HISTORY_IRI, - historyIri, - ) - ) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } requests latest-state resolution for ${target.artifactPath}, but ${historyPath} is not a history of that artifact.`, - ); - } - if ( - !hasNamedNodeObject( - quads, - historyIri, - RDF_TYPE_IRI, - SFLO_ARTIFACT_HISTORY_IRI, - ) - ) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } requests latest-state resolution for ${target.artifactPath}, but ${historyPath} is not declared as an ArtifactHistory.`, - ); - } - - const stateIri = resolveRequiredNamedNodeObject( - quads, - historyIri, - SFLO_LATEST_HISTORICAL_STATE_IRI, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } requests latest-state resolution for ${target.artifactPath}, but ${historyPath} has no latestHistoricalState.`, - ); - const statePath = requireMeshPathFromTargetArtifact( - meshBase, - stateIri, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } resolved latest-state source ${stateIri} outside the mesh.`, - ); - if (!statePath.startsWith(`${historyPath}/`)) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } resolved latest state ${statePath} outside requested history ${historyPath}.`, - ); - } - - const locatedFileIri = resolveOptionalNamedNodeObject( - quads, - stateIri, - SFLO_LOCATED_FILE_FOR_STATE_IRI, - parseErrorMessage, - ); - const snapshotPath = locatedFileIri - ? requireMeshPathFromTargetArtifact( - meshBase, - locatedFileIri, - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } resolved latest-state located file ${locatedFileIri} outside the mesh.`, - ) - : resolveDefaultPayloadHistoricalSnapshotPath( - meshBase, - targetKnopInventoryTurtle, - target, - ownerDesignatorPath, - regionKey, - parseErrorMessage, - statePath, - ); - - return { statePath, snapshotPath }; -} - -function resolveDefaultPayloadHistoricalSnapshotPath( - meshBase: string, - targetKnopInventoryTurtle: string, - target: ArtifactSourceTarget, - ownerDesignatorPath: string, - regionKey: string, - parseErrorMessage: string, - statePath: string, -): string { - const missingWorkingFileMessage = - `Could not derive the default latest-state snapshot path because page-source artifact ${target.artifactPath} used by region ${regionKey} of ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } has no working file name. Add sflo:locatedFileForState to the resolved HistoricalState or declare the payload artifact working file.`; - const inventoryState = resolvePayloadArtifactInventoryState( - meshBase, - targetKnopInventoryTurtle, - target.designatorPath, - { - parseErrorMessage, - missingWorkingFileMessage, - }, - ); - if (!inventoryState) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } targets ${target.artifactPath}, but that payload artifact is not registered in its owning Knop inventory.`, - ); - } - - return toPayloadHistoricalSnapshotPath( - statePath, - inventoryState.workingLocalRelativePath, - ); -} - -function resolveArtifactSourceWorkingLocalRelativePath( - meshBase: string, - targetKnopInventoryTurtle: string, - target: Omit, - ownerDesignatorPath: string, - regionKey: string, -): string { - const parseErrorMessage = - `Could not parse the current Knop inventory while resolving page-source artifact ${target.artifactPath} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - }.`; - const missingWorkingFileMessage = - `Could not resolve the current working file for page-source artifact ${target.artifactPath} used by region ${regionKey} of ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - }.`; - - if (target.kind === "payload") { - const inventoryState = resolvePayloadArtifactInventoryState( - meshBase, - targetKnopInventoryTurtle, - target.designatorPath, - { - parseErrorMessage, - missingWorkingFileMessage, - }, + sourceSubject, + { sourceDescription, contentMode: "text" }, ); - if (!inventoryState) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } targets ${target.artifactPath}, but that payload artifact is not registered in its owning Knop inventory.`, - ); - } - return inventoryState.workingLocalRelativePath; - } - if (target.kind === "referenceCatalog") { - const inventoryState = resolveReferenceCatalogInventoryState( - meshBase, - targetKnopInventoryTurtle, - target.designatorPath, - { - parseErrorMessage, - missingWorkingFileMessage, - }, - ); - if (!inventoryState) { + if ( + result.observed.localRelativePath === undefined || + result.content?.text === undefined + ) { throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } targets ${target.artifactPath}, but that ReferenceCatalog artifact is not registered in its owning Knop inventory.`, + `${sourceDescription} did not resolve to text content.`, ); } - return inventoryState.workingLocalRelativePath; - } - const inventoryState = resolveResourcePageDefinitionInventoryState( - meshBase, - targetKnopInventoryTurtle, - target.designatorPath, - { - parseErrorMessage, - missingWorkingFileMessage, - }, - ); - if (!inventoryState || inventoryState.artifactPath !== target.artifactPath) { - throw new ResourcePageDefinitionResolutionError( - `ResourcePageDefinition region ${regionKey} for ${ - formatDesignatorPathForDisplay(ownerDesignatorPath) - } targets ${target.artifactPath}, but that ResourcePageDefinition artifact is not registered in its owning Knop inventory.`, - ); - } - - return inventoryState.workingLocalRelativePath; -} - -function requireMeshPathFromTargetArtifact( - meshBase: string, - artifactIri: string, - errorMessage: string, -): string { - if (!artifactIri.startsWith(meshBase)) { - throw new ResourcePageDefinitionResolutionError(errorMessage); - } - - const meshPath = artifactIri.slice(meshBase.length); - if (meshPath.includes("#") || meshPath.includes("?")) { - throw new ResourcePageDefinitionResolutionError(errorMessage); - } - - return meshPath; -} - -function describeSupportedTargetArtifact( - artifactPath: string, - errorMessage: string, -): ArtifactSourceTarget { - if ( - artifactPath === ROOT_REFERENCE_CATALOG_PATH || - artifactPath.endsWith(REFERENCE_CATALOG_SUFFIX) - ) { return { - kind: "referenceCatalog", - designatorPath: artifactPath === ROOT_REFERENCE_CATALOG_PATH - ? "" - : artifactPath.slice(0, -REFERENCE_CATALOG_SUFFIX.length), - artifactPath, + key: regionKey, + sourcePath: result.observed.localRelativePath, + markdown: result.content.text, }; - } - - if ( - artifactPath === ROOT_PAGE_DEFINITION_ARTIFACT_PATH || - artifactPath.endsWith(PAGE_DEFINITION_ARTIFACT_SUFFIX) - ) { - return { - kind: "resourcePageDefinition", - designatorPath: artifactPath === ROOT_PAGE_DEFINITION_ARTIFACT_PATH - ? "" - : artifactPath.slice(0, -PAGE_DEFINITION_ARTIFACT_SUFFIX.length), - artifactPath, - }; - } - - try { - return { - kind: "payload", - designatorPath: normalizeSafeDesignatorPath( - artifactPath, - "target artifact path", - (message) => new ResourcePageDefinitionResolutionError(message), - { allowRoot: true }, - ), - artifactPath, - }; - } catch { - throw new ResourcePageDefinitionResolutionError(errorMessage); + } catch (error) { + if (error instanceof ArtifactResolutionError) { + throw new ResourcePageDefinitionResolutionError(error.message); + } + throw error; } } @@ -1151,100 +607,6 @@ async function readAllowedSourceText( } } -async function readMeshSourceText( - meshRoot: string, - sourcePath: string, - missingErrorMessage: string, -): Promise { - try { - return await Deno.readTextFile(join(meshRoot, sourcePath)); - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - throw new ResourcePageDefinitionResolutionError(missingErrorMessage); - } - throw error; - } -} - -function parseTargetKnopInventoryQuads( - meshBase: string, - turtle: string, - errorMessage: string, -): readonly Quad[] { - try { - return new Parser({ baseIRI: meshBase }).parse(turtle); - } catch { - throw new ResourcePageDefinitionResolutionError(errorMessage); - } -} - -function resolveRequiredNamedNodeObject( - quads: readonly Quad[], - subjectIri: string, - predicateIri: string, - errorMessage: string, -): string { - const value = resolveOptionalNamedNodeObject( - quads, - subjectIri, - predicateIri, - errorMessage, - ); - if (value === undefined) { - throw new ResourcePageDefinitionResolutionError(errorMessage); - } - return value; -} - -function resolveOptionalNamedNodeObject( - quads: readonly Quad[], - subjectIri: string, - predicateIri: string, - errorMessage: string, -): string | undefined { - const values = collectNamedNodeObjects(quads, subjectIri, predicateIri); - if (values.length > 1) { - throw new ResourcePageDefinitionResolutionError(errorMessage); - } - return values[0]; -} - -function hasNamedNodeObject( - quads: readonly Quad[], - subjectIri: string, - predicateIri: string, - objectIri: string, -): boolean { - return quads.some((quad) => - quad.subject.termType === "NamedNode" && - quad.subject.value === subjectIri && - quad.predicate.value === predicateIri && - quad.object.termType === "NamedNode" && - quad.object.value === objectIri - ); -} - -function toPayloadHistoricalSnapshotPath( - historyStatePath: string, - workingLocalRelativePath: string, -): string { - const fileName = toFileName(workingLocalRelativePath); - const manifestationSegment = toDefaultManifestationSegment(fileName); - return `${historyStatePath}/${manifestationSegment}/${fileName}`; -} - -function toFileName(path: string): string { - const segments = path.split("/"); - return segments[segments.length - 1]!; -} - -function toDefaultManifestationSegment(fileName: string): string { - const extensionIndex = fileName.lastIndexOf("."); - return extensionIndex > 0 && extensionIndex < fileName.length - 1 - ? fileName.slice(extensionIndex + 1) - : fileName.replaceAll(".", "-"); -} - async function listStylesheetPaths( meshRoot: string, assetBundlePath: string, diff --git a/src/runtime/weave/page_definition_test.ts b/src/runtime/weave/page_definition_test.ts index 71ff5b7..eab5f99 100644 --- a/src/runtime/weave/page_definition_test.ts +++ b/src/runtime/weave/page_definition_test.ts @@ -1,15 +1,21 @@ -import { assertEquals } from "@std/assert"; +import { assertEquals, assertRejects } from "@std/assert"; import { join } from "@std/path"; import { DEFAULT_RESOURCE_PAGE_PRESENTATION_PROFILE } from "../config/effective_config.ts"; import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; -import { loadActiveCustomIdentifierPage } from "./page_definition.ts"; +import { + loadActiveCustomIdentifierPage, + ResourcePageDefinitionResolutionError, +} from "./page_definition.ts"; + +const MESH_BASE = "https://semantic-flow.github.io/mesh-alice-bio/"; +const SFLO = "https://semantic-flow.github.io/sflo/ontology/"; Deno.test("loadActiveCustomIdentifierPage resolves authored regions without direct presentation config", async () => { await withPageDefinitionFixture(async ({ meshRoot, policy }) => { const page = await loadActiveCustomIdentifierPage( meshRoot, policy, - "https://semantic-flow.github.io/mesh-alice-bio/", + MESH_BASE, "alice", { artifactPath: "alice/_knop/_page", @@ -25,6 +31,26 @@ Deno.test("loadActiveCustomIdentifierPage resolves authored regions without dire }); }); +Deno.test("loadActiveCustomIdentifierPage resolves current-only ResourcePageDefinitions", async () => { + await withPageDefinitionFixture(async ({ meshRoot, policy }) => { + const page = await loadActiveCustomIdentifierPage( + meshRoot, + policy, + MESH_BASE, + "alice", + { + artifactPath: "alice/_knop/_page", + workingLocalRelativePath: "alice/_knop/_page/page.ttl", + currentPageDefinitionTurtle: pageDefinitionTurtle(), + currentArtifactHistoryExists: false, + }, + ); + + assertEquals(page?.definitionPath, "alice/_knop/_page"); + assertEquals(page?.regions[0]?.markdown, "# Alice\n"); + }); +}); + Deno.test("loadActiveCustomIdentifierPage resolves explicit generated ResourcePage panel selections", async () => { await withPageDefinitionFixture(async ({ meshRoot, policy }) => { const rawSourcePanelSelectionIri = @@ -34,7 +60,7 @@ Deno.test("loadActiveCustomIdentifierPage resolves explicit generated ResourcePa const page = await loadActiveCustomIdentifierPage( meshRoot, policy, - "https://semantic-flow.github.io/mesh-alice-bio/", + MESH_BASE, "alice", { artifactPath: "alice/_knop/_page", @@ -52,6 +78,123 @@ Deno.test("loadActiveCustomIdentifierPage resolves explicit generated ResourcePa }); }); +Deno.test("loadActiveCustomIdentifierPage resolves exact payload state page sources", async () => { + await withPageDefinitionFixture(async ({ meshRoot, policy }) => { + await materializePayloadArtifact(meshRoot); + await Deno.writeTextFile( + join(meshRoot, "alice/_history001/_s0001/md/source.md"), + "# Exact Alice\n", + ); + + const page = await loadActiveCustomIdentifierPage( + meshRoot, + policy, + MESH_BASE, + "alice", + { + artifactPath: "alice/_knop/_page", + workingLocalRelativePath: "alice/_knop/_page/page.ttl", + currentPageDefinitionTurtle: pageDefinitionTurtle( + undefined, + `sflo:targetArtifact <${MESH_BASE}alice> ; + sflo:targetHistoricalState <${MESH_BASE}alice/_history001/_s0001>`, + ), + currentArtifactHistoryExists: true, + }, + ); + + assertEquals( + page?.regions[0]?.sourcePath, + "alice/_history001/_s0001/md/source.md", + ); + assertEquals(page?.regions[0]?.markdown, "# Exact Alice\n"); + }); +}); + +Deno.test("loadActiveCustomIdentifierPage uses explicit fallback after unavailable exact source", async () => { + await withPageDefinitionFixture(async ({ meshRoot, policy }) => { + await materializePayloadArtifact(meshRoot); + + const page = await loadActiveCustomIdentifierPage( + meshRoot, + policy, + MESH_BASE, + "alice", + { + artifactPath: "alice/_knop/_page", + workingLocalRelativePath: "alice/_knop/_page/page.ttl", + currentPageDefinitionTurtle: pageDefinitionTurtle( + undefined, + `sflo:targetArtifact <${MESH_BASE}alice> ; + sflo:targetHistoricalState <${MESH_BASE}alice/_history001/_s0001> ; + sflo:hasFallbackArtifactResolutionSpec [ + a sflo:ArtifactResolutionSpec ; + sflo:targetLocalRelativePath "alice.md" + ]`, + ), + currentArtifactHistoryExists: true, + }, + ); + + assertEquals(page?.regions[0]?.sourcePath, "alice.md"); + assertEquals(page?.regions[0]?.markdown, "# Alice\n"); + }); +}); + +Deno.test("loadActiveCustomIdentifierPage does not broaden direct local sources with fallback", async () => { + await withPageDefinitionFixture(async ({ meshRoot, policy }) => { + await assertRejects( + () => + loadActiveCustomIdentifierPage( + meshRoot, + policy, + MESH_BASE, + "alice", + { + artifactPath: "alice/_knop/_page", + workingLocalRelativePath: "alice/_knop/_page/page.ttl", + currentPageDefinitionTurtle: pageDefinitionTurtle( + undefined, + `sflo:targetLocalRelativePath "missing.md" ; + sflo:hasFallbackArtifactResolutionSpec [ + a sflo:ArtifactResolutionSpec ; + sflo:targetLocalRelativePath "alice.md" + ]`, + ), + currentArtifactHistoryExists: true, + }, + ), + ResourcePageDefinitionResolutionError, + "applies artifact-resolution policy fields to a direct targetLocalRelativePath source", + ); + }); +}); + +Deno.test("loadActiveCustomIdentifierPage rejects direct targetAccessUrl sources", async () => { + await withPageDefinitionFixture(async ({ meshRoot, policy }) => { + await assertRejects( + () => + loadActiveCustomIdentifierPage( + meshRoot, + policy, + MESH_BASE, + "alice", + { + artifactPath: "alice/_knop/_page", + workingLocalRelativePath: "alice/_knop/_page/page.ttl", + currentPageDefinitionTurtle: pageDefinitionTurtle( + undefined, + `sflo:targetAccessUrl "https://example.org/alice.md"`, + ), + currentArtifactHistoryExists: true, + }, + ), + ResourcePageDefinitionResolutionError, + "uses targetAccessUrl", + ); + }); +}); + async function withPageDefinitionFixture( fn: ( fixture: { @@ -78,11 +221,45 @@ async function withPageDefinitionFixture( } } +async function materializePayloadArtifact(meshRoot: string): Promise { + await Deno.mkdir(join(meshRoot, "alice/_knop/_inventory"), { + recursive: true, + }); + await Deno.mkdir(join(meshRoot, "alice/_history001/_s0001/md"), { + recursive: true, + }); + await Deno.writeTextFile( + join(meshRoot, "alice/source.md"), + "# Working Alice\n", + ); + await Deno.writeTextFile( + join(meshRoot, "alice/_knop/_inventory/inventory.ttl"), + `@base <${MESH_BASE}> . +@prefix sflo: <${SFLO}> . + + a sflo:Knop ; + sflo:hasPayloadArtifact . + + a sflo:PayloadArtifact, sflo:RdfDocument ; + sflo:workingLocalRelativePath "alice/source.md" ; + sflo:currentArtifactHistory ; + sflo:hasArtifactHistory . + + a sflo:ArtifactHistory ; + sflo:latestHistoricalState . + + a sflo:HistoricalState ; + sflo:locatedFileForState . +`, + ); +} + function pageDefinitionTurtle( generatedPanelSelectionIri?: string, + sourceTurtle = `sflo:targetLocalRelativePath "alice.md"`, ): string { - return `@base . -@prefix sflo: . + return `@base <${MESH_BASE}alice/_knop/_page> . +@prefix sflo: <${SFLO}> . @prefix sfcfg: . <> a sflo:ResourcePageDefinition, sflo:DigitalArtifact, sflo:RdfDocument ; @@ -98,6 +275,6 @@ function pageDefinitionTurtle( sflo:hasResourcePageSource <#main-source> . <#main-source> a sflo:ResourcePageSource ; - sflo:targetLocalRelativePath "alice.md" . + ${sourceTurtle} . `; } diff --git a/src/runtime/weave/page_generation.ts b/src/runtime/weave/page_generation.ts index e708170..9d6b5c6 100644 --- a/src/runtime/weave/page_generation.ts +++ b/src/runtime/weave/page_generation.ts @@ -4,13 +4,17 @@ import { type NormalizedTargetSpec, resolveTargetSelections, } from "../../core/targeting.ts"; +import type { ResourcePageModel } from "../../core/weave/resource_page_models.ts"; import { WeaveInputError } from "../../core/weave/weave.ts"; import { listKnopDesignatorPaths } from "../mesh/inventory.ts"; import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; import type { StructuredLogger } from "../logging/logger.ts"; import type { RuntimeTiming } from "../timing.ts"; import type { HistoryTrackingPolicy } from "../config/effective_config.ts"; -import { loadEffectiveConfigForExecution } from "./execution_config.ts"; +import { + createEffectiveConfigProviderForExecution, + type EffectiveConfigProvider, +} from "./execution_config.ts"; import { loadMeshState, type MeshState } from "./mesh_state.ts"; import { collectResourcePageModels, @@ -50,21 +54,6 @@ export async function generatePreparedPages( phase("loadMeshState"), () => loadMeshState(options.meshRoot), ); - const effectiveConfig = await timeOptional( - options.timing, - phase("loadEffectiveConfig"), - () => - loadEffectiveConfigForExecution({ - meshConfigTurtle: meshState.currentMeshConfigTurtle, - meshConfigSource: meshState.currentMeshConfigTurtle - ? "_mesh/_config/config.ttl" - : undefined, - meshBase: meshState.meshBase, - meshInventoryTurtle: meshState.currentMeshInventoryTurtle, - historyTrackingPolicyOverride: options.historyTrackingPolicyOverride, - includeSemanticFlowMetadata: options.includeSemanticFlowMetadata, - }), - ); const allDesignatorPaths = timeOptionalSync( options.timing, phase("listDesignatorPaths"), @@ -84,6 +73,15 @@ export async function generatePreparedPages( options.targets, ), ); + const effectiveConfigProvider = createEffectiveConfigProviderForExecution({ + meshRoot: options.meshRoot, + meshState, + localPathPolicy: options.localPathPolicy, + historyTrackingPolicyOverride: options.historyTrackingPolicyOverride, + includeSemanticFlowMetadata: options.includeSemanticFlowMetadata, + timing: options.timing, + phasePrefix: phase("effectiveConfig"), + }); const pageFiles = await timeOptional( options.timing, phase("collectGeneratedPageFiles"), @@ -95,7 +93,7 @@ export async function generatePreparedPages( selectedDesignatorPaths, options.targets.length === 0, options.targets.length > 0, - effectiveConfig, + effectiveConfigProvider, resolveGeneratedAt(options.now), options.timing, phase("collectGeneratedPageFiles"), @@ -139,7 +137,7 @@ export async function collectGeneratedPageFiles( selectedDesignatorPaths: readonly string[], includeAllMeshPages: boolean, hasExplicitGenerateTargets: boolean, - effectiveConfig: Awaited>, + effectiveConfigProvider: EffectiveConfigProvider, generatedAt: Date, timing?: RuntimeTiming, phasePrefix = "collectGeneratedPageFiles", @@ -152,7 +150,7 @@ export async function collectGeneratedPageFiles( selectedDesignatorPaths, includeAllMeshPages, hasExplicitGenerateTargets, - effectiveConfig, + effectiveConfigProvider, timing, phasePrefix, }); @@ -165,11 +163,71 @@ export async function collectGeneratedPageFiles( generatedAt, includeSemanticFlowMetadata: false, meshFaviconPath, - resourcePagePresentation: effectiveConfig.resourcePagePresentation, + resourcePagePresentationForPage: (page) => + resourcePagePresentationForGeneratedPage( + effectiveConfigProvider, + page, + ), }), ); } +async function resourcePagePresentationForGeneratedPage( + effectiveConfigProvider: EffectiveConfigProvider, + page: ResourcePageModel, +) { + const ownerDesignatorPath = ownerDesignatorPathForPage(page); + return ownerDesignatorPath === undefined + ? await effectiveConfigProvider.resourcePagePresentationForMeshScope() + : await effectiveConfigProvider.resourcePagePresentationForTarget( + ownerDesignatorPath, + ); +} + +export function ownerDesignatorPathForPage( + page: ResourcePageModel, +): string | undefined { + if (page.path.startsWith("_mesh/")) { + return undefined; + } + if ( + page.kind === "identifier" || + page.kind === "customIdentifier" || + page.kind === "knop" + ) { + return page.designatorPath; + } + if (page.kind === "referenceCatalog") { + return page.ownerDesignatorPath; + } + if (page.ownerDesignatorPath !== undefined) { + return page.ownerDesignatorPath; + } + + const resourcePath = page.path === "index.html" + ? "" + : page.path.endsWith("/index.html") + ? page.path.slice(0, -"/index.html".length) + : page.path; + const knopMarker = resourcePath === "_knop" + ? 0 + : resourcePath.indexOf("/_knop"); + if (knopMarker === 0) { + return ""; + } + if (knopMarker > 0) { + return resourcePath.slice(0, knopMarker); + } + const historyMarker = resourcePath.indexOf("/_history"); + if (historyMarker > 0) { + return resourcePath.slice(0, historyMarker); + } + if (resourcePath.startsWith("_history")) { + return ""; + } + return resourcePath; +} + function resolveSelectedDesignatorPaths( allDesignatorPaths: readonly string[], targets: readonly NormalizedTargetSpec[], diff --git a/src/runtime/weave/page_generation_test.ts b/src/runtime/weave/page_generation_test.ts new file mode 100644 index 0000000..8550856 --- /dev/null +++ b/src/runtime/weave/page_generation_test.ts @@ -0,0 +1,37 @@ +import { assertEquals } from "@std/assert"; +import type { ResourcePageModel } from "../../core/weave/resource_page_models.ts"; +import { ownerDesignatorPathForPage } from "./page_generation.ts"; + +Deno.test("ownerDesignatorPathForPage prefers threaded owner for simple pages", () => { + const page: ResourcePageModel = { + kind: "simple", + path: "alice/_history001/_s0001/index.html", + ownerDesignatorPath: "alice/data", + description: "Historical state page", + }; + + assertEquals(ownerDesignatorPathForPage(page), "alice/data"); +}); + +Deno.test("ownerDesignatorPathForPage keeps mesh pages in mesh scope", () => { + const page: ResourcePageModel = { + kind: "simple", + path: "_mesh/_inventory/index.html", + ownerDesignatorPath: "alice", + description: "Mesh inventory page", + }; + + assertEquals(ownerDesignatorPathForPage(page), undefined); +}); + +Deno.test("ownerDesignatorPathForPage uses model designator for designator-backed pages", () => { + const page: ResourcePageModel = { + kind: "knop", + path: "alice/_knop/index.html", + designatorPath: "alice", + governedArtifacts: [], + supportingArtifacts: [], + }; + + assertEquals(ownerDesignatorPathForPage(page), "alice"); +}); diff --git a/src/runtime/weave/page_model_assembly.ts b/src/runtime/weave/page_model_assembly.ts index d33157c..7da44b6 100644 --- a/src/runtime/weave/page_model_assembly.ts +++ b/src/runtime/weave/page_model_assembly.ts @@ -13,9 +13,9 @@ import { findHistoryStateForManifestation, isHistoryComponentResource, } from "../../core/weave/resource_page_history_groups.ts"; -import type { EffectiveConfig } from "../config/effective_config.ts"; import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; import type { RuntimeTiming } from "../timing.ts"; +import type { EffectiveConfigProvider } from "./execution_config.ts"; import type { MeshState } from "./mesh_state.ts"; import { collectChildIdentifiersByResourcePath, @@ -47,7 +47,7 @@ export interface CollectResourcePageModelsInput { selectedDesignatorPaths: readonly string[]; includeAllMeshPages: boolean; hasExplicitGenerateTargets: boolean; - effectiveConfig: EffectiveConfig; + effectiveConfigProvider: EffectiveConfigProvider; timing?: RuntimeTiming; phasePrefix?: string; } @@ -62,7 +62,7 @@ export async function collectResourcePageModels( selectedDesignatorPaths, includeAllMeshPages, hasExplicitGenerateTargets, - effectiveConfig, + effectiveConfigProvider, timing, phasePrefix = "collectResourcePageModels", } = input; @@ -79,7 +79,8 @@ export async function collectResourcePageModels( localPathPolicy, meshState, selectedDesignatorPaths, - effectiveConfig, + (designatorPath) => + effectiveConfigProvider.configForTarget(designatorPath), hasExplicitGenerateTargets, timing, phase("loadDesignatorContexts"), @@ -112,28 +113,45 @@ export async function collectResourcePageModels( "Could not parse the current MeshInventory while collecting ResourcePage histories.", ), ); - const allPagePaths = timeOptionalSync( + const meshInventoryPagePaths = await timeOptional( timing, - phase("listRuntimeGeneratedResourcePagePaths"), - () => + phase("listMeshInventoryResourcePagePaths"), + async () => listRuntimeGeneratedResourcePagePaths( { meshBase: meshState.meshBase, inventoryTurtle: meshState.currentMeshInventoryTurtle, parseErrorMessage: "Could not parse the current MeshInventory while collecting ResourcePages.", - config: effectiveConfig, + config: await effectiveConfigProvider.configForMeshScope(), explicitRequest: hasExplicitGenerateTargets, }, ), ); + const ownerScopedPagePaths = designatorContexts.flatMap((context) => + context.pagePaths + ); + const ownerDesignatorPathByPagePath = new Map(); + for (const context of designatorContexts) { + for (const pagePath of context.pagePaths) { + if (!ownerDesignatorPathByPagePath.has(pagePath)) { + ownerDesignatorPathByPagePath.set(pagePath, context.designatorPath); + } + } + } + const selectedPagePaths = [ + ...new Set([ + ...meshInventoryPagePaths, + ...ownerScopedPagePaths, + ]), + ].sort((left, right) => left.localeCompare(right)); const generatedResourcePaths = collectGeneratedResourcePaths( - allPagePaths, + selectedPagePaths, includeAllMeshPages, selectedSet, ); const displayedChildResourcePaths = collectDisplayedChildResourcePaths( - allPagePaths, + selectedPagePaths, generatedResourcePaths, ); const childTypeHintContexts = await timeOptional( @@ -147,7 +165,8 @@ export async function collectResourcePageModels( displayedChildResourcePaths.filter((resourcePath) => !selectedSet.has(resourcePath) ), - effectiveConfig, + (designatorPath) => + effectiveConfigProvider.configForTarget(designatorPath), hasExplicitGenerateTargets, timing, phase("loadChildTypeHintContexts"), @@ -158,11 +177,11 @@ export async function collectResourcePageModels( [...designatorContexts, ...childTypeHintContexts], ); const childIdentifiersByResourcePath = collectChildIdentifiersByResourcePath( - allPagePaths, + selectedPagePaths, childRdfTypesByResourcePath, ); - for (const pagePath of allPagePaths) { + for (const pagePath of selectedPagePaths) { if ( !shouldGenerateRuntimePagePath( pagePath, @@ -186,6 +205,7 @@ export async function collectResourcePageModels( pageModels.push({ kind: "simple", path: pagePath, + ownerDesignatorPath: publicContext.designatorPath, description: describeSemanticFlowResource( meshState.meshBase, resourcePath, @@ -202,7 +222,9 @@ export async function collectResourcePageModels( path: pagePath, designatorPath: publicContext.designatorPath, definitionPath: publicContext.customIdentifierPage.definitionPath, - presentationConfigIri: effectiveConfig.resourcePagePresentation.iri, + presentationConfigIri: (await effectiveConfigProvider.configForTarget( + publicContext.designatorPath, + )).resourcePagePresentation.iri, generatedPanelSelectionIris: publicContext.customIdentifierPage.generatedPanelSelectionIris, stylesheetPaths: publicContext.customIdentifierPage.stylesheetPaths, @@ -255,9 +277,11 @@ export async function collectResourcePageModels( findRawSourcePanelsForPage(pagePath, designatorContexts); const historyGroups = meshHistoryGroups.get(resourcePath) ?? findHistoryGroupsForResource(resourcePath, designatorContexts); + const ownerDesignatorPath = ownerDesignatorPathByPagePath.get(pagePath); pageModels.push({ kind: "simple", path: pagePath, + ...(ownerDesignatorPath !== undefined ? { ownerDesignatorPath } : {}), description: describeSemanticFlowResource( meshState.meshBase, resourcePath, @@ -308,6 +332,7 @@ export async function collectResourcePageModels( : { kind: "simple" as const, path: pagePath, + ownerDesignatorPath: context.designatorPath, description: context.pageDescriptions.get(pagePath) ?? describeSemanticFlowResource( meshState.meshBase, diff --git a/src/runtime/weave/pages.ts b/src/runtime/weave/pages.ts index 930f58d..d8584ed 100644 --- a/src/runtime/weave/pages.ts +++ b/src/runtime/weave/pages.ts @@ -213,6 +213,11 @@ export interface ResourcePageRenderOptions { includeSemanticFlowMetadata?: boolean; meshFaviconPath?: string; resourcePagePresentation?: ResourcePagePresentationProfile; + resourcePagePresentationForPage?: ( + page: ResourcePageModel, + ) => + | ResourcePagePresentationProfile + | Promise; } export async function renderResourcePages( @@ -242,6 +247,10 @@ export async function renderResourcePage( ? toMeshResourceHref(meshRootHref, options.meshFaviconPath) : undefined; const escapedCanonical = escapeHtml(canonical); + const renderPresentation = await resolveRenderPresentationForPage( + options, + page, + ); if (page.kind !== "customIdentifier") { return await defaultResourcePageTheme.render( @@ -256,7 +265,7 @@ export async function renderResourcePage( canonical, options.generatedAt ?? new Date(), options.includeSemanticFlowMetadata ?? false, - resolveRenderPresentation(options), + renderPresentation, meshFaviconHref, ), ), @@ -270,7 +279,7 @@ export async function renderResourcePage( ); const customPresentation = resolveCustomIdentifierPresentation( page.presentationConfigIri, - resolveRenderPresentation(options), + renderPresentation, ); if (customPresentation) { return await defaultResourcePageTheme.render( @@ -363,19 +372,30 @@ ${ return assertNeverResourcePage(page); } -function resolveRenderPresentation( +async function resolveRenderPresentationForPage( + options: ResourcePageRenderOptions, + page: ResourcePageModel, +): Promise { + const presentation = options.resourcePagePresentationForPage + ? await options.resourcePagePresentationForPage(page) + : options.resourcePagePresentation; + return resolveStaticRenderPresentation(options, presentation); +} + +function resolveStaticRenderPresentation( options: ResourcePageRenderOptions, + presentation: ResourcePagePresentationProfile | undefined = options + .resourcePagePresentation, ): ResourcePagePresentationProfile { - const presentation = options.resourcePagePresentation ?? - DEFAULT_RESOURCE_PAGE_PRESENTATION_PROFILE; + const resolved = presentation ?? DEFAULT_RESOURCE_PAGE_PRESENTATION_PROFILE; if ( options.includeSemanticFlowMetadata === true && - presentation.identity === "semanticSiteDefault" + resolved.identity === "semanticSiteDefault" ) { return ALL_PANELS_RESOURCE_PAGE_PRESENTATION_PROFILE; } - return presentation; + return resolved; } function resolveCustomIdentifierPresentation( @@ -428,7 +448,7 @@ export function buildResourcePageDocumentModel( canonical, options.generatedAt ?? new Date(), options.includeSemanticFlowMetadata ?? false, - resolveRenderPresentation(options), + resolveStaticRenderPresentation(options), meshFaviconHref, ); } diff --git a/src/runtime/weave/version_execution.ts b/src/runtime/weave/version_execution.ts index 6b88b2e..9c3a458 100644 --- a/src/runtime/weave/version_execution.ts +++ b/src/runtime/weave/version_execution.ts @@ -23,10 +23,13 @@ import { loadWeaveableKnopCandidates, } from "./candidate_loader.ts"; import { - loadEffectiveConfigForExecution, + createEffectiveConfigProviderForExecution, namingPoliciesFromEffectiveConfig, + resourcePageGenerationConfigFromScopedEffectiveConfigs, resourcePageGenerationPoliciesFromEffectiveConfig, + resourcePageGenerationPoliciesFromScopedEffectiveConfigs, supportHistoryPoliciesFromEffectiveConfig, + supportHistoryPoliciesFromScopedEffectiveConfigs, } from "./execution_config.ts"; import { WeaveRuntimeError } from "./errors.ts"; import { @@ -73,26 +76,6 @@ export async function prepareVersionExecution( "prepare.loadMeshState", () => loadMeshState(workspaceRoot), ); - const effectiveConfig = await timeOptional( - timing, - "prepare.loadEffectiveConfig", - () => - loadEffectiveConfigForExecution({ - meshConfigTurtle: meshState.currentMeshConfigTurtle, - meshConfigSource: meshState.currentMeshConfigTurtle - ? "_mesh/_config/config.ttl" - : undefined, - meshBase: meshState.meshBase, - meshInventoryTurtle: meshState.currentMeshInventoryTurtle, - historyTrackingPolicyOverride, - }), - ); - const supportHistoryPolicies = supportHistoryPoliciesFromEffectiveConfig( - effectiveConfig, - ); - const namingPolicies = namingPoliciesFromEffectiveConfig(effectiveConfig); - const resourcePageGenerationPolicies = - resourcePageGenerationPoliciesFromEffectiveConfig(effectiveConfig); const allDesignatorPaths = timeOptionalSync( timing, "prepare.listDesignatorPaths", @@ -147,9 +130,31 @@ export async function prepareVersionExecution( targets, initialWeaveableKnops, ); + const targetMetadataTurtleByDesignatorPath = new Map( + initialWeaveableKnops.map((candidate) => [ + candidate.designatorPath, + candidate.currentKnopMetadataTurtle, + ]), + ); + const effectiveConfigProvider = createEffectiveConfigProviderForExecution({ + meshRoot: workspaceRoot, + meshState, + localPathPolicy, + targetMetadataTurtleByDesignatorPath, + historyTrackingPolicyOverride, + timing, + phasePrefix: "prepare.effectiveConfig", + }); + const meshEffectiveConfig = await effectiveConfigProvider + .configForMeshScope(); if (initialWeaveableKnops.length === 0) { if (targets.length === 0) { + const supportHistoryPolicies = supportHistoryPoliciesFromEffectiveConfig( + meshEffectiveConfig, + ); + const resourcePageGenerationPolicies = + resourcePageGenerationPoliciesFromEffectiveConfig(meshEffectiveConfig); return { meshState, plan: planMeshSupportResourcePages({ @@ -158,7 +163,7 @@ export async function prepareVersionExecution( currentMeshMetadataTurtle: meshState.currentMeshMetadataTurtle, currentMeshConfigTurtle: meshState.currentMeshConfigTurtle, supportHistoryPolicies, - resourcePageGenerationConfig: effectiveConfig, + resourcePageGenerationConfig: meshEffectiveConfig, resourcePageGenerationPolicies, }), }; @@ -212,6 +217,26 @@ export async function prepareVersionExecution( const nextCandidate = stagedWeaveableKnops[0]!; const nextDesignatorPath = nextCandidate.designatorPath; const target = targetByDesignatorPath.get(nextDesignatorPath); + const targetEffectiveConfig = await effectiveConfigProvider + .configForTarget(nextDesignatorPath); + const supportHistoryPolicies = + supportHistoryPoliciesFromScopedEffectiveConfigs( + meshEffectiveConfig, + targetEffectiveConfig, + ); + const namingPolicies = namingPoliciesFromEffectiveConfig( + targetEffectiveConfig, + ); + const resourcePageGenerationConfig = + resourcePageGenerationConfigFromScopedEffectiveConfigs( + meshEffectiveConfig, + targetEffectiveConfig, + ); + const resourcePageGenerationPolicies = + resourcePageGenerationPoliciesFromScopedEffectiveConfigs( + meshEffectiveConfig, + targetEffectiveConfig, + ); const nextPlan = timeOptionalSync( timing, "prepare.loop.planVersion", @@ -228,7 +253,7 @@ export async function prepareVersionExecution( weaveableKnops: [nextCandidate], supportHistoryPolicies, namingPolicies, - resourcePageGenerationConfig: effectiveConfig, + resourcePageGenerationConfig, resourcePageGenerationPolicies, }), ); diff --git a/tests/integration/extract_test.ts b/tests/integration/extract_test.ts index f525d91..e18ba9a 100644 --- a/tests/integration/extract_test.ts +++ b/tests/integration/extract_test.ts @@ -519,11 +519,17 @@ Deno.test("executeExtractAllTerms creates source references only for newly extra "sflo:targetArtifact .", ); assertFalse(bobReferencesTurtle.includes("sflo:targetHistoricalState")); + const bobInventoryAfterExtractAll = await Deno.readTextFile( + join(workspaceRoot, "bob/_knop/_inventory/inventory.ttl"), + ); assertStringIncludes( - await Deno.readTextFile( - join(workspaceRoot, "bob/_knop/_inventory/inventory.ttl"), + bobInventoryAfterExtractAll, + "sflo:hasReferenceCatalog ", + ); + assertFalse( + bobInventoryAfterExtractAll.includes( + "", ), - "sflo:hasReferenceCatalog ;", ); await executeWeave({ @@ -532,11 +538,17 @@ Deno.test("executeExtractAllTerms creates source references only for newly extra now: () => new Date("2026-05-04T00:00:00.000Z"), }); + const bobInventoryAfterWeave = await Deno.readTextFile( + join(workspaceRoot, "bob/_knop/_inventory/inventory.ttl"), + ); assertStringIncludes( - await Deno.readTextFile( - join(workspaceRoot, "bob/_knop/_inventory/inventory.ttl"), + bobInventoryAfterWeave, + "sflo:hasReferenceCatalog ", + ); + assertFalse( + bobInventoryAfterWeave.includes( + "", ), - "sflo:hasReferenceCatalog ;", ); const bobPageHtml = await Deno.readTextFile( join(workspaceRoot, "bob/index.html"), diff --git a/tests/integration/validate_version_generate_test.ts b/tests/integration/validate_version_generate_test.ts index d3aecda..1adcdc6 100644 --- a/tests/integration/validate_version_generate_test.ts +++ b/tests/integration/validate_version_generate_test.ts @@ -5,7 +5,7 @@ import { assertRejects, assertStringIncludes, } from "@std/assert"; -import { join } from "@std/path"; +import { dirname, join } from "@std/path"; import { compareRdfContent } from "../../dependencies/github.com/spectacular-voyage/accord/src/checker/compare_rdf.ts"; import { WeaveInputError } from "../../src/core/weave/weave.ts"; import { @@ -54,6 +54,80 @@ async function writeMeshConfig( ); } +async function writeMeshConfigSource( + meshRoot: string, + relativePath: string, + turtle: string, +): Promise { + const path = join(meshRoot, relativePath); + await Deno.mkdir(dirname(path), { recursive: true }); + await Deno.writeTextFile(path, turtle); +} + +async function appendMeshConfigSourceAttachment( + meshRoot: string, + relativePath: string, +): Promise { + const metadataPath = join(meshRoot, "_mesh/_meta/meta.ttl"); + const current = await Deno.readTextFile(metadataPath); + await Deno.writeTextFile( + metadataPath, + `${current.trimEnd()} + +<_mesh> [ + a ; + ${ + JSON.stringify(relativePath) + } +] . +`, + ); +} + +async function appendKnopConfigSourceAttachment( + meshRoot: string, + designatorPath: string, + predicateLocalName: "hasConfigSource" | "hasInheritableConfigSource", + relativePath: string, +): Promise { + const knopPath = designatorPath.length === 0 + ? "_knop" + : `${designatorPath}/_knop`; + const metadataPath = join(meshRoot, `${knopPath}/_meta/meta.ttl`); + const current = await Deno.readTextFile(metadataPath); + await Deno.writeTextFile( + metadataPath, + `${current.trimEnd()} + +<${knopPath}> [ + a ; + ${ + JSON.stringify(relativePath) + } +] . +`, + ); +} + +function resourcePagePresentationConfigTurtle( + presentationIri: string, +): string { + return `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPolicyBinding <#presentation> . + +<#presentation> a sfcfg:PolicyBinding ; + sfcfg:bindsPolicy <#presentation-policy> ; + sfcfg:appliesToPolicyTarget <#any> . + +<#presentation-policy> a sfcfg:PolicyDefinition ; + sfcfg:hasResourcePagePresentationPolicy <${presentationIri}> . + +<#any> a sfcfg:AnyGovernedArtifactPolicyTarget . +`; +} + Deno.test("executeValidate returns structured findings for version-only target fields", async () => { const workspaceRoot = await createTestTmpDir("weave-validate-target-fields-"); await materializeMeshAliceBioBranch("06-alice-bio-integrated", workspaceRoot); @@ -717,6 +791,176 @@ Deno.test("executeVersion honors mesh-local ResourcePage generation policy witho ); }); +Deno.test("executeVersion honors mesh-local ResourcePage generation policy from config source", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-version-config-source-page-generation-", + ); + await materializeMeshAliceBioBranch("06-alice-bio-integrated", workspaceRoot); + await writeMeshConfigSource( + workspaceRoot, + "_mesh/_config/page-policy.ttl", + `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPolicyBinding <#suppress-payload-pages> . + +<#suppress-payload-pages> a sfcfg:PolicyBinding ; + sfcfg:bindsPolicy <#suppress-pages> ; + sfcfg:appliesToPolicyTarget <#payload> . + +<#suppress-pages> a sfcfg:PolicyDefinition ; + sfcfg:hasResourcePageGenerationPolicy sfcfg:resourcePageGenerationPolicy_suppress . + +<#payload> a sfcfg:ArtifactRolePolicyTarget ; + sfcfg:hasArtifactRole sfcfg:artifactRole_payload . +`, + ); + await writeMeshConfig( + workspaceRoot, + `@base . +@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPublicationProfile sfcfg:publicationProfile_githubPages . +`, + ); + await appendMeshConfigSourceAttachment( + workspaceRoot, + "_mesh/_config/page-policy.ttl", + ); + + const result = await executeRuntimeVersion({ + meshRoot: workspaceRoot, + request: { + targets: [{ + designatorPath: "alice/data", + historySegment: "releases", + stateSegment: "v0.0.1", + manifestationSegment: "ttl", + }], + }, + }); + + assertEquals(result.versionedDesignatorPaths, ["alice/data"]); + assert( + result.createdPaths.includes( + "alice/data/releases/v0.0.1/ttl/alice-data.ttl", + ), + ); + assertFalse(result.createdPaths.includes("alice/data/index.html")); + await assertRejects( + () => Deno.stat(join(workspaceRoot, "alice/data/index.html")), + Deno.errors.NotFound, + ); +}); + +Deno.test("executeVersion honors inherited Knop config source for a single target", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-version-knop-config-source-page-generation-", + ); + await materializeMeshAliceBioBranch("06-alice-bio-integrated", workspaceRoot); + await writeMeshConfigSource( + workspaceRoot, + "alice/_knop/_config/page-policy.ttl", + `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPolicyBinding <#suppress-payload-pages> . + +<#suppress-payload-pages> a sfcfg:PolicyBinding ; + sfcfg:bindsPolicy <#suppress-pages> ; + sfcfg:appliesToPolicyTarget <#payload> . + +<#suppress-pages> a sfcfg:PolicyDefinition ; + sfcfg:hasResourcePageGenerationPolicy sfcfg:resourcePageGenerationPolicy_suppress . + +<#payload> a sfcfg:ArtifactRolePolicyTarget ; + sfcfg:hasArtifactRole sfcfg:artifactRole_payload . +`, + ); + await appendKnopConfigSourceAttachment( + workspaceRoot, + "alice", + "hasInheritableConfigSource", + "alice/_knop/_config/page-policy.ttl", + ); + + const result = await executeRuntimeVersion({ + meshRoot: workspaceRoot, + request: { + targets: [{ + designatorPath: "alice/data", + historySegment: "releases", + stateSegment: "v0.0.1", + manifestationSegment: "ttl", + }], + }, + }); + + assertEquals(result.versionedDesignatorPaths, ["alice/data"]); + assert( + result.createdPaths.includes( + "alice/data/releases/v0.0.1/ttl/alice-data.ttl", + ), + ); + assertFalse(result.createdPaths.includes("alice/data/index.html")); + await assertRejects( + () => Deno.stat(join(workspaceRoot, "alice/data/index.html")), + Deno.errors.NotFound, + ); +}); + +Deno.test("executeVersion keeps target-scoped config from controlling mesh-owned support history", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-version-target-config-mesh-support-scope-", + ); + await materializeMeshAliceBioBranch("06-alice-bio-integrated", workspaceRoot); + await writeMeshConfigSource( + workspaceRoot, + "alice/data/_knop/_config/mesh-inventory-policy.ttl", + `@prefix sfcfg: . + +<> a sfcfg:MeshConfig ; + sfcfg:hasPolicyBinding <#mesh-inventory-versioned> . + +<#mesh-inventory-versioned> a sfcfg:PolicyBinding ; + sfcfg:bindsPolicy <#versioned> ; + sfcfg:appliesToPolicyTarget <#mesh-inventory> . + +<#versioned> a sfcfg:PolicyDefinition ; + sfcfg:hasHistoryTrackingPolicy sfcfg:historyTrackingPolicy_versioned . + +<#mesh-inventory> a sfcfg:ArtifactRolePolicyTarget ; + sfcfg:hasArtifactRole sfcfg:artifactRole_meshInventory . +`, + ); + await appendKnopConfigSourceAttachment( + workspaceRoot, + "alice/data", + "hasConfigSource", + "alice/data/_knop/_config/mesh-inventory-policy.ttl", + ); + + const result = await executeRuntimeVersion({ + meshRoot: workspaceRoot, + request: { + targets: [{ + designatorPath: "alice/data", + historySegment: "releases", + stateSegment: "v0.0.1", + manifestationSegment: "ttl", + }], + }, + }); + + assertEquals(result.versionedDesignatorPaths, ["alice/data"]); + assertFalse( + result.createdPaths.some((path) => + path.startsWith("_mesh/_inventory/_history") + ), + ); +}); + Deno.test("executeGenerate honors mesh-local ResourcePage presentation config without command flags", async () => { const workspaceRoot = await createTestTmpDir( "weave-generate-mesh-config-presentation-", @@ -759,6 +1003,63 @@ Deno.test("executeGenerate honors mesh-local ResourcePage presentation config wi ); }); +Deno.test("executeGenerate renders explicit multi-target pages with per-target presentation config", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-generate-per-target-presentation-", + ); + await materializeMeshAliceBioBranch( + "07-alice-bio-integrated-woven", + workspaceRoot, + ); + await writeMeshConfigSource( + workspaceRoot, + "alice/_knop/_config/no-panels.ttl", + resourcePagePresentationConfigTurtle( + "https://semantic-flow.github.io/weave/defaults/resource-page-presentation/semantic-site-no-panels", + ), + ); + await writeMeshConfigSource( + workspaceRoot, + "alice/data/_knop/_config/all-panels.ttl", + resourcePagePresentationConfigTurtle( + "https://semantic-flow.github.io/weave/defaults/resource-page-presentation/semantic-site-all-panels", + ), + ); + await appendKnopConfigSourceAttachment( + workspaceRoot, + "alice", + "hasConfigSource", + "alice/_knop/_config/no-panels.ttl", + ); + await appendKnopConfigSourceAttachment( + workspaceRoot, + "alice/data", + "hasConfigSource", + "alice/data/_knop/_config/all-panels.ttl", + ); + + const result = await executeGenerate({ + meshRoot: workspaceRoot, + request: { + targets: [ + { designatorPath: "alice" }, + { designatorPath: "alice/data" }, + ], + }, + now: () => new Date("2026-05-04T00:00:00.000Z"), + }); + + assertEquals(result.generatedDesignatorPaths, ["alice", "alice/data"]); + assertFalse( + (await Deno.readTextFile(join(workspaceRoot, "alice/_knop/index.html"))) + .includes("Child Identifiers"), + ); + assertStringIncludes( + await Deno.readTextFile(join(workspaceRoot, "alice/data/index.html")), + "Semantic Flow metadata", + ); +}); + Deno.test("executeWeave honors mesh-local support history policy without command flags", async () => { const workspaceRoot = await createTestTmpDir( "weave-mesh-config-support-history-", diff --git a/tests/scripts/normalize_deno_junit_test.ts b/tests/scripts/normalize_deno_junit_test.ts index e04df35..90c3468 100644 --- a/tests/scripts/normalize_deno_junit_test.ts +++ b/tests/scripts/normalize_deno_junit_test.ts @@ -5,10 +5,12 @@ Deno.test("normalizeDenoJunitXml adds Codecov-friendly suite metadata", () => { const normalized = normalizeDenoJunitXml( ` - - + + - + + + @@ -22,10 +24,14 @@ Deno.test("normalizeDenoJunitXml adds Codecov-friendly suite metadata", () => { ); assertStringIncludes( normalized, - '', + '', + ); + assertStringIncludes( + normalized, + '', ); assertStringIncludes( normalized, - '', + '', ); }); diff --git a/tests/support/fixture_snapshot.ts b/tests/support/fixture_snapshot.ts new file mode 100644 index 0000000..e07a456 --- /dev/null +++ b/tests/support/fixture_snapshot.ts @@ -0,0 +1,265 @@ +import { dirname, join } from "@std/path"; + +const snapshotCacheRoot = "/tmp/semantic-flow-fixture-snapshots"; +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +export interface ResolvedFixtureRef { + readonly requestedRef: string; + readonly gitRef: string; + readonly commitSha: string; +} + +export interface FixtureSnapshotCacheOptions { + readonly label: string; + readonly repoPath: string; + readonly candidatesForRef: (ref: string) => readonly string[]; +} + +interface FixtureSnapshot { + readonly root: string; + readonly paths: readonly string[]; +} + +export class FixtureSnapshotCache { + readonly #label: string; + readonly #repoPath: string; + readonly #candidatesForRef: (ref: string) => readonly string[]; + readonly #resolvedRefs = new Map>(); + readonly #snapshots = new Map>(); + + constructor(options: FixtureSnapshotCacheOptions) { + this.#label = options.label; + this.#repoPath = options.repoPath; + this.#candidatesForRef = options.candidatesForRef; + } + + async resolveRef(ref: string): Promise { + const cached = this.#resolvedRefs.get(ref); + if (cached) { + return await cached; + } + + const pending = this.#resolveRefUncached(ref); + this.#resolvedRefs.set(ref, pending); + + try { + return await pending; + } catch (error) { + this.#resolvedRefs.delete(ref); + throw error; + } + } + + async resolveCommit(ref: string): Promise { + return (await this.resolveRef(ref)).commitSha; + } + + async readTextFile(ref: string, path: string): Promise { + const snapshot = await this.#snapshotForRef(ref); + return await Deno.readTextFile(join(snapshot.root, path)); + } + + async listFiles(ref: string): Promise { + return [...(await this.#snapshotForRef(ref)).paths]; + } + + async materialize(ref: string, targetDir: string): Promise { + const snapshot = await this.#snapshotForRef(ref); + + for (const path of snapshot.paths) { + const sourcePath = join(snapshot.root, path); + const targetPath = join(targetDir, path); + await Deno.mkdir(dirname(targetPath), { recursive: true }); + await Deno.copyFile(sourcePath, targetPath); + } + + return [...snapshot.paths]; + } + + async #resolveRefUncached(ref: string): Promise { + const candidates = this.#candidatesForRef(ref); + for (const candidate of candidates) { + const output = await new Deno.Command("git", { + args: [ + "-C", + this.#repoPath, + "rev-parse", + "--verify", + "--quiet", + `${candidate}^{commit}`, + ], + stdout: "piped", + stderr: "null", + }).output(); + + if (output.success) { + return { + requestedRef: ref, + gitRef: candidate, + commitSha: textDecoder.decode(output.stdout).trim(), + }; + } + } + + throw new Error( + `Failed to resolve fixture ref ${ref} in ${this.#repoPath}; checked ${ + candidates.join(", ") + }.`, + ); + } + + async #snapshotForRef(ref: string): Promise { + const resolved = await this.resolveRef(ref); + const cached = this.#snapshots.get(resolved.commitSha); + if (cached) { + return await cached; + } + + const pending = this.#buildSnapshot(resolved); + this.#snapshots.set(resolved.commitSha, pending); + + try { + return await pending; + } catch (error) { + this.#snapshots.delete(resolved.commitSha); + throw error; + } + } + + async #buildSnapshot( + resolved: ResolvedFixtureRef, + ): Promise { + const root = join( + snapshotCacheRoot, + safePathSegment(this.#label), + resolved.commitSha, + ); + const pathsFile = join(root, ".fixture-snapshot-paths.json"); + const completeFile = join(root, ".fixture-snapshot-complete"); + + if (await isFile(completeFile) && await isFile(pathsFile)) { + return { + root, + paths: JSON.parse(await Deno.readTextFile(pathsFile)) as string[], + }; + } + + await Deno.remove(root, { recursive: true }).catch((error) => { + if (!(error instanceof Deno.errors.NotFound)) { + throw error; + } + }); + + await Deno.mkdir(dirname(root), { recursive: true }); + const buildRoot = await Deno.makeTempDir({ + dir: dirname(root), + prefix: `${resolved.commitSha}.build-`, + }); + + try { + const paths = await this.#listGitTree(resolved); + for (const path of paths) { + const targetPath = join(buildRoot, path); + await Deno.mkdir(dirname(targetPath), { recursive: true }); + await Deno.writeFile( + targetPath, + await this.#readGitBlob(resolved, path), + ); + } + + await Deno.writeTextFile( + join(buildRoot, ".fixture-snapshot-paths.json"), + `${JSON.stringify(paths, null, 2)}\n`, + ); + await Deno.writeFile( + join(buildRoot, ".fixture-snapshot-complete"), + textEncoder.encode("complete\n"), + ); + + try { + await Deno.rename(buildRoot, root); + } catch (error) { + if (!(error instanceof Deno.errors.AlreadyExists)) { + throw error; + } + await Deno.remove(buildRoot, { recursive: true }); + } + + return { + root, + paths: JSON.parse(await Deno.readTextFile(pathsFile)) as string[], + }; + } catch (error) { + await Deno.remove(buildRoot, { recursive: true }).catch( + (removeError) => { + if (!(removeError instanceof Deno.errors.NotFound)) { + throw removeError; + } + }, + ); + throw error; + } + } + + async #listGitTree(resolved: ResolvedFixtureRef): Promise { + const output = await new Deno.Command("git", { + args: [ + "-C", + this.#repoPath, + "ls-tree", + "-r", + "--name-only", + resolved.commitSha, + ], + stdout: "piped", + stderr: "piped", + }).output(); + + if (!output.success) { + const message = textDecoder.decode(output.stderr).trim(); + throw new Error( + `Failed to list fixture files for ${resolved.requestedRef}: ${message}`, + ); + } + + return textDecoder.decode(output.stdout).split("\n").filter((path) => + path.length > 0 + ); + } + + async #readGitBlob( + resolved: ResolvedFixtureRef, + path: string, + ): Promise { + const output = await new Deno.Command("git", { + args: ["-C", this.#repoPath, "show", `${resolved.commitSha}:${path}`], + stdout: "piped", + stderr: "piped", + }).output(); + + if (!output.success) { + const message = textDecoder.decode(output.stderr).trim(); + throw new Error( + `Failed to read fixture file ${resolved.requestedRef}:${path}: ${message}`, + ); + } + + return output.stdout; + } +} + +async function isFile(path: string): Promise { + try { + return (await Deno.stat(path)).isFile; + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return false; + } + throw error; + } +} + +function safePathSegment(value: string): string { + return value.replaceAll(/[^A-Za-z0-9._-]/g, "-"); +} diff --git a/tests/support/fixture_snapshot_test.ts b/tests/support/fixture_snapshot_test.ts new file mode 100644 index 0000000..e36f77f --- /dev/null +++ b/tests/support/fixture_snapshot_test.ts @@ -0,0 +1,115 @@ +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { FixtureSnapshotCache } from "./fixture_snapshot.ts"; +import { createTestTmpDir } from "./test_tmp.ts"; + +Deno.test("FixtureSnapshotCache materializes a resolved Git ref once and reuses the snapshot", async () => { + const repoRoot = await createTestTmpDir("weave-fixture-snapshot-repo-"); + await git(repoRoot, "init", "--initial-branch=main"); + await Deno.mkdir(join(repoRoot, "nested"), { recursive: true }); + await Deno.writeTextFile(join(repoRoot, "alpha.txt"), "alpha\n"); + await Deno.writeTextFile(join(repoRoot, "nested/beta.txt"), "beta\n"); + await git(repoRoot, "add", "."); + await git( + repoRoot, + "-c", + "user.name=Weave Test", + "-c", + "user.email=weave@example.test", + "commit", + "-m", + "fixture", + ); + + const cache = new FixtureSnapshotCache({ + label: `fixture-snapshot-test-${crypto.randomUUID()}`, + repoPath: repoRoot, + candidatesForRef: (ref) => [ref], + }); + + const firstTarget = await createTestTmpDir("weave-fixture-snapshot-first-"); + assertEquals(await cache.materialize("main", firstTarget), [ + "alpha.txt", + "nested/beta.txt", + ]); + assertEquals( + await Deno.readTextFile(join(firstTarget, "alpha.txt")), + "alpha\n", + ); + assertEquals( + await cache.readTextFile("main", "nested/beta.txt"), + "beta\n", + ); + + await Deno.remove(repoRoot, { recursive: true }); + + const secondTarget = await createTestTmpDir("weave-fixture-snapshot-second-"); + assertEquals(await cache.materialize("main", secondTarget), [ + "alpha.txt", + "nested/beta.txt", + ]); + assertEquals( + await Deno.readTextFile(join(secondTarget, "nested/beta.txt")), + "beta\n", + ); +}); + +Deno.test("FixtureSnapshotCache reads cached refs from the resolved commit", async () => { + const repoRoot = await createTestTmpDir("weave-fixture-snapshot-ref-move-"); + await git(repoRoot, "init", "--initial-branch=main"); + await Deno.writeTextFile(join(repoRoot, "alpha.txt"), "alpha\n"); + await git(repoRoot, "add", "."); + await git( + repoRoot, + "-c", + "user.name=Weave Test", + "-c", + "user.email=weave@example.test", + "commit", + "-m", + "first fixture", + ); + + const cache = new FixtureSnapshotCache({ + label: `fixture-snapshot-ref-move-${crypto.randomUUID()}`, + repoPath: repoRoot, + candidatesForRef: (ref) => [ref], + }); + await cache.resolveCommit("main"); + + await Deno.writeTextFile(join(repoRoot, "alpha.txt"), "beta\n"); + await git(repoRoot, "add", "."); + await git( + repoRoot, + "-c", + "user.name=Weave Test", + "-c", + "user.email=weave@example.test", + "commit", + "-m", + "second fixture", + ); + + const target = await createTestTmpDir("weave-fixture-snapshot-ref-target-"); + await cache.materialize("main", target); + assertEquals(await Deno.readTextFile(join(target, "alpha.txt")), "alpha\n"); +}); + +async function git(cwd: string, ...args: string[]): Promise { + const output = await new Deno.Command("git", { + args, + cwd, + stdout: "piped", + stderr: "piped", + }).output(); + + if (!output.success) { + throw new Error( + [ + `git ${args.join(" ")} failed`, + new TextDecoder().decode(output.stdout).trim(), + new TextDecoder().decode(output.stderr).trim(), + ].filter(Boolean).join("\n"), + ); + } +} diff --git a/tests/support/mesh_alice_bio_fixture.ts b/tests/support/mesh_alice_bio_fixture.ts index 27b705e..3c2122d 100644 --- a/tests/support/mesh_alice_bio_fixture.ts +++ b/tests/support/mesh_alice_bio_fixture.ts @@ -1,4 +1,5 @@ -import { dirname, fromFileUrl, join } from "@std/path"; +import { fromFileUrl, join } from "@std/path"; +import { FixtureSnapshotCache } from "./fixture_snapshot.ts"; const repoRootPath = fromFileUrl(new URL("../../", import.meta.url)); const fixtureRepoPath = join( @@ -15,7 +16,11 @@ const frameworkRepoPath = join( "semantic-flow", "semantic-flow-framework", ); -const resolvedRefCache = new Map>(); +const fixtureSnapshots = new FixtureSnapshotCache({ + label: "mesh-alice-bio", + repoPath: fixtureRepoPath, + candidatesForRef: meshAliceBioRefCandidates, +}); // Temporary Alice fixture-ladder settings until the replay prefix and policy // move into an Accord/scenario master manifest. @@ -54,113 +59,32 @@ export function resolveMeshAliceBioConformanceManifestPath( ); } -async function resolveMeshAliceBioGitRef(ref: string): Promise { - const cached = resolvedRefCache.get(ref); - if (cached) { - return await cached; - } - - const pending = resolveMeshAliceBioGitRefUncached(ref); - resolvedRefCache.set(ref, pending); - - try { - return await pending; - } catch (error) { - resolvedRefCache.delete(ref); - throw error; - } -} - -async function resolveMeshAliceBioGitRefUncached( - ref: string, -): Promise { +function meshAliceBioRefCandidates(ref: string): string[] { const hasExplicitLadderPrefix = /^[a-z]\./.test(ref); const prefixedRef = hasExplicitLadderPrefix ? ref : `${MESH_ALICE_BIO_LADDER_BRANCH_PREFIX}${ref}`; - const candidates = hasExplicitLadderPrefix + return hasExplicitLadderPrefix ? [ref, `origin/${ref}`] : [prefixedRef, `origin/${prefixedRef}`]; - - for (const candidate of candidates) { - const command = new Deno.Command("git", { - args: [ - "-C", - fixtureRepoPath, - "rev-parse", - "--verify", - "--quiet", - `${candidate}^{commit}`, - ], - stdout: "null", - stderr: "null", - }); - const output = await command.output(); - if (output.success) { - return candidate; - } - } - - throw new Error( - `Failed to resolve fixture ref ${ref} in ${fixtureRepoPath}; checked ${ - candidates.join(", ") - }.`, - ); } export async function readMeshAliceBioBranchFile( ref: string, path: string, ): Promise { - const resolvedRef = await resolveMeshAliceBioGitRef(ref); - const command = new Deno.Command("git", { - args: ["-C", fixtureRepoPath, "show", `${resolvedRef}:${path}`], - stdout: "piped", - stderr: "piped", - }); - const output = await command.output(); - if (!output.success) { - const message = new TextDecoder().decode(output.stderr).trim(); - throw new Error(`Failed to read fixture file ${ref}:${path}: ${message}`); - } - return new TextDecoder().decode(output.stdout); + return await fixtureSnapshots.readTextFile(ref, path); } export async function listMeshAliceBioBranchFiles( ref: string, ): Promise { - const resolvedRef = await resolveMeshAliceBioGitRef(ref); - const command = new Deno.Command("git", { - args: ["-C", fixtureRepoPath, "ls-tree", "-r", "--name-only", resolvedRef], - stdout: "piped", - stderr: "piped", - }); - const output = await command.output(); - if (!output.success) { - const message = new TextDecoder().decode(output.stderr).trim(); - throw new Error(`Failed to list fixture files for ${ref}: ${message}`); - } - - return new TextDecoder() - .decode(output.stdout) - .split("\n") - .filter((path) => path.length > 0); + return await fixtureSnapshots.listFiles(ref); } export async function materializeMeshAliceBioBranch( ref: string, targetDir: string, ): Promise { - const paths = await listMeshAliceBioBranchFiles(ref); - - for (const path of paths) { - const absolutePath = join(targetDir, path); - await Deno.mkdir(dirname(absolutePath), { recursive: true }); - await Deno.writeTextFile( - absolutePath, - await readMeshAliceBioBranchFile(ref, path), - ); - } - - return paths; + return await fixtureSnapshots.materialize(ref, targetDir); } diff --git a/tests/support/mesh_branch_fantasy_rules_fixture.ts b/tests/support/mesh_branch_fantasy_rules_fixture.ts index 3cc5e5f..640a47b 100644 --- a/tests/support/mesh_branch_fantasy_rules_fixture.ts +++ b/tests/support/mesh_branch_fantasy_rules_fixture.ts @@ -1,4 +1,5 @@ -import { dirname, fromFileUrl, join } from "@std/path"; +import { fromFileUrl, join } from "@std/path"; +import { FixtureSnapshotCache } from "./fixture_snapshot.ts"; const repoRootPath = fromFileUrl(new URL("../../", import.meta.url)); const fixtureRepoPath = join( @@ -15,7 +16,11 @@ const frameworkRepoPath = join( "semantic-flow", "semantic-flow-framework", ); -const resolvedRefCache = new Map>(); +const fixtureSnapshots = new FixtureSnapshotCache({ + label: "mesh-branch-fantasy-rules", + repoPath: fixtureRepoPath, + candidatesForRef: meshBranchFantasyRulesRefCandidates, +}); // Temporary Branch Fantasy Rules fixture-ladder setting until the replay // prefix moves into an Accord/scenario master manifest. @@ -45,137 +50,43 @@ export function resolveMeshBranchFantasyRulesConformanceManifestPath( ); } -async function resolveMeshBranchFantasyRulesGitRef( - ref: string, -): Promise { - const cached = resolvedRefCache.get(ref); - if (cached) { - return await cached; - } - - const pending = resolveMeshBranchFantasyRulesGitRefUncached(ref); - resolvedRefCache.set(ref, pending); - - try { - return await pending; - } catch (error) { - resolvedRefCache.delete(ref); - throw error; - } -} - -async function resolveMeshBranchFantasyRulesGitRefUncached( - ref: string, -): Promise { +function meshBranchFantasyRulesRefCandidates(ref: string): string[] { const prefixedRef = ref.startsWith( MESH_BRANCH_FANTASY_RULES_LADDER_BRANCH_PREFIX, ) ? ref : `${MESH_BRANCH_FANTASY_RULES_LADDER_BRANCH_PREFIX}${ref}`; - const candidates = ref.startsWith( + return ref.startsWith( MESH_BRANCH_FANTASY_RULES_LADDER_BRANCH_PREFIX, ) ? [ref, `origin/${ref}`] : [prefixedRef, ref, `origin/${prefixedRef}`, `origin/${ref}`]; - - for (const candidate of candidates) { - const command = new Deno.Command("git", { - args: [ - "-C", - fixtureRepoPath, - "rev-parse", - "--verify", - "--quiet", - `${candidate}^{commit}`, - ], - stdout: "null", - stderr: "null", - }); - const output = await command.output(); - if (output.success) { - return candidate; - } - } - - throw new Error( - `Failed to resolve fixture ref ${ref} in ${fixtureRepoPath}; checked ${ - candidates.join(", ") - }.`, - ); } export async function resolveMeshBranchFantasyRulesCommit( ref: string, ): Promise { - const resolvedRef = await resolveMeshBranchFantasyRulesGitRef(ref); - const command = new Deno.Command("git", { - args: ["-C", fixtureRepoPath, "rev-parse", `${resolvedRef}^{commit}`], - stdout: "piped", - stderr: "piped", - }); - const output = await command.output(); - if (!output.success) { - const message = new TextDecoder().decode(output.stderr).trim(); - throw new Error(`Failed to resolve fixture commit ${ref}: ${message}`); - } - return new TextDecoder().decode(output.stdout).trim(); + return await fixtureSnapshots.resolveCommit(ref); } export async function readMeshBranchFantasyRulesBranchFile( ref: string, path: string, ): Promise { - const resolvedRef = await resolveMeshBranchFantasyRulesGitRef(ref); - const command = new Deno.Command("git", { - args: ["-C", fixtureRepoPath, "show", `${resolvedRef}:${path}`], - stdout: "piped", - stderr: "piped", - }); - const output = await command.output(); - if (!output.success) { - const message = new TextDecoder().decode(output.stderr).trim(); - throw new Error(`Failed to read fixture file ${ref}:${path}: ${message}`); - } - return new TextDecoder().decode(output.stdout); + return await fixtureSnapshots.readTextFile(ref, path); } export async function listMeshBranchFantasyRulesBranchFiles( ref: string, ): Promise { - const resolvedRef = await resolveMeshBranchFantasyRulesGitRef(ref); - const command = new Deno.Command("git", { - args: ["-C", fixtureRepoPath, "ls-tree", "-r", "--name-only", resolvedRef], - stdout: "piped", - stderr: "piped", - }); - const output = await command.output(); - if (!output.success) { - const message = new TextDecoder().decode(output.stderr).trim(); - throw new Error(`Failed to list fixture files for ${ref}: ${message}`); - } - - return new TextDecoder() - .decode(output.stdout) - .split("\n") - .filter((path) => path.length > 0); + return await fixtureSnapshots.listFiles(ref); } export async function materializeMeshBranchFantasyRulesBranch( ref: string, targetDir: string, ): Promise { - const paths = await listMeshBranchFantasyRulesBranchFiles(ref); - - for (const path of paths) { - const absolutePath = join(targetDir, path); - await Deno.mkdir(dirname(absolutePath), { recursive: true }); - await Deno.writeTextFile( - absolutePath, - await readMeshBranchFantasyRulesBranchFile(ref, path), - ); - } - - return paths; + return await fixtureSnapshots.materialize(ref, targetDir); } export async function materializeMeshBranchFantasyRulesPublicationWorkspace( diff --git a/tests/support/mesh_sidecar_fantasy_rules_fixture.ts b/tests/support/mesh_sidecar_fantasy_rules_fixture.ts index 6978975..ace9145 100644 --- a/tests/support/mesh_sidecar_fantasy_rules_fixture.ts +++ b/tests/support/mesh_sidecar_fantasy_rules_fixture.ts @@ -1,4 +1,5 @@ -import { dirname, fromFileUrl, join } from "@std/path"; +import { fromFileUrl, join } from "@std/path"; +import { FixtureSnapshotCache } from "./fixture_snapshot.ts"; const repoRootPath = fromFileUrl(new URL("../../", import.meta.url)); const fixtureRepoPath = join( @@ -15,7 +16,11 @@ const frameworkRepoPath = join( "semantic-flow", "semantic-flow-framework", ); -const resolvedRefCache = new Map>(); +const fixtureSnapshots = new FixtureSnapshotCache({ + label: "mesh-sidecar-fantasy-rules", + repoPath: fixtureRepoPath, + candidatesForRef: meshSidecarFantasyRulesRefCandidates, +}); // Temporary Sidecar Fantasy Rules fixture-ladder setting until the replay // prefix moves into an Accord/scenario master manifest. @@ -39,118 +44,35 @@ export function resolveMeshSidecarFantasyRulesConformanceManifestPath( ); } -async function resolveMeshSidecarFantasyRulesGitRef( - ref: string, -): Promise { - const cached = resolvedRefCache.get(ref); - if (cached) { - return await cached; - } - - const pending = resolveMeshSidecarFantasyRulesGitRefUncached(ref); - resolvedRefCache.set(ref, pending); - - try { - return await pending; - } catch (error) { - resolvedRefCache.delete(ref); - throw error; - } -} - -async function resolveMeshSidecarFantasyRulesGitRefUncached( - ref: string, -): Promise { +function meshSidecarFantasyRulesRefCandidates(ref: string): string[] { const prefixedRef = ref.startsWith( MESH_SIDECAR_FANTASY_RULES_LADDER_BRANCH_PREFIX, ) ? ref : `${MESH_SIDECAR_FANTASY_RULES_LADDER_BRANCH_PREFIX}${ref}`; - const candidates = ref.startsWith( + return ref.startsWith( MESH_SIDECAR_FANTASY_RULES_LADDER_BRANCH_PREFIX, ) ? [ref, `origin/${ref}`] : [prefixedRef, ref, `origin/${prefixedRef}`, `origin/${ref}`]; - - for (const candidate of candidates) { - const command = new Deno.Command("git", { - args: [ - "-C", - fixtureRepoPath, - "rev-parse", - "--verify", - "--quiet", - `${candidate}^{commit}`, - ], - stdout: "null", - stderr: "null", - }); - const output = await command.output(); - if (output.success) { - return candidate; - } - } - - throw new Error( - `Failed to resolve fixture ref ${ref} in ${fixtureRepoPath}; checked ${ - candidates.join(", ") - }.`, - ); } export async function readMeshSidecarFantasyRulesBranchFile( ref: string, path: string, ): Promise { - const resolvedRef = await resolveMeshSidecarFantasyRulesGitRef(ref); - const command = new Deno.Command("git", { - args: ["-C", fixtureRepoPath, "show", `${resolvedRef}:${path}`], - stdout: "piped", - stderr: "piped", - }); - const output = await command.output(); - if (!output.success) { - const message = new TextDecoder().decode(output.stderr).trim(); - throw new Error(`Failed to read fixture file ${ref}:${path}: ${message}`); - } - return new TextDecoder().decode(output.stdout); + return await fixtureSnapshots.readTextFile(ref, path); } export async function listMeshSidecarFantasyRulesBranchFiles( ref: string, ): Promise { - const resolvedRef = await resolveMeshSidecarFantasyRulesGitRef(ref); - const command = new Deno.Command("git", { - args: ["-C", fixtureRepoPath, "ls-tree", "-r", "--name-only", resolvedRef], - stdout: "piped", - stderr: "piped", - }); - const output = await command.output(); - if (!output.success) { - const message = new TextDecoder().decode(output.stderr).trim(); - throw new Error(`Failed to list fixture files for ${ref}: ${message}`); - } - - return new TextDecoder() - .decode(output.stdout) - .split("\n") - .filter((path) => path.length > 0); + return await fixtureSnapshots.listFiles(ref); } export async function materializeMeshSidecarFantasyRulesBranch( ref: string, targetDir: string, ): Promise { - const paths = await listMeshSidecarFantasyRulesBranchFiles(ref); - - for (const path of paths) { - const absolutePath = join(targetDir, path); - await Deno.mkdir(dirname(absolutePath), { recursive: true }); - await Deno.writeTextFile( - absolutePath, - await readMeshSidecarFantasyRulesBranchFile(ref, path), - ); - } - - return paths; + return await fixtureSnapshots.materialize(ref, targetDir); } diff --git a/tests/support/test_tmp.ts b/tests/support/test_tmp.ts index 40e2363..ba797e8 100644 --- a/tests/support/test_tmp.ts +++ b/tests/support/test_tmp.ts @@ -9,8 +9,6 @@ const isolatedEnvVars = [ "XDG_CACHE_HOME", ] as const; -type TestFn = (t: Deno.TestContext) => void | Promise; - interface TestTmpScope { readonly paths: string[]; } @@ -19,16 +17,8 @@ interface IsolatedTestEnv { restore(): void; } -interface DenoTestDefinitionLike { - readonly fn: TestFn; - readonly [key: string]: unknown; -} - -interface MutableDenoTest { - test: typeof Deno.test; -} - let activeScope: TestTmpScope | undefined; +let activeIsolatedEnv: IsolatedTestEnv | undefined; let cleanupInstalled = false; export async function createTestTmpDir(prefix: string): Promise { @@ -43,81 +33,47 @@ export function installTestTmpCleanup(): void { } cleanupInstalled = true; - const originalTest = Deno.test; - const callOriginalTest = originalTest as unknown as ( - ...args: unknown[] - ) => void; - - const mutableDeno = Deno as unknown as MutableDenoTest; - mutableDeno.test = ((...args: unknown[]) => { - if (isTestFn(args[0])) { - callOriginalTest(wrapTestFn(args[0])); - return; - } - if (typeof args[0] === "string" && isTestFn(args[1])) { - callOriginalTest(args[0], wrapTestFn(args[1])); - return; - } - if (typeof args[0] === "string" && isTestFn(args[2])) { - callOriginalTest(args[0], args[1], wrapTestFn(args[2])); - return; - } - if (isDenoTestDefinitionLike(args[0])) { - callOriginalTest({ - ...args[0], - fn: wrapTestFn(args[0].fn), - }); - return; - } - - callOriginalTest(...args); - }) as typeof Deno.test; -} - -function wrapTestFn(fn: TestFn): TestFn { - return async (t) => { - const previousScope = activeScope; + Deno.test.beforeEach(async () => { const scope: TestTmpScope = { paths: [] }; activeScope = scope; + activeIsolatedEnv = await installIsolatedTestEnv(); + }); + + Deno.test.afterEach(async () => { + const scope = activeScope; + const isolatedEnv = activeIsolatedEnv; + activeScope = undefined; + activeIsolatedEnv = undefined; - let isolatedEnv: IsolatedTestEnv | undefined; - let testError: unknown; + let restoreError: unknown; try { - isolatedEnv = await installIsolatedTestEnv(); - await fn(t); + isolatedEnv?.restore(); } catch (error) { - testError = error; - } finally { - try { - isolatedEnv?.restore(); - } catch (error) { - testError ??= error; - } - activeScope = previousScope; + restoreError = error; } let cleanupError: unknown; if (!shouldKeepTestTmp()) { try { - await cleanupTestTmpPaths(scope.paths); + await cleanupTestTmpPaths(scope?.paths ?? []); } catch (error) { cleanupError = error; } } - if (testError !== undefined && cleanupError !== undefined) { + if (restoreError !== undefined && cleanupError !== undefined) { throw new AggregateError( - [testError, cleanupError], - "Test failed and temporary directory cleanup also failed.", + [restoreError, cleanupError], + "Test environment restore and temporary directory cleanup both failed.", ); } - if (testError !== undefined) { - throw testError; + if (restoreError !== undefined) { + throw restoreError; } if (cleanupError !== undefined) { throw cleanupError; } - }; + }); } async function installIsolatedTestEnv(): Promise { @@ -186,18 +142,6 @@ function shouldKeepTestTmp(): boolean { return value === "1" || value === "true"; } -function isDenoTestDefinitionLike( - value: unknown, -): value is DenoTestDefinitionLike { - return value !== null && - typeof value === "object" && - typeof (value as { readonly fn?: unknown }).fn === "function"; -} - -function isTestFn(value: unknown): value is TestFn { - return typeof value === "function"; -} - // Install on import so direct `deno test ` and IDE test runners clean up // tests that use `createTestTmpDir`, even when they bypass the task preload. installTestTmpCleanup(); diff --git a/tests/support/test_tmp_test.ts b/tests/support/test_tmp_test.ts index a425bcf..3ac21ce 100644 --- a/tests/support/test_tmp_test.ts +++ b/tests/support/test_tmp_test.ts @@ -1,8 +1,11 @@ -import { assertFalse } from "@std/assert"; +import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; import { fromFileUrl, isAbsolute, relative } from "@std/path"; import { createTestTmpDir } from "./test_tmp.ts"; const repoRoot = fromFileUrl(new URL("../../", import.meta.url)); +const harnessPath = fromFileUrl( + new URL("./test_tmp_harness.ts", import.meta.url), +); Deno.test("createTestTmpDir allocates outside the repository checkout", async () => { const path = await createTestTmpDir("weave-test-tmp-outside-repo-"); @@ -13,6 +16,44 @@ Deno.test("createTestTmpDir allocates outside the repository checkout", async () ); }); +Deno.test("test tmp preload preserves original JUnit test locations", async () => { + const root = await createTestTmpDir("weave-test-tmp-junit-"); + const testPath = `${root}/location_test.ts`; + const junitPath = `${root}/junit.xml`; + await Deno.writeTextFile( + testPath, + 'Deno.test("location is the source file", () => {});\n', + ); + + const output = await new Deno.Command(Deno.execPath(), { + args: [ + "test", + `--preload=${harnessPath}`, + `--junit-path=${junitPath}`, + "--allow-env", + "--allow-read", + "--allow-write", + testPath, + ], + stdout: "null", + stderr: "piped", + }).output(); + + assertEquals( + output.code, + 0, + new TextDecoder().decode(output.stderr), + ); + + const junit = await Deno.readTextFile(junitPath); + assertStringIncludes(junit, 'name="location is the source file"'); + assertStringIncludes(junit, "location_test.ts"); + assertFalse( + junit.includes('classname="./tests/support/test_tmp.ts"'), + "JUnit should not attribute wrapped tests to the preload helper", + ); +}); + function isPathWithin(parent: string, child: string): boolean { const relativePath = relative(parent, child); return relativePath === "" ||