From 1841a17784f8af213f8ad1da5cb6c1db6e2fb930 Mon Sep 17 00:00:00 2001 From: Vuong Nguyen Date: Tue, 25 Aug 2026 19:10:16 -0400 Subject: [PATCH] release: v1.4.1, the hardening line (release-path gates, CI matrix, viewer tones, UI smoke, seven fixes) Signed-off-by: Vuong Nguyen --- .github/workflows/ci.yml | 81 +- .github/workflows/dependency-audit.yml | 94 +++ .github/workflows/release-finalize.yml | 2 +- .github/workflows/release.yml | 32 +- .gitignore | 5 + .husky/pre-commit | 10 +- .husky/pre-push | 95 ++- CHANGELOG.md | 119 +++ CONTRIBUTING.md | 6 +- README.md | 19 +- RELEASING.md | 39 +- SECURITY.md | 6 + adoption/rendering.md | 13 +- docs/boot-profile.md | 2 +- docs/context-changelog.json | 29 + docs/context-index.json | 18 +- .../0008-dependency-pinning-and-refresh.md | 114 +++ docs/overview.md | 17 +- docs/practice/trust-boundary.md | 41 +- fixtures/README.md | 112 ++- .../bad/1-latest/.github/workflows/latest.yml | 12 + .../.github/workflows/dynamic.yml | 12 + .../2-expanded-url/scripts/expanded-url.sh | 7 + .../scripts/glob-install.sh | 7 + .../bad/2-lib-script/scripts/lib/bare-pip.sh | 7 + .../.github/workflows/multi-command.yml | 12 + .../.github/workflows/requirements.yml | 12 + .../scripts/unmarked-whl.sh | 8 + .../.github/workflows/expanded-tgz.yml | 12 + .../bad/3-marked-var-tail/scripts/var-tail.sh | 8 + .../.github/workflows/npm-global.yml | 11 + .../scripts/unmarked-local.sh | 7 + .../.github/workflows/unpinned-uses.yml | 10 + .../.github/workflows/go-version.yml | 13 + .../packages/sdk-py/pyproject.toml | 18 + .../.github/workflows/publish.yml | 14 + .../scripts/lib/twine-literal.sh | 7 + .../.github/workflows/expanded-npx.yml | 12 + .../8-npx-unpinned/.github/workflows/npx.yml | 12 + .../.github/workflows/action-version.yml | 14 + .../pins/good/.github/workflows/pinned.yml | 38 + .../pins/good/packages/sdk-py/pyproject.toml | 22 + .../pins/good/scripts/lib/pinned-lib.sh | 6 + .../release-path/pins/good/scripts/pinned.sh | 13 + fixtures/snapshot-contract/_git-seed/HEAD | 1 + fixtures/snapshot-contract/golden-repo.txt | 9 + fixtures/snapshot-contract/golden-subtree.txt | 3 + fixtures/snapshot-contract/leji-test.json | 30 + fixtures/snapshot-contract/payload/a.txt | 1 + fixtures/snapshot-contract/payload/pkg/b.txt | 1 + .../payload/\357\275\232.txt" | 1 + .../payload/\360\237\230\200.txt" | 1 + fixtures/snapshot-contract/pkg-git-seed/HEAD | 1 + .../.leji-seed/.gitignore | 2 + .../docs/_sidebar.md | 5 + .../docs/boot-profile.md | 17 + .../docs/context/decisions.md | 5 + .../docs/context/domain.md | 5 + .../docs/decisions/0001-adopt-leji.md | 20 + .../docs/domain/overview.md | 3 + .../docs/index.html | 30 + .../valid-leji-ignore-existing/expected.json | 26 + fixtures/valid-leji-ignore-existing/leji.json | 23 + .../valid-leji-ignore-fresh/docs/_sidebar.md | 5 + .../docs/boot-profile.md | 17 + .../docs/context/decisions.md | 5 + .../docs/context/domain.md | 5 + .../docs/decisions/0001-adopt-leji.md | 20 + .../docs/domain/overview.md | 3 + .../valid-leji-ignore-fresh/docs/index.html | 30 + .../valid-leji-ignore-fresh/expected.json | 60 ++ fixtures/valid-leji-ignore-fresh/leji.json | 23 + .../docs/.leji-seed/viewer/_sidebar.md | 1 + .../docs/.leji-seed/viewer/index.html | 2 + .../valid-leji-ignore-legacy/docs/_sidebar.md | 5 + .../docs/boot-profile.md | 17 + .../docs/context/decisions.md | 5 + .../docs/context/domain.md | 5 + .../docs/decisions/0001-adopt-leji.md | 20 + .../docs/domain/overview.md | 3 + .../valid-leji-ignore-legacy/docs/index.html | 30 + .../valid-leji-ignore-legacy/expected.json | 26 + fixtures/valid-leji-ignore-legacy/leji.json | 23 + .../expected-export.manifest.json | 8 +- .../expected-export/content/overview.md | 4 +- .../expected-export.manifest.json | 8 +- .../expected-export/content/overview.md | 4 +- .../.expected-export.manifest.json | 8 +- .../.expected-export/content/overview.md | 4 +- .../expected-export.manifest.json | 8 +- .../expected-export/content/overview.md | 4 +- .../expected-export.manifest.json | 8 +- .../expected-export/content/overview.md | 4 +- .../expected-export.manifest.json | 8 +- .../expected-export/content/overview.md | 4 +- package-lock.json | 133 +++- package.json | 8 +- packages/create-leji/package.json | 4 +- packages/e2e/README.md | 69 ++ packages/e2e/assertions.ts | 229 ++++++ packages/e2e/package.json | 16 + packages/e2e/playwright.config.ts | 26 + packages/e2e/run.mjs | 432 ++++++++++ packages/e2e/specs/export.spec.ts | 69 ++ packages/e2e/specs/site.spec.ts | 90 +++ packages/e2e/specs/viewer.spec.ts | 66 ++ packages/e2e/static-server.mjs | 113 +++ packages/e2e/tsconfig.json | 13 + packages/mcp/assets/assets-manifest.json | 2 +- .../schemas/context-manifest.schema.json | 2 +- packages/mcp/package.json | 4 +- packages/sdk-go/README.md | 2 +- packages/sdk-go/go.mod | 5 +- packages/sdk-go/go.sum | 4 +- .../internal/assets/assets-manifest.json | 6 +- .../schemas/context-manifest.schema.json | 2 +- .../assets/templates/viewer/assets/vue.css | 149 ++-- .../assets/templates/viewer/index.html | 29 +- packages/sdk-go/internal/cli/cli.go | 90 ++- .../sdk-go/internal/cli/updatepin_test.go | 500 ++++++++++++ .../commands/conformance/conformance.go | 10 +- .../sdk-go/internal/commands/export/export.go | 43 +- .../internal/commands/export/overview_test.go | 192 +++++ .../sdk-go/internal/commands/init/guard.go | 16 +- .../sdk-go/internal/commands/init/init.go | 36 + .../internal/commands/serve/overview_test.go | 378 +++++++++ .../sdk-go/internal/commands/serve/serve.go | 225 +++++- .../internal/commands/serve/serve_test.go | 2 +- .../internal/commands/updatepin/updatepin.go | 88 +- .../sdk-go/internal/commands/viewer/viewer.go | 212 ++++- .../commands/viewer/viewer_more_test.go | 168 +++- .../internal/conformancetest/badge_test.go | 24 +- .../internal/conformancetest/canary_test.go | 96 +-- .../conformancetest/lejiignore_test.go | 500 ++++++++++++ .../conformancetest/snapshot_audit_test.go | 243 ++++++ .../conformancetest/snapshot_contract_test.go | 230 ++++++ .../internal/conformancetest/snapshot_test.go | 106 +++ packages/sdk-go/internal/findings/findings.go | 13 + packages/sdk-go/internal/fsx/fsx.go | 58 +- packages/sdk-go/internal/layout/layout.go | 13 + .../sdk-go/internal/lejiignore/lejiignore.go | 159 ++++ packages/sdk-go/internal/mounts/mounts.go | 89 ++- .../sdk-go/internal/mounts/mounts_test.go | 92 +++ packages/sdk-go/internal/schemas/schemas.go | 2 +- .../sdk-go/internal/sourceaudit/role_test.go | 509 ++++++++++++ packages/sdk-go/package.json | 2 +- packages/sdk-go/setup.sh | 2 +- packages/sdk-py/package.json | 2 +- packages/sdk-py/pyproject.toml | 10 +- packages/sdk-py/src/leji/__init__.py | 18 + .../src/leji/_assets/assets-manifest.json | 6 +- .../schemas/context-manifest.schema.json | 2 +- .../_assets/templates/viewer/assets/vue.css | 149 ++-- .../leji/_assets/templates/viewer/index.html | 29 +- packages/sdk-py/src/leji/cli.py | 68 +- packages/sdk-py/src/leji/conformance.py | 16 +- packages/sdk-py/src/leji/export_cmd.py | 42 +- packages/sdk-py/src/leji/findings.py | 7 + packages/sdk-py/src/leji/fsx.py | 77 +- packages/sdk-py/src/leji/init_cmd.py | 47 +- packages/sdk-py/src/leji/layout.py | 24 +- packages/sdk-py/src/leji/leji_ignore.py | 124 +++ packages/sdk-py/src/leji/mounts.py | 109 ++- packages/sdk-py/src/leji/serve_cmd.py | 197 ++++- packages/sdk-py/src/leji/update_pin.py | 74 +- packages/sdk-py/src/leji/viewer_cmd.py | 154 +++- packages/sdk-py/tests/helpers/snapshot.py | 63 ++ packages/sdk-py/tests/test_badge.py | 52 +- packages/sdk-py/tests/test_canary.py | 71 +- packages/sdk-py/tests/test_coverage.py | 8 +- packages/sdk-py/tests/test_export.py | 97 +++ packages/sdk-py/tests/test_leji_ignore.py | 325 ++++++++ packages/sdk-py/tests/test_mounts.py | 89 +++ packages/sdk-py/tests/test_snapshot_audit.py | 184 +++++ .../sdk-py/tests/test_snapshot_contract.py | 150 ++++ .../sdk-py/tests/test_source_audit_role.py | 387 +++++++++ packages/sdk-py/tests/test_units.py | 356 ++++++++- packages/sdk-py/tests/test_update_pin.py | 236 ++++++ packages/sdk/README.md | 30 + packages/sdk/assets-manifest.json | 6 +- packages/sdk/jsr.json | 2 +- packages/sdk/package.json | 2 +- .../sdk/schemas/context-manifest.schema.json | 2 +- packages/sdk/src/commands/conformance.ts | 28 +- packages/sdk/src/commands/export.ts | 45 +- packages/sdk/src/commands/init.ts | 42 +- .../sdk/src/commands/mounts-update-pin.ts | 68 +- packages/sdk/src/commands/serve.ts | 180 ++++- packages/sdk/src/commands/viewer.ts | 128 ++- packages/sdk/src/index.ts | 64 +- packages/sdk/src/lib/findings.ts | 9 +- packages/sdk/src/lib/fsx.ts | 56 +- packages/sdk/src/lib/layout.ts | 17 +- packages/sdk/src/lib/leji-ignore.ts | 106 +++ packages/sdk/src/lib/mounts.ts | 77 +- packages/sdk/templates/viewer/assets/vue.css | 149 ++-- packages/sdk/templates/viewer/index.html | 29 +- packages/sdk/test/badge.test.ts | 54 +- packages/sdk/test/canary.test.ts | 61 +- packages/sdk/test/export.test.ts | 71 +- packages/sdk/test/helpers/snapshot.ts | 64 ++ packages/sdk/test/leji-ignore.test.ts | 518 ++++++++++++ packages/sdk/test/mounts.test.ts | 45 ++ packages/sdk/test/preflight.test.ts | 36 +- packages/sdk/test/site-hero.test.ts | 173 ++++ packages/sdk/test/snapshot-audit.test.ts | 175 ++++ packages/sdk/test/snapshot-contract.test.ts | 137 ++++ packages/sdk/test/source-audit.test.ts | 589 +++++++++++++- packages/sdk/test/units.test.ts | 312 +++++++- packages/sdk/test/update-pin.test.ts | 220 ++++- packages/sdk/test/viewer-contrast.test.ts | 80 ++ packages/sdk/test/viewer-tones.test.ts | 61 ++ packages/site/public/_headers | 6 +- packages/site/src/data/runtimes.ts | 2 +- packages/site/src/layouts/Base.astro | 22 +- packages/site/src/pages/trust.astro | 7 +- schemas/context-manifest.schema.json | 2 +- scripts/check-release-pins.sh | 753 ++++++++++++++++++ scripts/lib/release-pins.sh | 83 ++ scripts/lib/twine-check.sh | 77 ++ scripts/parity-test.ts | 321 +++++++- scripts/release-pins.env | 25 + scripts/smoke-prepublish.sh | 150 +++- templates/viewer/assets/vue.css | 149 ++-- templates/viewer/index.html | 29 +- 225 files changed, 14473 insertions(+), 1242 deletions(-) create mode 100644 .github/workflows/dependency-audit.yml create mode 100644 docs/decisions/0008-dependency-pinning-and-refresh.md create mode 100644 fixtures/release-path/pins/bad/1-latest/.github/workflows/latest.yml create mode 100644 fixtures/release-path/pins/bad/2-dynamic-expansion/.github/workflows/dynamic.yml create mode 100644 fixtures/release-path/pins/bad/2-expanded-url/scripts/expanded-url.sh create mode 100644 fixtures/release-path/pins/bad/2-glob-requirement/scripts/glob-install.sh create mode 100644 fixtures/release-path/pins/bad/2-lib-script/scripts/lib/bare-pip.sh create mode 100644 fixtures/release-path/pins/bad/2-multi-command/.github/workflows/multi-command.yml create mode 100644 fixtures/release-path/pins/bad/2-requirements-file/.github/workflows/requirements.yml create mode 100644 fixtures/release-path/pins/bad/2-unmarked-local-whl/scripts/unmarked-whl.sh create mode 100644 fixtures/release-path/pins/bad/3-expanded-tgz-url/.github/workflows/expanded-tgz.yml create mode 100644 fixtures/release-path/pins/bad/3-marked-var-tail/scripts/var-tail.sh create mode 100644 fixtures/release-path/pins/bad/3-npm-global/.github/workflows/npm-global.yml create mode 100644 fixtures/release-path/pins/bad/3-unmarked-local-tgz/scripts/unmarked-local.sh create mode 100644 fixtures/release-path/pins/bad/4-unpinned-uses/.github/workflows/unpinned-uses.yml create mode 100644 fixtures/release-path/pins/bad/5-go-version/.github/workflows/go-version.yml create mode 100644 fixtures/release-path/pins/bad/6-toml-range/packages/sdk-py/pyproject.toml create mode 100644 fixtures/release-path/pins/bad/7-action-sha/.github/workflows/publish.yml create mode 100644 fixtures/release-path/pins/bad/7-twine-literal/scripts/lib/twine-literal.sh create mode 100644 fixtures/release-path/pins/bad/8-expanded-npx/.github/workflows/expanded-npx.yml create mode 100644 fixtures/release-path/pins/bad/8-npx-unpinned/.github/workflows/npx.yml create mode 100644 fixtures/release-path/pins/bad/9-action-version-range/.github/workflows/action-version.yml create mode 100644 fixtures/release-path/pins/good/.github/workflows/pinned.yml create mode 100644 fixtures/release-path/pins/good/packages/sdk-py/pyproject.toml create mode 100644 fixtures/release-path/pins/good/scripts/lib/pinned-lib.sh create mode 100644 fixtures/release-path/pins/good/scripts/pinned.sh create mode 100644 fixtures/snapshot-contract/_git-seed/HEAD create mode 100644 fixtures/snapshot-contract/golden-repo.txt create mode 100644 fixtures/snapshot-contract/golden-subtree.txt create mode 100644 fixtures/snapshot-contract/leji-test.json create mode 100644 fixtures/snapshot-contract/payload/a.txt create mode 100644 fixtures/snapshot-contract/payload/pkg/b.txt create mode 100644 "fixtures/snapshot-contract/payload/\357\275\232.txt" create mode 100644 "fixtures/snapshot-contract/payload/\360\237\230\200.txt" create mode 100644 fixtures/snapshot-contract/pkg-git-seed/HEAD create mode 100644 fixtures/valid-leji-ignore-existing/.leji-seed/.gitignore create mode 100644 fixtures/valid-leji-ignore-existing/docs/_sidebar.md create mode 100644 fixtures/valid-leji-ignore-existing/docs/boot-profile.md create mode 100644 fixtures/valid-leji-ignore-existing/docs/context/decisions.md create mode 100644 fixtures/valid-leji-ignore-existing/docs/context/domain.md create mode 100644 fixtures/valid-leji-ignore-existing/docs/decisions/0001-adopt-leji.md create mode 100644 fixtures/valid-leji-ignore-existing/docs/domain/overview.md create mode 100644 fixtures/valid-leji-ignore-existing/docs/index.html create mode 100644 fixtures/valid-leji-ignore-existing/expected.json create mode 100644 fixtures/valid-leji-ignore-existing/leji.json create mode 100644 fixtures/valid-leji-ignore-fresh/docs/_sidebar.md create mode 100644 fixtures/valid-leji-ignore-fresh/docs/boot-profile.md create mode 100644 fixtures/valid-leji-ignore-fresh/docs/context/decisions.md create mode 100644 fixtures/valid-leji-ignore-fresh/docs/context/domain.md create mode 100644 fixtures/valid-leji-ignore-fresh/docs/decisions/0001-adopt-leji.md create mode 100644 fixtures/valid-leji-ignore-fresh/docs/domain/overview.md create mode 100644 fixtures/valid-leji-ignore-fresh/docs/index.html create mode 100644 fixtures/valid-leji-ignore-fresh/expected.json create mode 100644 fixtures/valid-leji-ignore-fresh/leji.json create mode 100644 fixtures/valid-leji-ignore-legacy/docs/.leji-seed/viewer/_sidebar.md create mode 100644 fixtures/valid-leji-ignore-legacy/docs/.leji-seed/viewer/index.html create mode 100644 fixtures/valid-leji-ignore-legacy/docs/_sidebar.md create mode 100644 fixtures/valid-leji-ignore-legacy/docs/boot-profile.md create mode 100644 fixtures/valid-leji-ignore-legacy/docs/context/decisions.md create mode 100644 fixtures/valid-leji-ignore-legacy/docs/context/domain.md create mode 100644 fixtures/valid-leji-ignore-legacy/docs/decisions/0001-adopt-leji.md create mode 100644 fixtures/valid-leji-ignore-legacy/docs/domain/overview.md create mode 100644 fixtures/valid-leji-ignore-legacy/docs/index.html create mode 100644 fixtures/valid-leji-ignore-legacy/expected.json create mode 100644 fixtures/valid-leji-ignore-legacy/leji.json create mode 100644 packages/e2e/README.md create mode 100644 packages/e2e/assertions.ts create mode 100644 packages/e2e/package.json create mode 100644 packages/e2e/playwright.config.ts create mode 100644 packages/e2e/run.mjs create mode 100644 packages/e2e/specs/export.spec.ts create mode 100644 packages/e2e/specs/site.spec.ts create mode 100644 packages/e2e/specs/viewer.spec.ts create mode 100644 packages/e2e/static-server.mjs create mode 100644 packages/e2e/tsconfig.json create mode 100644 packages/sdk-go/internal/commands/export/overview_test.go create mode 100644 packages/sdk-go/internal/commands/serve/overview_test.go create mode 100644 packages/sdk-go/internal/conformancetest/lejiignore_test.go create mode 100644 packages/sdk-go/internal/conformancetest/snapshot_audit_test.go create mode 100644 packages/sdk-go/internal/conformancetest/snapshot_contract_test.go create mode 100644 packages/sdk-go/internal/conformancetest/snapshot_test.go create mode 100644 packages/sdk-go/internal/lejiignore/lejiignore.go create mode 100644 packages/sdk-go/internal/sourceaudit/role_test.go create mode 100644 packages/sdk-py/src/leji/leji_ignore.py create mode 100644 packages/sdk-py/tests/helpers/snapshot.py create mode 100644 packages/sdk-py/tests/test_leji_ignore.py create mode 100644 packages/sdk-py/tests/test_snapshot_audit.py create mode 100644 packages/sdk-py/tests/test_snapshot_contract.py create mode 100644 packages/sdk-py/tests/test_source_audit_role.py create mode 100644 packages/sdk/src/lib/leji-ignore.ts create mode 100644 packages/sdk/test/helpers/snapshot.ts create mode 100644 packages/sdk/test/leji-ignore.test.ts create mode 100644 packages/sdk/test/site-hero.test.ts create mode 100644 packages/sdk/test/snapshot-audit.test.ts create mode 100644 packages/sdk/test/snapshot-contract.test.ts create mode 100644 packages/sdk/test/viewer-contrast.test.ts create mode 100644 packages/sdk/test/viewer-tones.test.ts create mode 100644 scripts/check-release-pins.sh create mode 100644 scripts/lib/release-pins.sh create mode 100644 scripts/lib/twine-check.sh create mode 100644 scripts/release-pins.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2326cd3..d4c9ba9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,18 @@ jobs: # published tarball or on pkg.go.dev, where it cannot be corrected in place. - run: npm run version:check + release-pins: + name: Release-path pins (checker + self-test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The hook that runs this locally is bypassable with --no-verify, so the + # authoritative run is here. + - run: sh scripts/check-release-pins.sh + # And the checker itself is checked: a scanner that matches nothing also + # exits 0, so the fixtures prove each rule still fires. + - run: sh scripts/check-release-pins.sh --self-test + node-sdk: name: Node SDK (build + test, Node ${{ matrix.node-version }}) runs-on: ubuntu-latest @@ -169,33 +181,48 @@ jobs: named test/export.test.ts 2 '^strict: .+$' mcp: - name: MCP server (build + test) + name: MCP server (build + test, Node ${{ matrix.node-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The server publishes engines.node >=22, so the floor is exercised here + # for the same reason it is on the SDK job above: a suite that only ever + # runs on the newest runtime tests everything except what it claims. + node-version: [22, 24] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: ${{ matrix.node-version }} - run: npm ci - run: npm run build -w packages/sdk - run: npm run build -w packages/mcp - run: npm test -w packages/mcp python-sdk: - name: Python SDK (lint + type + test) + name: Python SDK (lint + type + test, Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Both ends of what the package claims on PyPI, and the version in the + # middle that the rest of CI runs on: 3.10 is the requires-python floor, + # 3.14 the newest classifier, and a suite exercised only at one point in + # that span tests neither promise. + python-version: ["3.10", "3.12", "3.14"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Install working-directory: packages/sdk-py - run: python -m pip install --upgrade pip && pip install -e ".[dev]" + run: python -m pip install pip==26.2.1 && pip install -e ".[dev]" - name: Ruff working-directory: packages/sdk-py run: ruff check . && ruff format --check . @@ -205,6 +232,13 @@ jobs: - name: Pytest working-directory: packages/sdk-py run: pytest -q + # The PyPI upload runs twine check --strict and refuses what it cannot + # render, after the tag exists. The same check on every PR, on a + # distribution built here, so metadata never reaches a tag unrendered. + - name: twine check --strict (the publish action's own gate) + run: | + . scripts/lib/twine-check.sh + twine_check python3 packages/sdk-py "$RUNNER_TEMP/twine-gate" go-sdk: name: Go SDK (fmt + vet + test) @@ -215,7 +249,7 @@ jobs: submodules: recursive - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.23" + go-version: "1.27.0" cache-dependency-path: packages/sdk-go/go.sum - name: gofmt working-directory: packages/sdk-go @@ -230,7 +264,8 @@ jobs: - name: goreleaser check uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: - version: "~> v2" + # The same goreleaser the release job installs (scripts/release-pins.env). + version: "v2.18.0" workdir: packages/sdk-go args: check @@ -246,7 +281,7 @@ jobs: node-version: 24 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.23" + go-version: "1.27.0" cache-dependency-path: packages/sdk-go/go.sum - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -267,7 +302,7 @@ jobs: node-version: 24 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.23" + go-version: "1.27.0" cache-dependency-path: packages/sdk-go/go.sum - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -276,7 +311,7 @@ jobs: - run: npm run build -w packages/sdk - name: Install the Python SDK working-directory: packages/sdk-py - run: python -m pip install --upgrade pip && pip install -e ".[dev]" + run: python -m pip install pip==26.2.1 && pip install -e ".[dev]" # Compiled here, where the network still exists, so nothing inside the # namespace needs to reach a module proxy to build what it runs. - name: Compile the Go fixture tests @@ -323,6 +358,32 @@ jobs: - run: npm ci - run: npm run build -w leji-site + ui-smoke: + name: UI smoke (viewer, export, and site in a browser) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: npm ci + # Both builds are inputs to the suite: it runs the CLI out of packages/sdk/dist + # and previews the site out of packages/site/dist. + - run: npm run build -w packages/sdk + - run: npm run build -w packages/site + - run: npm run browsers -w packages/e2e + - run: npm run e2e -w packages/e2e + # Traces and screenshots are retained only for failures, so a red job is + # inspectable without a rerun and a green one uploads nothing. + - name: Playwright report and traces + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ui-smoke-report + path: | + packages/e2e/report/ + packages/e2e/test-results/ + dogfood: name: Dogfood (repo conforms to its own spec) runs-on: ubuntu-latest diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..1b5c6c9 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,94 @@ +name: Dependency audit + +# Advisories against dependencies that have not changed: the pins are exact, so +# nothing here moves on its own, and a monthly run is the only trigger that fires +# when no one has committed anything. The pull-request, release/* and rc/* triggers +# cover the other direction, a dependency surface that did change. Policy and failure +# thresholds: docs/decisions/0008-dependency-pinning-and-refresh.md. +# Actions and scanners are pinned, like everything else on the release path. + +on: + schedule: + - cron: "0 6 1 * *" + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/dependency-audit.yml" + - "package.json" + - "package-lock.json" + - "packages/*/package.json" + - "packages/sdk-py/pyproject.toml" + - "packages/sdk-go/go.mod" + push: + # release/* and rc/*: the audit runs against the exact bytes a release is cut + # from, on the branch the candidate is prepared on and on the one it is proven + # on. + branches: ["release/*", "rc/*"] + +permissions: + contents: read + +jobs: + npm-audit: + name: npm (root lockfile, all workspaces) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + # The scanner is npm itself, so it is pinned to the same version the + # release workflow publishes with, not to whatever the Node release bundles. + - run: npm install -g npm@12.0.2 && npm --version + - run: npm ci + # Every workspace resolves through the root lockfile, so one audit covers + # all of them. High or above fails the run. + - run: npm audit --audit-level=high + + pip-audit: + name: Python (resolved dependency export, Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The same span the Python SDK is tested across, for the same reason: a + # dependency closure can be interpreter-conditioned, so an audit at one + # point in the span says nothing about the ends. 3.10 is the + # requires-python floor, 3.14 the newest classifier, 3.12 the version the + # rest of CI runs on. + python-version: ["3.10", "3.12", "3.14"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + # The audit boundary is the SDK's resolved runtime closure, not the runner's + # environment: install the package, export exactly what that resolved to, + # and audit the export. The SDK itself is excluded, being what is audited + # rather than a dependency of it, and pip-audit is installed only after the + # export so that its own dependencies stay out of the boundary. + - name: Resolve the SDK's dependencies + run: | + python -m venv .venv + .venv/bin/pip install -e ./packages/sdk-py + .venv/bin/pip freeze --exclude-editable > resolved.txt + cat resolved.txt + - name: Audit the export + run: | + .venv/bin/pip install pip-audit==2.10.1 + .venv/bin/pip-audit --strict -r resolved.txt + + govulncheck: + name: Go (module and standard library) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.27.0" + cache-dependency-path: packages/sdk-go/go.sum + # govulncheck reports the vulnerabilities this code can actually reach, so + # a finding here is a call path, not an inventory entry. + - name: govulncheck + working-directory: packages/sdk-go + run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 2126e91..fd1dc7d 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -13,7 +13,7 @@ on: release_tag: description: "Tag the draft release is attached to (the goreleaser/Go tag)" required: true - default: "packages/sdk-go/v1.4.0" + default: "packages/sdk-go/v1.4.1" permissions: contents: write # publish the release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 379c814..97cf228 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: python-version: "3.12" - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.23" + go-version: "1.27.0" cache-dependency-path: packages/sdk-go/go.sum - run: npm ci - name: Build artifacts, cold-install each, assert CLI battery + cross-SDK parity @@ -63,7 +63,7 @@ jobs: node-version: 24 registry-url: "https://registry.npmjs.org" # OIDC trusted publishing needs npm >= 11.5.1 (Node 24 may ship older). - - run: npm install -g npm@latest && npm --version + - run: npm install -g npm@12.0.2 && npm --version - run: npm ci - name: Verify tag matches package version run: | @@ -98,9 +98,11 @@ jobs: PKG="$(node -p "require('./packages/sdk/jsr.json').version")" test "$TAG" = "$PKG" || { echo "tag packages/sdk/v$TAG != jsr.json version $PKG"; exit 1; } # JSR publishes the TS source (per jsr.json), not the npm dist tarball. + # The jsr CLI is pinned like every other tool on this path; the version + # mirrors JSR_VERSION in scripts/release-pins.env. - name: Publish to JSR working-directory: packages/sdk - run: npx jsr publish + run: npx jsr@0.14.3 publish # --- packages/create-leji/v* : npm `create-leji` (first-class, own tag) --- @@ -118,7 +120,7 @@ jobs: with: node-version: 24 registry-url: "https://registry.npmjs.org" - - run: npm install -g npm@latest && npm --version + - run: npm install -g npm@12.0.2 && npm --version - run: npm ci - name: Verify tag matches package version run: | @@ -146,7 +148,7 @@ jobs: with: node-version: 24 registry-url: "https://registry.npmjs.org" - - run: npm install -g npm@latest && npm --version + - run: npm install -g npm@12.0.2 && npm --version - run: npm ci - name: Verify tag matches package version run: | @@ -185,10 +187,20 @@ jobs: test "$TAG" = "$PKG" || { echo "tag packages/sdk-py/v$TAG != pyproject version $PKG"; exit 1; } - name: Test working-directory: packages/sdk-py - run: python -m pip install --upgrade pip && pip install -e ".[dev]" && pytest -q + run: python -m pip install pip==26.2.1 && pip install -e ".[dev]" && pytest -q - name: Build sdist + wheel working-directory: packages/sdk-py - run: python -m pip install --upgrade build && python -m build + run: python -m pip install build==1.5.0 && python -m build + # The last gate before the irreversible act, on the exact bytes about to be + # uploaded: the publish action rejects a distribution twine cannot render, + # and it rejects it after the tag exists. Same function as the smoke and + # the pre-push hook, same twine the action below bundles. + - name: twine check --strict on the built distribution + run: | + . scripts/lib/twine-check.sh + twine_check python3 packages/sdk-py "$RUNNER_TEMP/twine-gate" packages/sdk-py/dist + # SHA, version comment, and the twine the gate above installs all come from + # scripts/release-pins.env; moving this pin means moving that file. - name: Publish uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: @@ -209,7 +221,7 @@ jobs: fetch-depth: 0 # full history so goreleaser sees tags - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.23" + go-version: "1.27.0" cache-dependency-path: packages/sdk-go/go.sum - name: Verify tag matches SDK version run: | @@ -227,7 +239,9 @@ jobs: - name: Build cross-platform binaries (goreleaser, no publish) uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: - version: "~> v2" + # The action is pinned by SHA; this input pins the goreleaser it + # installs, and mirrors GORELEASER_VERSION in scripts/release-pins.env. + version: "v2.18.0" workdir: packages/sdk-go args: release --clean --skip=publish env: diff --git a/.gitignore b/.gitignore index d2e529c..d2b06bc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,11 @@ dist/ # Leji generated viewer (leji viewer / view output) .leji/ +# UI smoke output (Playwright report, traces and screenshots) +packages/e2e/report/ +packages/e2e/test-results/ +packages/e2e/.work/ + # Tests & coverage coverage/ coverage.out diff --git a/.husky/pre-commit b/.husky/pre-commit index b02ad51..8ccc9fe 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,6 +2,11 @@ # TruffleHog secret scan on staged changes. The same scan runs in CI # (.github/workflows/ci.yml → secret-scan). # +# The staged list is piped straight into xargs -0: `-z` carries raw UTF-8 path +# bytes and NUL delimiters, while plain --name-only octal-quotes non-ASCII +# paths (trufflehog then fails lstat and skips the file). A shell variable +# cannot hold NULs, so the list never lands in one. +# # Bypass for a confirmed false positive (last resort): git commit --no-verify if ! command -v trufflehog >/dev/null 2>&1; then @@ -10,13 +15,12 @@ if ! command -v trufflehog >/dev/null 2>&1; then exit 0 fi -files=$(git diff --cached --name-only --diff-filter=ACM) -[ -z "$files" ] && exit 0 +git diff --cached --quiet --diff-filter=ACM && exit 0 echo "trufflehog: scanning staged files…" # Lob excluded: its test-key pattern matches pytest function names (FP since # trufflehog 3.96.0); keep in sync with ci.yml → secret-scan. -printf '%s\n' "$files" | tr '\n' '\0' | xargs -0 trufflehog filesystem --only-verified --fail --no-update --exclude-detectors=lob || { +git diff --cached --name-only -z --diff-filter=ACM | xargs -0 trufflehog filesystem --only-verified --fail --no-update --exclude-detectors=lob || { echo "" echo "✖ trufflehog found a verified secret in your staged changes (see above)." echo " Remove it before committing. Last resort (false positive): git commit --no-verify" diff --git a/.husky/pre-push b/.husky/pre-push index 954006c..2659eef 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,8 +1,12 @@ #!/usr/bin/env sh # Pre-push guard: (1) branch-naming allowlist, (2) version coherence across -# manifests and prose, then (3) TruffleHog secret scan on the commits being pushed -# (defense in depth on top of pre-commit and CI). Reads ref updates on stdin per -# the git pre-push contract. +# manifests and prose, (3) TruffleHog secret scan on the commits being pushed +# (defense in depth on top of pre-commit and CI), (4) release-path pins, and +# (5) on the refs that lead to a tag, the distribution gate the PyPI upload +# performs. Reads ref updates on stdin per the git pre-push contract. +# +# Stages 3 to 5 all run and the hook exits once: a missing tool skips its own +# stage with a warning and never the stages after it. # # The branch-name check is fast local feedback; the server-side ruleset on origin # is the authoritative gate (this hook is bypassable with --no-verify). @@ -60,31 +64,82 @@ else echo "⚠ node not found — skipping local version check (CI still checks)." fi +_status=0 +_root="$(git rev-parse --show-toplevel)" + # (3) TruffleHog secret scan. -if ! command -v trufflehog >/dev/null 2>&1; then +if command -v trufflehog >/dev/null 2>&1; then + repo="file://$_root" + th=0 + + while read -r local_ref local_sha remote_ref remote_sha; do + [ "$local_sha" = "$ZERO" ] && continue # deleting a ref: nothing to scan + if [ "$remote_sha" = "$ZERO" ]; then + echo "trufflehog: scanning new ref $local_ref (full history)…" + trufflehog git "$repo" --only-verified --fail --no-update || th=1 + else + echo "trufflehog: scanning ${remote_sha}..${local_sha}…" + trufflehog git "$repo" --since-commit "$remote_sha" --only-verified --fail --no-update || th=1 + fi + done <= 3.10 found, skipping the local distribution check (CI still checks)." + else + echo "twine: building packages/sdk-py and running twine check --strict…" + _dist_tmp="$(mktemp -d)" + if ! twine_check "$_py" "$_root/packages/sdk-py" "$_dist_tmp"; then + echo "" + echo "✖ the built distribution fails twine check --strict (see above)." + echo " The PyPI upload runs the same check, after the tag. Fix it first," + echo " or override with: git push --no-verify" + _status=1 + fi + rm -rf "$_dist_tmp" + fi +fi + +if [ "$_status" -ne 0 ]; then exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 5421579..7635df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,124 @@ # Changelog +## Unreleased + +A hardening release. The CLI keeps its own generated tree out of git, the viewer takes the +brand's typography, the federation commands say which act failed and how to recover, and the +release path is pinned end to end under a recorded refresh policy. No normative specification +change and no schema constraint change (one description string is clarified); the spec stays on +the frozen v1.0 line and all reference packages move to 1.4.1 together. + +### Added + +- **A recorded dependency pinning and refresh policy**, at + `docs/decisions/0008-dependency-pinning-and-refresh.md`: which dependency classes are pinned + exactly and which stay compatible ranges, that every release refreshes the exact pins and + re-pins actions to current SHAs, and a 90-day floor that runs the refresh even when no + release is cut. `SECURITY.md` carries the matching public commitment: reports acknowledged + and triaged on a best-effort basis, typically within 7 days; a forward-only patch release + where a compatible fix exists; patches on the latest minor line only. +- **A scheduled dependency audit** (`.github/workflows/dependency-audit.yml`): monthly, and on + any change to a dependency manifest, `npm audit` runs over the root lockfile at high or + above, `pip-audit` over the Python SDK's resolved runtime closure, and `govulncheck` over the + Go SDK. The scanners are pinned like everything else on the release path. +- **A pin check for the release path**, `scripts/check-release-pins.sh`: it refuses `@latest`, + `--upgrade`, a `pip install` or `npm install -g` without an exact version, a `go-version` + without a patch component, and a `uses:` without a 40-hex SHA. Contributors get it as a stage + of the pre-push hook; CI runs it as its own job, with a self-test that proves each rule still + fires, and the pre-publish smoke runs it in layer 0. Alongside it, the PyPI upload's own + `twine check --strict` now runs on the gates that come before a tag: in the pre-publish + smoke, in CI on every pull request, and in the pre-push hook when a `release/*` or `rc/*` + ref is pushed. The tag-triggered release workflow runs the same check once more on the + distribution it built, so the upload action is never the first thing to see a rejection. +- **A browser smoke suite** at `packages/e2e` (Playwright on Chromium) over the served viewer, + an exported tree, and the site, so the live and static renderings are checked against one + set of assertions: `npm run e2e` locally, a `ui-smoke` job in CI that keeps traces and + screenshots on failure. It is a private workspace and ships in no published package. + +### Changed + +- **The layer map is rendered, not written.** `overview.md` is seeded once and never + rewritten after that: the map between its `leji:generated-map` markers is substituted + when the page is read, by `leji view` and by `leji export`, so reindexing a layer + changes `context-index.json` and leaves the committed page alone. Nothing rewrites an + existing file, and a map an earlier version left in your `overview.md` is now ignored + at render. To clear it by hand, delete every line between + `` and ``. +- **The CLI ignores its own directory from inside.** The first time a command creates a role + under `.leji/` (`mounts/`, `viewer/`, `dist/`, `work/`), it writes `.leji/.gitignore` holding + exactly `*`, so a repository whose root `.gitignore` never received the `.leji/` line commits + none of that tree either. `leji init` and `leji adopt` still add the root line. An existing + `.leji/.gitignore` is left byte-identical and never merged, with one notice per invocation on + stderr (on stderr under `--json` too, never in the document), so your own file is kept by + being left alone. The write refuses like every other: a symlinked `.leji`, or an entry that + is not a regular file, is never written through. +- **The generated tree and the local hints file are documented** in the root and SDK READMEs: + the four `.leji/` roles, that none of it is committed, and that `.leji/mounts.local.json` is + a per-machine hints file the CLI reads and never writes. Migrating from an earlier version: + if that hints file was committed, untrack it with `git rm --cached .leji/mounts.local.json` + and keep the bare `.leji/` line at the root, since onboarding refuses while anything under + `.leji/` is tracked and the nested ignore file takes precedence over a negation written at + the root; a `docs/.leji/` tree left by 1.3.x is unused in 1.4.x and can be deleted. With the + hints file uncommitted, a fresh clone hydrates through the resolver store or the manifest's + remote URLs, so a pinned commit has to be reachable on its remote. +- **The viewer takes the brand's typography.** Headings and emphasis, body copy, and a muted + tone are declared once as `--leji-text`, `--leji-text-body`, and `--leji-text-muted`, and + replace the stock theme's neutral greys throughout the shell and the content. Body links and + inline code now take the fixed accessible green `--leji-link` (`#007D59`, AA on white and on + the inline-code ground) instead of the accent, so contrast holds for every value of + `viewer.theme.primary`; the chrome (navigation, active sidebar entries, search highlights, + the progress bar) still takes the accent, and the accent's own fallback is Leji green rather + than the stock docsify green, so a context layer that declares none is rendered in the brand + color. The manifest schema's description of `viewer.theme.primary` says the same: it drives + the viewer chrome, active states, and diagram accents, and body text does not follow it. The + field's validation is unchanged. +- **leji.org ships analytics-free.** The site source loads nothing third-party: the analytics + script, and its origin in `img-src`, `script-src`, and `connect-src`, appear only when a + build sets both `PUBLIC_ANALYTICS_SCRIPT` and `PUBLIC_ANALYTICS_SITE`. `public/_headers` + carries a first-party-only policy to match. A deployment adds its own analytics, and the + Trust page states it that way. +- **The published floors are exercised.** CI runs the Python SDK at 3.10, 3.12, and 3.14 (the + `requires-python` floor, the version the rest of CI uses, and the newest classifier) and the + MCP server on Node 22 and 24, the floor its `engines.node` declares. The pre-publish smoke + installs the SDK, `create-leji`, and the MCP server tarballs into a Node 22 container and + drives all three there, and its result line says so when Docker is absent and the leg is + skipped. The Go SDK is built and tested at Go 1.27.0, which is now also the floor its + `go.mod` declares, so every floor named here is a floor CI exercises. +- **The Go floor moves to 1.27.0.** `packages/sdk-go/go.mod` declares `go 1.27.0` (up from + `go 1.23`), and `golang.org/x/text` moves to v0.41.0 with it. Building the Go SDK, or + `go install`ing the `leji` binary from source, now needs Go 1.27.0 or newer; the published + release binaries are unaffected, since they carry no toolchain requirement. `CONTRIBUTING.md` + and the Go setup script state the new floor. +- **The test suites pin what they used to sample**: the badge and canary suites in all three + SDKs share one directory-snapshot helper held to a golden fixture, the capture-cap test + asserts bytes and termination rather than elapsed time, and the homepage's hero transcript is + pinned to what the CLI actually prints. + +### Fixed + +- **`mounts update-pin --fetch` names the act that failed.** A `--fetch` run observes the + declared source in three acts (retain the current pin, refresh the witness, retain the + target) and a refusal reported only the rule. Findings now carry an optional `detail` string, + serialized immediately after `message`, reading `: ` with the act named as + `current pin`, `target`, or `witness`; the human line appends the same as + `(detail: : )`. Where the current pin is the act that failed, the message also + gives the route forward: run without `--fetch` against a local hint that holds the current + pin and the target with complete ancestry, or pass `--to --allow-non-fast-forward` + against such a hint to move past an upstream that rewrote its history. +- **`mounts hydrate --fetch` reports the same detail** on its per-mount findings, so a + hydration that could not establish the store or refresh the witness says which act it was and + what the resolver ran into. Neither command's flow changes: a failed act still refuses. +- **The viewer's meta CSP no longer claims `frame-ancestors`.** A `` policy cannot + deliver that directive, so the browser ignored it and logged an error on every page. + `leji viewer serve` still sends it as a response header. A static host serving an export from + `.leji/dist/` sets that directive itself, the way this repository's site does in + `packages/site/public/_headers`. +- **The pre-commit secret scan sees non-ASCII paths.** The staged list reached the scanner + through `git diff --cached --name-only`, which octal-quotes a non-ASCII path, so those files + failed `lstat` and went unscanned. The hook pipes the `-z` form straight into `xargs -0` + instead. The pre-push hook now also runs every stage and exits once, so a missing tool skips + its own stage and never the ones after it. + ## 1.4.0 · 2026-08-21 The OSS feature program: export, badge, pin updates, rendering parity, a unified `.leji/` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7d8a7e..40a9090 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -The Leji spec is at 1.0, extracted from lived practice; the reference SDKs and tooling are at 1.4.0. The 1.0 spec line is GA and frozen at the v1.3.0 reference-tooling release: schema changes within it are additive only, and breaking changes require a new line per spec/versioning.md. +The Leji spec is at 1.0, extracted from lived practice; the reference SDKs and tooling are at 1.4.1. The 1.0 spec line is GA and frozen at the v1.3.0 reference-tooling release: schema changes within it are additive only, and breaking changes require a new line per spec/versioning.md. ## Development setup @@ -11,7 +11,7 @@ npm run setup:go # Go: installs goreleaser (the only Go dev tool not bundled wit npm test # runs the Node, Python, and Go suites ``` -Prerequisites: Node 24+, a Python >=3.10 (the Python SDK pins 3.12 via `packages/sdk-py/.python-version`), and Go 1.23+. The `setup:*` scripts detect each toolchain and print install hints if it is missing. Both are idempotent and machine-local (the Python `.venv` is git-ignored), so re-run them after cloning or switching machines. +Prerequisites: Node 24+, a Python >=3.10 (the Python SDK pins 3.12 via `packages/sdk-py/.python-version`), and Go 1.27+. The `setup:*` scripts detect each toolchain and print install hints if it is missing. Both are idempotent and machine-local (the Python `.venv` is git-ignored), so re-run them after cloning or switching machines. **Running your work-in-progress CLI**, two channels, one machine-wide at a time: @@ -25,7 +25,7 @@ Prerequisites: Node 24+, a Python >=3.10 (the Python SDK pins 3.12 via `packages - **Spec proposals.** Open an issue first: the problem, the intent, and the lived case behind it. Leji specifies proven practice; proposals grounded in something a real team does carry more weight than ideas in the abstract. - **Pull requests.** Normative changes (anything under `spec/` or `schemas/`) ride PR review and require a `CHANGELOG.md` entry plus a machine-readable `CHANGELOG.json` entry. Yes, the spec dogfoods itself. - **Contributor terms.** Every commit needs a DCO sign-off (`git commit -s`); contributions ship under the license for their content type. See [Contributor terms](#contributor-terms). -- **Tooling.** SDK changes need tests and must keep `leji validate` passing against `examples/`. The Node, Python, and Go SDKs (`packages/sdk`, `packages/sdk-py`, `packages/sdk-go`) are behaviorally identical: a behavior change in one rides into all three, pinned by the shared `fixtures/` suite. Behavior develops and proves out fully in the TypeScript SDK first, the canonical implementation, against the LIVE channel ([testing-cli-adoptions](docs/practice/testing-cli-adoptions.md)); the Go and Python ports are made only from settled TypeScript behavior, pinned by the shared fixtures at port time. The Go SDK builds with Go 1.23+; `gofmt`, `go vet ./...`, and `go test ./...` must pass. +- **Tooling.** SDK changes need tests and must keep `leji validate` passing against `examples/`. The Node, Python, and Go SDKs (`packages/sdk`, `packages/sdk-py`, `packages/sdk-go`) are behaviorally identical: a behavior change in one rides into all three, pinned by the shared `fixtures/` suite. Behavior develops and proves out fully in the TypeScript SDK first, the canonical implementation, against the LIVE channel ([testing-cli-adoptions](docs/practice/testing-cli-adoptions.md)); the Go and Python ports are made only from settled TypeScript behavior, pinned by the shared fixtures at port time. The Go SDK builds with Go 1.27+; `gofmt`, `go vet ./...`, and `go test ./...` must pass. - **Language policy (Node side).** TypeScript + ESM everywhere: SDK source and tests, the site (`astro.config.ts` included), and repo scripts (run natively by Node's type stripping; develop on Node 24+). The one deliberate exception is `packages/create-leji/index.js`, a zero-build published shim. No `.mjs`: every package declares `"type": "module"`. - **Style.** Spec prose is plain English, normative keywords per RFC 2119 (MUST/SHOULD/MAY), human-readable first. diff --git a/README.md b/README.md index 9818a5f..8fd9645 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **An open specification for the shared context layer of AI-native teams.** Leji (from the word *legible*, pronounced LEH-jee) defines a versioned, repo-owned context layer of how a team thinks: domain language, constraints, decision records, conventions, agent guardrails, one reviewed body of context that people and AI agents both read, changed through the same review gate as the code. -> **Status: 1.4.0.** The reference SDKs (`@leji-org/leji` on npm and JSR, `leji` on PyPI, and the Go module) are at 1.4.0. The specification and schemas are on the v1.0 line, **GA and frozen at the v1.3.0 reference-tooling release**: any incompatible change ships as a new line. See [spec/versioning.md](spec/versioning.md). +> **Status: 1.4.1.** The reference SDKs (`@leji-org/leji` on npm and JSR, `leji` on PyPI, and the Go module) are at 1.4.1. The specification and schemas are on the v1.0 line, **GA and frozen at the v1.3.0 reference-tooling release**: any incompatible change ships as a new line. See [spec/versioning.md](spec/versioning.md). ## Principles @@ -30,6 +30,23 @@ The name is the thesis: the context layer makes a team's operating context **leg | `packages/create-leji` | `npm create leji`: the zero-install bootstrap, routing to the SDK's `init` or `adopt` by what the target directory already holds | | `packages/site/` | The spec website (plain Astro; deployable by anyone) | +## Generated files + +The CLI keeps everything it generates under one `.leji/` directory at the repository root, in four roles: `mounts/` (materialized federation mounts), `viewer/` (generated viewer chrome), `dist/` (exported viewer builds), and `work/` (the transient onboarding workspace). All of it is machine-local, and none of it is committed. The first time a command creates one of those roles, the CLI writes `.leji/.gitignore` containing `*`, so the directory ignores itself; an existing `.leji/.gitignore` is left as it is, with a notice on stderr. `leji init` and `leji adopt` also add a bare `.leji/` line to the repository's root `.gitignore`. + +One file the CLI writes is not generated output: `overview.md` at the context root. It is seeded once, when no `overview.md` stands there, and is never rewritten after that. The layer map lives between the `leji:generated-map` markers the seed leaves empty, and is rendered into the page when the page is read: by `leji view` and by `leji export`. A reindex therefore changes `context-index.json` and nothing else. If an older version of the CLI wrote a map into your `overview.md`, that block is ignored now; delete the lines between the markers whenever it suits you. + +An exported viewer under `.leji/dist/` is plain static files whose Content-Security-Policy travels in the page's `` element, and a meta policy cannot carry `frame-ancestors`, so a host serving an export sets that directive as a response header itself, the way this repository's own site does in [`packages/site/public/_headers`](packages/site/public/_headers). + +`.leji/mounts.local.json` is a per-machine hints file: it points the resolver at local checkouts of the context layers a federation mounts. The CLI reads it and never writes it. Do not commit it; a path on one machine is not a path on another. + +Migrating from an earlier version: + +- If `.leji/mounts.local.json` was committed, untrack it with `git rm --cached .leji/mounts.local.json`, and keep the bare `.leji/` line in the root `.gitignore`. Onboarding refuses to run while anything under `.leji/` is tracked, and the nested `.leji/.gitignore` takes precedence over any negation written at the root. +- A `docs/.leji/` tree left by 1.3.x is unused in 1.4.x and can be deleted. + +Because the hints file stays uncommitted, a fresh clone hydrates its mounts through the resolver store or the manifest's remote URLs, so a pinned commit has to be reachable on its remote. + ## License Code, schemas, templates, and the SDK: Apache-2.0. Specification prose and rationale: CC-BY-4.0. See [LICENSE.md](LICENSE.md). diff --git a/RELEASING.md b/RELEASING.md index 81ac466..03fb11a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -34,6 +34,13 @@ recorded exception, and direct pushes stay blocked either way. PyPI wheel, Go binary), cold-installs it in a throwaway sandbox, and runs the CLI battery plus cross-SDK parity. Must print `Pre-publish smoke GREEN`. It publishes nothing; it rehearses the artifacts before the irreversible tag. + Four of its lines are preconditions of the **first** tag, not just of a green + run, because the PyPI upload repeats them after the tag exists, where nothing + can be corrected in place: `release-path pins exact`, `wheel built`, + `twine check --strict (wheel + sdist)`, and `invalid sdist fixture rejected by + twine check --strict` (the gate proving it can still fail). Read them: a tag + cut past any of the four is a publish that can still fail once it is too late + to change anything. 6. For changes touching CLI behavior, adoption, templates, schemas, assets, or viewer packaging: complete one representative adoption run on a real repository using a PACKED artifact (`npm run cli:packed:refresh`; see @@ -56,11 +63,11 @@ publish is irreversible. | Tag | Publishes | |---|---| -| `packages/sdk/v1.4.0` | npm `@leji-org/leji` **and** JSR `@leji-org/leji` (one tag, two jobs) | -| `packages/create-leji/v1.4.0` | npm `create-leji` | -| `packages/sdk-py/v1.4.0` | PyPI `leji` | -| `packages/sdk-go/v1.4.0` | Go module index + goreleaser binaries | -| `packages/mcp/v1.4.0` | npm `@leji-org/mcp` | +| `packages/sdk/v1.4.1` | npm `@leji-org/leji` **and** JSR `@leji-org/leji` (one tag, two jobs) | +| `packages/create-leji/v1.4.1` | npm `create-leji` | +| `packages/sdk-py/v1.4.1` | PyPI `leji` | +| `packages/sdk-go/v1.4.1` | Go module index + goreleaser binaries | +| `packages/mcp/v1.4.1` | npm `@leji-org/mcp` | Cut all five at the same version once the pre-flight (above) is green. Tag the sdk first: `create-leji` and `@leji-org/mcp` both depend on @@ -70,14 +77,14 @@ sdk's npm publish job to go green and confirm the version is live** ``` # 1. The sdk tag; then WAIT for the npm publish to be green and live. -git tag packages/sdk/v1.4.0 && git push origin packages/sdk/v1.4.0 -npm view @leji-org/leji version # must print 1.4.0 before continuing - -# 2. Only after @leji-org/leji@1.4.0 is live on npm: -git tag packages/sdk-py/v1.4.0 && git push origin packages/sdk-py/v1.4.0 -git tag packages/sdk-go/v1.4.0 && git push origin packages/sdk-go/v1.4.0 -git tag packages/create-leji/v1.4.0 && git push origin packages/create-leji/v1.4.0 -git tag packages/mcp/v1.4.0 && git push origin packages/mcp/v1.4.0 +git tag packages/sdk/v1.4.1 && git push origin packages/sdk/v1.4.1 +npm view @leji-org/leji version # must print 1.4.1 before continuing + +# 2. Only after @leji-org/leji@1.4.1 is live on npm: +git tag packages/sdk-py/v1.4.1 && git push origin packages/sdk-py/v1.4.1 +git tag packages/sdk-go/v1.4.1 && git push origin packages/sdk-go/v1.4.1 +git tag packages/create-leji/v1.4.1 && git push origin packages/create-leji/v1.4.1 +git tag packages/mcp/v1.4.1 && git push origin packages/mcp/v1.4.1 ``` ## Finalize: publish the Go binaries (required) @@ -88,7 +95,7 @@ public until the separate `release-finalize` workflow publishes it. Skipping thi leaves the announcement pointing at a release nobody can download. After every publish job is green, run the `release-finalize` workflow manually and -give it the Go tag as `release_tag` (e.g. `packages/sdk-go/v1.4.0`). It publishes +give it the Go tag as `release_tag` (e.g. `packages/sdk-go/v1.4.1`). It publishes the draft release and enables Discussions. Confirm the release is no longer marked draft before announcing. @@ -116,8 +123,8 @@ immutable, so inspect the wheel and sdist before tagging The Go module lives at `packages/sdk-go`, so its import path is `github.com/leji-org/leji/packages/sdk-go`. Go resolves versions of a module in a subdirectory **only** from tags that carry the module subpath prefix -(`packages/sdk-go/v1.4.0`); a plain `v1.4.0` will **not** make -`go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@v1.4.0` resolve. +(`packages/sdk-go/v1.4.1`); a plain `v1.4.1` will **not** make +`go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@v1.4.1` resolve. There is no upload step: pkg.go.dev indexes the tag on first request. ## One-time setup (before the first tag) diff --git a/SECURITY.md b/SECURITY.md index d8a3314..4ec5bcf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,3 +3,9 @@ Leji is a specification plus a local SDK that runs on your machine. There is no account and no telemetry: nothing reports back. Contexing, LLC, Leji's steward, is developing a hosted service at leji.ai; the reference tooling never contacts it. `leji viewer serve` starts a local HTTP server on the loopback interface, and the federation commands you invoke deliberately (`leji mounts hydrate --fetch`, `leji mounts update-pin --fetch`, `leji conformance --federation=verify`) contact the repository you named. To report a vulnerability in the SDK or tooling, use GitHub private vulnerability reporting on [leji-org/leji](https://github.com/leji-org/leji/security/advisories/new), or email security@leji.org. Please don't file public issues for vulnerabilities before a fix is available. + +## Supported versions and patching + +Security reports are acknowledged and triaged on a best-effort basis, typically within 7 days. Where a compatible fix exists, a forward-only patch release follows, typically within 7 days for high or critical findings and at the next release otherwise; where none exists, we publish status and mitigation. Only the latest minor line receives patches. + +The pinning, refresh, and audit policy behind that commitment is recorded in [decision 0008](docs/decisions/0008-dependency-pinning-and-refresh.md). diff --git a/adoption/rendering.md b/adoption/rendering.md index 6853f52..b5ee85d 100644 --- a/adoption/rendering.md +++ b/adoption/rendering.md @@ -68,8 +68,9 @@ in a document is a thematic break. **A `mermaid` fence renders as a diagram where the renderer supports mermaid, and as a code block where it does not.** Both are conforming. Diagram support is therefore never a compatibility requirement, and a writer can use a diagram -without stranding a reader whose renderer has none. The generated map on a -seeded overview page uses this fence. +without stranding a reader whose renderer has none. The layer map on an overview +page uses this fence: the seeded page leaves its markers empty, and the viewer and +`leji export` render the map between them when the page is read. **A `leji-index` fence is data and renders as code.** The block is the curated category map that tooling parses; a renderer displays it and never interprets it. @@ -78,10 +79,10 @@ reach the reader as data rather than as interpreted markup. **HTML comments are legal and invisible.** They are the one HTML form a context layer uses, because Leji's own markers are comments: a generated block is -delimited by comment markers so a regeneration can rewrite what sits between them -and leave the surrounding prose alone. A renderer shows nothing for a comment, and -keeps the comment in the bytes it serves so the next regeneration still finds its -markers. Invisible does not mean structurally inert: a comment that opens a line +delimited by comment markers so what sits between them can be rewritten, or +substituted at render time, and the surrounding prose left alone. A renderer shows +nothing for a comment, and keeps the comment in the bytes it serves so the next +pass still finds its markers. Invisible does not mean structurally inert: a comment that opens a line absorbs the rest of that line into an HTML block (the CommonMark type-2 rule), so prose after it on the same line ends up outside the surrounding paragraph. A comment meant to sit mid-paragraph goes after text on its line, never first. diff --git a/docs/boot-profile.md b/docs/boot-profile.md index e4fab20..7a23c49 100644 --- a/docs/boot-profile.md +++ b/docs/boot-profile.md @@ -5,7 +5,7 @@ ## Identity -This is the Leji repository: the home of the Leji specification, its reference SDKs and tooling, and the leji.org site. Leji is an open specification for the shared context layer of AI-native teams: a versioned, repo-owned record of how a team thinks, read by people and AI agents alike. It is public. Everything here is single-sourced: the normative spec (`spec/`) and its JSON Schemas (`schemas/`), the reference `leji` CLI in TypeScript, Python, and Go (`packages/sdk`, `packages/sdk-py`, `packages/sdk-go`), the MCP server and `create-leji` scaffolder (`packages/mcp`, `packages/create-leji`), and the website (`packages/site`). The spec and schemas are on the v1.0 line (GA, frozen at the v1.3.0 reference-tooling release); the packages release together, currently 1.4.0. +This is the Leji repository: the home of the Leji specification, its reference SDKs and tooling, and the leji.org site. Leji is an open specification for the shared context layer of AI-native teams: a versioned, repo-owned record of how a team thinks, read by people and AI agents alike. It is public. Everything here is single-sourced: the normative spec (`spec/`) and its JSON Schemas (`schemas/`), the reference `leji` CLI in TypeScript, Python, and Go (`packages/sdk`, `packages/sdk-py`, `packages/sdk-go`), the MCP server and `create-leji` scaffolder (`packages/mcp`, `packages/create-leji`), and the website (`packages/site`). The spec and schemas are on the v1.0 line (GA, frozen at the v1.3.0 reference-tooling release); the packages release together, currently 1.4.1. ## Loading diff --git a/docs/context-changelog.json b/docs/context-changelog.json index f1300d0..b7395a4 100644 --- a/docs/context-changelog.json +++ b/docs/context-changelog.json @@ -321,6 +321,35 @@ "decisionRefs": [ "commercial-hosted-viewer" ] + }, + { + "id": "dependency-pinning-and-refresh-policy", + "date": "2026-08-24", + "type": "added", + "summary": "Decision 0008: pin classes, release-is-a-refresh, the 90-day floor, and the public security-response commitment.", + "paths": [ + "docs/decisions/0008-dependency-pinning-and-refresh.md", + "SECURITY.md" + ], + "categories": [ + "decisions", + "governance" + ], + "decisionRefs": [ + "dependency-pinning-and-refresh" + ] + }, + { + "id": "trust-boundary-metadata-file-exception", + "date": "2026-08-24", + "type": "changed", + "summary": "Trust boundary: the one named exception to the role rule (.leji/.gitignore, judged on the requested entry) and the runtime-assembled-key residual of its audit pin.", + "paths": [ + "docs/practice/trust-boundary.md" + ], + "categories": [ + "practice" + ] } ] } diff --git a/docs/context-index.json b/docs/context-index.json index fb5baad..5bcfcef 100644 --- a/docs/context-index.json +++ b/docs/context-index.json @@ -1,10 +1,10 @@ { "$schema": "https://leji.org/schemas/v1.0/context-index.schema.json", "schemaVersion": "1.0", - "generatedAt": "2026-08-21T13:04:16.912Z", + "generatedAt": "2026-08-25T23:11:39.546Z", "generator": { "name": "leji", - "version": "1.4.0" + "version": "1.4.1" }, "rootPath": "docs/", "entries": [ @@ -90,6 +90,16 @@ "0004-declare-spec-1-0-ga.md" ] }, + { + "id": "dependency-pinning-and-refresh", + "path": "docs/decisions/0008-dependency-pinning-and-refresh.md", + "title": "Dependency pinning and refresh policy", + "category": "decisions", + "kind": "record", + "date": "2026-08-24", + "lastModified": "2026-08-25", + "contentHash": "sha256:421dabd4dea5c4c8" + }, { "id": "glossary", "path": "docs/domain/glossary.md", @@ -136,8 +146,8 @@ "category": "practice", "kind": "intent", "summary": "What the leji CLI guarantees about where it writes and what it reads, and the one mechanism in each SDK that holds the guarantee.", - "lastModified": "2026-08-21", - "contentHash": "sha256:c651e5e0d6f2e10f", + "lastModified": "2026-08-25", + "contentHash": "sha256:97482200455e816d", "freshness": { "reviewAfter": "2027-02-17" } diff --git a/docs/decisions/0008-dependency-pinning-and-refresh.md b/docs/decisions/0008-dependency-pinning-and-refresh.md new file mode 100644 index 0000000..0186f6d --- /dev/null +++ b/docs/decisions/0008-dependency-pinning-and-refresh.md @@ -0,0 +1,114 @@ +--- +id: dependency-pinning-and-refresh +title: Dependency pinning and refresh policy +status: accepted +date: 2026-08-24 +deciders: + - Vuong Nguyen +affectedPaths: + - SECURITY.md + - .github/workflows/dependency-audit.yml + - .github/workflows/ci.yml + - packages/sdk-py/pyproject.toml + - package.json +affectedCategories: + - governance + - decisions +--- + +# Dependency pinning and refresh policy + +## Context + +This repository publishes into three package ecosystems, and every install on the +release path is pinned to an exact version, so that the toolchain and the +JavaScript and Go dependency sets are the same for any two builds of one commit; +the Python runtime dependencies are the one deliberate range, below. An exact pin without a rule that refreshes it is +drift with a timestamp on it: the versions stop moving, and the advisories keep +arriving. + +Two things were missing. Nothing said when pins move, so they moved when someone +noticed. And `SECURITY.md` said how to report a vulnerability without saying what +gets patched or how fast, which leaves a reporter guessing and leaves the project +free to answer differently every time. + +Dependabot covers part of the ground: security updates and the dependency graph +are on, scheduled version-update pull requests are off. Its security updates +reach direct dependencies in the ecosystems it parses, which is neither the whole +shipped dependency surface nor a schedule anyone committed to. + +## Decision + +### Pin classes + +- **Build and publish toolchain, GitHub Actions, JavaScript runtime + dependencies: exact.** Actions are pinned to full commit SHAs with the version + in a trailing comment. The `^1.4.0` ranges between packages inside this + monorepo are the one deliberate exception; they are aligned to the released + version at every release. +- **Python runtime dependencies: compatible ranges** (`jsonschema>=4.18,<5`, + `PyYAML>=6,<7`). A library on PyPI that pins its dependencies exactly makes + itself uninstallable next to everything else in an application's environment, + so the range is what the SDK owes its users. CI runs the supported range at + both ends rather than only at the newest interpreter. +- **Python development tools: exact**, since nothing installs them alongside + anything else. +- **Go: the minimal versions in `go.mod`**, verified against `go.sum`. +- **No `latest` and no `--upgrade` on the release path**, in any workflow, + manifest, or script. A check enforces this. + +### A release is a refresh + +Every release bumps each exact pin to the latest compatible version, re-pins +Actions to current commit SHAs, and runs the three dependency audits below. +Between releases, pins move only under the patching rule. + +### Security response + +Security reports are acknowledged and triaged on a best-effort basis, typically within 7 days. Where a compatible fix exists, a forward-only patch release follows, typically within 7 days for high or critical findings and at the next release otherwise; where none exists, we publish status and mitigation. Only the latest minor line receives patches. + +This covers shipped runtime dependencies, transitive dependencies, and the +publish path alike. For a transitive finding, `npm audit fix` is a first attempt +to be verified, never the resolution on its own. Released artifacts are never +modified: a fix is a new version. + +### The 90-day floor + +If no release goes out for 90 days, the refresh runs anyway. It ends in either a +maintenance patch release or a recorded finding that nothing needed to change. + +### The audit workflow + +`.github/workflows/dependency-audit.yml` runs monthly on a schedule, on demand, +on pull requests that touch a dependency manifest or the workflow itself, and on +every push to a release-candidate branch. It runs three scanners, each pinned: +`npm audit` over the root lockfile, `pip-audit` over a resolved export of the +Python SDK's dependencies, and `govulncheck` over the Go SDK. + +The failure threshold differs by ecosystem because the tools differ. `npm audit` +fails the run at high severity or above. `pip-audit` and `govulncheck` fail on +any finding: neither reports a severity that could be thresholded, and each +already narrows its report, `pip-audit` to what is installed and `govulncheck` to +what the code actually calls. + +## Consequences + +A scheduled run goes red when a genuine advisory lands against an unchanged pin, +including on a quiet month when nothing was committed. That is the point of the +schedule, and it is the only signal that arrives without a code change to trigger +it. The response rule above is what bounds it. + +Scheduled version-update pull requests stay off. The release refresh is the +mechanism that moves pins, and a parallel stream of update pull requests between +releases would compete with it while adding review load. Dependabot security +updates and the dependency graph stay on. + +Exact pins mean this project carries the cost of its own refresh at every +release, in exchange for a toolchain that resolves identically, JavaScript and Go +dependency sets that do too, and a supply chain where every version that ships +was chosen by someone. + +Compatible ranges on the Python runtime dependencies mean an adopter's installed +versions may differ from the ones CI resolved. That is the deliberate trade for +being installable, and it is why the Python audit runs against a resolved export +taken at audit time rather than against a checked-in list. diff --git a/docs/overview.md b/docs/overview.md index 51a0a52..ff3867b 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -4,22 +4,11 @@ This is the **Leji context layer** for `leji`: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by `leji viewer` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by `leji export`; this file is never rewritten. -```mermaid -flowchart LR - boot["🤖 Boot profile"] - cat_domain["📖 Domain · 1 doc"] - boot --> cat_domain - cat_system["⚙️ System · 1 doc"] - boot --> cat_system - cat_practice["🛠️ Practice · 3 docs"] - boot --> cat_practice - cat_decisions["🧭 Decisions · 7 docs"] - boot --> cat_decisions -``` + - Write a `mermaid` fenced code block in any document and it renders as a diagram here. diff --git a/docs/practice/trust-boundary.md b/docs/practice/trust-boundary.md index 135f479..0e1daea 100644 --- a/docs/practice/trust-boundary.md +++ b/docs/practice/trust-boundary.md @@ -28,6 +28,12 @@ federation store and projection cache). It is gitignored. Exactly one role is se serves as the chrome around your content; every other role is denied by name, and no export carries a byte of any of them. +The tree also ignores itself: the first time a command creates a role under `.leji/`, +it ensures `.leji/.gitignore` holds exactly `*`, so a repository whose own root +`.gitignore` never received the `.leji/` line still commits none of it. That one file +sits directly under `.leji/` and belongs to no role, which is why it is the single +named exception below. + ## The containment rule Every write and every clear is judged on the RESOLVED target, immediately before the act: @@ -40,7 +46,8 @@ Every write and every clear is judged on the RESOLVED target, immediately before 2. **Its own role, or no role.** A target that lands under root `.leji/` is refused unless the acting command owns that exact role. The export writes into `.leji/dist/` and nowhere else under `.leji/`; the viewer writes into `.leji/viewer/`; content such as - `overview.md` has no `.leji/` role at all, so any `.leji/` landing refuses it. + `overview.md` has no `.leji/` role at all, so any `.leji/` landing refuses it. The + metadata-file exception below is the one target this rule allows outside every role. 3. **Unresolvable is refused.** A path that cannot be resolved because of a permission or I/O error is not rebuilt from its spelling and written to. Only genuine absence is treated as a not-yet-created target, resolved through its nearest existing ancestor so @@ -62,7 +69,8 @@ Two sentences carry it, and every SDK implements both: it, so the file that was judged is the file that is read. That covers every read-modify-write in the tool: the `.gitignore` merge, the manifest edit that binds an agent, the pre-commit hook merge, the CI workflow merge, the agent-host settings - merge, the overview map refresh, the stored index that generation carries ids from, + merge, the overview page the viewer seeds and then renders from, + the stored index that generation carries ids from, the changelog compaction, the vendor entrypoints an adoption archives and rewrites, and the export marker that authorizes clearing a previous export. @@ -134,6 +142,35 @@ descriptor `openWriteGuarded` returned, and these three exceptions, which are al - **Verification staging under the OS temp directory**: verifying a cached projection is a read-only question, so asking it must not write into the tree being asked about. +### The metadata file, the one exception to the role rule + +Everything above is about which symbols may touch the filesystem raw. One exception is +about the role rule itself: `.leji/.gitignore`, the file that keeps the tool's tree out +of the repository. It sits directly under `.leji/`, so it belongs to no role and rule 2 +would refuse it. It is judged where the REQUESTED target is still visible, never by the +role rule, which sees only the resolved path, and only ALL THREE of these make it +writable: + +1. the requested path is exactly the layer root's `.leji/.gitignore`, and it resolves to + itself; +2. the root's `.leji` is a real directory, not a symlink; +3. the entry is absent or a regular file, not a symlink and not anything else. + +Any other case is refused, never allowed: a `.leji` symlinked out of the tree refuses +exactly as it does today, and an entry that redirects onto ordinary content is refused +rather than written through. Each SDK constructs this verdict at exactly ONE named +symbol, which its source-audit test pins the way the write allow-list is pinned, so +the exception cannot quietly grow a second site. That pin covers every statically spelled +construction of the verdict, reflective forms included; a key assembled at runtime is the +one class no static audit can see, and it is named here so it is a known residual, not a +gap nobody looked at. + +The file is written under the read-then-act rule like every other read-modify-write +here: what stands at the target is read verified, `*` plus a newline is created +exclusively when nothing does, a file already holding those bytes is left alone, and a +file holding anything else is left BYTE-IDENTICAL; the run says so once on stderr, +under every output mode including `--json`, and merges nothing. + The subprocess allowances are separate and just as explicit. Most are `git`, and all but one of those are read-only queries about the host repository; the exception is the federation resolver, whose `git init` and `git fetch` write only into a store or cache diff --git a/fixtures/README.md b/fixtures/README.md index 7c283d0..c3947b3 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -21,9 +21,9 @@ repository plus an `expected.json` stating what `leji validate` must report. behavior would depend on the host repository's git state. - Beyond `validate`, a fixture may pin other commands with optional blocks: `"conformance": {exit, claimedLevel, verifiedLevel}`, `"indexCheck": {exit, - stale}`, `"export"`, `"trustCanary"`, `"badge"` and `"updatePin"` (below), plus - `"seeds"` (below). Harnesses assert them only when present, and ignore keys they - do not know. + stale}`, `"export"`, `"trustCanary"`, `"lejiIgnore"`, `"badge"` and `"updatePin"` + (below), plus `"seeds"` (below). Harnesses assert them only when present, and + ignore keys they do not know. - Schema-violation fixtures keep one violation per artifact entry so finding multiplicity stays identical across validator engines (Ajv vs jsonschema). @@ -170,6 +170,65 @@ fixture's own `expected.json` sits inside the content root and is exported like any other file, so a token literal there would count as a leak, and the scans would need an exclusion. `occurrences: 0` is worth more with nothing excluded. +## The `lejiIgnore` block + +`"lejiIgnore"` pins the self-managed `.leji/.gitignore`: the tool ignores its own +tree from inside, so a repository whose root `.gitignore` never received the +`.leji/` line is still clean after the first command that creates a role under +`.leji/`. Asserted only when present. It carries an ARRAY of scenarios, because the +behavior turns on what already stands at the target and on which command runs, and +one layer serves several of those. + +The block is the frozen contract for all three SDKs: the same scenario letters, the +same trees, the same six questions. Paths are repository-root-relative POSIX inside +the fixture working copy. + +| Field | Meaning | +| --- | --- | +| `id` | the scenario letter, unique within the block | +| `note` | what this scenario exists to catch | +| `args` | argv after `leji`. The harness appends `--root ` | +| `plant` | optional: one symlink the harness plants **before** the run (below). A fixture cannot commit a symlink, and two of the scenarios are about one | +| `exit` | expected process exit code: `0` the run succeeded, `1` an error finding refused it | +| `ignoreFile` | what stands at `.leji/.gitignore` after the run, judged on the ORIGINAL entry: `regular`, `symlink` (the planted link, never followed), or `absent` | +| `bytes` | the exact content the file must hold when `ignoreFile` is `regular`; `null` otherwise. The created form is always `*` plus one newline | +| `notices` | how many times the frozen stderr line `leji: .leji/.gitignore exists and was left as is (expected content: *)` appears. One invocation says it at most once, whatever it establishes | +| `untrackedUnderLeji` | optional: `git status --porcelain` entries under the root `.leji/` after the run, sorted. Always `[]`. Asserted over a COMMITTED working copy (`git init`, `git add -A`, commit, then run), because the question is meaningless over an uncommitted tree | +| `preserved` | optional: paths present before the run that must still be byte-identical after it | +| `jsonParses` | optional: `true` means `args` carries `--json` and stdout must parse as one JSON document that does not carry the notice (it is stderr only, under every output mode) | + +### The `plant` field + +| Field | Meaning | +| --- | --- | +| `symlinkAt` | where the harness creates the symlink | +| `symlinkTo` | what it points at: a fixture-root-relative POSIX path, or the literal `outside`, which the harness resolves to a directory it creates BESIDE the working copy (the one shape no contained path can express) | +| `targetKind` | `dir` or `file`: what the harness creates at the target before linking to it | +| `targetBytes` | for `file`, the bytes it is created with | + +### The six scenarios + +Lettered, and the letters are part of the contract: + +- **(A)** a fresh layer with no root `.leji/` line, `leji viewer build` → the file is + exactly `*` plus a newline and nothing under `.leji/` is untracked; +- **(B)** the same layer, `leji export` → ONE invocation establishing two roles + (chrome and export output) writes one file and emits no notice; +- **(C)** a layer adopted before the unified layout, carrying an old `docs/.leji/` + tree and no root line → nothing under the ROOT `.leji/` is untracked (the + `docs/.leji/` leftovers are the documented migration case, not this one's); +- **(D)** a pre-existing `.leji/.gitignore` with somebody else's content → byte-identical + after the run, exactly one stderr notice, and `--json` stdout still parses; +- **(E)** `.leji` is a symlink out of the repository → refused as it is today, and no + file is written through it; +- **(F)** `.leji/.gitignore` is itself a symlink → refused, and the file it points at is + untouched. + +The swap case (an entry planted between the verified read and the exclusive create) +is unit-level in each SDK rather than here, for the same reason the `updatePin` +refusals are: producing it means driving the library with an interception, not +preparing a state. + ## The `badge` block `"badge"` pins one `leji badge` run over the fixture layer. Asserted only when @@ -522,6 +581,53 @@ them one surface. the line alone and the summary starts on the next line at the column, so it is never concatenated onto the label. +## Snapshot contract: `fixtures/snapshot-contract/` + +The byte contract for the tree-snapshot helper each SDK's badge and canary suites +share. It is not a layer and carries no `expected.json`, so every layer harness +skips it; `leji-test.json` carries its declaration instead. One serialization, +asserted by one golden test per SDK, is what keeps the three helpers a single +contract rather than three that drift. + +A snapshot is one line per entry, paths POSIX and relative to the walked directory, +the lines sorted **bytewise over UTF-8**: + +| Entry | Line | +| --- | --- | +| regular file | `pathsha256:` | +| directory | `path/dir`, so a created empty directory is visible | +| symlink or any other non-regular entry | `pathnon-regular`, never followed | + +Exactly one entry is excluded: `/.git`, when it lies inside the walked +directory. `repoRoot` defaults to the walked directory, which is the +whole-repository call; a subtree call passes the repository root explicitly, so a +nested `.git` stays content. + +- **The walked tree is `payload/`, and nothing else is inside it.** The seed + sources and the goldens are siblings of `payload/`, never children: the payload + is walked whole, so a golden placed inside it would have to contain its own + digest, and a seed source inside it would be recorded as content. That is also + why these seed sources are not dot-prefixed the way `.leji-seed/` is. A + dot-prefix exists to hide a seed from a content walk that would otherwise export + it; this walk records every entry it is given, dot-prefixed or not, so isolation + is positional here rather than by name. +- **The two `.git` seeds follow the seed convention** (`from`/`to`, materialized by + the harness into its own working copy): `_git-seed` to `payload/.git` and + `pkg-git-seed` to `payload/pkg/.git`. Git refuses to track a directory named + `.git`, and the two together are what the exclusion contract is about: the first + is the repository's own and is excluded, the second is ordinary content. +- **Two entries are created by the golden test, not committed:** `payload/empty/`, + because git tracks no empty directory, and the symlink `payload/link`, because a + seed carries no symlink. `leji-test.json` declares both. +- **The goldens are frozen bytes.** `golden-repo.txt` is `payload` walked with + `repoRoot` `payload`; `golden-subtree.txt` is `payload/pkg` walked with + `repoRoot` `payload`. The third case, `payload/pkg` walked as its own repository + root, is the subtree golden minus its `.git` lines, derived by the test. +- **`payload/z.txt` and `payload/😀.txt` pin the sort.** Their UTF-8 order + (`EF BD 9A` before `F0 9F 98 80`) is the reverse of their UTF-16 code-unit order, + so an implementation sorting UTF-16 units, or sorting decoded paths in a runtime + that orders them that way, fails the golden instead of passing on ASCII. + ## Ecosystem detection: `fixtures/ecosystem/` `fixtures/ecosystem/` is not a layer family (111 cases: the detection decision diff --git a/fixtures/release-path/pins/bad/1-latest/.github/workflows/latest.yml b/fixtures/release-path/pins/bad/1-latest/.github/workflows/latest.yml new file mode 100644 index 0000000..de20459 --- /dev/null +++ b/fixtures/release-path/pins/bad/1-latest/.github/workflows/latest.yml @@ -0,0 +1,12 @@ +name: Floating tag +# Rule 1: the regression this whole gate exists for, a tool taken at @latest. +# Whatever installs it, the tag is the defect. + +on: [push] + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: go install golang.org/x/vuln/cmd/govulncheck@latest diff --git a/fixtures/release-path/pins/bad/2-dynamic-expansion/.github/workflows/dynamic.yml b/fixtures/release-path/pins/bad/2-dynamic-expansion/.github/workflows/dynamic.yml new file mode 100644 index 0000000..f6927cd --- /dev/null +++ b/fixtures/release-path/pins/bad/2-dynamic-expansion/.github/workflows/dynamic.yml @@ -0,0 +1,12 @@ +name: Install from a variable +# Rule 2: the requirement is decided at runtime, so no reader of this file can +# say what gets installed. Exactly pinned is a property of the text, not of a run. + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: pip install "$PACKAGE" diff --git a/fixtures/release-path/pins/bad/2-expanded-url/scripts/expanded-url.sh b/fixtures/release-path/pins/bad/2-expanded-url/scripts/expanded-url.sh new file mode 100644 index 0000000..2f0fe07 --- /dev/null +++ b/fixtures/release-path/pins/bad/2-expanded-url/scripts/expanded-url.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Rule 2: a slash proves nothing. This target is a URL a variable decides, so +# what gets installed is chosen off the release path entirely. + +bad_url_install() { + "$1/bin/pip" install --quiet "$URL/tool.whl" +} diff --git a/fixtures/release-path/pins/bad/2-glob-requirement/scripts/glob-install.sh b/fixtures/release-path/pins/bad/2-glob-requirement/scripts/glob-install.sh new file mode 100644 index 0000000..5f733e8 --- /dev/null +++ b/fixtures/release-path/pins/bad/2-glob-requirement/scripts/glob-install.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Rule 2: a glob installs whatever happens to match at the time, which is one +# stale artifact away from publishing the wrong bytes. + +bad_glob() { + "$1/bin/pip" install --quiet ./wheels/*.whl +} diff --git a/fixtures/release-path/pins/bad/2-lib-script/scripts/lib/bare-pip.sh b/fixtures/release-path/pins/bad/2-lib-script/scripts/lib/bare-pip.sh new file mode 100644 index 0000000..bf95d5b --- /dev/null +++ b/fixtures/release-path/pins/bad/2-lib-script/scripts/lib/bare-pip.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Rule 2: a library script installs through a venv's own pip, which is where an +# unpinned install hides once the workflows themselves are clean. + +bad_install() { + "$1/bin/pip" install --quiet twine +} diff --git a/fixtures/release-path/pins/bad/2-multi-command/.github/workflows/multi-command.yml b/fixtures/release-path/pins/bad/2-multi-command/.github/workflows/multi-command.yml new file mode 100644 index 0000000..9bcc6c9 --- /dev/null +++ b/fixtures/release-path/pins/bad/2-multi-command/.github/workflows/multi-command.yml @@ -0,0 +1,12 @@ +name: Second command on the line +# Rule 2: the unpinned install is not the first command on the line, so a +# scanner that reads only the start of a line misses it. + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: python -m venv .venv && pip install build diff --git a/fixtures/release-path/pins/bad/2-requirements-file/.github/workflows/requirements.yml b/fixtures/release-path/pins/bad/2-requirements-file/.github/workflows/requirements.yml new file mode 100644 index 0000000..ff5f6ea --- /dev/null +++ b/fixtures/release-path/pins/bad/2-requirements-file/.github/workflows/requirements.yml @@ -0,0 +1,12 @@ +name: Install from a requirements file +# Rule 2: a requirements file is a second list of installs that this scanner +# never reads. Nothing on the release path needs one. + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: python -m pip install -r requirements.txt diff --git a/fixtures/release-path/pins/bad/2-unmarked-local-whl/scripts/unmarked-whl.sh b/fixtures/release-path/pins/bad/2-unmarked-local-whl/scripts/unmarked-whl.sh new file mode 100644 index 0000000..af09e20 --- /dev/null +++ b/fixtures/release-path/pins/bad/2-unmarked-local-whl/scripts/unmarked-whl.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +# Rule 2: the right shape without the declaration. The exception is the marker, +# not the path: an unmarked expansion is a violation however local it looks, and +# the pip half of the exception is no looser than the npm half. + +bad_unmarked_wheel() { + "$1/bin/pip" install --quiet "$TMP/pybuild/dist/$WHL_STEM.whl" +} diff --git a/fixtures/release-path/pins/bad/3-expanded-tgz-url/.github/workflows/expanded-tgz.yml b/fixtures/release-path/pins/bad/3-expanded-tgz-url/.github/workflows/expanded-tgz.yml new file mode 100644 index 0000000..98d3c54 --- /dev/null +++ b/fixtures/release-path/pins/bad/3-expanded-tgz-url/.github/workflows/expanded-tgz.yml @@ -0,0 +1,12 @@ +name: Global install from an expanded URL +# Rule 3: it ends in .tgz and it is still not a local artifact. Only a target +# rooted at $ROOT/ on a marked line is the built-here case. + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: npm install -g "$URL/tool.tgz" diff --git a/fixtures/release-path/pins/bad/3-marked-var-tail/scripts/var-tail.sh b/fixtures/release-path/pins/bad/3-marked-var-tail/scripts/var-tail.sh new file mode 100644 index 0000000..70a9b82 --- /dev/null +++ b/fixtures/release-path/pins/bad/3-marked-var-tail/scripts/var-tail.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +# Rule 3: marked, rooted, and still refused. The target ends in a variable, so +# nothing in this text says it is an archive: only a literal suffix does. The +# fix is to spell the suffix out, as the smoke does. + +bad_var_tail() { + npm i -g "$ROOT/$TGZ" >/dev/null 2>&1 # release-pins: local artifact built above +} diff --git a/fixtures/release-path/pins/bad/3-npm-global/.github/workflows/npm-global.yml b/fixtures/release-path/pins/bad/3-npm-global/.github/workflows/npm-global.yml new file mode 100644 index 0000000..060e467 --- /dev/null +++ b/fixtures/release-path/pins/bad/3-npm-global/.github/workflows/npm-global.yml @@ -0,0 +1,11 @@ +name: Unpinned global npm +# Rule 3: a global npm install with no version at all. + +on: [push] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: npm install -g npm && npm --version diff --git a/fixtures/release-path/pins/bad/3-unmarked-local-tgz/scripts/unmarked-local.sh b/fixtures/release-path/pins/bad/3-unmarked-local-tgz/scripts/unmarked-local.sh new file mode 100644 index 0000000..3981a43 --- /dev/null +++ b/fixtures/release-path/pins/bad/3-unmarked-local-tgz/scripts/unmarked-local.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Rule 3: the right shape without the declaration. The exception is the marker, +# not the path: an unmarked expansion is a violation however local it looks. + +bad_unmarked() { + npm i "$ROOT/x.tgz" +} diff --git a/fixtures/release-path/pins/bad/4-unpinned-uses/.github/workflows/unpinned-uses.yml b/fixtures/release-path/pins/bad/4-unpinned-uses/.github/workflows/unpinned-uses.yml new file mode 100644 index 0000000..62de66a --- /dev/null +++ b/fixtures/release-path/pins/bad/4-unpinned-uses/.github/workflows/unpinned-uses.yml @@ -0,0 +1,10 @@ +name: Action pinned by tag +# Rule 4: a movable tag is not a pin, however precise it looks. + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 diff --git a/fixtures/release-path/pins/bad/5-go-version/.github/workflows/go-version.yml b/fixtures/release-path/pins/bad/5-go-version/.github/workflows/go-version.yml new file mode 100644 index 0000000..7210030 --- /dev/null +++ b/fixtures/release-path/pins/bad/5-go-version/.github/workflows/go-version.yml @@ -0,0 +1,13 @@ +name: Go minor line +# Rule 5: a go-version without a patch component resolves to whatever the +# runner's toolchain has cached that week. + +on: [push] + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.23" diff --git a/fixtures/release-path/pins/bad/6-toml-range/packages/sdk-py/pyproject.toml b/fixtures/release-path/pins/bad/6-toml-range/packages/sdk-py/pyproject.toml new file mode 100644 index 0000000..0f10426 --- /dev/null +++ b/fixtures/release-path/pins/bad/6-toml-range/packages/sdk-py/pyproject.toml @@ -0,0 +1,18 @@ +# Rule 6: a dev extra left as a range. The build requirements are exact, so the +# fixture trips one rule and only one. + +[build-system] +requires = ["hatchling==1.32.0"] +build-backend = "hatchling.build" + +[project] +name = "bad" +version = "0.0.0" +dependencies = [ + "jsonschema>=4.18,<5", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7,<9", +] diff --git a/fixtures/release-path/pins/bad/7-action-sha/.github/workflows/publish.yml b/fixtures/release-path/pins/bad/7-action-sha/.github/workflows/publish.yml new file mode 100644 index 0000000..0fc645b --- /dev/null +++ b/fixtures/release-path/pins/bad/7-action-sha/.github/workflows/publish.yml @@ -0,0 +1,14 @@ +name: Publish action drifted +# Rule 7: a 40-character SHA that rule 4 accepts, but not the SHA the pin tuple +# names. The comment still says the pinned version, which is exactly how an +# action bump leaves the local twine gate standing in for the wrong upload. + +on: [push] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.14.2 + with: + packages-dir: dist diff --git a/fixtures/release-path/pins/bad/7-twine-literal/scripts/lib/twine-literal.sh b/fixtures/release-path/pins/bad/7-twine-literal/scripts/lib/twine-literal.sh new file mode 100644 index 0000000..3fe765d --- /dev/null +++ b/fixtures/release-path/pins/bad/7-twine-literal/scripts/lib/twine-literal.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# Rule 7: exactly pinned, so rule 2 is satisfied, and still wrong: the version +# is a literal that no longer matches the tuple in scripts/release-pins.env. + +bad_twine() { + "$1/bin/pip" install --quiet "twine==6.1.0" +} diff --git a/fixtures/release-path/pins/bad/8-expanded-npx/.github/workflows/expanded-npx.yml b/fixtures/release-path/pins/bad/8-expanded-npx/.github/workflows/expanded-npx.yml new file mode 100644 index 0000000..4b13e2c --- /dev/null +++ b/fixtures/release-path/pins/bad/8-expanded-npx/.github/workflows/expanded-npx.yml @@ -0,0 +1,12 @@ +name: npx from a variable +# Rule 8: npx fetches what the expansion names. There is no local-artifact case +# for it, so there is no exception either. + +on: [push] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: npx "$PACKAGE" publish diff --git a/fixtures/release-path/pins/bad/8-npx-unpinned/.github/workflows/npx.yml b/fixtures/release-path/pins/bad/8-npx-unpinned/.github/workflows/npx.yml new file mode 100644 index 0000000..b5c8943 --- /dev/null +++ b/fixtures/release-path/pins/bad/8-npx-unpinned/.github/workflows/npx.yml @@ -0,0 +1,12 @@ +name: npx without a version +# Rule 8: npx fetches the package at run time, so an unpinned name here is the +# same floating install as npm install -g, one step closer to a registry. + +on: [push] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: npx --yes jsr publish diff --git a/fixtures/release-path/pins/bad/9-action-version-range/.github/workflows/action-version.yml b/fixtures/release-path/pins/bad/9-action-version-range/.github/workflows/action-version.yml new file mode 100644 index 0000000..7b15ff2 --- /dev/null +++ b/fixtures/release-path/pins/bad/9-action-version-range/.github/workflows/action-version.yml @@ -0,0 +1,14 @@ +name: Action version range +# Rule 9: the action is SHA-pinned, and the tool it installs is not. A range +# input is a floating install wearing a pinned action's clothes. + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + version: "~> v2" + args: release --clean diff --git a/fixtures/release-path/pins/good/.github/workflows/pinned.yml b/fixtures/release-path/pins/good/.github/workflows/pinned.yml new file mode 100644 index 0000000..5000926 --- /dev/null +++ b/fixtures/release-path/pins/good/.github/workflows/pinned.yml @@ -0,0 +1,38 @@ +name: Good +# Every shape the checker must accept: SHA-pinned actions with their version +# comment, a patch-level go-version, an exact global npm install, an exact npx +# package, an exact action version input, exact pip requirements, the option +# forms that carry no version at all, a trailing comment on an install line, and +# a whole-line comment that names an unpinned install without performing one. + +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.27.0" + - run: npm install -g npm@12.0.2 && npm --version + - run: npx --yes jsr@0.14.3 publish --dry-run + - name: Install + working-directory: packages/sdk-py + run: | + # A comment may say pip install build or npm install -g npm@latest: + # it installs nothing, so it is not a violation. + python -m pip install pip==26.2.1 + pip install -e ".[dev]" + pip install build==1.5.0 # the same build the workflow pins + - name: Build binaries + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + version: "v2.18.0" + args: release --clean + - name: Publish + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: packages/sdk-py/dist diff --git a/fixtures/release-path/pins/good/packages/sdk-py/pyproject.toml b/fixtures/release-path/pins/good/packages/sdk-py/pyproject.toml new file mode 100644 index 0000000..28c9116 --- /dev/null +++ b/fixtures/release-path/pins/good/packages/sdk-py/pyproject.toml @@ -0,0 +1,22 @@ +# Exact build requirements and dev extras; runtime dependencies stay ranges +# (library etiquette on PyPI), which rule 6 must not object to. + +[build-system] +requires = ["hatchling==1.32.0"] +build-backend = "hatchling.build" + +[project] +name = "good" +version = "0.0.0" +requires-python = ">=3.10" +dependencies = [ + "jsonschema>=4.18,<5", + "PyYAML>=6,<7", +] + +[project.optional-dependencies] +dev = [ + "pytest==8.4.2", + "coverage==7.15.4", + "build==1.5.0", +] diff --git a/fixtures/release-path/pins/good/scripts/lib/pinned-lib.sh b/fixtures/release-path/pins/good/scripts/lib/pinned-lib.sh new file mode 100644 index 0000000..393d445 --- /dev/null +++ b/fixtures/release-path/pins/good/scripts/lib/pinned-lib.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +# A library script under scripts/lib is scanned exactly like the rest. + +good_install() { + "$1/bin/pip" install --quiet "twine==${TWINE_VERSION}" +} diff --git a/fixtures/release-path/pins/good/scripts/pinned.sh b/fixtures/release-path/pins/good/scripts/pinned.sh new file mode 100644 index 0000000..37d5431 --- /dev/null +++ b/fixtures/release-path/pins/good/scripts/pinned.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +# A comment may name pip install build or npm install -g npm@latest without +# installing anything; the checker reads executable lines only. + +"$1/bin/pip" install --quiet "pip==${PIP_VERSION}" "build==${BUILD_VERSION}" + +# The one expanded-target exception, one shape for both installers: an artifact +# this run built, rooted at $ROOT or $TMP, ending in a literal archive suffix, +# declared by the marker that closes the line. These are the smoke's three cold +# installs. +npm i -g --prefix "$2" "$ROOT/$TGZ_STEM.tgz" >/dev/null 2>&1 # release-pins: local artifact built above +npm i --prefix "$2" --offline --no-audit --no-fund "$ROOT/$CTGZ_STEM.tgz" "$ROOT/$TGZ_STEM.tgz" >/dev/null 2>&1 # release-pins: local artifact built above +"$1/bin/pip" install --quiet "$TMP/pybuild/dist/$WHL_STEM.whl" >/dev/null 2>&1 # release-pins: local artifact built above diff --git a/fixtures/snapshot-contract/_git-seed/HEAD b/fixtures/snapshot-contract/_git-seed/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/fixtures/snapshot-contract/_git-seed/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/fixtures/snapshot-contract/golden-repo.txt b/fixtures/snapshot-contract/golden-repo.txt new file mode 100644 index 0000000..72fb5cc --- /dev/null +++ b/fixtures/snapshot-contract/golden-repo.txt @@ -0,0 +1,9 @@ +a.txt sha256:87428fc522803d31065e7bce3cf03fe475096631e5e07bbd7a0fde60c4cf25c7 +empty/ dir +link non-regular +pkg/ dir +pkg/.git/ dir +pkg/.git/HEAD sha256:f673dd0346a6cea55edaa4aa670156c39e90443e056cd59510420afb87338724 +pkg/b.txt sha256:0263829989b6fd954f72baaf2fc64bc2e2f01d692d4de72986ea808f6e99813f +z.txt sha256:9b56f7623d4f5e2f00b1517a8f60341ae9ff21ff36e34fa53357cf358ff28752 +😀.txt sha256:d744e0ee836148320d73e0d69631b60cc9eb4ec732cf9d78afd0d70661fcff31 diff --git a/fixtures/snapshot-contract/golden-subtree.txt b/fixtures/snapshot-contract/golden-subtree.txt new file mode 100644 index 0000000..6c2c25b --- /dev/null +++ b/fixtures/snapshot-contract/golden-subtree.txt @@ -0,0 +1,3 @@ +.git/ dir +.git/HEAD sha256:f673dd0346a6cea55edaa4aa670156c39e90443e056cd59510420afb87338724 +b.txt sha256:0263829989b6fd954f72baaf2fc64bc2e2f01d692d4de72986ea808f6e99813f diff --git a/fixtures/snapshot-contract/leji-test.json b/fixtures/snapshot-contract/leji-test.json new file mode 100644 index 0000000..fb7fc9b --- /dev/null +++ b/fixtures/snapshot-contract/leji-test.json @@ -0,0 +1,30 @@ +{ + "note": "The byte contract for each SDK's shared badge/canary tree-snapshot helper. Not a layer: it carries no expected.json, so the layer harnesses skip it. The walked payload is payload/; the seeds and the goldens sit beside it, outside the walk, so a golden never has to contain its own digest.", + "seeds": [ + { "from": "_git-seed", "to": "payload/.git" }, + { "from": "pkg-git-seed", "to": "payload/pkg/.git" } + ], + "ordering": { + "note": "The declared order is bytewise over UTF-8. payload/z.txt (EF BD 9A) and payload/😀.txt (F0 9F 98 80, UTF-16 D83D DE00) are the vector that separates it from a UTF-16 code-unit sort: bytewise puts the first before the second, a JavaScript default sort puts them the other way, so a port that sorts UTF-16 units fails the golden instead of passing on ASCII.", + "vectors": ["payload/z.txt", "payload/😀.txt"] + }, + "runtime": { + "note": "git tracks neither an empty directory nor a symlink target this fixture may keep dangling, so the golden test creates both in its own working copy before it walks.", + "directories": ["payload/empty"], + "symlinks": [{ "at": "payload/link", "to": "a.txt" }] + }, + "goldens": { + "repo": { + "file": "golden-repo.txt", + "walk": "payload", + "repoRoot": "payload", + "note": "the whole-repository call: the repository's own .git is excluded, pkg/.git is content" + }, + "subtree": { + "file": "golden-subtree.txt", + "walk": "payload/pkg", + "repoRoot": "payload", + "note": "the subtree call: paths are relative to the walked directory and the subtree's .git is content. The same walk with repoRoot payload/pkg drops the .git lines, which the golden test derives from this file." + } + } +} diff --git a/fixtures/snapshot-contract/payload/a.txt b/fixtures/snapshot-contract/payload/a.txt new file mode 100644 index 0000000..7898192 --- /dev/null +++ b/fixtures/snapshot-contract/payload/a.txt @@ -0,0 +1 @@ +a diff --git a/fixtures/snapshot-contract/payload/pkg/b.txt b/fixtures/snapshot-contract/payload/pkg/b.txt new file mode 100644 index 0000000..6178079 --- /dev/null +++ b/fixtures/snapshot-contract/payload/pkg/b.txt @@ -0,0 +1 @@ +b diff --git "a/fixtures/snapshot-contract/payload/\357\275\232.txt" "b/fixtures/snapshot-contract/payload/\357\275\232.txt" new file mode 100644 index 0000000..6021412 --- /dev/null +++ "b/fixtures/snapshot-contract/payload/\357\275\232.txt" @@ -0,0 +1 @@ +z diff --git "a/fixtures/snapshot-contract/payload/\360\237\230\200.txt" "b/fixtures/snapshot-contract/payload/\360\237\230\200.txt" new file mode 100644 index 0000000..b41d3db --- /dev/null +++ "b/fixtures/snapshot-contract/payload/\360\237\230\200.txt" @@ -0,0 +1 @@ +😀 diff --git a/fixtures/snapshot-contract/pkg-git-seed/HEAD b/fixtures/snapshot-contract/pkg-git-seed/HEAD new file mode 100644 index 0000000..97d1722 --- /dev/null +++ b/fixtures/snapshot-contract/pkg-git-seed/HEAD @@ -0,0 +1 @@ +ref: refs/heads/nested diff --git a/fixtures/valid-leji-ignore-existing/.leji-seed/.gitignore b/fixtures/valid-leji-ignore-existing/.leji-seed/.gitignore new file mode 100644 index 0000000..545fdbd --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/.leji-seed/.gitignore @@ -0,0 +1,2 @@ +# kept by the repository owner +!keep-me diff --git a/fixtures/valid-leji-ignore-existing/docs/_sidebar.md b/fixtures/valid-leji-ignore-existing/docs/_sidebar.md new file mode 100644 index 0000000..cfd2043 --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/docs/_sidebar.md @@ -0,0 +1,5 @@ +- [Boot profile](boot-profile.md) +- Domain + - [Overview](domain/overview.md) +- Decisions + - [Adopt the Leji context layer](decisions/0001-adopt-leji.md) diff --git a/fixtures/valid-leji-ignore-existing/docs/boot-profile.md b/fixtures/valid-leji-ignore-existing/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/docs/boot-profile.md @@ -0,0 +1,17 @@ +# Boot Profile + +## Identity + +A fixture context layer. + +## Loading + +Read `docs/domain/` before any task. + +## Posture + +- Stop and ask before destructive changes. + +## Maintenance + +Decisions are recorded in `docs/decisions/`. diff --git a/fixtures/valid-leji-ignore-existing/docs/context/decisions.md b/fixtures/valid-leji-ignore-existing/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-leji-ignore-existing/docs/context/domain.md b/fixtures/valid-leji-ignore-existing/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-leji-ignore-existing/docs/decisions/0001-adopt-leji.md b/fixtures/valid-leji-ignore-existing/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/docs/decisions/0001-adopt-leji.md @@ -0,0 +1,20 @@ +--- +id: adopt-leji +title: Adopt the Leji context layer +status: accepted +date: 2026-06-12 +--- + +# Adopt the Leji context layer + +## Context + +Fixture decision context. + +## Decision + +Adopt Leji at the core level. + +## Consequences + +Fixture consequences. diff --git a/fixtures/valid-leji-ignore-existing/docs/domain/overview.md b/fixtures/valid-leji-ignore-existing/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-leji-ignore-existing/docs/index.html b/fixtures/valid-leji-ignore-existing/docs/index.html new file mode 100644 index 0000000..f6a3f7e --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/docs/index.html @@ -0,0 +1,30 @@ + + + + + fixture + + + + +
Loading the context layer…
+ + + + diff --git a/fixtures/valid-leji-ignore-existing/expected.json b/fixtures/valid-leji-ignore-existing/expected.json new file mode 100644 index 0000000..3d253e8 --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/expected.json @@ -0,0 +1,26 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "seeds": [ + { + "from": ".leji-seed", + "to": ".leji" + } + ], + "lejiIgnore": { + "scenarios": [ + { + "id": "D", + "note": "Somebody else's .leji/.gitignore is left byte-identical, said once on stderr, and never in the JSON document.", + "args": ["export", "--json"], + "exit": 0, + "ignoreFile": "regular", + "bytes": "# kept by the repository owner\n!keep-me\n", + "notices": 1, + "jsonParses": true + } + ] + } +} diff --git a/fixtures/valid-leji-ignore-existing/leji.json b/fixtures/valid-leji-ignore-existing/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-leji-ignore-existing/leji.json @@ -0,0 +1,23 @@ +{ + "leji": "1.0", + "name": "fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + }, + "decisions": { + "indexes": [ + "docs/context/decisions.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/valid-leji-ignore-fresh/docs/_sidebar.md b/fixtures/valid-leji-ignore-fresh/docs/_sidebar.md new file mode 100644 index 0000000..cfd2043 --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/docs/_sidebar.md @@ -0,0 +1,5 @@ +- [Boot profile](boot-profile.md) +- Domain + - [Overview](domain/overview.md) +- Decisions + - [Adopt the Leji context layer](decisions/0001-adopt-leji.md) diff --git a/fixtures/valid-leji-ignore-fresh/docs/boot-profile.md b/fixtures/valid-leji-ignore-fresh/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/docs/boot-profile.md @@ -0,0 +1,17 @@ +# Boot Profile + +## Identity + +A fixture context layer. + +## Loading + +Read `docs/domain/` before any task. + +## Posture + +- Stop and ask before destructive changes. + +## Maintenance + +Decisions are recorded in `docs/decisions/`. diff --git a/fixtures/valid-leji-ignore-fresh/docs/context/decisions.md b/fixtures/valid-leji-ignore-fresh/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-leji-ignore-fresh/docs/context/domain.md b/fixtures/valid-leji-ignore-fresh/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-leji-ignore-fresh/docs/decisions/0001-adopt-leji.md b/fixtures/valid-leji-ignore-fresh/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/docs/decisions/0001-adopt-leji.md @@ -0,0 +1,20 @@ +--- +id: adopt-leji +title: Adopt the Leji context layer +status: accepted +date: 2026-06-12 +--- + +# Adopt the Leji context layer + +## Context + +Fixture decision context. + +## Decision + +Adopt Leji at the core level. + +## Consequences + +Fixture consequences. diff --git a/fixtures/valid-leji-ignore-fresh/docs/domain/overview.md b/fixtures/valid-leji-ignore-fresh/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-leji-ignore-fresh/docs/index.html b/fixtures/valid-leji-ignore-fresh/docs/index.html new file mode 100644 index 0000000..f6a3f7e --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/docs/index.html @@ -0,0 +1,30 @@ + + + + + fixture + + + + +
Loading the context layer…
+ + + + diff --git a/fixtures/valid-leji-ignore-fresh/expected.json b/fixtures/valid-leji-ignore-fresh/expected.json new file mode 100644 index 0000000..5c057aa --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/expected.json @@ -0,0 +1,60 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "lejiIgnore": { + "scenarios": [ + { + "id": "A", + "note": "A layer whose root .gitignore never carried the .leji/ line is clean after the first role creation.", + "args": ["viewer", "build"], + "exit": 0, + "ignoreFile": "regular", + "bytes": "*\n", + "notices": 0, + "untrackedUnderLeji": [] + }, + { + "id": "B", + "note": "One invocation establishing two roles (chrome and export output) writes one file and says nothing.", + "args": ["export"], + "exit": 0, + "ignoreFile": "regular", + "bytes": "*\n", + "notices": 0, + "untrackedUnderLeji": [] + }, + { + "id": "E", + "note": "A .leji symlinked out of the repository is refused as it is today; no file is written through it.", + "plant": { + "symlinkAt": ".leji", + "symlinkTo": "outside", + "targetKind": "dir" + }, + "args": ["viewer", "build"], + "exit": 1, + "ignoreFile": "absent", + "bytes": null, + "notices": 0 + }, + { + "id": "F", + "note": "A symlinked .leji/.gitignore is refused, and the file it points at is untouched.", + "plant": { + "symlinkAt": ".leji/.gitignore", + "symlinkTo": "decoy.txt", + "targetKind": "file", + "targetBytes": "not the ignore file\n" + }, + "args": ["viewer", "build"], + "exit": 1, + "ignoreFile": "symlink", + "bytes": null, + "notices": 0, + "preserved": ["decoy.txt"] + } + ] + } +} diff --git a/fixtures/valid-leji-ignore-fresh/leji.json b/fixtures/valid-leji-ignore-fresh/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-leji-ignore-fresh/leji.json @@ -0,0 +1,23 @@ +{ + "leji": "1.0", + "name": "fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + }, + "decisions": { + "indexes": [ + "docs/context/decisions.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/valid-leji-ignore-legacy/docs/.leji-seed/viewer/_sidebar.md b/fixtures/valid-leji-ignore-legacy/docs/.leji-seed/viewer/_sidebar.md new file mode 100644 index 0000000..567d941 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/.leji-seed/viewer/_sidebar.md @@ -0,0 +1 @@ +# generated by an earlier release; nothing in 1.4.x reads it diff --git a/fixtures/valid-leji-ignore-legacy/docs/.leji-seed/viewer/index.html b/fixtures/valid-leji-ignore-legacy/docs/.leji-seed/viewer/index.html new file mode 100644 index 0000000..128fc0d --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/.leji-seed/viewer/index.html @@ -0,0 +1,2 @@ + +stale chrome diff --git a/fixtures/valid-leji-ignore-legacy/docs/_sidebar.md b/fixtures/valid-leji-ignore-legacy/docs/_sidebar.md new file mode 100644 index 0000000..cfd2043 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/_sidebar.md @@ -0,0 +1,5 @@ +- [Boot profile](boot-profile.md) +- Domain + - [Overview](domain/overview.md) +- Decisions + - [Adopt the Leji context layer](decisions/0001-adopt-leji.md) diff --git a/fixtures/valid-leji-ignore-legacy/docs/boot-profile.md b/fixtures/valid-leji-ignore-legacy/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/boot-profile.md @@ -0,0 +1,17 @@ +# Boot Profile + +## Identity + +A fixture context layer. + +## Loading + +Read `docs/domain/` before any task. + +## Posture + +- Stop and ask before destructive changes. + +## Maintenance + +Decisions are recorded in `docs/decisions/`. diff --git a/fixtures/valid-leji-ignore-legacy/docs/context/decisions.md b/fixtures/valid-leji-ignore-legacy/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-leji-ignore-legacy/docs/context/domain.md b/fixtures/valid-leji-ignore-legacy/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-leji-ignore-legacy/docs/decisions/0001-adopt-leji.md b/fixtures/valid-leji-ignore-legacy/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/decisions/0001-adopt-leji.md @@ -0,0 +1,20 @@ +--- +id: adopt-leji +title: Adopt the Leji context layer +status: accepted +date: 2026-06-12 +--- + +# Adopt the Leji context layer + +## Context + +Fixture decision context. + +## Decision + +Adopt Leji at the core level. + +## Consequences + +Fixture consequences. diff --git a/fixtures/valid-leji-ignore-legacy/docs/domain/overview.md b/fixtures/valid-leji-ignore-legacy/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-leji-ignore-legacy/docs/index.html b/fixtures/valid-leji-ignore-legacy/docs/index.html new file mode 100644 index 0000000..f6a3f7e --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/docs/index.html @@ -0,0 +1,30 @@ + + + + + fixture + + + + +
Loading the context layer…
+ + + + diff --git a/fixtures/valid-leji-ignore-legacy/expected.json b/fixtures/valid-leji-ignore-legacy/expected.json new file mode 100644 index 0000000..87760b1 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/expected.json @@ -0,0 +1,26 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "seeds": [ + { + "from": "docs/.leji-seed", + "to": "docs/.leji" + } + ], + "lejiIgnore": { + "scenarios": [ + { + "id": "C", + "note": "A layer adopted before the unified layout: the old docs/.leji/ tree is the documented migration case, and nothing under the root .leji/ is untracked after a run.", + "args": ["viewer", "build"], + "exit": 0, + "ignoreFile": "regular", + "bytes": "*\n", + "notices": 0, + "untrackedUnderLeji": [] + } + ] + } +} diff --git a/fixtures/valid-leji-ignore-legacy/leji.json b/fixtures/valid-leji-ignore-legacy/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-leji-ignore-legacy/leji.json @@ -0,0 +1,23 @@ +{ + "leji": "1.0", + "name": "fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + }, + "decisions": { + "indexes": [ + "docs/context/decisions.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/valid-render-lint-unsupported/expected-export.manifest.json b/fixtures/valid-render-lint-unsupported/expected-export.manifest.json index d1d59ea..3d3f45c 100644 --- a/fixtures/valid-render-lint-unsupported/expected-export.manifest.json +++ b/fixtures/valid-render-lint-unsupported/expected-export.manifest.json @@ -106,16 +106,16 @@ "size": 14016 }, "assets/vue.css": { - "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", - "size": 26849 + "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", + "size": 28873 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", - "size": 10806 + "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", + "size": 11350 } } } diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/overview.md b/fixtures/valid-render-lint-unsupported/expected-export/content/overview.md index da88774..536933a 100644 --- a/fixtures/valid-render-lint-unsupported/expected-export/content/overview.md +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/overview.md @@ -4,8 +4,8 @@ This is the **Leji context layer** for `fixture`: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by `leji viewer` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by `leji export`; this file is never rewritten. ```mermaid diff --git a/fixtures/valid-render-subset/expected-export.manifest.json b/fixtures/valid-render-subset/expected-export.manifest.json index d1d59ea..3d3f45c 100644 --- a/fixtures/valid-render-subset/expected-export.manifest.json +++ b/fixtures/valid-render-subset/expected-export.manifest.json @@ -106,16 +106,16 @@ "size": 14016 }, "assets/vue.css": { - "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", - "size": 26849 + "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", + "size": 28873 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", - "size": 10806 + "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", + "size": 11350 } } } diff --git a/fixtures/valid-render-subset/expected-export/content/overview.md b/fixtures/valid-render-subset/expected-export/content/overview.md index da88774..536933a 100644 --- a/fixtures/valid-render-subset/expected-export/content/overview.md +++ b/fixtures/valid-render-subset/expected-export/content/overview.md @@ -4,8 +4,8 @@ This is the **Leji context layer** for `fixture`: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by `leji viewer` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by `leji export`; this file is never rewritten. ```mermaid diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json b/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json index d1d59ea..3d3f45c 100644 --- a/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json +++ b/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json @@ -106,16 +106,16 @@ "size": 14016 }, "assets/vue.css": { - "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", - "size": 26849 + "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", + "size": 28873 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", - "size": 10806 + "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", + "size": 11350 } } } diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/overview.md b/fixtures/valid-trust-canary-dot-root/.expected-export/content/overview.md index da88774..536933a 100644 --- a/fixtures/valid-trust-canary-dot-root/.expected-export/content/overview.md +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/overview.md @@ -4,8 +4,8 @@ This is the **Leji context layer** for `fixture`: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by `leji viewer` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by `leji export`; this file is never rewritten. ```mermaid diff --git a/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json b/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json index d1d59ea..3d3f45c 100644 --- a/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json +++ b/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json @@ -106,16 +106,16 @@ "size": 14016 }, "assets/vue.css": { - "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", - "size": 26849 + "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", + "size": 28873 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", - "size": 10806 + "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", + "size": 11350 } } } diff --git a/fixtures/valid-trust-canary-nested-root/expected-export/content/overview.md b/fixtures/valid-trust-canary-nested-root/expected-export/content/overview.md index da88774..536933a 100644 --- a/fixtures/valid-trust-canary-nested-root/expected-export/content/overview.md +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/overview.md @@ -4,8 +4,8 @@ This is the **Leji context layer** for `fixture`: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by `leji viewer` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by `leji export`; this file is never rewritten. ```mermaid diff --git a/fixtures/valid-unified-leji-fresh/expected-export.manifest.json b/fixtures/valid-unified-leji-fresh/expected-export.manifest.json index d1d59ea..3d3f45c 100644 --- a/fixtures/valid-unified-leji-fresh/expected-export.manifest.json +++ b/fixtures/valid-unified-leji-fresh/expected-export.manifest.json @@ -106,16 +106,16 @@ "size": 14016 }, "assets/vue.css": { - "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", - "size": 26849 + "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", + "size": 28873 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", - "size": 10806 + "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", + "size": 11350 } } } diff --git a/fixtures/valid-unified-leji-fresh/expected-export/content/overview.md b/fixtures/valid-unified-leji-fresh/expected-export/content/overview.md index da88774..536933a 100644 --- a/fixtures/valid-unified-leji-fresh/expected-export/content/overview.md +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/overview.md @@ -4,8 +4,8 @@ This is the **Leji context layer** for `fixture`: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by `leji viewer` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by `leji export`; this file is never rewritten. ```mermaid diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json b/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json index d1d59ea..3d3f45c 100644 --- a/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json +++ b/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json @@ -106,16 +106,16 @@ "size": 14016 }, "assets/vue.css": { - "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", - "size": 26849 + "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", + "size": 28873 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", - "size": 10806 + "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", + "size": 11350 } } } diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export/content/overview.md b/fixtures/valid-unified-leji-stale-tree/expected-export/content/overview.md index da88774..536933a 100644 --- a/fixtures/valid-unified-leji-stale-tree/expected-export/content/overview.md +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/overview.md @@ -4,8 +4,8 @@ This is the **Leji context layer** for `fixture`: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by `leji viewer` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by `leji export`; this file is never rewritten. ```mermaid diff --git a/package-lock.json b/package-lock.json index 0db00ef..5471f79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "leji-monorepo", - "version": "1.3.0", + "name": "@leji-org/monorepo", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "leji-monorepo", - "version": "1.3.0", + "name": "@leji-org/monorepo", + "version": "1.4.1", "license": "SEE LICENSE IN LICENSE.md", "workspaces": [ "packages/*" @@ -1579,6 +1579,10 @@ "resolved": "packages/sdk-py", "link": true }, + "node_modules/@leji-org/e2e": { + "resolved": "packages/e2e", + "link": true + }, "node_modules/@leji-org/leji": { "resolved": "packages/sdk", "link": true @@ -1663,6 +1667,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -2456,9 +2476,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3298,9 +3318,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -3719,9 +3739,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", + "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3808,9 +3828,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -3939,9 +3959,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -4517,9 +4537,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -4881,6 +4901,53 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -6307,10 +6374,10 @@ } }, "packages/create-leji": { - "version": "1.3.0", + "version": "1.4.1", "license": "Apache-2.0", "dependencies": { - "@leji-org/leji": "^1.3.0" + "@leji-org/leji": "^1.4.1" }, "bin": { "create-leji": "index.js" @@ -6319,12 +6386,20 @@ "node": ">=22" } }, + "packages/e2e": { + "name": "@leji-org/e2e", + "version": "0.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@playwright/test": "1.62.1" + } + }, "packages/mcp": { "name": "@leji-org/mcp", - "version": "1.3.0", + "version": "1.4.1", "license": "Apache-2.0", "dependencies": { - "@leji-org/leji": "^1.3.0", + "@leji-org/leji": "^1.4.1", "@modelcontextprotocol/sdk": "1.30.0" }, "bin": { @@ -6341,7 +6416,7 @@ }, "packages/sdk": { "name": "@leji-org/leji", - "version": "1.3.0", + "version": "1.4.1", "license": "Apache-2.0", "dependencies": { "ajv": "8.20.0", @@ -6361,11 +6436,11 @@ }, "packages/sdk-go": { "name": "@leji-internal/sdk-go", - "version": "1.3.0" + "version": "1.4.1" }, "packages/sdk-py": { "name": "@leji-internal/sdk-py", - "version": "1.3.0" + "version": "1.4.1" }, "packages/site": { "name": "leji-site", diff --git a/package.json b/package.json index 440e0e7..a9f1fe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@leji-org/monorepo", - "version": "1.4.0", + "version": "1.4.1", "private": true, "description": "Leji: an open specification for the shared context layer of AI-native teams. Workspace root for the spec, schemas, site, and SDKs.", "workspaces": [ @@ -22,6 +22,7 @@ "test:unit": "npm run test:unit --workspaces --if-present", "test:proc": "npm run test:proc --workspaces --if-present", "coverage": "npm run coverage --workspaces --if-present", + "e2e": "npm run e2e -w packages/e2e", "smoke:prepublish": "bash scripts/smoke-prepublish.sh", "version:set": "node scripts/version.ts", "version:check": "node scripts/version.ts --check", @@ -29,8 +30,8 @@ "assets:check": "node scripts/sync-assets.ts --check", "ncu": "npx npm-check-updates --format group --workspaces --root", "ncu:u": "npx npm-check-updates -u --workspaces --root && npm install", - "lint": "prettier --check \"packages/sdk/src/**/*.ts\" \"packages/sdk/test/**/*.ts\" \"packages/mcp/src/**/*.ts\" \"packages/mcp/test/**/*.ts\" \"packages/create-leji/*.js\" \"packages/create-leji/test/**/*.js\" \"packages/site/src/**/*.{ts,astro}\" \"packages/site/astro.config.ts\" \"scripts/*.ts\"", - "lint:fix": "prettier --write \"packages/sdk/src/**/*.ts\" \"packages/sdk/test/**/*.ts\" \"packages/mcp/src/**/*.ts\" \"packages/mcp/test/**/*.ts\" \"packages/create-leji/*.js\" \"packages/create-leji/test/**/*.js\" \"packages/site/src/**/*.{ts,astro}\" \"packages/site/astro.config.ts\" \"scripts/*.ts\"", + "lint": "prettier --check \"packages/e2e/**/*.ts\" \"packages/sdk/src/**/*.ts\" \"packages/sdk/test/**/*.ts\" \"packages/mcp/src/**/*.ts\" \"packages/mcp/test/**/*.ts\" \"packages/create-leji/*.js\" \"packages/create-leji/test/**/*.js\" \"packages/site/src/**/*.{ts,astro}\" \"packages/site/astro.config.ts\" \"scripts/*.ts\"", + "lint:fix": "prettier --write \"packages/e2e/**/*.ts\" \"packages/sdk/src/**/*.ts\" \"packages/sdk/test/**/*.ts\" \"packages/mcp/src/**/*.ts\" \"packages/mcp/test/**/*.ts\" \"packages/create-leji/*.js\" \"packages/create-leji/test/**/*.js\" \"packages/site/src/**/*.{ts,astro}\" \"packages/site/astro.config.ts\" \"scripts/*.ts\"", "lint:all": "npm run lint && npm run lint --workspaces --if-present", "parity": "node scripts/parity-test.ts", "prepare": "husky" @@ -48,6 +49,7 @@ "allowScripts": { "esbuild@0.28.1": true, "fsevents@2.3.3": true, + "fsevents@2.3.2": false, "sharp@0.34.5": true } } diff --git a/packages/create-leji/package.json b/packages/create-leji/package.json index dee2714..edde44f 100644 --- a/packages/create-leji/package.json +++ b/packages/create-leji/package.json @@ -1,6 +1,6 @@ { "name": "create-leji", - "version": "1.4.0", + "version": "1.4.1", "description": "Bootstrap a Leji context layer: `npm create leji` runs `leji init` on a new repository and `leji adopt` on one that already has docs or an agent entrypoint. Leji is the open specification for the shared context layer of AI-native teams.", "keywords": [ "leji", @@ -39,6 +39,6 @@ "test": "node --test" }, "dependencies": { - "@leji-org/leji": "^1.4.0" + "@leji-org/leji": "^1.4.1" } } diff --git a/packages/e2e/README.md b/packages/e2e/README.md new file mode 100644 index 0000000..f43370d --- /dev/null +++ b/packages/e2e/README.md @@ -0,0 +1,69 @@ +# UI smoke suite + +Three journeys, in a real browser, over the surfaces a unit test cannot reach: + +- `specs/viewer.spec.ts`: the fixture layer served by `leji viewer serve`. +- `specs/export.spec.ts`: the same layer exported by `leji export` and served by a plain static host (`static-server.mjs`, no dependency of its own). +- `specs/site.spec.ts`: leji.org, as `astro preview` serves the built site. + +The viewer and export specs import the same predicates from `assertions.ts`, so live and static are judged by one set and cannot drift apart. + +This workspace is private and is never published. Its only dependency is `@playwright/test`, and only Chromium is installed. + +## Run it + +Once per machine, and again after the pinned Playwright version changes: + +```bash +npm run browsers -w packages/e2e +``` + +Then, from the repository root: + +```bash +npm run build -w packages/sdk +npm run build -w packages/site +npm run e2e +``` + +Both builds are required: the suite runs the CLI out of `packages/sdk/dist` and previews the site out of `packages/site/dist`. + +`npm run e2e` runs `run.mjs`, which checks the three ports are free, copies `fixtures/valid-render-subset` to `packages/e2e/.work/fixture`, gives it a context index and an export, starts the three servers, runs Playwright, and then takes down everything it started, followed by `.work/`. Nothing is written into `fixtures/`. + +Arguments reach Playwright, but option-shaped ones have to skip the root script, which npm would read them as flags of: + +```bash +npm run e2e -- specs/viewer.spec.ts +npm run e2e -w packages/e2e -- --headed --repeat-each=3 +``` + +## The exit-path contract + +**What is guaranteed: the direct children.** The three servers and Playwright are each a direct child of `run.mjs`, and each is signalled by its own pid, through the handle Node holds for it: never a process group, never a pid read from the process table. So the set of processes this script can signal is exactly the set it spawned, by construction rather than by timing. Cleanup sends `SIGTERM` to each, waits up to three seconds for the sockets to close, then `SIGKILL`s whatever is still there and waits up to three seconds more. Both waits are bounded: a child that ignores both signals delays the exit but cannot hang it, and if one is still running at the end the run says so by name and pid. + +That covers every exit path: a normal exit, a failing run, a Playwright crash or kill, a failure during preparation, and `Ctrl+C` or a `SIGTERM` to the script (exit `128 +` the signal number: `130` for `SIGINT`, `143` for `SIGTERM`). Cleanup runs exactly once whichever path reaches it, and exactly one path exits, so the code the caller sees is not a race. + +**What is best-effort: grandchildren.** A browser started by Playwright is Playwright's child, not this script's, and this script will not reach around it to signal something it did not spawn. Playwright shuts its browsers down when it is asked to stop, and in practice a `SIGTERM` mid-run leaves nothing behind, but that is Playwright's behavior, not a guarantee made here. A browser that outlives a hard-killed Playwright holds no port of ours and is reported by nothing here; find it by its parent having gone away (`ps -o pid,ppid,command | grep ms-playwright`) and end that specific process, rather than killing every Playwright browser on the machine. + +**On Windows**, `child.kill()` is `TerminateProcess`, which ends the child alone, so it is followed by `taskkill /T /F /PID` to take the tree with it. `taskkill`'s result is checked: anything other than success or "no such process" is printed, naming the child and its pid, rather than being assumed to have worked. + +**What cannot be covered, and is not claimed:** a `SIGKILL` of `run.mjs` itself, which gets no chance to act. Its three servers keep running and keep their ports, and Playwright and its browser may survive alongside them (those hold no port, so only the servers are reported by the next run). The next run does not paper over that: it refuses to start, naming the port that is held and why: + +``` +e2e: port 23922 (the static export server) is already in use. Another e2e run, or a +server left behind by one that was killed, is holding it; stop that process and run again. +``` + +## Servers and ports + +`run.mjs` starts three servers on fixed ports: the viewer (`leji viewer serve`) on 23921, the static export (`static-server.mjs`) on 23922, the site preview (`astro preview`) on 23923. Each has to accept a connection within 60 seconds or the run stops with that server named. Playwright's own `webServer` is deliberately not used: it starts its servers before `globalSetup`, which is too early for a fixture that has to be built first, and it detaches each one into a session of its own, which puts them out of reach of any cleanup but its own. + +The ports are checked before anything starts (each by a two-second bounded bind), so a second concurrent run on the same machine stops with the port named rather than sharing a server with the first. + +## When something fails + +`report/` holds the HTML report and `test-results/` the traces and screenshots kept for failures; both are gitignored, and the CI job uploads them as artifacts. Open the report with: + +```bash +npm run report -w packages/e2e +``` diff --git a/packages/e2e/assertions.ts b/packages/e2e/assertions.ts new file mode 100644 index 0000000..c3cc9c6 --- /dev/null +++ b/packages/e2e/assertions.ts @@ -0,0 +1,229 @@ +// The predicates the live viewer and its static export are both judged by, in one +// module both specs import. The parity claim is only worth something if there is +// literally one set of assertions: two files that started identical drift, and a +// suite that has drifted still reports green. + +import { expect, type Locator, type Page } from '@playwright/test'; + +// Fixed ports in a high range, so the base URLs are stable enough for a spec, a +// CI artifact, and a README to name them. `run.mjs` refuses to start when one of +// them is already bound, so a second concurrent run on the same machine stops +// with the port named instead of quietly sharing a server with the first; runs on +// one machine serialize, which is the behavior, not a collision-prevention scheme. +export const VIEWER_URL = 'http://127.0.0.1:23921'; +export const STATIC_URL = 'http://127.0.0.1:23922'; +export const SITE_URL = 'http://127.0.0.1:23923'; + +// The same layer again with `viewer.theme.primary` set, live and exported. A second +// copy rather than a second route: the accent reaches CSS at generation time, so +// the only way to judge what a custom accent does to the tones is to generate a +// layer that has one. `run.mjs` prepares the copy and holds these two ports. +export const ACCENT_VIEWER_URL = 'http://127.0.0.1:23924'; +export const ACCENT_STATIC_URL = 'http://127.0.0.1:23925'; + +/** The accent each fixture pair carries: the Leji default (no `viewer.theme`) and + * the custom one the accent copy declares. Named here so a spec states which layer + * it is judging instead of repeating a hex. */ +export const DEFAULT_ACCENT = '#009F71'; +export const CUSTOM_ACCENT = '#2244AA'; + +/** The fixture layer's name, as `leji.json` declares it. */ +const LAYER_NAME = 'fixture'; + +/** + * One console error or uncaught page error, with everything an allowance needs to + * be aimed at it and nothing that would let it spread. `text` alone is not enough: + * a failed request reaches the console as the bare sentence "Failed to load + * resource: …", so every 404 in the suite reads identically and a substring + * allowance written for one would cover them all. + */ +export interface ConsoleEntry { + /** `error` for a console error, `pageerror` for an uncaught exception. */ + type: 'error' | 'pageerror'; + /** The message, verbatim. */ + text: string; + /** The page the message was reported on. */ + pageUrl: string; + /** The resource the message is about, empty when the browser named none. */ + sourceUrl: string; +} + +/** A predicate that permits exactly one known entry class. Every allowance in + * this module is anchored on the message, the resource it names, and the origin + * of the page it came from, so a new error class can never inherit one. */ +export type ConsoleAllowance = (entry: ConsoleEntry) => boolean; + +/** A URL's origin, or the empty string for anything that does not parse. */ +function originOf(url: string): string { + try { + return new URL(url).origin; + } catch { + return ''; + } +} + +/** Console errors and uncaught page errors, from this page, in order. A page is + * fresh per test, so the collection is per test with no reset to forget. */ +export function collectConsoleErrors(page: Page): ConsoleEntry[] { + const entries: ConsoleEntry[] = []; + page.on('console', (message) => { + if (message.type() !== 'error') return; + entries.push({ type: 'error', text: message.text(), pageUrl: page.url(), sourceUrl: message.location().url }); + }); + page.on('pageerror', (error) => { + entries.push({ type: 'pageerror', text: error.message, pageUrl: page.url(), sourceUrl: '' }); + }); + return entries; +} + +/** Every console error the page produced, minus the ones the caller has named. + * The allowances below are the only ones in the suite, and each says why it + * exists and what would retire it. */ +export function expectNoConsoleErrors(entries: ConsoleEntry[], allowed: readonly ConsoleAllowance[] = []): void { + expect(entries.filter((entry) => !allowed.some((permits) => permits(entry)))).toEqual([]); +} + +/** Chromium's wording for a request that answered 404. Matched exactly: a + * variation is a different message class, and a different message class is not + * covered by an allowance written for this one. */ +const RESOURCE_404 = 'Failed to load resource: the server responded with a status of 404 (Not Found)'; + +/** + * A 404 on exactly this resource, reported on a page served by exactly this + * origin. The two tests that assert a 404 are the only callers: each has already + * counted and identified its own on the response side, and this keeps the console + * check from being the one place that would also swallow somebody else's. + */ +export function resourceNotFoundAllowance(resourceUrl: string, pageOrigin: string): ConsoleAllowance { + return (entry) => + entry.type === 'error' && + entry.text === RESOURCE_404 && + entry.sourceUrl === resourceUrl && + originOf(entry.pageUrl) === pageOrigin; +} + +/** + * The viewer chrome is quiet in both flavors, so no console error is allowed on + * any viewer page, live or exported. The one allowance that stood here (Chromium + * reporting `frame-ancestors` ignored in a `` policy, once per page) retired + * when `templates/viewer/index.html` stopped claiming the directive: the served + * flavor sends it as a response header, and in an export the host sets it. + */ +export const VIEWER_CONSOLE_ALLOWANCES: readonly ConsoleAllowance[] = []; + +/** The site ships analytics-free, so no console error is allowed on any site page. */ +export const SITE_CONSOLE_ALLOWANCES: readonly ConsoleAllowance[] = []; + +/** The home route renders the layer: its name in the chrome, a populated sidebar, + * and the seeded overview as the page. */ +export async function assertLayerHome(page: Page, base: string): Promise { + await page.goto(`${base}/#/`); + await expect(page.locator('.app-name')).toContainText(LAYER_NAME); + await expect(page.locator('.sidebar-nav')).toBeVisible(); + await expect(page.locator('.sidebar-nav').getByRole('link', { name: 'Boot profile' })).toBeVisible(); + await expect(page.locator('.markdown-section h1').first()).toHaveText(LAYER_NAME); +} + +/** + * A `../`-style link on a nested page routes inside the app, without a reload. + * Both halves are asserted, because either alone passes for the wrong reason: a + * full page load also ends up at the right URL, and a marker survives a click + * that navigated nowhere. So: a marker set on `window` before the click, the + * hash after it, and the destination's own heading. + */ +export async function assertRelativeLinkInApp(page: Page, base: string): Promise { + await page.goto(`${base}/#/render/commonmark-core`); + await expect(page.locator('.markdown-section h1').first()).toBeVisible(); + await page.evaluate(() => { + (window as unknown as { __lejiSameDocument?: boolean }).__lejiSameDocument = true; + }); + await page.getByRole('link', { name: 'link to another page' }).click(); + await expect(page).toHaveURL(`${base}/#/domain/overview`); + await expect(page.locator('.markdown-section h1').first()).toHaveText('Overview'); + const sameDocument = await page.evaluate( + () => (window as unknown as { __lejiSameDocument?: boolean }).__lejiSameDocument === true, + ); + expect(sameDocument).toBe(true); +} + +/** An image beside a document one directory down actually + * renders. `naturalWidth` is the assertion because a broken `img` is present, + * visible, and zero pixels wide. */ +export async function assertImageAtDepth(page: Page, base: string): Promise { + await page.goto(`${base}/#/render/commonmark-core`); + const image = page.locator('.markdown-section img[alt="A fixture diagram"]'); + await expect(image).toBeVisible(); + await expect.poll(async () => image.evaluate((node: HTMLImageElement) => node.naturalWidth)).toBeGreaterThan(0); +} + +/** + * An unknown route renders the in-app not-found page and asks for exactly one + * thing it cannot have: the missing document. `notFoundPage: false` is what keeps + * the runtime from issuing a second, always-failing fetch for a `_404.md` no + * layer ships, so the absence of that request is asserted as directly as the + * presence of the message. + */ +export async function assertUnknownRouteInApp(page: Page, base: string): Promise { + const notFound: string[] = []; + const requested: string[] = []; + page.on('request', (request) => requested.push(request.url())); + page.on('response', (response) => { + if (response.status() === 404) notFound.push(response.url()); + }); + await page.goto(`${base}/#/does-not-exist`); + await expect(page.locator('.markdown-section h1').first()).toHaveText('404 - Not found'); + expect(notFound).toEqual([`${base}/content/does-not-exist.md`]); + expect(requested.filter((url) => url.includes('_404'))).toEqual([]); +} + +/** A `#RRGGBB` color as the string `getComputedStyle` reports for it. */ +function rgb(hex: string): string { + const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)); + return `rgb(${r}, ${g}, ${b})`; +} + +/** The color the browser actually paints an element, which is the only form of this + * question worth asking: a stylesheet says many things about one element, and the + * cascade decides which of them wins. */ +async function colorOf(locator: Locator): Promise { + await expect(locator).toBeVisible(); + return locator.evaluate((node) => getComputedStyle(node as Element).color); +} + +/** + * The tone contract, judged as pixels rather than as CSS text. + * + * Body links and inline code take the fixed accessible green whatever the layer's + * accent is: that is the whole point of the fixed token, and it is why this runs + * against a layer with a custom accent as well as one without. The chrome does take + * the accent, so the active sidebar item is asserted to be exactly it: without that + * half, a viewer that ignored `viewer.theme.primary` entirely would also pass. + * Headings and copy are the brand's own typography tones, which no accent touches. + */ +export async function assertTones(page: Page, base: string, { accent }: { accent: string }): Promise { + await page.goto(`${base}/#/render/commonmark-core`); + const content = page.locator('.markdown-section'); + + // Never `.markdown-section a` unqualified: docsify wraps every heading in an + // anchor, so the first link on the page is chrome, not body text. + const link = content.getByRole('link', { name: 'link to another page' }); + expect(await colorOf(link)).toBe(rgb('#007D59')); + expect(await colorOf(content.locator('p code').first())).toBe(rgb('#007D59')); + + expect(await colorOf(content.locator('h1').first())).toBe(rgb('#183B32')); + expect(await colorOf(content.locator('p').first())).toBe(rgb('#4D5B56')); + + // The document's own sidebar entry, which docsify marks active on arrival. The + // outer entry comes first in document order; the in-page heading list nested + // under it takes the accent from the same rule. + expect(await colorOf(page.locator('.sidebar-nav li.active > a').first())).toBe(rgb(accent)); +} + +/** The generated Manifest page: chrome that lives outside the content tree and is + * reached through it, so it is the one page a broken content mount still hides. */ +export async function assertManifestPage(page: Page, base: string): Promise { + await page.goto(`${base}/#/_manifest`); + await expect(page.locator('.markdown-section h1').first()).toHaveText(`${LAYER_NAME}: Manifest`); + await expect(page.locator('.markdown-section').getByRole('heading', { name: 'Identity' })).toBeVisible(); + await expect(page.locator('.markdown-section')).toContainText('Fixture Owner'); +} diff --git a/packages/e2e/package.json b/packages/e2e/package.json new file mode 100644 index 0000000..d21f0a9 --- /dev/null +++ b/packages/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "@leji-org/e2e", + "version": "0.0.0", + "private": true, + "description": "UI smoke suite: the served viewer, the same layer exported and served statically, and the site, judged in a real browser.", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "browsers": "playwright install --with-deps chromium", + "e2e": "node run.mjs", + "report": "playwright show-report report" + }, + "devDependencies": { + "@playwright/test": "1.62.1" + } +} diff --git a/packages/e2e/playwright.config.ts b/packages/e2e/playwright.config.ts new file mode 100644 index 0000000..bc13317 --- /dev/null +++ b/packages/e2e/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test'; + +// No `webServer` here. The three servers the specs address are started by +// `run.mjs` before Playwright is spawned, and taken down by it afterwards: they +// have to exist after the fixture is built (which Playwright starts its servers +// too early to allow) and they have to be reachable for cleanup afterwards +// (which a `webServer`, detached into a session of its own, is not). Each spec +// addresses its server by the fixed base URL exported from `assertions.ts`. + +export default defineConfig({ + testDir: './specs', + outputDir: 'test-results', + fullyParallel: false, + workers: 1, + // One retry in CI absorbs a genuinely flaky animation frame; locally a flake + // stays visible, because that is where it gets fixed. + retries: process.env.CI ? 1 : 0, + timeout: 30_000, + expect: { timeout: 5_000 }, + reporter: [['list'], ['html', { outputFolder: 'report', open: 'never' }]], + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], +}); diff --git a/packages/e2e/run.mjs b/packages/e2e/run.mjs new file mode 100644 index 0000000..4b3e95a --- /dev/null +++ b/packages/e2e/run.mjs @@ -0,0 +1,432 @@ +// The suite's one entry point: prepare the fixtures, start the five servers the +// specs address, run Playwright over them, and take down everything it started. +// +// Two things are owned here rather than by Playwright, for the same reason: +// Playwright starts every `webServer` before `globalSetup` runs and detaches each +// one into a session of its own, so preparation cannot happen early enough and +// the servers cannot be reached afterwards. So this script builds the trees first +// and spawns the five servers itself. +// +// Every process this script signals is a DIRECT child of it, signalled by the pid +// its own `spawn` returned and only while Node still holds that child. Nothing is +// read from the process table, no process group is ever signalled, and no pid this +// script did not spawn can be named, which is the whole of the guarantee, and +// exactly as far as it goes: a grandchild (a browser under Playwright) is the +// child's to end, and the README says so rather than claiming otherwise. +// +// Nothing is written into `fixtures/`: the repository's fixture is copied out twice +// (once as it stands, once with a custom `viewer.theme.primary`), and each copy is +// what gets a viewer, an export, and a pair of servers. + +import { spawn, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const POSIX = process.platform !== 'win32'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '../..'); +const work = path.join(here, '.work'); +const fixture = path.join(work, 'fixture'); +// The same layer with `viewer.theme.primary` set. The accent is baked into the +// chrome when the viewer is generated, so a custom accent can only be judged on a +// layer that declares one, hence a second copy, prepared and served like the first. +const accentFixture = path.join(work, 'fixture-accent'); +const ACCENT = '#2244AA'; +const source = path.join(repoRoot, 'fixtures/valid-render-subset'); +const cli = path.join(repoRoot, 'packages/sdk/dist/cli.js'); + +/** Fixed ports in a high range, matching the base URLs in `assertions.ts`. */ +const VIEWER_PORT = 23921; +const STATIC_PORT = 23922; +const SITE_PORT = 23923; +const ACCENT_VIEWER_PORT = 23924; +const ACCENT_STATIC_PORT = 23925; + +/** How long a server gets to accept a connection before the run gives up. */ +const READY_TIMEOUT_MS = 60_000; +/** How long a signalled child gets to exit before it is killed outright. */ +const TERM_GRACE_MS = 3_000; +/** How long a port check gets to answer before the run gives up on it. */ +const PORT_CHECK_TIMEOUT_MS = 2_000; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --- the children this script owns ---------------------------------------- +// +// Every child is spawned plainly, in this process's own group, and no signal is +// ever sent to a group. A group signal reaches processes by an id that outlives +// the group and can be reused, so +// the only way to be certain a signal lands on this script's own work is to send +// it through the child handle Node keeps: `child.kill()` addresses the pid Node +// spawned, and Node will not send to it once it has reaped it. Narrower than a +// group signal, and true by construction rather than by timing. + +/** `{ name, child, alive, failure, exited }` for each child, in start order. */ +const owned = []; + +/** Track a child: its liveness, the error if it never started, and a promise of + * its exit that always settles (a child that failed to spawn at all settles on + * `error`, so cleanup never waits for an exit event that will not come). */ +function adopt(name, child) { + const record = { name, child, alive: true, failure: null, exited: null }; + record.exited = new Promise((resolve) => { + const settle = (result) => { + record.alive = false; + resolve(result); + }; + child.once('exit', (code, signal) => settle({ code, signal })); + child.once('error', (error) => { + record.failure = error; + settle({ code: null, signal: null }); + }); + }); + owned.push(record); + return record; +} + +/** A child's ending as an exit status: its own code, or the shell's convention of + * 128 plus the signal that killed it. The signal NUMBER, not a flat 128: which + * signal ended a run is the first thing anyone reading a red job wants. */ +function exitStatus({ code, signal }) { + if (signal) return 128 + (os.constants.signals[signal] ?? 0); + return code ?? 1; +} + +/** Windows has no signals: `child.kill()` maps onto `TerminateProcess`, which ends + * the child and nothing below it, so `taskkill /T /F` follows to take the tree. + * Its result is checked and reported: a tree this script could not remove is a + * fact the run has to state, not one to swallow. Exit code 128 means "no such + * process": the child was already gone, which is the ordinary case. */ +function taskkill(record, pid) { + const result = spawnSync('taskkill', ['/T', '/F', '/PID', String(pid)], { + windowsHide: true, + encoding: 'utf8', + }); + if (result.error) { + console.error( + `e2e: taskkill could not run for the ${record.name} process tree (pid ${pid}): ${result.error.message}`, + ); + return; + } + if (result.status !== 0 && result.status !== 128) { + const detail = (result.stderr ?? '').trim() || (result.stdout ?? '').trim(); + console.error( + `e2e: taskkill exited ${result.status} for the ${record.name} process tree (pid ${pid}): ${detail}`, + ); + } +} + +/** Signal every child still running, by its own pid and through the handle Node + * holds for it. A child that ended between the check and the call is already + * reaped, and `child.kill()` on it is a no-op rather than a signal to a stranger. */ +function signalOwned(signal) { + for (const record of owned) { + const pid = record.child.pid; + if (!record.alive || typeof pid !== 'number') continue; + try { + record.child.kill(signal); + } catch { + // Already gone; nothing to take down. + } + if (!POSIX) taskkill(record, pid); + } +} + +/** Take down every child this script started: SIGTERM, three seconds for the + * sockets to close, then SIGKILL for whatever is left and three more. Both waits + * are bounded, so a child that ignores both signals delays the exit but cannot + * hang it, and the README says that plainly rather than promising every exit + * event is in hand. */ +async function terminateOwned() { + const live = owned.filter((record) => record.alive); + if (live.length === 0) return; + const allExited = Promise.all(live.map((record) => record.exited)); + signalOwned('SIGTERM'); + await Promise.race([allExited, delay(TERM_GRACE_MS)]); + if (live.some((record) => record.alive)) { + signalOwned('SIGKILL'); + await Promise.race([allExited, delay(TERM_GRACE_MS)]); + } + const stubborn = live.filter((record) => record.alive).map((record) => `${record.name} (pid ${record.child.pid})`); + if (stubborn.length > 0) console.error(`e2e: still running after SIGKILL: ${stubborn.join(', ')}`); +} + +function removeWork() { + fs.rmSync(work, { recursive: true, force: true }); +} + +// --- one cleanup, one exit ------------------------------------------------- +// +// Cleanup is a single promise, whoever asks for it: the main flow at the end of a +// run, or a signal handler part-way through one. Both await the same settlement, +// and the `terminating` flag decides which of them gets to exit, so a signal that +// also ends the child the main flow is awaiting cannot produce two exit paths +// racing for the code. A SIGKILL of this process cannot be handled at all; the +// README says what that leaves behind and how the next run reports it. + +let cleanupOnce = null; +/** Take down the children and remove `.work/`, exactly once per run. */ +function cleanup() { + if (cleanupOnce === null) { + cleanupOnce = (async () => { + await terminateOwned(); + removeWork(); + })(); + } + return cleanupOnce; +} + +/** Set when a signal owns the exit: the main flow then finishes without calling + * `process.exit`, and the handler exits with the signal's status. */ +let terminating = false; + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + if (terminating) return; + terminating = true; + const code = 128 + (os.constants.signals[signal] ?? 0); + // The signal owns the exit code either way: a cleanup failure is reported, + // never allowed to turn a 130 or 143 into an unhandled rejection. + void cleanup().then( + () => process.exit(code), + (error) => { + console.error(`cleanup failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(code); + }, + ); + }); +} + +// --- running things ------------------------------------------------------- + +/** Run a command to completion as an owned child, inheriting stdio; resolves with + * how it ended, or rejects with the reason it never ran at all. */ +async function run(name, command, args, options = {}) { + const child = spawn(command, args, { stdio: 'inherit', windowsHide: true, ...options }); + const record = adopt(name, child); + const ending = await record.exited; + if (record.failure !== null) throw record.failure; + return ending; +} + +/** A server's output, line by line, on this script's own streams, tagged with the + * server it came from. Piped rather than inherited so three servers and Playwright + * cannot interleave mid-line, and so a server that keeps talking after the run is + * over is visibly still ours. */ +function tagOutput(name, child) { + for (const [stream, sink] of [ + [child.stdout, process.stdout], + [child.stderr, process.stderr], + ]) { + if (stream === null) continue; + stream.setEncoding('utf8'); + let pending = ''; + stream.on('data', (chunk) => { + const lines = (pending + chunk).split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) sink.write(`[${name}] ${line}\n`); + }); + stream.on('end', () => { + if (pending !== '') sink.write(`[${name}] ${pending}\n`); + }); + } +} + +/** Whether anything is listening on a loopback port, asked by binding it: the + * question the servers are about to ask, answered the same way they would. Bounded + * like every other wait here: a bind that neither succeeds nor fails within two + * seconds is not an answer, and the run stops rather than hanging on the check + * that exists to stop it hanging. */ +function portFree(port) { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + const timer = setTimeout(() => { + probe.close(); + reject(new Error(`port ${port} could not be checked within ${PORT_CHECK_TIMEOUT_MS / 1000}s`)); + }, PORT_CHECK_TIMEOUT_MS); + timer.unref(); + const settle = (act) => { + clearTimeout(timer); + act(); + }; + probe.once('error', (error) => settle(() => (error.code === 'EADDRINUSE' ? resolve(false) : reject(error)))); + probe.once('listening', () => probe.close(() => settle(() => resolve(true)))); + probe.listen(port, '127.0.0.1'); + }); +} + +/** Refuse to start on top of anything already holding one of the three ports: + * a second concurrent run, or a server left behind by a run that was killed + * outright. Loudly, by name and number, because the alternative is a suite that + * silently asserts against somebody else's server. */ +async function requirePortsFree() { + for (const [name, port] of [ + ['viewer', VIEWER_PORT], + ['static export', STATIC_PORT], + ['site preview', SITE_PORT], + ['custom-accent viewer', ACCENT_VIEWER_PORT], + ['custom-accent static export', ACCENT_STATIC_PORT], + ]) { + if (!(await portFree(port))) { + throw new Error( + `port ${port} (the ${name} server) is already in use. ` + + `Another e2e run, or a server left behind by one that was killed, is holding it; ` + + `stop that process and run again.`, + ); + } + } +} + +/** Wait until a port accepts a connection, or the server that should be holding + * it dies, or the budget runs out. */ +async function waitForPort(record, port) { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!record.alive) { + const why = + record.failure === null + ? `exited before it listened on ${port}` + : `failed to start: ${record.failure.message}`; + throw new Error(`the ${record.name} server ${why}`); + } + const listening = await new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }); + const done = (answer) => { + socket.destroy(); + resolve(answer); + }; + socket.setTimeout(1_000, () => done(false)); + socket.once('connect', () => done(true)); + socket.once('error', () => done(false)); + }); + if (listening) return; + await delay(100); + } + throw new Error(`the ${record.name} server did not listen on ${port} within ${READY_TIMEOUT_MS / 1000}s`); +} + +/** Start one server as an owned child and wait until it answers on its port. */ +async function startServer({ name, port, args, cwd }) { + const child = spawn(process.execPath, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + // A spawn failure is recorded by `adopt` and reported by the readiness wait, + // which sees the child is not alive and says why. + const record = adopt(name, child); + tagOutput(name, child); + await waitForPort(record, port); +} + +/** The five servers, as commands. Astro is resolved the way the site workspace + * resolves it and run directly, so the preview is this script's own child rather + * than an npm wrapper's grandchild. */ +function serverPlan() { + const siteRoot = path.join(repoRoot, 'packages/site'); + const astroPackage = createRequire(path.join(siteRoot, 'package.json')).resolve('astro/package.json'); + const astroBin = path.resolve( + path.dirname(astroPackage), + JSON.parse(fs.readFileSync(astroPackage, 'utf8')).bin.astro, + ); + return [ + { + name: 'viewer', + port: VIEWER_PORT, + args: [cli, 'viewer', 'serve', '--root', fixture, '--port', String(VIEWER_PORT)], + cwd: here, + }, + { + name: 'static', + port: STATIC_PORT, + args: [path.join(here, 'static-server.mjs'), path.join(fixture, '.leji/dist'), String(STATIC_PORT)], + cwd: here, + }, + { + // The site is built by a prior step (the CI job, or the README's local + // instructions); preview serves that build, never a dev server. + name: 'site', + port: SITE_PORT, + args: [astroBin, 'preview', '--root', siteRoot, '--port', String(SITE_PORT), '--host', '127.0.0.1'], + cwd: repoRoot, + }, + { + name: 'viewer (accent)', + port: ACCENT_VIEWER_PORT, + args: [cli, 'viewer', 'serve', '--root', accentFixture, '--port', String(ACCENT_VIEWER_PORT)], + cwd: here, + }, + { + name: 'static (accent)', + port: ACCENT_STATIC_PORT, + args: [ + path.join(here, 'static-server.mjs'), + path.join(accentFixture, '.leji/dist'), + String(ACCENT_STATIC_PORT), + ], + cwd: here, + }, + ]; +} + +/** Index and export one prepared copy, so its live and static servers are never a + * generation apart. + * + * The context index first: a real layer has one on disk, and the export copies what + * is there. Without it the exported tree is the only one of the two whose + * classification badge cannot resolve, and the parity the shared assertions claim + * would be judged against a layer the fixture never is. + * + * Then one command for both trees: `export` regenerates `.leji/viewer` (what + * `leji viewer serve` serves) and writes `.leji/dist` (what the static host serves). + */ +async function build(label, root) { + const indexed = exitStatus(await run(`leji index (${label})`, process.execPath, [cli, 'index', '--root', root])); + if (indexed !== 0) throw new Error(`leji index failed for the ${label} layer (exit ${indexed})`); + const exported = exitStatus(await run(`leji export (${label})`, process.execPath, [cli, 'export', '--root', root])); + if (exported !== 0) throw new Error(`leji export failed for the ${label} layer (exit ${exported})`); +} + +/** The accent copy's manifest: the fixture's own, plus the one field that makes it + * a different layer. Written as JSON rather than patched as text, so the copy stays + * valid however the fixture's manifest is formatted. */ +function declareAccent(root) { + const file = path.join(root, 'leji.json'); + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); + manifest.viewer = { ...manifest.viewer, theme: { ...manifest.viewer?.theme, primary: ACCENT } }; + fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n'); +} + +async function prepare() { + // Also the cleanup for a run that was killed outright: whatever a SIGKILLed + // orchestrator left behind is removed here, before anything is copied. + removeWork(); + fs.mkdirSync(work, { recursive: true }); + fs.cpSync(source, fixture, { recursive: true }); + fs.cpSync(source, accentFixture, { recursive: true }); + declareAccent(accentFixture); + await build('default', fixture); + await build('accent', accentFixture); +} + +let code = 1; +try { + // Before anything is built: a bound port is somebody else's server, and the + // run has to stop while it can still say so plainly. + await requirePortsFree(); + await prepare(); + for (const server of serverPlan()) await startServer(server); + const playwrightCli = createRequire(import.meta.url).resolve('@playwright/test/cli'); + code = exitStatus( + await run('playwright', process.execPath, [playwrightCli, 'test', ...process.argv.slice(2)], { cwd: here }), + ); +} catch (error) { + console.error(`e2e: ${error instanceof Error ? error.message : String(error)}`); +} finally { + await cleanup(); +} +// Only when no signal has claimed the exit. If one has, its handler is awaiting +// the same cleanup and exits with `128 + signal`; this path stands down rather +// than racing it for the code the caller sees. +if (!terminating) process.exit(code); diff --git a/packages/e2e/specs/export.spec.ts b/packages/e2e/specs/export.spec.ts new file mode 100644 index 0000000..c751b1f --- /dev/null +++ b/packages/e2e/specs/export.spec.ts @@ -0,0 +1,69 @@ +// The static export: the same layer, `leji export`ed and served by a plain static +// host that generates nothing. Same assertions as the served viewer, from the same +// module, so "live and static agree" is a fact about one predicate set rather than +// a claim about two files that once looked alike. + +import { test } from '@playwright/test'; +import { + ACCENT_STATIC_URL, + CUSTOM_ACCENT, + DEFAULT_ACCENT, + STATIC_URL, + VIEWER_CONSOLE_ALLOWANCES, + assertImageAtDepth, + assertLayerHome, + assertManifestPage, + assertRelativeLinkInApp, + assertTones, + assertUnknownRouteInApp, + collectConsoleErrors, + expectNoConsoleErrors, + resourceNotFoundAllowance, +} from '../assertions.js'; + +test.describe('viewer (static export)', () => { + test('the home route renders the layer', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertLayerHome(page, STATIC_URL); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('a relative link on a nested page routes in-app', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertRelativeLinkInApp(page, STATIC_URL); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('an image beside a nested document renders', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertImageAtDepth(page, STATIC_URL); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('an unknown route renders the in-app not-found page', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertUnknownRouteInApp(page, STATIC_URL); + expectNoConsoleErrors(errors, [ + ...VIEWER_CONSOLE_ALLOWANCES, + resourceNotFoundAllowance(`${STATIC_URL}/content/does-not-exist.md`, STATIC_URL), + ]); + }); + + test('the manifest page renders', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertManifestPage(page, STATIC_URL); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('the text tones are the brand set and the chrome is the default accent', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertTones(page, STATIC_URL, { accent: DEFAULT_ACCENT }); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('a custom accent moves the chrome and leaves the text tones alone', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertTones(page, ACCENT_STATIC_URL, { accent: CUSTOM_ACCENT }); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); +}); diff --git a/packages/e2e/specs/site.spec.ts b/packages/e2e/specs/site.spec.ts new file mode 100644 index 0000000..ce7c2bd --- /dev/null +++ b/packages/e2e/specs/site.spec.ts @@ -0,0 +1,90 @@ +// leji.org, as `astro preview` serves the built site. Every selector here is an +// existing role, accessible label, heading, or URL: the suite reads the site as a +// reader does and never asks it to grow a hook for the sake of a test. + +import { expect, test } from '@playwright/test'; +import { + SITE_CONSOLE_ALLOWANCES, + SITE_URL, + collectConsoleErrors, + expectNoConsoleErrors, + resourceNotFoundAllowance, +} from '../assertions.js'; + +test.describe('site', () => { + test('the index page renders the hero session and the install tabs', async ({ page }) => { + const errors = collectConsoleErrors(page); + const response = await page.goto(`${SITE_URL}/`); + expect(response?.status()).toBe(200); + await expect(page.getByRole('heading', { level: 1 })).toContainText('Make your'); + await expect( + page.getByRole('group', { name: 'A real Leji session: adopt an existing repo, validate it, score it' }), + ).toBeVisible(); + const tabs = page.getByRole('tablist', { name: 'Install Leji' }); + await expect(tabs).toBeVisible(); + await expect(tabs.getByRole('tab')).not.toHaveCount(0); + await expect(page.getByRole('tabpanel')).toBeVisible(); + expectNoConsoleErrors(errors, SITE_CONSOLE_ALLOWANCES); + }); + + test('the quickstart page renders its steps', async ({ page }) => { + const errors = collectConsoleErrors(page); + const response = await page.goto(`${SITE_URL}/quickstart/`); + expect(response?.status()).toBe(200); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Build a context layer, check it conforms.'); + await expect(page.getByRole('region', { name: 'Quickstart steps' })).toBeVisible(); + expectNoConsoleErrors(errors, SITE_CONSOLE_ALLOWANCES); + }); + + test('the agent-ready page renders the two commands and their copy buttons', async ({ page }) => { + const errors = collectConsoleErrors(page); + const response = await page.goto(`${SITE_URL}/agent-ready/`); + expect(response?.status()).toBe(200); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Your repo, agent-ready in two commands.'); + const commands = page.getByRole('region', { name: 'The two commands' }); + await expect(commands).toBeVisible(); + await expect(commands.getByRole('button', { name: 'Copy install command' })).toBeVisible(); + await expect(commands.getByRole('button', { name: 'Copy the start command' })).toBeVisible(); + expectNoConsoleErrors(errors, SITE_CONSOLE_ALLOWANCES); + }); + + test('the spec index renders', async ({ page }) => { + const errors = collectConsoleErrors(page); + const response = await page.goto(`${SITE_URL}/spec/`); + expect(response?.status()).toBe(200); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + expectNoConsoleErrors(errors, SITE_CONSOLE_ALLOWANCES); + }); + + test('the manifest schema page states what the viewer accent drives', async ({ page }) => { + const errors = collectConsoleErrors(page); + const response = await page.goto(`${SITE_URL}/schemas/context-manifest/`); + expect(response?.status()).toBe(200); + // The field reference is generated from `schemas/context-manifest.schema.json` + // at build, so this reads the published contract rather than a copy of it: the + // sentence is the one adopters are given about `viewer.theme.primary`, and it + // has to keep agreeing with what the viewer actually does with the accent. + const viewerField = page.locator('dt#viewer + dd'); + await expect(viewerField).toBeVisible(); + await expect(viewerField).toContainText('body links and inline code use the fixed accessible tone'); + expectNoConsoleErrors(errors, SITE_CONSOLE_ALLOWANCES); + }); + + test('an unknown URL answers 404 with the branded page', async ({ page }) => { + const errors = collectConsoleErrors(page); + // The status matters as much as the rendering: a 404 page served with a 200 + // is a soft 404, which is what search engines index and what a link checker + // reports as healthy. + const response = await page.goto(`${SITE_URL}/no-such-page/`); + expect(response?.status()).toBe(404); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Page not found.'); + await expect(page.getByRole('link', { name: 'the specification' })).toBeVisible(); + // The 404 document load is itself a failed resource load, so the browser + // reports it: the one error this test asserts, named by the exact URL it + // came from and the origin that served it. + expectNoConsoleErrors(errors, [ + ...SITE_CONSOLE_ALLOWANCES, + resourceNotFoundAllowance(`${SITE_URL}/no-such-page/`, SITE_URL), + ]); + }); +}); diff --git a/packages/e2e/specs/viewer.spec.ts b/packages/e2e/specs/viewer.spec.ts new file mode 100644 index 0000000..c32245d --- /dev/null +++ b/packages/e2e/specs/viewer.spec.ts @@ -0,0 +1,66 @@ +// The live viewer: `leji viewer serve` over the fixture layer, judged in a browser. + +import { test } from '@playwright/test'; +import { + ACCENT_VIEWER_URL, + CUSTOM_ACCENT, + DEFAULT_ACCENT, + VIEWER_CONSOLE_ALLOWANCES, + VIEWER_URL, + assertImageAtDepth, + assertLayerHome, + assertManifestPage, + assertRelativeLinkInApp, + assertTones, + assertUnknownRouteInApp, + collectConsoleErrors, + expectNoConsoleErrors, + resourceNotFoundAllowance, +} from '../assertions.js'; + +test.describe('viewer (served)', () => { + test('the home route renders the layer', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertLayerHome(page, VIEWER_URL); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('a relative link on a nested page routes in-app', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertRelativeLinkInApp(page, VIEWER_URL); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('an image beside a nested document renders', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertImageAtDepth(page, VIEWER_URL); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('an unknown route renders the in-app not-found page', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertUnknownRouteInApp(page, VIEWER_URL); + expectNoConsoleErrors(errors, [ + ...VIEWER_CONSOLE_ALLOWANCES, + resourceNotFoundAllowance(`${VIEWER_URL}/content/does-not-exist.md`, VIEWER_URL), + ]); + }); + + test('the manifest page renders', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertManifestPage(page, VIEWER_URL); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('the text tones are the brand set and the chrome is the default accent', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertTones(page, VIEWER_URL, { accent: DEFAULT_ACCENT }); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); + + test('a custom accent moves the chrome and leaves the text tones alone', async ({ page }) => { + const errors = collectConsoleErrors(page); + await assertTones(page, ACCENT_VIEWER_URL, { accent: CUSTOM_ACCENT }); + expectNoConsoleErrors(errors, VIEWER_CONSOLE_ALLOWANCES); + }); +}); diff --git a/packages/e2e/static-server.mjs b/packages/e2e/static-server.mjs new file mode 100644 index 0000000..8fbe835 --- /dev/null +++ b/packages/e2e/static-server.mjs @@ -0,0 +1,113 @@ +// A plain static host for an exported context layer, with no dependency of its +// own: the export's claim is that any static host serves it, so the export spec +// must judge it through something that behaves like one and adds nothing. The +// viewer's own `leji viewer serve` would not prove that: it generates the +// sidebar and the context index per request, which a bucket never does. +// +// node static-server.mjs +// +// GET/HEAD only, files under only, a directory resolving to its +// index.html, everything else 404. + +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; + +// Every extension the export writes, so a real render is judged: the chrome +// (html/js/css/svg), the layer's markdown and its context index, the vendored +// fonts (a woff2 served as octet-stream still renders, but a 404 would show up +// as a console error and would be this server's fault, not the export's), and +// the licence text beside them. +const CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.txt': 'text/plain; charset=utf-8', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +const [dirArg, portArg] = process.argv.slice(2); +if (!dirArg || !portArg) { + console.error('usage: node static-server.mjs '); + process.exit(2); +} +const root = fs.realpathSync(path.resolve(dirArg)); +const port = Number(portArg); + +/** True when a path is the served root or sits under it. */ +function contained(abs) { + return abs === root || abs.startsWith(root + path.sep); +} + +/** A path with every symlink resolved, or null when it does not exist. */ +function real(abs) { + try { + return fs.realpathSync(abs); + } catch { + return null; + } +} + +/** + * The file a request addresses, or null for anything outside the served + * directory. Containment is judged twice: once on the lexically joined path, and + * again on the path with every symlink resolved, because `statSync` and + * `readFileSync` both follow links and a link planted under the export would + * otherwise hand out any file this process can read. A directory's `index.html` + * is resolved and re-checked the same way, since it is a second path. + */ +function resolveTarget(urlPath) { + let decoded; + try { + decoded = decodeURIComponent(urlPath.split('?')[0].split('#')[0]); + } catch { + return null; + } + const requested = path.join(root, path.normalize(decoded).replace(/^(\.\.[/\\])+/, '')); + if (!contained(requested)) return null; + const resolved = real(requested); + if (resolved === null || !contained(resolved)) return null; + let stat; + try { + stat = fs.statSync(resolved); + } catch { + return null; + } + if (stat.isDirectory()) { + const index = real(path.join(resolved, 'index.html')); + if (index === null || !contained(index)) return null; + return fs.statSync(index).isFile() ? index : null; + } + return stat.isFile() ? resolved : null; +} + +const server = http.createServer((req, res) => { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { allow: 'GET, HEAD' }).end(); + return; + } + const file = resolveTarget(req.url ?? '/'); + if (file === null) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('not found'); + return; + } + const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream'; + const body = fs.readFileSync(file); + res.writeHead(200, { 'content-type': type, 'content-length': body.length }); + if (req.method === 'HEAD') res.end(); + else res.end(body); +}); + +server.listen(port, '127.0.0.1', () => { + console.log(`static export → http://127.0.0.1:${port}/`); +}); diff --git a/packages/e2e/tsconfig.json b/packages/e2e/tsconfig.json new file mode 100644 index 0000000..0e4427a --- /dev/null +++ b/packages/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "types": ["node"], + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["*.ts", "specs"] +} diff --git a/packages/mcp/assets/assets-manifest.json b/packages/mcp/assets/assets-manifest.json index 9c02b67..06f599d 100644 --- a/packages/mcp/assets/assets-manifest.json +++ b/packages/mcp/assets/assets-manifest.json @@ -15,7 +15,7 @@ "schemas/agent-profile.schema.json": "sha256:9597a0ff39db7587daf210177fdc7ede41f9efeaab54596289534209826ba657", "schemas/context-changelog.schema.json": "sha256:616fd7bddd1f07638e2cbdc2cfa665166f4739283c5194eca34fbf923218ced4", "schemas/context-index.schema.json": "sha256:c3618e356622793326076a424d53843bfccf00511520cdba010c6946262ab440", - "schemas/context-manifest.schema.json": "sha256:dd24a91bb4938f6b6b986928140a997774d5c90bcb720bfc57ef5d7e332b56e2", + "schemas/context-manifest.schema.json": "sha256:3d8ea744f37f06ceabc277da05d852c89895eb2f47d5aece5eecea76345a7cd4", "schemas/decision-record.schema.json": "sha256:f5db3e68be8b2233b9029949d79109b4784ce43ef1a1cd26e44a0427c8915b07" } } diff --git a/packages/mcp/assets/schemas/context-manifest.schema.json b/packages/mcp/assets/schemas/context-manifest.schema.json index 585c59e..a6f1f0f 100644 --- a/packages/mcp/assets/schemas/context-manifest.schema.json +++ b/packages/mcp/assets/schemas/context-manifest.schema.json @@ -312,7 +312,7 @@ "properties": { "primary": { "type": "string", - "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives links, the active state, and diagram accents." + "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives the viewer chrome, active states, and diagram accents; body links and inline code use the fixed accessible tone." } } }, diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 7e661f8..7cee9de 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@leji-org/mcp", - "version": "1.4.0", + "version": "1.4.1", "description": "Local stdio MCP server for Leji: retrieve the spec and schemas, and run validation and conformance against a context layer, natively from an AI agent.", "keywords": [ "leji", @@ -49,7 +49,7 @@ "coverage": "npm run build && c8 --reporter=text --include='dist/**/*.js' --all node --test" }, "dependencies": { - "@leji-org/leji": "^1.4.0", + "@leji-org/leji": "^1.4.1", "@modelcontextprotocol/sdk": "1.30.0" }, "devDependencies": { diff --git a/packages/sdk-go/README.md b/packages/sdk-go/README.md index 11d1ffa..c5bd9a8 100644 --- a/packages/sdk-go/README.md +++ b/packages/sdk-go/README.md @@ -63,7 +63,7 @@ gofmt -l . # prints nothing go test ./... # all green, including the shared fixtures ``` -The SDK version is a build-time constant defaulting to `1.4.0`; override it with +The SDK version is a build-time constant defaulting to `1.4.1`; override it with `-ldflags "-X github.com/leji-org/leji/packages/sdk-go/internal/schemas.SDKVersion="`. - Specification: https://leji.org diff --git a/packages/sdk-go/go.mod b/packages/sdk-go/go.mod index f403bab..84ea507 100644 --- a/packages/sdk-go/go.mod +++ b/packages/sdk-go/go.mod @@ -1,11 +1,10 @@ module github.com/leji-org/leji/packages/sdk-go -go 1.23 +go 1.27.0 require ( github.com/dlclark/regexp2 v1.11.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 + golang.org/x/text v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) - -require golang.org/x/text v0.14.0 diff --git a/packages/sdk-go/go.sum b/packages/sdk-go/go.sum index ea4c91d..ffd2ffa 100644 --- a/packages/sdk-go/go.sum +++ b/packages/sdk-go/go.sum @@ -2,8 +2,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/packages/sdk-go/internal/assets/assets-manifest.json b/packages/sdk-go/internal/assets/assets-manifest.json index 8f441ca..069ab81 100644 --- a/packages/sdk-go/internal/assets/assets-manifest.json +++ b/packages/sdk-go/internal/assets/assets-manifest.json @@ -5,7 +5,7 @@ "schemas/agent-profile.schema.json": "sha256:9597a0ff39db7587daf210177fdc7ede41f9efeaab54596289534209826ba657", "schemas/context-changelog.schema.json": "sha256:616fd7bddd1f07638e2cbdc2cfa665166f4739283c5194eca34fbf923218ced4", "schemas/context-index.schema.json": "sha256:c3618e356622793326076a424d53843bfccf00511520cdba010c6946262ab440", - "schemas/context-manifest.schema.json": "sha256:dd24a91bb4938f6b6b986928140a997774d5c90bcb720bfc57ef5d7e332b56e2", + "schemas/context-manifest.schema.json": "sha256:3d8ea744f37f06ceabc277da05d852c89895eb2f47d5aece5eecea76345a7cd4", "schemas/decision-record.schema.json": "sha256:f5db3e68be8b2233b9029949d79109b4784ce43ef1a1cd26e44a0427c8915b07", "templates/README.md": "sha256:3fa28c144a26076cc75dc2a6d23014d61370abcb2073afa7d5bd3f26af4884c7", "templates/agent-profile.md": "sha256:fb1cf77aeaffc10718795231b936a7eab9b54c9221f4de072677e3afd3656545", @@ -42,9 +42,9 @@ "templates/viewer/assets/source-sans-pro-600-vietnamese.woff2": "sha256:615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "templates/viewer/assets/third-party-licenses.txt": "sha256:010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "templates/viewer/assets/viewer-boot.js": "sha256:39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "templates/viewer/assets/vue.css": "sha256:af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "templates/viewer/assets/vue.css": "sha256:61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", "templates/viewer/assets/zoom-image.min.js": "sha256:c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", - "templates/viewer/index.html": "sha256:127dadfdca91e9e93739b4e2898ab1ec33fe0b5658354288d960e3e2989f6998", + "templates/viewer/index.html": "sha256:fd6e43cde0d72678dd26c9ad38753f046ed3c5e3e550fbd211c0f4307edd8806", "templates/writing-style.md": "sha256:ee17bb1b97cbe87c4d8ef59b80b2e1d03d997d8839a98d3eb2080c540efa7b2c" } } diff --git a/packages/sdk-go/internal/assets/schemas/context-manifest.schema.json b/packages/sdk-go/internal/assets/schemas/context-manifest.schema.json index 585c59e..a6f1f0f 100644 --- a/packages/sdk-go/internal/assets/schemas/context-manifest.schema.json +++ b/packages/sdk-go/internal/assets/schemas/context-manifest.schema.json @@ -312,7 +312,7 @@ "properties": { "primary": { "type": "string", - "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives links, the active state, and diagram accents." + "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives the viewer chrome, active states, and diagram accents; body links and inline code use the fixed accessible tone." } } }, diff --git a/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css b/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css index 1fc6850..7836ffc 100644 --- a/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css +++ b/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css @@ -131,8 +131,8 @@ span.emoji { vertical-align: middle; } .progress { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); height: 2px; left: 0px; position: fixed; @@ -143,12 +143,12 @@ span.emoji { z-index: 999999; } .search a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .search .search-keyword { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-style: normal; font-weight: bold; } @@ -159,7 +159,7 @@ body { body { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; - color: #34495e; + color: var(--leji-text-body); font-family: 'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif; font-size: 15px; letter-spacing: 0; @@ -218,14 +218,14 @@ li input[type='checkbox'] { transition: color 0.3s; } .app-nav a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .app-nav a.active { - border-bottom: 2px solid #42b983; - border-bottom: 2px solid var(--theme-color, #42b983); - color: #42b983; - color: var(--theme-color, #42b983); + border-bottom: 2px solid #009f71; + border-bottom: 2px solid var(--theme-color, #009f71); + color: #009f71; + color: var(--theme-color, #009f71); } .app-nav li { display: inline-block; @@ -283,8 +283,8 @@ li input[type='checkbox'] { } .github-corner svg { color: #fff; - fill: #42b983; - fill: var(--theme-color, #42b983); + fill: #009f71; + fill: var(--theme-color, #009f71); height: 80px; width: 80px; } @@ -304,7 +304,7 @@ main.hidden { transition: all 0.3s; } .anchor span { - color: #34495e; + color: var(--leji-text); } .anchor:hover { text-decoration: underline; @@ -393,8 +393,8 @@ main.hidden { opacity: 0.4; } .sidebar-toggle span { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); display: block; margin-bottom: 4px; width: 16px; @@ -489,7 +489,7 @@ body.sticky .sidebar-toggle { background-color: #efefef; } .markdown-section p.tip em { - color: #34495e; + color: var(--leji-text-body); } .markdown-section p.warn { background: rgba(66,185,131,0.1); @@ -653,14 +653,14 @@ section.cover ul { padding: 0; } section.cover .cover-main > p:last-child a { - border-color: #42b983; - border-color: var(--theme-color, #42b983); + border-color: #009f71; + border-color: var(--theme-color, #009f71); border-radius: 2rem; border-style: solid; border-width: 1px; box-sizing: border-box; - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); display: inline-block; font-size: 1.05rem; letter-spacing: 0.1rem; @@ -670,8 +670,8 @@ section.cover .cover-main > p:last-child a { transition: all 0.15s ease; } section.cover .cover-main > p:last-child a:last-child { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); color: #fff; } section.cover .cover-main > p:last-child a:last-child:hover { @@ -682,13 +682,13 @@ section.cover .cover-main > p:last-child a:hover { color: inherit; } section.cover blockquote > p > a { - border-bottom: 2px solid #42b983; - border-bottom: 2px solid var(--theme-color, #42b983); + border-bottom: 2px solid #009f71; + border-bottom: 2px solid var(--theme-color, #009f71); transition: color 0.3s; } section.cover blockquote > p > a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } body { background-color: #fff; @@ -696,13 +696,13 @@ body { /* sidebar */ .sidebar { background-color: #fff; - color: #364149; + color: var(--leji-text-body); } .sidebar li { margin: 6px 0 6px 0; } .sidebar ul li a { - color: #505d6b; + color: var(--leji-text-body); font-size: 14px; font-weight: normal; overflow: hidden; @@ -718,8 +718,8 @@ body { } .sidebar ul li.active > a { border-right: 2px solid; - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-weight: 600; } .app-sub-sidebar li::before { @@ -733,12 +733,12 @@ body { .markdown-section h3, .markdown-section h4, .markdown-section strong { - color: #2c3e50; + color: var(--leji-text); font-weight: 600; } .markdown-section a { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-weight: 600; } .markdown-section h1 { @@ -760,7 +760,7 @@ body { font-size: 1rem; } .markdown-section h6 { - color: #777; + color: var(--leji-text); font-size: 1rem; } .markdown-section figure, @@ -778,9 +778,9 @@ body { padding-left: 1.5rem; } .markdown-section blockquote { - border-left: 4px solid #42b983; - border-left: 4px solid var(--theme-color, #42b983); - color: #858585; + border-left: 4px solid #009f71; + border-left: 4px solid var(--theme-color, #009f71); + color: var(--leji-text-body); margin: 2em 0; padding-left: 20px; } @@ -792,7 +792,7 @@ body { margin: 1em 0; } .markdown-section em { - color: #7f8c8d; + color: var(--leji-text-body); } .markdown-section code, .markdown-section pre, @@ -901,8 +901,8 @@ body { color: #2973b7; } .token.string { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .token.selector { color: #6679cc; @@ -920,8 +920,8 @@ body { .token.control, .token.directive, .token.unit { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .token.keyword, .token.function { @@ -972,6 +972,26 @@ code .token { group/active treatment. CDN-free: the theme's webfonts are vendored locally (see the @font-face rules at the top of this file). ========================================================================== */ +/* Every text color in this file and in the viewer shell belongs to exactly one of + four classes, and a new one has to be placed in a class before it is written: + + i. typography: the three tones below and nothing else. Headings and emphasis + take --leji-text, all normal-size copy --leji-text-body, and --leji-text-muted + is declared for large-text use only (3.99:1 on white, below AA at normal + size), so it currently has no consumer. + ii. links and active chrome: --leji-link (the fixed AA body-link tone), + --theme-color (the layer's accent, viewer.theme.primary), --leji-deep, + --leji-brand. + iii. inverse text on dark or accent grounds: the literal #fff on a colored + ground, which is not a typography tone. + iv. code and syntax: the fenced-code text, the Prism .token.* colors, and the + inline-code ground. + + Surfaces (--leji-paper, --leji-line, --leji-code-bg) and icons (--leji-caret, the + group triangles) are not text and belong to none of the four. + + The legacy neutrals the stock docsify theme shipped are denied outright, in a + unit test over this directory (packages/sdk/test/viewer-tones.test.ts). */ :root { --theme-color: #009f71; /* recolors every var(--theme-color) rule above */ --leji-brand: #009f71; /* the Leji mark green: brand moments, never small text */ @@ -979,17 +999,18 @@ code .token { --leji-deep: #164e42; --leji-accent: #78d7b5; --leji-paper: #f7f8f5; /* the brand's light canvas: sidebar, chips, panels */ - --leji-ink: #34495e; - --leji-ink-soft: #555555; + --leji-text: #183b32; /* headings and emphasis */ + --leji-text-body: #4d5b56; /* every normal-size run of copy */ + --leji-text-muted: #76827d; /* large text only: 3.99:1 on white */ --leji-line: #cde5d9; /* the brand's border tone, not a neutral gray */ --leji-code-bg: #e8f4ee; - --leji-caret: #aaaaaa; /* lighter than the ink for the group triangles */ + --leji-caret: #aaaaaa; /* an icon tone, lighter than the text, not typography */ color-scheme: light; } body { background: var(--leji-paper); - color: var(--leji-ink); + color: var(--leji-text-body); } /* --- sidebar shell --- */ @@ -1014,7 +1035,7 @@ body { border-top: 1px solid var(--leji-line); } .sidebar ul li a { - color: var(--leji-ink-soft); + color: var(--leji-text-body); transition: color 0.12s; } .sidebar ul li a:hover { @@ -1060,7 +1081,7 @@ body { .sidebar-nav > ul > li.folder { /* 600 is Source Sans Pro's bold face; 500 would resolve down to regular. */ font-weight: 600; - color: var(--leji-ink); + color: var(--leji-text); } .sidebar-nav > ul > li.folder .app-sub-sidebar { font-weight: 400; @@ -1077,7 +1098,7 @@ body { content: none !important; } .sidebar-nav .app-sub-sidebar li a { - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .sidebar-nav .app-sub-sidebar li.active > a, .sidebar-nav .app-sub-sidebar li a:hover { @@ -1092,10 +1113,10 @@ body { background: #fff; border: 1px solid var(--leji-line); border-radius: 6px; - color: var(--leji-ink); + color: var(--leji-text-body); } .search input::placeholder { - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .search input:focus { outline: none; @@ -1133,7 +1154,7 @@ body { font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .sidebar-toggle { background-color: var(--leji-paper) !important; @@ -1152,15 +1173,27 @@ body { font-weight: 600; } +/* h3, h4, h6 and strong take the heading tone from the rules above; h1, h2 and h5 + say it here rather than inheriting, so every heading level carries one tone by + declaration and a stock-theme change cannot quietly split them. */ +.markdown-section h1, +.markdown-section h2, +.markdown-section h5 { + color: var(--leji-text); +} + +/* Body links take the fixed accessible tone, never the layer's accent: AA on the + content ground holds for every value of viewer.theme.primary. The chrome above + keeps --theme-color, so a custom accent still colors the viewer. */ .markdown-section a { - color: var(--theme-color); + color: var(--leji-link); } /* brand-tinted inline code, replacing the stock orange. Scoped away from pre > code so fenced blocks keep the stock token colors. */ .markdown-section code, .markdown-section p code, .markdown-section li code { - color: var(--theme-color); + color: var(--leji-link); background: var(--leji-code-bg); } /* The fenced-code panel takes the same ground, replacing the stock neutral gray. */ @@ -1173,5 +1206,5 @@ body { } .markdown-section blockquote { border-left: 3px solid var(--leji-accent); - color: var(--leji-ink-soft); + color: var(--leji-text-body); } diff --git a/packages/sdk-go/internal/assets/templates/viewer/index.html b/packages/sdk-go/internal/assets/templates/viewer/index.html index 924ce01..5745eb6 100644 --- a/packages/sdk-go/internal/assets/templates/viewer/index.html +++ b/packages/sdk-go/internal/assets/templates/viewer/index.html @@ -18,12 +18,16 @@ Docsify sets element styles at runtime; that is not a script-execution path. frame-src 'none' closes the framing path: without it a governed document could frame a same-origin /content/ page, which inherits none of this. - The local server sends the same policy as a response header on every - response, so it also covers documents fetched outside this shell. + The directive naming who may frame this page is deliberately absent: a meta + policy cannot deliver it, so claiming it here would protect nothing and cost + an ignored-directive console error on every page. The local server sends + this policy as a response header on every response, with that directive + added, so the served viewer is covered including documents fetched outside + this shell; a static host serving an export sets it in its own headers. --> {{LEJI_NAME_HTML}} @@ -34,7 +38,8 @@ /* Viewer default styling: white content, the brand canvas on the sidebar and its chrome, tight nesting — the shape teams hand-build for internal dashboards. Accents ride --theme-color (set by Docsify from - viewer.theme.primary). */ + viewer.theme.primary); the text tones are the --leji-text* custom + properties assets/vue.css declares, which loads before this block. */ body { background-color: #ffffff; } @@ -48,7 +53,7 @@ z-index: 5; font-size: 0.7rem; letter-spacing: 0.02em; - color: #555555; + color: var(--leji-text-body); background: #f7f8f5; border: 1px solid #cde5d9; border-radius: 999px; @@ -80,7 +85,7 @@ width: max-content; text-align: center; font-size: 0.8rem; - color: #999999; + color: var(--leji-text-body); animation: lj-nav-pulse 1.2s ease-in-out infinite; } @keyframes lj-nav-pulse { @@ -94,7 +99,7 @@ content: none !important; } .sidebar-nav li > a { - color: #2c3e50; + color: var(--leji-text); padding: 6px 0; } .sidebar-nav li > a:hover { @@ -136,7 +141,7 @@ /* Group headers: the theme's default bold rendering, at full size. */ .sidebar-nav li > strong, .sidebar-nav li > p > strong { - color: #1a1a1a; + color: var(--leji-text); font-weight: 600; user-select: none; } @@ -178,7 +183,7 @@ } .sidebar-nav ul ul li > a { font-size: 14px; - color: #555; + color: var(--leji-text-body); padding: 2px 0; } .sidebar-nav ul ul li > a:hover { @@ -197,7 +202,7 @@ .search input { background-color: #ffffff; border: 1px solid #cde5d9; - color: #2c3e50; + color: var(--leji-text-body); } .search .results-panel { background-color: #ffffff; @@ -205,7 +210,7 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .search .results-panel .matching-post > a { - color: #2c3e50; + color: var(--leji-text); } .search .results-panel .matching-post > a:hover { color: var(--theme-color, #009f71); @@ -241,7 +246,7 @@ font-size: 11px; letter-spacing: normal; text-transform: none; - color: #9aa0a6; + color: var(--leji-text-body); z-index: 30; } .leji-powered a { diff --git a/packages/sdk-go/internal/cli/cli.go b/packages/sdk-go/internal/cli/cli.go index 015d178..070321e 100644 --- a/packages/sdk-go/internal/cli/cli.go +++ b/packages/sdk-go/internal/cli/cli.go @@ -37,6 +37,7 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/jsonenc" "github.com/leji-org/leji/packages/sdk-go/internal/layer" "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/mounts" "github.com/leji-org/leji/packages/sdk-go/internal/schemas" @@ -451,7 +452,7 @@ func printUnindexedNudge(count int) { // // Exits: 0 written (warnings allowed), 1 an error finding — or, under `--strict`, a // lint finding — with the target left byte-untouched, 2 a usage error or a refusal. -func runExport(f flags) int { +func runExport(f flags, ignoreContext *lejiignore.Context) int { load := manifest.LoadManifest(f.root) out := "" if f.hasOut { @@ -467,7 +468,7 @@ func runExport(f flags) int { } return reportExport(f, export.BuildResult{Out: declared, Findings: load.Findings}) } - r, err := export.BuildViewer(f.root, load.Manifest, out, export.Options{Strict: f.strict}) + r, err := export.BuildViewer(f.root, load.Manifest, out, export.Options{Strict: f.strict, IgnoreContext: ignoreContext}) if err != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 @@ -670,7 +671,12 @@ func printFindings(fs []findings.Finding) { if f.Severity == findings.Error { sev = "error " } - fmt.Printf("%s %s%s: %s\n", sev, f.Rule, where, f.Message) + // A rule that names the act it failed at says which one, on the same line. + detail := "" + if f.Detail != "" { + detail = " (detail: " + f.Detail + ")" + } + fmt.Printf("%s %s%s: %s%s\n", sev, f.Rule, where, f.Message, detail) } } @@ -704,6 +710,11 @@ func findingToMap(f findings.Finding) *jsonObj { o.set("construct", f.Construct) } o.set("message", f.Message) + // A rule with more than one act names the one it failed at, immediately after + // the message; every other rule carries no such key. + if f.Detail != "" { + o.set("detail", f.Detail) + } return o } @@ -902,6 +913,13 @@ func Run(argv []string) int { } } + // One notice state for this invocation, created at the command entry point and + // handed to every path that can create a `.leji/` role: whatever a command + // establishes, it says at most once that it left an existing `.leji/.gitignore` + // alone. A second repository, or a long-lived host calling the SDK directly, never + // inherits it. + ignoreContext := lejiignore.NewContext() + switch command { case "validate": result, verr := validate.ValidateLayer(f.root, f.content) @@ -1392,7 +1410,7 @@ func Run(argv []string) int { fmt.Fprintln(os.Stderr, usage) return 2 } - result, rerr := conformance.Report(f.root, f.federation == "verify") + result, rerr := conformance.Report(f.root, f.federation == "verify", ignoreContext) if rerr != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", rerr.Error()) return 2 @@ -1487,6 +1505,7 @@ func Run(argv []string) int { AllowNonFastForward: f.allowNonFF, Fetch: f.fetch, DryRun: f.dryRun, + IgnoreContext: ignoreContext, }) if uerr != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", uerr.Error()) @@ -1495,7 +1514,7 @@ func Run(argv []string) int { return reportUpdatePin(f, r) } if sub == "hydrate" { - r, err := mounts.HydrateMounts(f.root, load.Manifest, mounts.HydrateOptions{Fetch: f.fetch}) + r, err := mounts.HydrateMounts(f.root, load.Manifest, mounts.HydrateOptions{Fetch: f.fetch, IgnoreContext: ignoreContext}) if err != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 @@ -1515,7 +1534,7 @@ func Run(argv []string) int { projectionFailed: o.ProjectionFailed, }) } - issues := mountFindings(rows) + issues := mountFindings(rows, r.Reasons) hadError := false for _, o := range r.Outcomes { if o.Status == "error" { @@ -1592,7 +1611,7 @@ func Run(argv []string) int { } // No per-row findings here: `status` never fetches, so it has nothing of // its own to report. - issues := mountFindings(nil) + issues := mountFindings(nil, nil) if f.json { fmt.Println(mountsStatusJSON(rows, issues)) } else { @@ -1631,7 +1650,7 @@ func Run(argv []string) int { } return 0 case "export": - return runExport(f) + return runExport(f, ignoreContext) case "view", "viewer": // `leji view` is an alias for `leji viewer serve` that also opens the browser. // `leji viewer` generates only; `leji viewer serve` serves. @@ -1647,7 +1666,7 @@ func Run(argv []string) int { return 2 } if command == "viewer" && sub == "build" { - return runExport(f) + return runExport(f, ignoreContext) } wantServe := isAlias || sub == "serve" wantOpen := f.open || isAlias @@ -1655,7 +1674,7 @@ func Run(argv []string) int { if load.Manifest == nil { return emit("viewer", load.Findings, f.json, nil) } - result, err := viewer.GenerateViewer(f.root, load.Manifest) + result, err := viewer.GenerateViewer(f.root, load.Manifest, ignoreContext) if err != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 @@ -1686,7 +1705,10 @@ func Run(argv []string) int { if !f.json { logf = func(line string) { fmt.Println(line) } } - ln, srv, err := serve.Serve(f.root, port, load.Manifest.RootPath, logf) + // The generation above just projected the index: the first layer map is that + // snapshot rather than a second generation of the same tree. + ln, srv, err := serve.Serve(f.root, port, load.Manifest.RootPath, logf, + serve.Options{Entries: result.IndexEntries}) if err != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 @@ -1738,7 +1760,7 @@ func Run(argv []string) int { if f.dir == "." && f.root != "." { dir = f.root } - opts := initcmd.AdoptOptions{Dir: dir, Yes: f.yes, DryRun: f.dryRun, WireAdapters: f.wireAdapters, NoAgents: f.noAgents, Agent: f.agent, Mode: f.mode} + opts := initcmd.AdoptOptions{Dir: dir, Yes: f.yes, DryRun: f.dryRun, WireAdapters: f.wireAdapters, NoAgents: f.noAgents, Agent: f.agent, Mode: f.mode, IgnoreContext: ignoreContext} if f.hasName { opts.Name = f.name } @@ -1788,7 +1810,7 @@ func Run(argv []string) int { dependencyFailed = initcmd.DependencyAddFailed(offer) } mcp := initcmd.OfferMcpInstall(initcmd.McpOfferOptions{Root: result.Root, Detected: result.Detected, Interactive: interactive, Agent: f.agent}, hio, os.Stdout) - if gerr := initcmd.OfferApprovalGuard(initcmd.GuardOfferOptions{Root: result.Root, RootPath: result.Manifest.RootPath, Detected: result.Detected, Interactive: interactive, Agent: f.agent}, hio, os.Stdout); gerr != nil { + if gerr := initcmd.OfferApprovalGuard(initcmd.GuardOfferOptions{Root: result.Root, RootPath: result.Manifest.RootPath, Detected: result.Detected, Interactive: interactive, Agent: f.agent, IgnoreContext: ignoreContext}, hio, os.Stdout); gerr != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", gerr.Error()) return 2 } @@ -1813,7 +1835,7 @@ func Run(argv []string) int { } // StdinTTY gates the interactive mode question the same way the handoff // offer is gated: piped/CI runs never see it. - opts := initcmd.Options{Dir: dir, Yes: f.yes, Level: f.level, DryRun: f.dryRun, NoAgents: f.noAgents, Agent: f.agent, Mode: f.mode, StdinTTY: stdinIsTTY()} + opts := initcmd.Options{Dir: dir, Yes: f.yes, Level: f.level, DryRun: f.dryRun, NoAgents: f.noAgents, Agent: f.agent, Mode: f.mode, StdinTTY: stdinIsTTY(), IgnoreContext: ignoreContext} if f.hasName { opts.Name = f.name } @@ -1846,7 +1868,7 @@ func Run(argv []string) int { Root: result.Root, Report: initEco, Interactive: interactive, }, os.Stdout) mcp := initcmd.OfferMcpInstall(initcmd.McpOfferOptions{Root: result.Root, Detected: result.Detected, Interactive: interactive, Agent: f.agent}, hio, os.Stdout) - if gerr := initcmd.OfferApprovalGuard(initcmd.GuardOfferOptions{Root: result.Root, RootPath: result.Manifest.RootPath, Detected: result.Detected, Interactive: interactive, Agent: f.agent}, hio, os.Stdout); gerr != nil { + if gerr := initcmd.OfferApprovalGuard(initcmd.GuardOfferOptions{Root: result.Root, RootPath: result.Manifest.RootPath, Detected: result.Detected, Interactive: interactive, Agent: f.agent, IgnoreContext: ignoreContext}, hio, os.Stdout); gerr != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", gerr.Error()) return 2 } @@ -2235,7 +2257,9 @@ func nullableStr(p *string) any { // observed and nothing beyond it. The row-level warnings describe the run that is // happening, never a remembered one, and all are visibility rather than failure — // `hydrate` stays best-effort, so none moves the exit code. -func mountFindings(rows []mountFindingRow) []findings.Finding { +// reasons carries the resolver's reason for the one `--fetch` act that failed, per +// mount: the finding names the act, the reason says what the act ran into. +func mountFindings(rows []mountFindingRow, reasons map[string]string) []findings.Finding { var out []findings.Finding for _, r := range rows { // An unavailable mount whose pinned layer would not project: the outcome alone @@ -2249,20 +2273,31 @@ func mountFindings(rows []mountFindingRow) []findings.Finding { r.name, )) } + reason := reasons[r.name] if r.storeFetched != nil && !*r.storeFetched { - out = append(out, findings.New( + detail := "" + if reason != "" { + detail = "current pin: " + reason + } + out = append(out, findings.NewWithDetail( "mount-store-fetch-failed", findings.Warning, "the managed store could not be established by the requested fetch", r.name, + detail, )) } if r.witnessRefreshFailed { - out = append(out, findings.New( + detail := "" + if reason != "" { + detail = "witness: " + reason + } + out = append(out, findings.NewWithDetail( "mount-witness-refresh-failed", findings.Warning, "the managed witness ref could not be refreshed by the requested fetch", r.name, + detail, )) } } @@ -2449,11 +2484,26 @@ func reportUpdatePin(f flags, r updatepin.Result) int { case updatepin.ActionDryRun: fmt.Printf("Would update leji.json: %s pin %s → %s (dry run)%s\n", r.Mount.Name, from12, to12, overridden) case updatepin.ActionRefused: - prose := updatepin.Reasons[r.Reason] + // The refusal's own finding is what the document carries, so the human line + // is read off it rather than looked up a second time: one sentence, and the + // act it failed at when the rule names one. + prose, detail := "", "" + for _, f := range sorted { + if f.Severity == findings.Error { + prose, detail = f.Message, f.Detail + break + } + } + if prose == "" { + prose = updatepin.Reasons[r.Reason] + } if prose == "" { prose = r.Reason } - fmt.Printf("Refused: %s\n", prose) + if detail != "" { + detail = " (detail: " + detail + ")" + } + fmt.Printf("Refused: %s%s\n", prose, detail) } if ok { return 0 diff --git a/packages/sdk-go/internal/cli/updatepin_test.go b/packages/sdk-go/internal/cli/updatepin_test.go index 5d7e842..510f038 100644 --- a/packages/sdk-go/internal/cli/updatepin_test.go +++ b/packages/sdk-go/internal/cli/updatepin_test.go @@ -6,6 +6,7 @@ package cli import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" @@ -119,6 +120,104 @@ func buildAcmeSibling(t *testing.T, dir string) { recipeGit(t, dir, "config", "uploadpack.allowAnySHA1InWant", "true") } +// recipeGitFails reports whether a git command FAILS, without failing the test: the +// orphan-target scaffold proves its topology rather than trusting the recipe. +func recipeGitFails(cwd string, args ...string) bool { + cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false", "-c", "core.autocrlf=false"}, args...)...) + cmd.Dir = cwd + env := make([]string, 0, len(os.Environ())) + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "GIT_DIR=") { + env = append(env, e) + } + } + cmd.Env = env + return cmd.Run() != nil +} + +// buildOrphanTargetRepo is the rewritten upstream before anything is pruned: `a` on +// its own branch `old`, and an unrelated orphan on `main`. A repository in this +// shape holds BOTH commits, which is what the refusal's route calls a local hint; +// pruning it is what makes a source that can no longer serve the pin. +func buildOrphanTargetRepo(t *testing.T, dir string) (pin, target string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + recipeGit(t, dir, "init", "-q", "-b", "old", ".") + pin = recipeCommit(t, dir, "a.md") + recipeGit(t, dir, "checkout", "-q", "--orphan", "main") + recipeGit(t, dir, "rm", "-q", "-rf", ".") + target = recipeCommit(t, dir, "o.md") + return pin, target +} + +// buildOrphanTargetSource is the orphan-target scaffold: a source that no longer +// serves the commit the host pins, while still advertising a target unrelated to +// it: an upstream that rewrote its history, which is the case the `current pin` +// act exists to name. Both halves are asserted here, because a scaffold that +// quietly kept the pin reachable (which is what pruning a branch the target still +// reaches would do) would prove nothing on any platform's git. +func buildOrphanTargetSource(t *testing.T, dir string) (pin, target string) { + t.Helper() + pin, target = buildOrphanTargetRepo(t, dir) + recipeGit(t, dir, "branch", "-D", "old") + recipeGit(t, dir, "reflog", "expire", "--expire=now", "--all") + recipeGit(t, dir, "gc", "-q", "--prune=now") + // Fetching a commit by id is how the resolver retains a pin: the source serves + // one the way a real host does, so the refusal is the missing object and never a + // server declining to serve an unadvertised id. + recipeGit(t, dir, "config", "uploadpack.allowAnySHA1InWant", "true") + if !recipeGitFails(dir, "cat-file", "-e", pin) { + t.Fatal("orphan-target scaffold: the source still serves the pin") + } + advertised := recipeGit(t, dir, "ls-remote", dir) + if !regexp.MustCompile(target + `\s+refs/heads/main`).MatchString(advertised) { + t.Fatalf("orphan-target scaffold: main advertises %q, not the orphan target %s", advertised, target) + } + return pin, target +} + +// objectKeys reads one JSON object's keys in the order they were emitted: the key +// order the three SDKs freeze is a property of the bytes, not of a decode. +func objectKeys(t *testing.T, raw []byte) []string { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(raw)) + if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { + t.Fatalf("not a JSON object: %s", raw) + } + keys := []string{} + for dec.More() { + tok, err := dec.Token() + if err != nil { + t.Fatal(err) + } + key, ok := tok.(string) + if !ok { + t.Fatalf("not an object key: %v", tok) + } + keys = append(keys, key) + var skip json.RawMessage + if err := dec.Decode(&skip); err != nil { + t.Fatal(err) + } + } + return keys +} + +// rawFindings returns the emitted findings as raw objects, so a test can ask what +// keys a finding carries rather than what a typed decode kept. +func rawFindings(t *testing.T, stdout string) []json.RawMessage { + t.Helper() + var doc struct { + Findings []json.RawMessage `json:"findings"` + } + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatalf("%v (%s)", err, stdout) + } + return doc.Findings +} + // storeSpec mirrors the `store` field of one `updatePin` case. type storeSpec struct { Pin *string `json:"pin"` @@ -379,6 +478,7 @@ type updatePinDocument struct { Severity string `json:"severity"` Path string `json:"path"` Message string `json:"message"` + Detail string `json:"detail"` } `json:"findings"` Summary struct { Errors int `json:"errors"` @@ -585,6 +685,18 @@ func runUpdatePinCase(t *testing.T, fixtureName string, block updatePinBlock, c t.Fatalf("%s: findings = %+v, want %+v", c.ID, got, want) } } + // Only a rule with more than one act names one: every other refusal is the + // document it was before the act detail existed. + for i, f := range doc.Findings { + if f.Rule == "mount-store-fetch-failed" || f.Rule == "mount-witness-refresh-failed" { + continue + } + for _, k := range objectKeys(t, rawFindings(t, stdout)[i]) { + if k == "detail" { + t.Fatalf("%s: %s carries no detail", c.ID, f.Rule) + } + } + } if c.ComparisonRepository != nil && doc.PinReport["comparisonRepository"] != *c.ComparisonRepository { t.Fatalf("%s: comparisonRepository = %v", c.ID, doc.PinReport["comparisonRepository"]) } @@ -823,6 +935,14 @@ func TestUpdatePinTargetRetentionFailureRefusesWithTheManifestUntouched(t *testi if len(doc.Findings) != 1 || doc.Findings[0].Rule != "mount-store-fetch-failed" { t.Fatalf("findings = %+v", doc.Findings) } + // The act is named, and it is the TARGET's: the same rule id, from the other side + // of the gate, is a different act with no route past it. + if doc.Findings[0].Detail != "target: the pin could not be retained by a ref in the managed store" { + t.Fatalf("detail = %q", doc.Findings[0].Detail) + } + if doc.Findings[0].Message != "the requested fetch could not retain the commit in the managed store" { + t.Fatalf("message = %q", doc.Findings[0].Message) + } after, err := os.ReadFile(mp) if err != nil { t.Fatal(err) @@ -837,6 +957,386 @@ func TestUpdatePinTargetRetentionFailureRefusesWithTheManifestUntouched(t *testi } } +// --- which act failed, and the route past the one that has one ---------------- + +const routeSentence = "the requested fetch could not retain the commit in the managed store; " + + "if a local hint holds the current pin and the target with complete ancestry, run without `--fetch`; " + + "to move past a rewritten upstream, pass `--to --allow-non-fast-forward` against such a hint" + +func TestUpdatePinCurrentPinTheSourceNoLongerServesNamesThatActWithTheRoute(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + host := filepath.Join(dir, "host") + pin, target := buildOrphanTargetSource(t, source) + copyFixture(t, "warn-update-pin", host) + repin(t, host, pin, false) + mp := filepath.Join(host, "leji.json") + before, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + argv := []string{"mounts", "update-pin", "product-context", "--fetch", "--root", host} + var code int + var stdout string + withSourceRoutedTo(t, source, func() { + code, stdout, _ = captureRun(t, append(append([]string{}, argv...), "--json")) + }) + if code != 1 { + t.Fatalf("exit %d (%s)", code, stdout) + } + var doc updatePinDocument + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatal(err) + } + if strOr(doc.Reason, "") != "mount-store-fetch-failed" { + t.Fatalf("reason = %v", doc.Reason) + } + if doc.Mount.To != nil { + t.Fatalf("the run refused before it had a target: %v", doc.Mount.To) + } + if doc.Findings[0].Detail != "current pin: the pin could not be fetched from the source" { + t.Fatalf("detail = %q", doc.Findings[0].Detail) + } + // `detail` sits immediately after `message`: the key order the three SDKs freeze. + if got := objectKeys(t, rawFindings(t, stdout)[0]); strings.Join(got, ",") != "rule,severity,path,message,detail" { + t.Fatalf("finding keys = %v", got) + } + if doc.Findings[0].Message != routeSentence { + t.Fatalf("message = %q", doc.Findings[0].Message) + } + // The same sentence reaches a person, with the act on the same line. + var humanCode int + var human string + withSourceRoutedTo(t, source, func() { + humanCode, human, _ = captureRun(t, argv) + }) + if humanCode != 1 { + t.Fatalf("human exit %d (%s)", humanCode, human) + } + want := "Refused: " + routeSentence + " (detail: current pin: the pin could not be fetched from the source)" + if strings.TrimSpace(human) != want { + t.Fatalf("human line =\n%q\nwant\n%q", strings.TrimSpace(human), want) + } + after, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("leji.json is byte-untouched") + } + if target == pin { + t.Fatal("the advertised target is not the commit the host pins") + } +} + +func TestUpdatePinTheSameInjectedFailureAimedAtTheCurrentPinNamesThatAct(t *testing.T) { + dir := t.TempDir() + sibling := filepath.Join(dir, "sibling") + host := filepath.Join(dir, "host") + buildAcmeSibling(t, sibling) + copyFixture(t, "warn-update-pin", host) + repin(t, host, oidA, false) + // One hook, two acts: it names a commit, and each act retains its own, so the + // pin's id aims it at the act before the gate rather than the one after it. + t.Setenv("LEJI_TEST_FAIL_PIN_REF", oidA) + var code int + var stdout string + withSourceRoutedTo(t, sibling, func() { + code, stdout, _ = captureRun(t, []string{ + "mounts", "update-pin", "product-context", "--fetch", "--root", host, "--json", + }) + }) + if code != 1 { + t.Fatalf("exit %d (%s)", code, stdout) + } + var doc updatePinDocument + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatal(err) + } + if strOr(doc.Reason, "") != "mount-store-fetch-failed" { + t.Fatalf("reason = %v", doc.Reason) + } + if doc.Findings[0].Detail != "current pin: the pin could not be retained by a ref in the managed store" { + t.Fatalf("detail = %q", doc.Findings[0].Detail) + } + if doc.Findings[0].Message != routeSentence { + t.Fatalf("message = %q", doc.Findings[0].Message) + } +} + +func TestUpdatePinATrackingRefTheSourceDoesNotAdvertiseRefusesAtTheWitnessAct(t *testing.T) { + dir := t.TempDir() + sibling := filepath.Join(dir, "sibling") + host := filepath.Join(dir, "host") + buildAcmeSibling(t, sibling) + copyFixture(t, "warn-update-pin", host) + repin(t, host, oidA, false) + // The store already holds the pin, so its retention needs no network at all and + // the witness refresh is the only act left that can fail. + pin := oidA + buildStore(t, host, sibling, storeSpec{Pin: &pin}) + mp := filepath.Join(host, "leji.json") + raw, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mp, []byte(strings.Replace(string(raw), "refs/heads/main", "refs/heads/release", 1)), 0o644); err != nil { + t.Fatal(err) + } + var code int + var stdout string + withSourceRoutedTo(t, sibling, func() { + code, stdout, _ = captureRun(t, []string{ + "mounts", "update-pin", "product-context", "--fetch", "--root", host, "--json", + }) + }) + if code != 1 { + t.Fatalf("exit %d (%s)", code, stdout) + } + var doc updatePinDocument + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatal(err) + } + if strOr(doc.Reason, "") != "mount-witness-refresh-failed" { + t.Fatalf("reason = %v", doc.Reason) + } + if doc.Findings[0].Detail != "witness: the tracking ref could not be fetched from the source" { + t.Fatalf("detail = %q", doc.Findings[0].Detail) + } + // The witness act has no route of its own: the rule's own sentence stands. + if doc.Findings[0].Message != "the requested fetch could not refresh the managed witness ref" { + t.Fatalf("message = %q", doc.Findings[0].Message) + } +} + +func TestUpdatePinHydrateOverThatSourceNamesTheActAndLeavesTheRowUnchanged(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + host := filepath.Join(dir, "host") + pin, _ := buildOrphanTargetSource(t, source) + copyFixture(t, "warn-update-pin", host) + repin(t, host, pin, false) + var code int + var stdout string + withSourceRoutedTo(t, source, func() { + code, stdout, _ = captureRun(t, []string{"mounts", "hydrate", "--fetch", "--json", "--root", host}) + }) + // Best-effort, as ever: the mount stays unavailable and the run does not fail. + if code != 0 { + t.Fatalf("exit %d (%s)", code, stdout) + } + var payload struct { + Outcomes []struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` + } `json:"outcomes"` + Findings []struct { + Rule string `json:"rule"` + Detail string `json:"detail"` + } `json:"findings"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatal(err) + } + if payload.Outcomes[0].Status != "unavailable" { + t.Fatalf("status = %q", payload.Outcomes[0].Status) + } + // The outcome row is the row it has always been: the detail it already carried. + if payload.Outcomes[0].Detail != "the pin could not be fetched from the source" { + t.Fatalf("outcome detail = %q", payload.Outcomes[0].Detail) + } + if got := objectKeys(t, rawFindings(t, stdout)[0]); strings.Join(got, ",") != "rule,severity,path,message,detail" { + t.Fatalf("finding keys = %v", got) + } + if payload.Findings[0].Rule != "mount-store-fetch-failed" || + payload.Findings[0].Detail != "current pin: the pin could not be fetched from the source" { + t.Fatalf("finding = %+v", payload.Findings[0]) + } + // The reasons the findings were built from are a transport, never a member of the + // document: the writer picks its fields, and this is not one of them. + if strings.Contains(stdout, "reasons") { + t.Fatalf("the reasons transport reached the document: %s", stdout) + } + var human string + withSourceRoutedTo(t, source, func() { + _, human, _ = captureRun(t, []string{"mounts", "hydrate", "--fetch", "--root", host}) + }) + want := "warning mount-store-fetch-failed product-context: the managed store could not be established " + + "by the requested fetch (detail: current pin: the pin could not be fetched from the source)" + if !strings.Contains(human, want+"\n") { + t.Fatalf("human output\n%s\nmust carry\n%s", human, want) + } +} + +// TestHydrateWitnessThatWillNotPublishEmitsTheSecondWitnessClass is the CLI half of +// `internal/mounts`' reference-transaction test: the witness act's second frozen +// failure class, seen as the bytes `mounts hydrate --json` emits. A +// `reference-transaction` hook lets the tracking ref arrive and then refuses the +// canonical ref's swap, the way a lock, a permission error or a full disk does. +func TestHydrateWitnessThatWillNotPublishEmitsTheSecondWitnessClass(t *testing.T) { + dir := t.TempDir() + sibling := filepath.Join(dir, "sibling") + host := filepath.Join(dir, "host") + buildAcmeSibling(t, sibling) + copyFixture(t, "warn-update-pin", host) + repin(t, host, oidA, false) + // The store already holds the pin, so retention needs no network and the witness + // swap is the only act left that can fail. + pin := oidA + buildStore(t, host, sibling, storeSpec{Pin: &pin}) + store := storePath(t, host) + witnessRef := mounts.WitnessRefFor(acmeIdentity(t), "refs/heads/main") + hooks := filepath.Join(store, "hooks") + if err := os.MkdirAll(hooks, 0o755); err != nil { + t.Fatal(err) + } + // Each stdin line is " "; every other ref (the fetched temporary, + // the pin ref) passes through untouched. + script := "#!/bin/sh\n[ \"$1\" = prepared ] || exit 0\ngrep -q \" " + witnessRef + "$\" || exit 0\nexit 1\n" + if err := os.WriteFile(filepath.Join(hooks, "reference-transaction"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + var code int + var stdout string + withSourceRoutedTo(t, sibling, func() { + code, stdout, _ = captureRun(t, []string{"mounts", "hydrate", "--fetch", "--json", "--root", host}) + }) + // Best-effort: a witness that would not publish is a warning, never a failed run. + if code != 0 { + t.Fatalf("exit %d (%s)", code, stdout) + } + var payload struct { + Findings []struct { + Rule string `json:"rule"` + Severity string `json:"severity"` + Detail string `json:"detail"` + } `json:"findings"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatal(err) + } + // The scaffold's pin is a commit with no layer in it, so the projection warns too; + // the witness finding is the one this test is about, found by its rule. + witness := -1 + for i, f := range payload.Findings { + if f.Rule == "mount-witness-refresh-failed" { + witness = i + } + } + if witness < 0 || payload.Findings[witness].Severity != "warning" { + t.Fatalf("findings = %+v (%s)", payload.Findings, stdout) + } + // The witness act's second failure class, in the bytes the three SDKs freeze. + if payload.Findings[witness].Detail != "witness: the witness ref could not be published" { + t.Fatalf("detail = %q", payload.Findings[witness].Detail) + } + if got := objectKeys(t, rawFindings(t, stdout)[witness]); strings.Join(got, ",") != "rule,severity,path,message,detail" { + t.Fatalf("finding keys = %v", got) + } + var human string + withSourceRoutedTo(t, sibling, func() { + _, human, _ = captureRun(t, []string{"mounts", "hydrate", "--fetch", "--root", host}) + }) + want := "warning mount-witness-refresh-failed product-context: the managed witness ref could not be " + + "refreshed by the requested fetch (detail: witness: the witness ref could not be published)" + if !strings.Contains(human, want+"\n") { + t.Fatalf("human output\n%s\nmust carry\n%s", human, want) + } +} + +func TestUpdatePinTheRouteTheCurrentPinRefusalAdvertises(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + hint := filepath.Join(dir, "hint") + host := filepath.Join(dir, "host") + // One recipe, two repositories: the source is pruned, the hint keeps `old`, so it + // holds the current pin and the orphan target with complete ancestry. + pin, target := buildOrphanTargetSource(t, source) + heldPin, heldTarget := buildOrphanTargetRepo(t, hint) + if heldPin != pin || heldTarget != target { + t.Fatalf("the hint holds %s/%s, not %s/%s", heldPin, heldTarget, pin, target) + } + copyFixture(t, "warn-update-pin", host) + repin(t, host, pin, false) + if err := os.MkdirAll(filepath.Join(host, ".leji"), 0o755); err != nil { + t.Fatal(err) + } + local, _ := json.Marshal(map[string]any{"mounts": map[string]any{"product-context": map[string]string{"repo": hint}}}) + if err := os.WriteFile(filepath.Join(host, ".leji", "mounts.local.json"), append(local, '\n'), 0o644); err != nil { + t.Fatal(err) + } + mp := filepath.Join(host, "leji.json") + before, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + move := []string{"mounts", "update-pin", "product-context", "--to", target, "--allow-non-fast-forward"} + // With `--fetch` the source is asked for the current pin first, and that act is + // the one that fails: the hint holding both operands does not save the run. + var code int + var stdout string + withSourceRoutedTo(t, source, func() { + code, stdout, _ = captureRun(t, append(append([]string{}, move...), "--fetch", "--root", host, "--json")) + }) + if code != 1 { + t.Fatalf("exit %d (%s)", code, stdout) + } + var refused updatePinDocument + if err := json.Unmarshal([]byte(stdout), &refused); err != nil { + t.Fatal(err) + } + if strOr(refused.Reason, "") != "mount-store-fetch-failed" { + t.Fatalf("reason = %v", refused.Reason) + } + if refused.Findings[0].Detail != "current pin: the pin could not be fetched from the source" { + t.Fatalf("detail = %q", refused.Findings[0].Detail) + } + if now, err := os.ReadFile(mp); err != nil || string(now) != string(before) { + t.Fatalf("leji.json is byte-untouched: %v", err) + } + // The store that refused run established holds nothing, so the move below is the + // hint's answer and no leftover managed operand. + if !recipeGitFails(storePath(t, host), "cat-file", "-e", pin) { + t.Fatal("the managed store never got the pin") + } + // Without `--fetch`, exactly as the refusal says: the hint answers, the override + // carries the move past the rewritten history, and the pin moves. + var movedCode int + var movedOut string + withSourceRoutedTo(t, "", func() { + movedCode, movedOut, _ = captureRun(t, append(append([]string{}, move...), "--root", host, "--json")) + }) + if movedCode != 0 { + t.Fatalf("exit %d (%s)", movedCode, movedOut) + } + var moved updatePinDocument + if err := json.Unmarshal([]byte(movedOut), &moved); err != nil { + t.Fatal(err) + } + if moved.Action != "updated" || !moved.Override { + t.Fatalf("action=%q override=%v", moved.Action, moved.Override) + } + if moved.PinReport["comparisonRepository"] != "hint" || moved.PinReport["witnessProvenance"] != "unmanaged" || + moved.PinReport["ancestryComplete"] != true { + t.Fatalf("pinReport = %+v", moved.PinReport) + } + if strOr(moved.Mount.From, "") != pin || strOr(moved.Mount.To, "") != target { + t.Fatalf("mount = %+v", moved.Mount) + } + if len(moved.Findings) != 1 || moved.Findings[0].Rule != "mount-pin-non-fast-forward-override" || + moved.Findings[0].Severity != "warning" { + t.Fatalf("findings = %+v", moved.Findings) + } + after, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + if string(after) != strings.Replace(string(before), pin, target, 1) { + t.Fatal("exactly the pin span moved") + } +} + func TestUpdatePinScannerRefusalReachesTheCLIAtExitTwo(t *testing.T) { dir := t.TempDir() sibling := filepath.Join(dir, "sibling") diff --git a/packages/sdk-go/internal/commands/conformance/conformance.go b/packages/sdk-go/internal/commands/conformance/conformance.go index b5eef8a..25b2425 100644 --- a/packages/sdk-go/internal/commands/conformance/conformance.go +++ b/packages/sdk-go/internal/commands/conformance/conformance.go @@ -16,6 +16,7 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/fsx" "github.com/leji-org/leji/packages/sdk-go/internal/git" "github.com/leji-org/leji/packages/sdk-go/internal/layer" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" mountslib "github.com/leji-org/leji/packages/sdk-go/internal/mounts" ) @@ -91,7 +92,12 @@ func countAttested(items []ChecklistItem) int { // Report scores the layer. ClaimedLevel/VerifiedLevel are "" for none. With // federation it runs the networked pin-reachability probe; the error return // carries its filesystem failures (TS exceptions). -func Report(root string, federation bool) (Result, error) { +// ignoreContext is the invocation's notice state for the self-managed +// `.leji/.gitignore`. Only the `--federation` probe reaches a mounts operation that +// can establish a role, and it reaches one PER DECLARED MOUNT, so the context is +// threaded rather than left to each call: one invocation notices at most once, +// however many mounts it probes. +func Report(root string, federation bool, ignoreContext ...*lejiignore.Context) (Result, error) { var items []ChecklistItem var fs []findings.Finding m := manifest.LoadManifest(root).Manifest @@ -285,7 +291,7 @@ func Report(root string, federation bool) (Result, error) { type badMount struct{ name, state, detail string } var bad *badMount for _, mt := range mounts { - r, rerr := mountslib.CheckPinReachability(root, mountslib.MountDecl{Name: mt.Name, Source: mt.Source, Pin: mt.Pin, TrackingRef: mt.TrackingRef}) + r, rerr := mountslib.CheckPinReachability(root, mountslib.MountDecl{Name: mt.Name, Source: mt.Source, Pin: mt.Pin, TrackingRef: mt.TrackingRef}, lejiignore.From(ignoreContext...)) if rerr != nil { return Result{}, rerr } diff --git a/packages/sdk-go/internal/commands/export/export.go b/packages/sdk-go/internal/commands/export/export.go index d4d26c4..5a2d6f8 100644 --- a/packages/sdk-go/internal/commands/export/export.go +++ b/packages/sdk-go/internal/commands/export/export.go @@ -20,6 +20,7 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/findings" "github.com/leji-org/leji/packages/sdk-go/internal/fsx" "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/renderlint" ) @@ -91,9 +92,13 @@ type BuildResult struct { } // Options is how one export run is driven. Strict is the gate: a lint finding fails -// the run before the target is cleared, mirroring `status --strict`. +// the run before the target is cleared, mirroring `status --strict`. IgnoreContext is +// the invocation's notice state for the self-managed `.leji/.gitignore`, passed +// through to the generation pass this run nests so one invocation notices once; a +// direct SDK call that omits it notices at most once for that call. type Options struct { - Strict bool + Strict bool + IgnoreContext *lejiignore.Context } // carriedItem is one entry the content walk enumerated: a rootPath-relative POSIX @@ -118,7 +123,14 @@ var testHookAfterEnumerate func() // byte-untouched. The exported index.html carries the protect-your-context warning as // a comment. func BuildViewer(root string, m *manifest.Manifest, outRel string, opts Options) (BuildResult, error) { - gen, err := viewer.GenerateViewer(root, m) + // One context for the whole run, whether the caller supplied it or not: this + // command establishes two roles (the chrome it regenerates and its own output), + // and a caller that passes none is still one call. + ignoreContext := opts.IgnoreContext + if ignoreContext == nil { + ignoreContext = lejiignore.NewContext() + } + gen, err := viewer.GenerateViewer(root, m, ignoreContext) if err != nil { return BuildResult{}, err } @@ -470,6 +482,17 @@ func BuildViewer(root string, m *manifest.Manifest, outRel string, opts Options) if err := mkdirDest(outContent); err != nil { return BuildResult{}, err } + // The output role exists: ensure the tool's own ignore file, as every role + // establisher does. The generation pass above shares this run's context, so an + // existing file is noticed once for the whole invocation rather than per role. + ignored, err := lejiignore.EnsureFile(rootAbs, ignoreContext) + if err != nil { + return BuildResult{}, err + } + if ignored == lejiignore.Refused { + return BuildResult{}, errors.New(`refusing to write "` + layout.LejiIgnoreRel + + `": it does not resolve to a regular file inside ` + layout.LejiDir + `/; remove the symlink`) + } for _, item := range carried { dest := filepath.Join(outContent, filepath.FromSlash(item.rel)) if item.dir { @@ -481,8 +504,22 @@ func BuildViewer(root string, m *manifest.Manifest, outRel string, opts Options) // Markdown was read once already: the exported file is that snapshot, so what // the lint judged is what the export carries. A document the re-check dropped // has no snapshot and is not exported. + // + // The overview homepage is the one path whose exported copy is not its source: + // the layer map is substituted between its markers here, after the lint has + // judged the source bytes, from the entries the generation above already + // projected. The layer's own file is not touched, and the map an export carries + // is the map the local server renders from the same function. if strings.ToLower(path.Ext(item.rel)) == ".md" { if bytes, ok := linted[item.rel]; ok { + if item.rel == viewer.OverviewRel { + // Decoded the way Node's Buffer.toString('utf8') decodes, so a source + // that is not valid UTF-8 exports as the same bytes in all three SDKs. + // Only the rendered branch decodes; a markerless page keeps its own. + if text, markersFound := viewer.RenderOverview(viewer.DecodeUTF8(bytes), m, gen.IndexEntries); markersFound { + bytes = []byte(text) + } + } if err := writeDest(dest, bytes); err != nil { return BuildResult{}, err } diff --git a/packages/sdk-go/internal/commands/export/overview_test.go b/packages/sdk-go/internal/commands/export/overview_test.go new file mode 100644 index 0000000..47e12d8 --- /dev/null +++ b/packages/sdk-go/internal/commands/export/overview_test.go @@ -0,0 +1,192 @@ +package export + +// --- the exported overview carries the map; the lint reads the source --- +// The layer map is substituted into the exported COPY of the overview homepage, after +// the lint has judged the source bytes and without the source being touched. Pinned +// against the frozen TypeScript contract. + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/commands/viewer" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" + "github.com/leji-org/leji/packages/sdk-go/internal/renderlint" +) + +func TestExportedOverviewCarriesTheRenderedMapAndTheLintJudgesTheSource(t *testing.T) { + dir := exampleCopy(t) + // An author's page: prose around the markers, and inside them a stale hand-edit + // carrying an out-of-subset construct. The construct's line number is what proves + // which bytes the lint read, since the substitution below changes every line after + // the markers. + source := "# The layer\n\nIntro prose.\n\n\n" + + "A raw element left inside the markers.\n" + + "\n\nClosing prose.\n" + writeUnder(t, dir, "docs/"+viewer.OverviewRel, source) + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the example manifest must load") + } + r, err := BuildViewer(dir, m, "out", Options{}) + if err != nil { + t.Fatalf("BuildViewer: %v", err) + } + linted := false + for _, f := range r.Findings { + if f.Rule == renderlint.RenderUnsupportedRule && f.Path == "docs/overview.md" && f.Line == 6 { + linted = true + } + } + if !linted { + t.Fatalf("the lint must report the construct at its line in the SOURCE: %v", r.Findings) + } + + // The source is the author's file: untouched by an export that renders from it. + onDisk, err := os.ReadFile(filepath.Join(dir, "docs", viewer.OverviewRel)) + if err != nil { + t.Fatalf("read overview: %v", err) + } + if string(onDisk) != source { + t.Fatal("the export must never write the page it renders from") + } + exported, err := os.ReadFile(filepath.Join(dir, "out", "content", viewer.OverviewRel)) + if err != nil { + t.Fatalf("read the exported overview: %v", err) + } + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + rendered, markersFound := viewer.RenderOverview(source, m, gen.IndexEntries) + if !markersFound { + t.Fatal("the authored page carries the markers") + } + if string(exported) != rendered { + t.Fatalf("expected the exported copy to be the source with the marked span substituted, got:\n%s", exported) + } + if !strings.Contains(string(exported), "```mermaid\nflowchart LR") { + t.Fatal("the map is the map") + } + if !strings.Contains(string(exported), "# The layer") { + t.Fatal("the prose around the markers rides along") + } + if !strings.Contains(string(exported), "Closing prose.") { + t.Fatal("including what follows them") + } + if strings.Contains(string(exported), "") { + t.Fatal("and the stale hand-edit between them is gone") + } +} + +func TestExportedOverviewWithoutMarkersIsTheSourceByteForByte(t *testing.T) { + dir := exampleCopy(t) + source := "# Fully custom\n\nNo markers here at all.\n" + writeUnder(t, dir, "docs/"+viewer.OverviewRel, source) + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the example manifest must load") + } + if _, err := BuildViewer(dir, m, "out", Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + onDisk, err := os.ReadFile(filepath.Join(dir, "docs", viewer.OverviewRel)) + if err != nil { + t.Fatalf("read overview: %v", err) + } + if string(onDisk) != source { + t.Fatal("the source is untouched") + } + exported, err := os.ReadFile(filepath.Join(dir, "out", "content", viewer.OverviewRel)) + if err != nil { + t.Fatalf("read the exported overview: %v", err) + } + if string(exported) != source { + t.Fatalf("with nowhere to render the map, the exported copy is the source, got:\n%s", exported) + } +} + +func TestExportedOverviewDecodesInvalidUTF8LikeTheReference(t *testing.T) { + // An authored page carrying invalid UTF-8 OUTSIDE the marker span. The exported copy + // is rendered, so it is decoded first, and it must be decoded the way Node decodes: + // the expectation below is the reference SDK's own output for these prose bytes. + dir := exampleCopy(t) + source := append([]byte("# T"), 0xFF) + source = append(source, "tle\n\nIntro.\n\n\nstale\n\n\nTa"...) + source = append(source, 0xC0, 0x80) + source = append(source, "il\n"...) + abs := filepath.Join(dir, "docs", viewer.OverviewRel) + if err := os.WriteFile(abs, source, 0o644); err != nil { + t.Fatalf("write overview: %v", err) + } + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the example manifest must load") + } + if _, err := BuildViewer(dir, m, "out", Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + onDisk, err := os.ReadFile(abs) + if err != nil { + t.Fatalf("read overview: %v", err) + } + if !bytes.Equal(onDisk, source) { + t.Fatal("the source is untouched, invalid bytes included") + } + exported, err := os.ReadFile(filepath.Join(dir, "out", "content", viewer.OverviewRel)) + if err != nil { + t.Fatalf("read the exported overview: %v", err) + } + // Byte-identity with the reference: the render is DecodeUTF8 then the substitution, + // and DecodeUTF8 is pinned against Node's own output in the viewer package. + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + want, markersFound := viewer.RenderOverview(viewer.DecodeUTF8(source), m, gen.IndexEntries) + if !markersFound { + t.Fatal("the authored page carries the marker pair") + } + if string(exported) != want { + t.Fatalf("the exported copy is not the reference rendering:\n got: %q\nwant: %q", exported, want) + } + // The prose either side of the span carries the reference's replacements, and no raw + // invalid byte survived into the export. + if !strings.Contains(string(exported), "# T�tle") || + !strings.Contains(string(exported), "Ta��il") { + t.Fatalf("the replacements are not where the reference puts them:\n%s", exported) + } + if bytes.ContainsRune(exported, 0xFF) || bytes.Contains(exported, []byte{0xC0, 0x80}) { + t.Fatal("no raw invalid byte may reach the export of a rendered page") + } +} + +func TestExportedMarkerlessOverviewKeepsItsRawBytes(t *testing.T) { + // The other half of the same rule: with no markers there is nothing to render, so + // the export copies the snapshot it linted. Nothing is decoded, and an invalid byte + // survives verbatim, in every SDK. + dir := exampleCopy(t) + source := append([]byte("# Fully custom\n\nNo markers, and a raw "), 0xFF) + source = append(source, " byte.\n"...) + abs := filepath.Join(dir, "docs", viewer.OverviewRel) + if err := os.WriteFile(abs, source, 0o644); err != nil { + t.Fatalf("write overview: %v", err) + } + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the example manifest must load") + } + if _, err := BuildViewer(dir, m, "out", Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + exported, err := os.ReadFile(filepath.Join(dir, "out", "content", viewer.OverviewRel)) + if err != nil { + t.Fatalf("read the exported overview: %v", err) + } + if !bytes.Equal(exported, source) { + t.Fatalf("a markerless page exports as its raw bytes:\n got: %q\nwant: %q", exported, source) + } +} diff --git a/packages/sdk-go/internal/commands/init/guard.go b/packages/sdk-go/internal/commands/init/guard.go index f6c5ea1..a1aac0d 100644 --- a/packages/sdk-go/internal/commands/init/guard.go +++ b/packages/sdk-go/internal/commands/init/guard.go @@ -13,6 +13,7 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/fsx" "github.com/leji-org/leji/packages/sdk-go/internal/jsonenc" "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" ) // The onboarding approval guard: a transient Claude Code PreToolUse hook that @@ -150,8 +151,10 @@ func decodeOrderedValue(dec *json.Decoder) (any, error) { // .claude/settings.json (created if absent, other settings preserved). // Idempotent: an existing guard entry is left untouched. rootPath no longer // selects the workspace — it is one root-relative tree — and is kept only so the -// exported signature holds. -func EnsureApprovalGuard(root, rootPath string) (GuardAction, error) { +// exported signature holds. ignoreContext is the invocation's notice state for the +// self-managed `.leji/.gitignore`, which this function ensures because it creates +// `.leji/work/hooks/`; omitted means a context local to this call. +func EnsureApprovalGuard(root, rootPath string, ignoreContext ...*lejiignore.Context) (GuardAction, error) { _ = rootPath rootAbs, err := filepath.Abs(root) if err != nil { @@ -216,6 +219,10 @@ func EnsureApprovalGuard(root, rootPath string) (GuardAction, error) { if err := writeFileAtomic(rootAbs, scriptAbs, scriptRel, approvalGuardScript(lejiRel)); err != nil { return "", err } + // `.leji/work/hooks/` now exists: this is a role establisher like any other. + if err := ensureLejiIgnoreOrRefuse(rootAbs, lejiignore.From(ignoreContext...)); err != nil { + return "", err + } if present { return "unchanged", nil } @@ -245,6 +252,9 @@ type GuardOfferOptions struct { Interactive bool // Agent forces a specific launchable host (claude-code/codex); empty means detect. Agent string + // IgnoreContext is the invocation's notice state for the self-managed + // `.leji/.gitignore`, passed through to the install this offer nests. + IgnoreContext *lejiignore.Context } // OfferApprovalGuard offers the onboarding approval guard for a Claude Code @@ -280,7 +290,7 @@ func OfferApprovalGuard(opts GuardOfferOptions, hio *HandoffIO, out io.Writer) e if !(answer == "" || answer == "y" || answer == "yes") { return nil } - action, err := EnsureApprovalGuard(opts.Root, opts.RootPath) + action, err := EnsureApprovalGuard(opts.Root, opts.RootPath, opts.IgnoreContext) if err != nil { return err } diff --git a/packages/sdk-go/internal/commands/init/init.go b/packages/sdk-go/internal/commands/init/init.go index f5c4522..c4267f9 100644 --- a/packages/sdk-go/internal/commands/init/init.go +++ b/packages/sdk-go/internal/commands/init/init.go @@ -26,6 +26,7 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/fsx" "github.com/leji-org/leji/packages/sdk-go/internal/git" "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/writeplan" ) @@ -54,6 +55,10 @@ type Options struct { // In/Out are overridable for tests; default to os.Stdin / os.Stdout. In io.Reader Out io.Writer + // IgnoreContext is the invocation's notice state for the self-managed + // `.leji/.gitignore`, which this command ensures when it creates the onboarding + // workspace. Nil means a context local to this call. + IgnoreContext *lejiignore.Context } // workingModes are the layer's working modes: a team of one ("solo") or a team ("team"). @@ -484,6 +489,21 @@ func guardedOrRefuse(rel string, verdict layout.TargetVerdict, err error) error return nil } +// ensureLejiIgnoreOrRefuse ensures the tool's own ignore file: the transient +// onboarding workspace is a `.leji/` role, so these commands ensure it exactly as +// every other role establisher does. A refusal is the refusal this command has always +// raised for an escaping target. +func ensureLejiIgnoreOrRefuse(root string, ignoreContext *lejiignore.Context) error { + outcome, err := lejiignore.EnsureFile(root, ignoreContext) + if err != nil { + return err + } + if outcome == lejiignore.Refused { + return escapeRefusal(layout.LejiIgnoreRel) + } + return nil +} + // initRole is the `.leji/` role an init or adopt write legitimately lands in: the // transient onboarding workspace is the tool's own `work` role, and everything else // these commands write is user content with no `.leji/` role at all. @@ -2004,6 +2024,12 @@ func InitLayer(opts Options) (Result, error) { return Result{}, err } } + // The onboarding workspace is a `.leji/` role and now exists, so the tool ignores + // its own tree from inside: the nested counterpart to the root `.gitignore` line + // above, and the one that covers a layer whose root file never received it. + if err := ensureLejiIgnoreOrRefuse(root, opts.IgnoreContext); err != nil { + return Result{}, err + } // The whole of the `leji index` rule, not half of it: WriteIndex reports a hard // generation failure in Result.Findings with a nil error, so checking only the @@ -2732,6 +2758,10 @@ type AdoptOptions struct { // NoAgents skips generating the portable `AGENTS.md` pointer (written by // default when absent; an existing file keeps the migrate/--wire-adapters flow). NoAgents bool + // IgnoreContext is the invocation's notice state for the self-managed + // `.leji/.gitignore`, which this command ensures when it creates the onboarding + // workspace. Nil means a context local to this call. + IgnoreContext *lejiignore.Context } // AdoptResult is the init result plus what adoption found and did. @@ -3082,6 +3112,12 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { } } } + // The onboarding workspace is a `.leji/` role and now exists, so the tool ignores + // its own tree from inside: the nested counterpart to the root `.gitignore` line + // above, and the one that covers a layer whose root file never received it. + if err := ensureLejiIgnoreOrRefuse(root, opts.IgnoreContext); err != nil { + return AdoptResult{}, err + } // Same rule as `leji index`: WriteIndex reports a hard generation failure in // Result.Findings with a nil error, so the file is claimed only when it was diff --git a/packages/sdk-go/internal/commands/serve/overview_test.go b/packages/sdk-go/internal/commands/serve/overview_test.go new file mode 100644 index 0000000..62fcac0 --- /dev/null +++ b/packages/sdk-go/internal/commands/serve/overview_test.go @@ -0,0 +1,378 @@ +package serve + +// --- the overview map is served, never stored --- +// The layer map used to be written into the committed overview.md on every run that +// changed a document count. It is now substituted between the author's markers when +// the page is read: these pin the served half against the frozen TypeScript contract +// (the exported half is in the export package). + +import ( + "bytes" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/commands/viewer" + "github.com/leji-org/leji/packages/sdk-go/internal/findings" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +// overviewLayer is the example layer with its viewer generated, at its resolved path +// (symlink targets and mount comparisons below are about real locations). +func overviewLayer(t *testing.T) (string, *manifest.Manifest) { + t.Helper() + dir := exampleCopy(t) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the example manifest must load") + } + if _, err := viewer.GenerateViewer(dir, m); err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + return dir, m +} + +func TestServeRendersTheLayerMapIntoTheOverviewPage(t *testing.T) { + dir, m := overviewLayer(t) + overview := filepath.Join(dir, m.RootPath, viewer.OverviewRel) + // A document added after the seed: the served map counts the tree of right now. + writeUnder(t, dir, "docs/domain/pricing.md", "# Pricing\n\nHow we price.\n") + before, err := os.ReadFile(overview) + if err != nil { + t.Fatalf("read overview: %v", err) + } + base := serveOnFreePort(t, dir, m.RootPath) + + code, raw := fetch(t, base+"/content/overview.md") + body := string(raw) + if code != http.StatusOK { + t.Fatalf("GET /content/overview.md: status %d, want 200", code) + } + res, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + if !strings.Contains(body, "```mermaid\nflowchart LR") { + t.Fatalf("expected the served page to carry the map, got: %s", body) + } + if !strings.Contains(body, "cat_domain[\"📖 Domain · 2 docs\"]") { + t.Fatal("expected the document added after the seed to be counted") + } + rendered, _ := viewer.RenderOverview(string(before), m, res.IndexEntries) + if body != rendered { + t.Fatal("expected the served page to be the source with the marked span substituted") + } + after, err := os.ReadFile(overview) + if err != nil { + t.Fatalf("read overview: %v", err) + } + if string(after) != string(before) { + t.Fatal("serving the page must never write it") + } +} + +func TestServeRendersTheOverviewFromARelativeRoot(t *testing.T) { + // What the CLI actually passes: `--root .` survives EvalSymlinks as ".", so the + // route's guards see a relative root and an absolutized resolved source. Judging + // one against the other reads as "outside the repository" and refuses every page, + // which no absolute-path test can catch. Mutation that reddens: judge the resolved + // source against rootAbs rather than against its resolved form. + dir, m := overviewLayer(t) + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(wd) }) + base := serveOnFreePort(t, ".", m.RootPath) + + code, raw := fetch(t, base+"/content/overview.md") + if code != http.StatusOK { + t.Fatalf("GET /content/overview.md from a relative root: status %d, want 200", code) + } + if !strings.Contains(string(raw), "```mermaid\nflowchart LR") { + t.Fatalf("expected the map rendered into the served page, got: %s", raw) + } +} + +func TestServeKeepsTheLastGoodMapWhenTheLayerStopsIndexing(t *testing.T) { + dir, m := overviewLayer(t) + overview := filepath.Join(dir, m.RootPath, viewer.OverviewRel) + source, err := os.ReadFile(overview) + if err != nil { + t.Fatalf("read overview: %v", err) + } + base := serveOnFreePort(t, dir, m.RootPath) + overviewText := func() string { + code, raw := fetch(t, base+"/content/overview.md") + body := string(raw) + if code != http.StatusOK { + t.Fatalf("GET /content/overview.md: status %d, want 200", code) + } + return body + } + + good := overviewText() + if !strings.Contains(good, "```mermaid\nflowchart LR") { + t.Fatal("expected a healthy tree to render the fresh map") + } + // A genuine generation failure: the manifest no longer parses, so this fetch has + // no index at all. The page keeps the map it last had rather than losing it. + manifestAbs := filepath.Join(dir, "leji.json") + manifestText, err := os.ReadFile(manifestAbs) + if err != nil { + t.Fatalf("read leji.json: %v", err) + } + if err := os.WriteFile(manifestAbs, []byte("{ not json"), 0o644); err != nil { + t.Fatalf("write leji.json: %v", err) + } + if overviewText() != good { + t.Fatal("expected the last good map while the tree cannot be indexed") + } + // Repaired, with the tree moved on: the map is the one the tree has now. + if err := os.WriteFile(manifestAbs, manifestText, 0o644); err != nil { + t.Fatalf("restore leji.json: %v", err) + } + writeUnder(t, dir, "docs/domain/pricing.md", "# Pricing\n\nHow we price.\n") + if !strings.Contains(overviewText(), "cat_domain[\"📖 Domain · 2 docs\"]") { + t.Fatal("expected the repaired tree to render afresh") + } + after, err := os.ReadFile(overview) + if err != nil { + t.Fatalf("read overview: %v", err) + } + if string(after) != string(source) { + t.Fatal("none of it may write the source file") + } +} + +func TestServeRefusesAnOverviewOutsideTheContentRoot(t *testing.T) { + dir, m := overviewLayer(t) + overview := filepath.Join(dir, m.RootPath, viewer.OverviewRel) + // An ordinary file elsewhere in the repository: the route is a content route + // first, and a content route serves nothing from outside its own mount. + writeUnder(t, dir, "elsewhere.md", "# Elsewhere\n") + if err := os.Remove(overview); err != nil { + t.Fatalf("remove overview: %v", err) + } + if err := os.Symlink(filepath.Join(dir, "elsewhere.md"), overview); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + base := serveOnFreePort(t, dir, m.RootPath) + + code, raw := fetch(t, base+"/content/overview.md") + body := string(raw) + if code != http.StatusForbidden { + t.Fatalf("a target outside the content mount must be refused: status %d, want 403", code) + } + if strings.Contains(body, "Elsewhere") { + t.Fatal("and nothing of it may be served") + } +} + +func TestServeRefusesAnOverviewSymlinkedIntoAPrivateRole(t *testing.T) { + // The content root here IS the repository root, so the private role is inside the + // mount and the servable whitelist is the check that answers: refused as today. + dir, _ := overviewLayer(t) + writeUnder(t, dir, ".leji/work/private.md", "# Private notes\n") + if err := os.Symlink(filepath.Join(dir, ".leji", "work", "private.md"), + filepath.Join(dir, "overview.md")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + base := serveOnFreePort(t, dir, ".") + + code, raw := fetch(t, base+"/content/overview.md") + body := string(raw) + if code != http.StatusNotFound { + t.Fatalf("a private role is not servable however it is reached: status %d, want 404", code) + } + if strings.Contains(body, "Private notes") { + t.Fatal("and nothing of it may be served") + } +} + +func TestCheckBeforeActOverviewSwappedBetweenAuthorizationAndReadIsRefused(t *testing.T) { + // The window the overview route's binding exists for: the link is retargeted AFTER + // the resolution that authorizes the source and BEFORE the bytes are taken, at a + // target inside the repository but outside the content mount, where the + // private-role and containment guards alone say yes. Deterministic, not a race: the + // hook performs the swap inline, so the window is exercised on every run (the idiom + // the export canaries use). + // + // Mutation that reddens: give the route the pre-review shape, a ResolvedPath that + // authorizes the path followed by a read that resolves the path again + // (fsx.VerifiedTargetRead), and the swapped-in file's bytes are served with a 200. + dir, m := overviewLayer(t) + overview := filepath.Join(dir, m.RootPath, viewer.OverviewRel) + // The legitimate target is a real page inside the content root; overview.md is the + // link, so the swap changes only where it points. + inside := filepath.Join(dir, m.RootPath, "home.md") + if err := os.Rename(overview, inside); err != nil { + t.Fatalf("move the seeded page aside: %v", err) + } + outside := filepath.Join(dir, "outside.md") + const secret = "SECRET-OUTSIDE-THE-CONTENT-ROOT" + writeUnder(t, dir, "outside.md", "# Outside\n\n"+secret+"\n") + if err := os.Symlink(inside, overview); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + base := serveOnFreePort(t, dir, m.RootPath) + + // Armed for the one request, and swaps once: the route has just authorized the + // resolved source, and the entry it came from is retargeted before the open. + swapped := false + testHookAfterAuthorize = func() { + if swapped { + return + } + swapped = true + if err := os.Remove(overview); err != nil { + t.Errorf("remove the link: %v", err) + return + } + if err := os.Symlink(outside, overview); err != nil { + t.Errorf("retarget the link: %v", err) + } + } + code, raw := fetch(t, base+"/content/overview.md") + body := string(raw) + testHookAfterAuthorize = nil + + if !swapped { + t.Fatal("the link must have been retargeted inside the route, after the authorizing resolution") + } + if target, err := os.Readlink(overview); err != nil || target != outside { + t.Fatalf("the link must still point outside the content mount: %q, %v", target, err) + } + if strings.Contains(body, secret) { + t.Fatalf("no byte from outside the content mount may be served: %q", body) + } + // The mapping the ordinary content route uses: the source resolves outside the + // mount, so the mount answers, and it answers before anything is read. + if code != http.StatusForbidden { + t.Fatalf("the swapped-in target must be refused: status %d, want 403", code) + } +} + +func TestServeDecodesInvalidUTF8LikeTheReference(t *testing.T) { + // The served half of the decode rule: a rendered page is decoded the way Node + // decodes, and a markerless one is served as its raw bytes. + dir, m := overviewLayer(t) + overview := filepath.Join(dir, m.RootPath, viewer.OverviewRel) + rendered := append([]byte("# T"), 0xFF) + rendered = append(rendered, "tle\n\n\nstale\n\n\nTa"...) + rendered = append(rendered, 0xC0, 0x80) + rendered = append(rendered, "il\n"...) + if err := os.WriteFile(overview, rendered, 0o644); err != nil { + t.Fatalf("write overview: %v", err) + } + base := serveOnFreePort(t, dir, m.RootPath) + + code, raw := fetch(t, base+"/content/overview.md") + if code != http.StatusOK { + t.Fatalf("GET /content/overview.md: status %d, want 200", code) + } + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + want, markersFound := viewer.RenderOverview(viewer.DecodeUTF8(rendered), m, gen.IndexEntries) + if !markersFound { + t.Fatal("the authored page carries the marker pair") + } + if string(raw) != want { + t.Fatalf("the served page is not the reference rendering:\n got: %q\nwant: %q", raw, want) + } + if !strings.Contains(string(raw), "# T�tle") || !strings.Contains(string(raw), "Ta��il") { + t.Fatalf("the replacements are not where the reference puts them:\n%s", raw) + } + if bytes.ContainsRune(raw, 0xFF) { + t.Fatal("no raw invalid byte may be served from a rendered page") + } + // Markerless: nothing to render, so the source bytes go out exactly as they stand. + markerless := append([]byte("# Custom\n\nA raw "), 0xFF) + markerless = append(markerless, " byte.\n"...) + if err := os.WriteFile(overview, markerless, 0o644); err != nil { + t.Fatalf("write overview: %v", err) + } + code, raw = fetch(t, base+"/content/overview.md") + if code != http.StatusOK { + t.Fatalf("GET /content/overview.md: status %d, want 200", code) + } + if !bytes.Equal(raw, markerless) { + t.Fatalf("a markerless page is served as its raw bytes:\n got: %q\nwant: %q", raw, markerless) + } +} + +func TestServeZeroEntrySnapshotSuppressesTheStartupGeneration(t *testing.T) { + // A successful generation over a layer that governs nothing projects ZERO entries, + // not "no snapshot". If that answer reaches Serve as a nil slice it reads as absent + // and the server generates the index a second time at startup, which the caller had + // just done. Mutation that reddens: return a nil IndexEntries from a successful + // zero-entry generation, or treat an empty snapshot as absent here. + dir := exampleCopy(t) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + // The example layer with every category pointed at an empty directory: a valid + // manifest whose categories govern nothing, so generation succeeds and projects zero + // entries. The stored index goes too, so nothing reports a vanished id. + writeUnder(t, dir, "docs/empty/notes.txt", "not a governed document\n") + for _, rel := range []string{"docs/context/domain.md", "docs/context/system.md", "docs/context/decisions.md"} { + writeUnder(t, dir, rel, "# Category\n```leji-index\n- path: docs/empty/\n```\n") + } + if err := os.Remove(filepath.Join(dir, "docs", "context-index.json")); err != nil { + t.Fatalf("remove the stored index: %v", err) + } + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the example manifest must load") + } + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + for _, f := range gen.Findings { + if f.Severity == findings.Error { + t.Fatalf("the empty layer must generate cleanly, got: %v", gen.Findings) + } + } + if gen.Entries != 0 { + t.Fatalf("this layer governs nothing, got %d entries", gen.Entries) + } + if gen.IndexEntries == nil { + t.Fatal("a successful generation carries a snapshot, empty or not, never nil") + } + + generations := 0 + testHookIndexGenerated = func() { generations++ } + defer func() { testHookIndexGenerated = nil }() + ln, srv, err := Serve(dir, 0, m.RootPath, nil, Options{Entries: gen.IndexEntries}) + if err != nil { + t.Fatalf("Serve: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + if generations != 0 { + t.Fatalf("the supplied snapshot must suppress the startup generation, saw %d", generations) + } + // And the snapshot really is the map the first fetch renders from: no generation has + // run, so an empty map is what a reader gets. + go func() { _ = srv.Serve(ln) }() + code, raw := fetch(t, "http://"+ln.Addr().String()+"/content/overview.md") + if code != http.StatusOK { + t.Fatalf("GET /content/overview.md: status %d, want 200", code) + } + if !strings.Contains(string(raw), "```mermaid\nflowchart LR") { + t.Fatalf("expected the (empty) map rendered into the page, got:\n%s", raw) + } + if strings.Contains(string(raw), "cat_") { + t.Fatalf("a layer that governs nothing has no category nodes:\n%s", raw) + } +} diff --git a/packages/sdk-go/internal/commands/serve/serve.go b/packages/sdk-go/internal/commands/serve/serve.go index b6d9c1e..6c5a9e6 100644 --- a/packages/sdk-go/internal/commands/serve/serve.go +++ b/packages/sdk-go/internal/commands/serve/serve.go @@ -7,6 +7,7 @@ package serve import ( "fmt" + "io" "net" "net/http" "net/url" @@ -142,15 +143,53 @@ func serveFrom(w http.ResponseWriter, rootAbs, mountRoot, sub string, inert bool } // sidebarCache is the live-sidebar cache entry: the tree fingerprint it was built -// from, the assembled sidebar, and (when the index generated cleanly) the -// serialized context index served live for the classification chip. +// from, the assembled sidebar, (when the index generated cleanly) the serialized +// context index served live for the classification chip, and the manifest and entries +// that index projected, so the overview map is a projection of the same generation +// rather than a second one. type sidebarCache struct { key string body string indexJSON string hasIndex bool + manifest *manifest.Manifest + entries []indexgen.IndexEntry } +// layerIndex is one clean generation of the layer: the manifest and the entries the +// overview map is rendered from. Kept as process state (the last one that generated +// cleanly), never as a file. +type layerIndex struct { + manifest *manifest.Manifest + entries []indexgen.IndexEntry +} + +// Options are what a direct caller already knows and the server would otherwise +// recompute. Entries is an index snapshot for the initial layer map: a caller that +// has just generated the viewer hands over what it projected, and a caller that +// passes none gets one live generation at startup instead. +// +// NIL is the only "no snapshot" spelling, standing for the reference's `undefined`; +// an EMPTY non-nil slice is a snapshot that says the layer governs nothing, and it +// suppresses the startup generation exactly as a full one does. `GenerateViewer` +// never returns a nil `IndexEntries`, so a caller that passes what it projected +// always passes a snapshot. +type Options struct { + Entries []indexgen.IndexEntry +} + +// testHookAfterAuthorize, when set by a test in this package, runs inside the overview +// route's allow predicate once every check has passed on the resolved source and +// before its bytes are taken. It exists for the one canary that has to land a symlink +// swap in that window deterministically, and is nil in every other run. +var testHookAfterAuthorize func() + +// testHookIndexGenerated, when set by a test in this package, runs once per live index +// generation this server performs. It exists so a test can prove a generation did NOT +// happen (the supplied snapshot suppressing the startup one), which no response can +// show, and is nil in every other run. +var testHookIndexGenerated func() + // statusWriter records the status code written so the access log can report it. type statusWriter struct { http.ResponseWriter @@ -171,7 +210,27 @@ func (w *statusWriter) WriteHeader(code int) { // The generated sidebar and the stored context index are served live from the // tree (fingerprint-cached), so a long-running viewer never shows a deleted or // moved document. logf, when set, receives one terse access-log line per request. -func newHandler(rootAbs, base, contentAbs, viewerAbs string, logf func(string)) http.Handler { +func newHandler(rootAbs, base, contentAbs, viewerAbs string, logf func(string), opts Options) http.Handler { + // The content mount as it really is on disk: the boundary a resolved source is + // judged against has to be resolved itself, or a symlinked rootPath component would + // put every legitimate document outside its own mount. + contentReal := contentAbs + if resolved, ok := fsx.ResolvedPath(contentAbs); ok { + contentReal = resolved + } + // withinContent reports whether a RESOLVED path lies under the content mount. + withinContent := func(resolved string) bool { + return strings.HasPrefix(resolved, contentReal+string(filepath.Separator)) + } + // The layer root in the same resolved, absolute form: rootAbs is whatever the + // caller named (the CLI's default `--root .` stays relative through EvalSymlinks), + // and the guards below judge a path the resolver has already absolutized. Judging + // an absolute source against a relative root reads as "outside the repository" and + // refuses every page. + resolvedRoot := rootAbs + if resolved, ok := fsx.ResolvedPath(rootAbs); ok { + resolvedRoot = resolved + } // Live-sidebar cache, invalidated by a tree fingerprint: one stat pass over // leji.json + every markdown file under the content root (paths, mtimes, // sizes — no content reads). The common unchanged-tree reload serves the @@ -211,6 +270,9 @@ func newHandler(rootAbs, base, contentAbs, viewerAbs string, logf func(string)) // An operational failure reading the stored index is the same "cannot refresh" // answer a tree that will not index cleanly gives: the served page falls back to // the generated artifact rather than taking down the preview server. + if testHookIndexGenerated != nil { + testHookIndexGenerated() + } idx, ierr := indexgen.GenerateIndex(rootAbs, load.Manifest) if ierr != nil { return nil @@ -225,7 +287,12 @@ func newHandler(rootAbs, base, contentAbs, viewerAbs string, logf func(string)) entries = idx.Index.Entries } var discard []findings.Finding - c := &sidebarCache{key: key, body: viewer.AssembleSidebar(rootAbs, load.Manifest, entries, &discard)} + c := &sidebarCache{ + key: key, + body: viewer.AssembleSidebar(rootAbs, load.Manifest, entries, &discard), + manifest: load.Manifest, + entries: entries, + } if idx.Index != nil { c.indexJSON = indexgen.SerializeIndex(idx.Index) c.hasIndex = true @@ -233,6 +300,36 @@ func newHandler(rootAbs, base, contentAbs, viewerAbs string, logf func(string)) cache = c return c } + // liveIndex is the one live index generation behind every generated route, cached + // by the same fingerprint: the sidebar, the served context index, and the overview + // map are projections of ONE index per tree state, never of three. Nil when the + // layer cannot be indexed right now (no manifest, an error finding, or a generator + // that failed), which is each route's cue to fall back. + liveIndex := func() *sidebarCache { + mu.Lock() + defer mu.Unlock() + key := treeFingerprint() + c := cache + if c == nil || c.key != key { + c = refresh(key) + } + return c + } + // The layer map is process state, not a file. The overview route renders it into + // the page's markers per fetch, from the live index above; the last index that + // generated cleanly is kept, so a tree caught mid-edit still shows the map it last + // had rather than a page with a hole in it. The initial one is computed here, by + // the same generation the sidebar route makes per fetch, unless the caller handed + // over the snapshot its own generation just produced. + var lastGoodMu sync.Mutex + var lastGoodMap *layerIndex + if opts.Entries == nil { + if c := liveIndex(); c != nil { + lastGoodMap = &layerIndex{manifest: c.manifest, entries: c.entries} + } + } else if load := manifest.LoadManifest(rootAbs); load.Manifest != nil { + lastGoodMap = &layerIndex{manifest: load.Manifest, entries: opts.Entries} + } serveText := func(w http.ResponseWriter, contentType, body string) { w.Header().Set("content-type", contentType) w.WriteHeader(http.StatusOK) @@ -283,14 +380,7 @@ func newHandler(rootAbs, base, contentAbs, viewerAbs string, logf func(string)) // document. When the tree is mid-edit and will not index cleanly, fall back // to the last generated artifact rather than failing the dashboard. if rel == "content/_sidebar.md" { - mu.Lock() - key := treeFingerprint() - c := cache - if c == nil || c.key != key { - c = refresh(key) - } - mu.Unlock() - if c != nil { + if c := liveIndex(); c != nil { serveText(w, "text/markdown; charset=utf-8", c.body) return } @@ -303,14 +393,7 @@ func newHandler(rootAbs, base, contentAbs, viewerAbs string, logf func(string)) load := manifest.LoadManifest(rootAbs) if load.Manifest != nil { if idxRel, ok := viewer.RelativeToRoot(manifest.EffectiveIndexPath(load.Manifest), load.Manifest.RootPath); ok && rel == "content/"+idxRel { - mu.Lock() - key := treeFingerprint() - c := cache - if c == nil || c.key != key { - c = refresh(key) - } - mu.Unlock() - if c != nil && c.key == key && c.hasIndex { + if c := liveIndex(); c != nil && c.hasIndex { serveText(w, "application/json; charset=utf-8", c.indexJSON) return } @@ -324,6 +407,96 @@ func newHandler(rootAbs, base, contentAbs, viewerAbs string, logf func(string)) serveFrom(w, rootAbs, viewerAbs, "_manifest.md", false) return } + // The overview homepage is served RENDERED: the source bytes with the layer map + // substituted between the author's markers, so the counts a reader sees are the + // ones the tree has right now and the committed file is never rewritten to say + // so. The route is a content route first: it makes every check serveFrom makes + // on this path, with the same answers, plus the generation guards (repository + // containment, no private `.leji/` role), because this is the one content path + // the tool also writes. + // + // EVERY one of those checks is bound to the VERIFIED target, not to a path + // resolved beforehand: the guarded read judges the resolved location, opens it, + // proves the descriptor is that same regular file, and the bytes come from that + // descriptor. A pre-read realpath plus a separate read leaves the window this + // closes: a link swapped in between resolves somewhere else (inside the + // repository, outside the content mount) and the read follows it past a check + // that judged the old target. + if rel == "content/"+viewer.OverviewRel { + abs := filepath.Join(contentAbs, viewer.OverviewRel) + // By name first, exactly as serveFrom does, before anything is resolved. + if !layout.ServablePath(rootAbs, abs) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + src, err := fsx.OpenVerifiedSource(abs, func(resolved string) bool { + ok := withinContent(resolved) && + layout.ServablePath(resolvedRoot, resolved) && + layout.WritableTarget(resolvedRoot, resolved, "").OK + if ok && testHookAfterAuthorize != nil { + testHookAfterAuthorize() + } + return ok + }) + var source []byte + landed := "" + if err == nil { + if src.Resolved { + landed = src.Real + } + if src.File != nil { + body, rerr := io.ReadAll(src.File) + _ = src.File.Close() + if rerr == nil { + source = body + } + } + } + if source == nil { + // The refusal names where the source resolves NOW: outside the content + // mount is the mount's own answer (403), and everything else (a private + // role, a directory, an absent or unresolvable entry) is a plain miss. + if landed != "" && !withinContent(landed) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden")) + return + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + // A live generation that fails outright (an unreadable content root, an + // invalid manifest, a document the walk cannot read) serves the last map that + // did generate; before the first one ever did, the source bytes as they are. + // A source without markers is served unchanged whatever the index says. + var index *layerIndex + if c := liveIndex(); c != nil { + index = &layerIndex{manifest: c.manifest, entries: c.entries} + } + lastGoodMu.Lock() + if index != nil { + lastGoodMap = index + } + mapIndex := index + if mapIndex == nil { + mapIndex = lastGoodMap + } + lastGoodMu.Unlock() + body := source + if mapIndex != nil { + // Decoded the way Node's Buffer.toString('utf8') decodes, so a source that + // is not valid UTF-8 renders to the same bytes in all three SDKs. Only the + // rendered branch decodes; a markerless page is served raw below. + if text, markersFound := viewer.RenderOverview(viewer.DecodeUTF8(source), mapIndex.manifest, mapIndex.entries); markersFound { + body = []byte(text) + } + } + w.Header().Set("content-type", "text/markdown; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + return + } if rel == "content" || strings.HasPrefix(rel, "content/") { sub := "" if rel != "content" { @@ -401,8 +574,10 @@ func urlPathToRel(urlPath string) string { // Serve serves the viewer at the web root on 127.0.0.1, returning the listener and // http.Server. Port 0 picks a free port. rootRel is the context root (e.g. "docs"); // the viewer is served at "/" and content docs under "/content/". logf, when set, -// receives one access-log line per request. -func Serve(root string, port int, rootRel string, logf func(string)) (net.Listener, *http.Server, error) { +// receives one access-log line per request. A caller that has just generated the +// viewer may hand over the index snapshot it projected as the initial layer map; +// one that passes none gets a live generation at startup instead. +func Serve(root string, port int, rootRel string, logf func(string), opts ...Options) (net.Listener, *http.Server, error) { rootAbs := resolveRoot(root) base := fsx.StripSlash(rootRel) contentAbs := rootAbs @@ -419,7 +594,11 @@ func Serve(root string, port int, rootRel string, logf func(string)) (net.Listen return nil, nil, err } viewerAbs := layout.Abs(rootAbs, layout.ViewerRel) - srv := &http.Server{Handler: newHandler(rootAbs, base, contentAbs, viewerAbs, logf)} + options := Options{} + if len(opts) > 0 { + options = opts[0] + } + srv := &http.Server{Handler: newHandler(rootAbs, base, contentAbs, viewerAbs, logf, options)} return ln, srv, nil } diff --git a/packages/sdk-go/internal/commands/serve/serve_test.go b/packages/sdk-go/internal/commands/serve/serve_test.go index 9ee28dc..2ed79c8 100644 --- a/packages/sdk-go/internal/commands/serve/serve_test.go +++ b/packages/sdk-go/internal/commands/serve/serve_test.go @@ -37,7 +37,7 @@ func TestServePathContainment(t *testing.T) { } linkSupported := os.Symlink(secretPath, filepath.Join(contentAbs, "escape.txt")) == nil - handler := newHandler(contentAbs, "", contentAbs, viewerAbs, nil) + handler := newHandler(contentAbs, "", contentAbs, viewerAbs, nil, Options{}) type tc struct { name string diff --git a/packages/sdk-go/internal/commands/updatepin/updatepin.go b/packages/sdk-go/internal/commands/updatepin/updatepin.go index ae89aea..433a4e6 100644 --- a/packages/sdk-go/internal/commands/updatepin/updatepin.go +++ b/packages/sdk-go/internal/commands/updatepin/updatepin.go @@ -6,7 +6,8 @@ // claim of freshness. `--fetch` observes the declared source — and nothing else — // in three acts: retain the current pin, refresh the witness once, and (after the // gate passes) retain the target. Any of them failing REFUSES the move; a pin move -// is not best-effort, which is `hydrate`'s model rather than this one. +// is not best-effort, which is `hydrate`'s model rather than this one. The reason +// names the act. // // The manifest is rewritten by replacing the addressed pin's own byte span // (manifest.ReplaceMountPinInManifestText), never by reserializing, so the three @@ -17,10 +18,12 @@ import ( "encoding/json" "fmt" "path/filepath" + "strings" "time" "github.com/leji-org/leji/packages/sdk-go/internal/findings" "github.com/leji-org/leji/packages/sdk-go/internal/fsx" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/mounts" ) @@ -72,6 +75,11 @@ type Options struct { // Now is the injectable observation clock, so tests and fixtures are stable; // zero means the wall clock, read once per run. Now time.Time + // IgnoreContext is the invocation's notice state for the self-managed + // `.leji/.gitignore`. One `--fetch` run retains TWICE (the current pin, then the + // target), and both establish the managed store, so the context is threaded + // rather than left to each call: one invocation notices at most once. + IgnoreContext *lejiignore.Context } // ShortOid is a pin at the length every human-facing line uses. @@ -109,7 +117,10 @@ func Run(root string, m *manifest.Manifest, opts Options) (Result, error) { } mount, declared := declaredMount(m, opts.Name) - refuse := func(reason string, to *string, pinReport *mounts.PinReport) Result { + // `detail` is an optional trailing argument, as the TS reference's is: the act + // this refusal failed at, when the rule names more than one. + refuse := func(reason string, to *string, pinReport *mounts.PinReport, detail ...string) Result { + act := optional(detail) block := MountBlock{Name: opts.Name, To: to} if declared { if identity, ok := mounts.NormalizeSource(mount.Source); ok { @@ -125,7 +136,9 @@ func Run(root string, m *manifest.Manifest, opts Options) (Result, error) { PinReport: pinReport, Action: ActionRefused, Reason: reason, - Findings: []findings.Finding{findings.New(reason, findings.Error, reasonProse(reason), opts.Name)}, + Findings: []findings.Finding{ + findings.NewWithDetail(reason, findings.Error, reasonProse(reason, act), opts.Name, act), + }, } } @@ -190,19 +203,19 @@ func Run(root string, m *manifest.Manifest, opts Options) (Result, error) { // the managed store holds both operands, then refresh the witness exactly once. // A failure here refuses the move — best-effort belongs to `hydrate`. if opts.Fetch { - store, _, err := mounts.RetainPinInStore(root, mount, identity, mount.Pin) + store, retainErr, err := mounts.RetainPinInStore(root, mount, identity, mount.Pin, opts.IgnoreContext) if err != nil { return Result{}, err } if store == "" { return refuse("mount-store-fetch-failed", nil, - degraded("mount-store-fetch-failed", effectiveRef)), nil + degraded("mount-store-fetch-failed", effectiveRef), "current pin: "+retainErr), nil } witnessMount := mount witnessMount.TrackingRef = effectiveRef - if !mounts.RefreshWitness(store, witnessMount, identity) { + if ok, refreshErr := mounts.RefreshWitness(store, witnessMount, identity); !ok { return refuse("mount-witness-refresh-failed", nil, - degraded("mount-witness-refresh-failed", effectiveRef)), nil + degraded("mount-witness-refresh-failed", effectiveRef), "witness: "+refreshErr), nil } } @@ -260,9 +273,12 @@ func Run(root string, m *manifest.Manifest, opts Options) (Result, error) { // A refusal after the comparison settled reports the comparison it refused on, // and carries whatever the run had already decided: an override exercised at the // gate is still reported by a run that then refused for another reason. - refuseSettled := func(reason string, override bool, warnings []findings.Finding) Result { + refuseSettled := func(reason string, override bool, warnings []findings.Finding, detail ...string) Result { + act := optional(detail) r := settled(ActionRefused, override, append( - []findings.Finding{findings.New(reason, findings.Error, reasonProse(reason), mount.Name)}, + []findings.Finding{ + findings.NewWithDetail(reason, findings.Error, reasonProse(reason, act), mount.Name, act), + }, warnings..., )) r.Reason = reason @@ -302,12 +318,12 @@ func Run(root string, m *manifest.Manifest, opts Options) (Result, error) { // (b iii) The target is retained only once the gate has passed, so a refused run // never establishes a pin ref for a commit it declined to move to. if opts.Fetch { - store, _, err := mounts.RetainPinInStore(root, mount, identity, target) + store, retainErr, err := mounts.RetainPinInStore(root, mount, identity, target, opts.IgnoreContext) if err != nil { return Result{}, err } if store == "" { - return refuseSettled("mount-store-fetch-failed", override, warnings), nil + return refuseSettled("mount-store-fetch-failed", override, warnings, "target: "+retainErr), nil } } @@ -423,9 +439,29 @@ func declarationUnchanged(text string, declaration declarationSnapshot) bool { return false } +// optional reads an optional trailing argument, absent being the empty string. +func optional(values []string) string { + if len(values) > 0 { + return values[0] + } + return "" +} + // reasonProse renders a stable reason code as the sentence a person reads; the code -// itself is what `--json` emits. -func reasonProse(reason string) string { +// itself is what `--json` emits. A code whose acts have different routes forward +// carries one entry per act, keyed `: ` exactly as the finding's detail +// spells it, so the table stays the single source of every string this command +// prints; every other code answers for all of its acts at once. +func reasonProse(reason string, detail ...string) string { + if d := optional(detail); d != "" { + act := "" + if i := strings.Index(d, ": "); i > 0 { + act = d[:i] + } + if qualified, ok := Reasons[reason+": "+act]; ok { + return qualified + } + } if prose, ok := Reasons[reason]; ok { return prose } @@ -436,16 +472,22 @@ func reasonProse(reason string) string { // a person reads the sentence. The codes above `mount-unknown` are shared with // `mounts status`, whose prose lives beside the status reasons. var Reasons = map[string]string{ - "mount-unknown": "no mount with this name is declared", - "mount-source-unnormalizable": "source is not a normalizable locator", - "mount-no-tracking-ref": "no trackingRef declared; the source's advertised default branch needs --fetch", - "mount-tracking-ref-invalid": "trackingRef is not a fully qualified branch or tag", - "mount-default-ref-unavailable": "the source advertises no default branch this run could resolve", - "mount-pin-unavailable": "no reachable object store holds the pin (declare a hint, or pass --fetch)", - "mount-witness-unavailable": "no object store holding the pin resolves the witness ref; run `leji mounts hydrate --fetch`", - "mount-source-ambiguous": "more than one submodule matches the source; declare an explicit hint in .leji/mounts.local.json", - "mount-ancestry-incomplete": "incomplete ancestry; the comparison repository cannot answer the range", - "mount-store-fetch-failed": "the requested fetch could not retain the commit in the managed store", + "mount-unknown": "no mount with this name is declared", + "mount-source-unnormalizable": "source is not a normalizable locator", + "mount-no-tracking-ref": "no trackingRef declared; the source's advertised default branch needs --fetch", + "mount-tracking-ref-invalid": "trackingRef is not a fully qualified branch or tag", + "mount-default-ref-unavailable": "the source advertises no default branch this run could resolve", + "mount-pin-unavailable": "no reachable object store holds the pin (declare a hint, or pass --fetch)", + "mount-witness-unavailable": "no object store holding the pin resolves the witness ref; run `leji mounts hydrate --fetch`", + "mount-source-ambiguous": "more than one submodule matches the source; declare an explicit hint in .leji/mounts.local.json", + "mount-ancestry-incomplete": "incomplete ancestry; the comparison repository cannot answer the range", + "mount-store-fetch-failed": "the requested fetch could not retain the commit in the managed store", + // The current-pin act is the one an operator can route past: an upstream that + // rewrote its history no longer serves the commit this manifest pins, and the + // move is still available against a repository that does hold both operands. + "mount-store-fetch-failed: current pin": "the requested fetch could not retain the commit in the managed store; " + + "if a local hint holds the current pin and the target with complete ancestry, run without `--fetch`; " + + "to move past a rewritten upstream, pass `--to --allow-non-fast-forward` against such a hint", "mount-witness-refresh-failed": "the requested fetch could not refresh the managed witness ref", "mount-target-unavailable": "the requested target commit is not held by the comparison repository", "mount-pin-not-fast-forward": "the target is not a descendant of the current pin (pass --to --allow-non-fast-forward to move anyway)", diff --git a/packages/sdk-go/internal/commands/viewer/viewer.go b/packages/sdk-go/internal/commands/viewer/viewer.go index 900a76a..ccd8eb4 100644 --- a/packages/sdk-go/internal/commands/viewer/viewer.go +++ b/packages/sdk-go/internal/commands/viewer/viewer.go @@ -19,6 +19,7 @@ import ( "strconv" "strings" "unicode" + "unicode/utf8" "github.com/leji-org/leji/packages/sdk-go/internal/assets" "github.com/leji-org/leji/packages/sdk-go/internal/commands/indexgen" @@ -28,6 +29,7 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/jsonenc" "github.com/leji-org/leji/packages/sdk-go/internal/layer" "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/mounts" ) @@ -47,6 +49,14 @@ type Result struct { Written []string Findings []findings.Finding Entries int + // IndexEntries is never nil after GenerateViewer returns, empty run included: it is + // the snapshot a caller hands the local server, and there nil means "no snapshot" + // (the reference's `undefined`) rather than "no entries". + // + // IndexEntries are the index entries this run projected, so a caller that renders + // from the same generation (the export's overview map) reads one snapshot rather + // than making a second one. Empty when the run refused to project anything. + IndexEntries []indexgen.IndexEntry } // categoryLabels label the layer-map (mermaid) category nodes; the sidebar @@ -830,6 +840,11 @@ func sidebarLabel(root, relPath, rootRel string) string { return filenameLabel(rootRel) } +// OverviewRel is the overview homepage, named relative to the context root: the one +// content path the tool seeds, and the one whose read renders the layer map into it. +// Shared by generation, the local server's route, and the export's copy. +const OverviewRel = "overview.md" + // referenceTree is the browse zone: every markdown file under rootPath that is NOT // governed (in the index) and NOT viewer/layer chrome (boot profile, agent // profiles, category index files, overview.md, the generated _sidebar.md). @@ -849,11 +864,11 @@ func referenceTree(root string, m *manifest.Manifest, governedPaths map[string]b } } } - overviewRel := "overview.md" + overviewRel := OverviewRel sidebarRel := "_sidebar.md" manifestPageRel := "_manifest.md" if rootDirRel != "." { - overviewRel = rootDirRel + "/overview.md" + overviewRel = rootDirRel + "/" + OverviewRel sidebarRel = rootDirRel + "/_sidebar.md" manifestPageRel = rootDirRel + "/_manifest.md" } @@ -883,11 +898,16 @@ func referenceTree(root string, m *manifest.Manifest, governedPaths map[string]b return nodes } -// The overview homepage is seeded once then user-owned. The layer map lives -// between these markers; `leji viewer` regenerates only the marked block. +// The overview homepage is seeded once then user-owned. The markers are the author's +// placement mark for the layer map: the map is substituted between them at render +// time, by the viewer and by `leji export`, and the file itself is never rewritten. const ( mapStart = "" mapEnd = "" + // mapPlaceholder is the line the seed leaves between the markers, so a reader of + // the source file knows why the span is empty. Whatever an author leaves there is + // ignored at render, this line included. + mapPlaceholder = "" ) // buildLayerMap renders a deterministic mermaid map of the layer: boot profile -> @@ -928,19 +948,118 @@ func mapBlock(m *manifest.Manifest, entries []indexgen.IndexEntry) string { return mapStart + "\n```mermaid\n" + buildLayerMap(m, entries) + "\n```\n" + mapEnd } -// buildOverviewSeed is the starter home page: a short owner-editable explainer -// plus the generated layer map inside the regen markers. -func buildOverviewSeed(m *manifest.Manifest, entries []indexgen.IndexEntry) string { +// DecodeUTF8 is Node's `Buffer.toString('utf8')`, which is what the reference SDK hands +// RenderOverview: valid UTF-8 passes through untouched, and every invalid sequence +// becomes U+FFFD, one replacement per MAXIMAL SUBPART: the WHATWG substitution rule V8 +// implements (`E2 82` at the end of the input is one replacement, `C0 80` is two). +// +// Go's own conversions answer differently: `string(b)` keeps the invalid bytes verbatim, +// and a utf8.DecodeRune loop emits one replacement per BYTE. Either would make a rendered +// overview carry different bytes from the reference's for the same source, so the decode +// is spelled out here rather than borrowed. Only the RENDERED path decodes: a page whose +// markers are missing is served and exported as its raw bytes in all three SDKs. +func DecodeUTF8(b []byte) string { + // Already valid: the bytes are their own decoding, and the common case pays one scan. + if utf8.Valid(b) { + return string(b) + } + var out strings.Builder + out.Grow(len(b)) + var ( + codepoint rune + bytesNeeded int + bytesSeen int + lower byte = 0x80 + upper byte = 0xBF + ) + for i := 0; i < len(b); i++ { + c := b[i] + if bytesNeeded == 0 { + switch { + case c <= 0x7F: + out.WriteByte(c) + case c >= 0xC2 && c <= 0xDF: + bytesNeeded, codepoint = 1, rune(c&0x1F) + case c >= 0xE0 && c <= 0xEF: + if c == 0xE0 { + lower = 0xA0 // no overlong three-byte form + } + if c == 0xED { + upper = 0x9F // no surrogate + } + bytesNeeded, codepoint = 2, rune(c&0x0F) + case c >= 0xF0 && c <= 0xF4: + if c == 0xF0 { + lower = 0x90 // no overlong four-byte form + } + if c == 0xF4 { + upper = 0x8F // nothing past U+10FFFF + } + bytesNeeded, codepoint = 3, rune(c&0x07) + default: + out.WriteRune(utf8.RuneError) + } + continue + } + if c < lower || c > upper { + // The maximal subpart ends before this byte: one replacement for what was + // consumed, and the byte is reprocessed from a clean state rather than + // swallowed, which is why `C2 41` decodes to U+FFFD followed by "A". + codepoint, bytesNeeded, bytesSeen = 0, 0, 0 + lower, upper = 0x80, 0xBF + out.WriteRune(utf8.RuneError) + i-- + continue + } + lower, upper = 0x80, 0xBF + codepoint = codepoint<<6 | rune(c&0x3F) + bytesSeen++ + if bytesSeen == bytesNeeded { + out.WriteRune(codepoint) + codepoint, bytesNeeded, bytesSeen = 0, 0, 0 + } + } + if bytesNeeded != 0 { + out.WriteRune(utf8.RuneError) // a sequence the end of the input cut short + } + return out.String() +} + +// RenderOverview is the overview homepage as it is READ, never as it is stored: the +// source bytes with the marked span replaced by the map this index projects. The one +// function behind both consumers (the local server renders it per fetch, the export +// renders the copy it writes), so the served and the exported page carry the same +// bytes. +// +// Whatever stands between the markers in source is ignored: the map is derived from +// the index, so the file is never rewritten to hold it. Without the marker pair there +// is nowhere to put the map, and the source is returned unchanged (markersFound +// false) for the caller to warn about. +func RenderOverview(source string, m *manifest.Manifest, entries []indexgen.IndexEntry) (text string, markersFound bool) { + start := strings.Index(source, mapStart) + end := strings.Index(source, mapEnd) + if start < 0 || end <= start { + return source, false + } + return source[:start] + mapBlock(m, entries) + source[end+len(mapEnd):], true +} + +// buildOverviewSeed is the starter home page: a short owner-editable explainer plus +// the empty marker pair the layer map is rendered into. Written once, when no +// overview.md stands at the content root, and never rewritten after that. +func buildOverviewSeed(m *manifest.Manifest) string { return "# " + m.Name + ` This is the **Leji context layer** for ` + "`" + m.Name + "`" + `: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by ` + "`leji viewer`" + ` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by ` + "`leji export`" + `; this file is never rewritten. -` + mapBlock(m, entries) + ` +` + mapStart + ` +` + mapPlaceholder + ` +` + mapEnd + ` - Write a ` + "```mermaid" + ` code block in any document and it renders as a diagram here. - Run ` + "`leji conformance`" + ` to see the level this layer claims and verifies. @@ -1855,7 +1974,12 @@ func mermaidEnabled(m *manifest.Manifest) bool { // root `.leji/viewer/` role: a Docsify `index.html` and a `_sidebar.md` projected // from the index. Presentation is non-normative; this is the reference projection // of context-index.json into a browsable surface. -func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { +// +// ignoreContext is the invocation's notice state for the self-managed +// `.leji/.gitignore` (this run creates a role, so it ensures that file): a caller +// that has one passes it through, and a direct SDK call that passes none notices at +// most once for that call. +func GenerateViewer(root string, m *manifest.Manifest, ignoreContext ...*lejiignore.Context) (Result, error) { result, err := indexgen.GenerateIndex(root, m) if err != nil { return Result{}, err @@ -1864,11 +1988,16 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { // errors and write nothing, the same refusal WriteIndex makes. for _, f := range result.Findings { if f.Severity == findings.Error { - return Result{Written: nil, Findings: result.Findings, Entries: 0}, nil + return Result{Written: nil, Findings: result.Findings, Entries: 0, + IndexEntries: []indexgen.IndexEntry{}}, nil } } - var entries []indexgen.IndexEntry - if result.Index != nil { + // Non-nil even when the layer governs nothing: this slice is also the snapshot a + // caller hands the local server, where nil is the "I have no snapshot" signal (the + // reference's `undefined`). A successful generation over an empty layer projected an + // answer, and that answer is zero entries, not the absence of one. + entries := []indexgen.IndexEntry{} + if result.Index != nil && result.Index.Entries != nil { entries = result.Index.Entries } var findingsEarly []findings.Finding @@ -1952,7 +2081,8 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { "/ resolves outside the repository; remove the symlink" } findingList = append(findingList, findings.New("viewer-target-refused", findings.Error, message, layout.ViewerRel)) - return Result{Written: written, Findings: findingList, Entries: 0}, nil + return Result{Written: written, Findings: findingList, Entries: 0, + IndexEntries: []indexgen.IndexEntry{}}, nil } // The chrome's role in the unified root `.leji/` (gitignored): outside the context @@ -1982,19 +2112,34 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { } } + // The role now exists, so the tool ignores its own tree from inside: a layer + // whose root .gitignore never carried the `.leji/` line is clean after this run. + // A refusal is an error finding like any other refused write here. + ignored, err := lejiignore.EnsureFile(resolvedRoot, lejiignore.From(ignoreContext...)) + if err != nil { + return Result{}, err + } + if ignored == lejiignore.Refused { + findingList = append(findingList, findings.New("viewer-target-refused", findings.Error, + "refusing to write "+layout.LejiIgnoreRel+": it does not resolve to a regular file inside "+ + layout.LejiDir+"/; remove the symlink", layout.LejiIgnoreRel)) + } + // The overview/home page is user-owned content (not chrome): seeded once, never - // overwritten. On regen only the marked map block is refreshed; if the owner - // removed the markers, the page is left alone. + // written again. The layer map is rendered between its markers when the page is + // read (by the local server and by the export), so a reindex that changes the + // document counts leaves this file exactly as its author last saved it. If the + // markers are gone there is nowhere to render the map, which is a warning. // // Check-before-act: overview.md is content — its target must resolve WITHIN // the layer root AND never into a private `.leji/` role. It is judged on the // RESOLVED path (no `.leji/` role of its own) BEFORE anything is read or written, // so an overview.md symlinked into `.leji/work/` or `.leji/mounts/` is refused - // before the seed or the refresh writes through it — and the write itself then + // before the seed writes through it or the page is read, and the seed itself then // lands via the guarded-write chokepoint on that path. - overviewRel := "overview.md" + overviewRel := OverviewRel if rootDir != "." { - overviewRel = rootDir + "/overview.md" + overviewRel = rootDir + "/" + OverviewRel } overviewAbs := filepath.Join(root, overviewRel) overviewResolved, overviewResolvable := fsx.ResolvedPathUnder(resolvedRoot, overviewAbs) @@ -2017,13 +2162,13 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { " (private); remove the symlink", overviewRel)) case overviewRead.Status == fsx.ReadRefused: // A standing entry that cannot be verified as a regular file inside the layer: - // the map is neither seeded through it nor refreshed from bytes read by path. + // the page is neither seeded through it nor read from a path that could redirect. findingList = append(findingList, findings.New("viewer-target-refused", findings.Error, "refusing to write overview.md: it does not resolve to a regular file inside the repository; "+ "remove the symlink", overviewRel)) case overviewRead.Status == fsx.ReadAbsent: seeded, err := fsx.WriteFileGuarded(resolvedRoot, overviewAbs, "", - []byte(buildOverviewSeed(m, entries)), fsx.WriteOptions{}) + []byte(buildOverviewSeed(m)), fsx.WriteOptions{}) if err != nil { return Result{}, err } @@ -2031,22 +2176,13 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { written = append(written, overviewRel) } default: - // The refresh rewrites the page it just read, so those bytes come from the - // verified descriptor rather than from a second read by pathname. - existing := string(overviewRead.Bytes) - start := strings.Index(existing, mapStart) - end := strings.Index(existing, mapEnd) - if start >= 0 && end > start { - updated := existing[:start] + mapBlock(m, entries) + existing[end+len(mapEnd):] - if updated != existing { - if _, err := fsx.WriteFileGuarded(resolvedRoot, overviewAbs, "", - []byte(updated), fsx.WriteOptions{}); err != nil { - return Result{}, err - } - } - } else { + // A standing page is READ and not written: the only thing generation decides + // here is whether the map has a place to be rendered into. The bytes come from + // the verified descriptor rather than from a second read by pathname, so the + // page the guards judged is the page the answer is about. + if _, markersFound := RenderOverview(DecodeUTF8(overviewRead.Bytes), m, entries); !markersFound { findingList = append(findingList, findings.New("overview-markers-missing", findings.Warning, - "overview.md has no generated-map markers; left as-is (map not refreshed)", overviewRel)) + "overview.md has no generated-map markers; the map is not rendered", overviewRel)) } } @@ -2059,7 +2195,7 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { return Result{}, err } - return Result{Written: written, Findings: findingList, Entries: len(entries)}, nil + return Result{Written: written, Findings: findingList, Entries: len(entries), IndexEntries: entries}, nil } // ActiveExtensions are the extensions a browser would run as an active, diff --git a/packages/sdk-go/internal/commands/viewer/viewer_more_test.go b/packages/sdk-go/internal/commands/viewer/viewer_more_test.go index 05ab4b7..e2d49f3 100644 --- a/packages/sdk-go/internal/commands/viewer/viewer_more_test.go +++ b/packages/sdk-go/internal/commands/viewer/viewer_more_test.go @@ -1,6 +1,9 @@ package viewer import ( + "bytes" + "crypto/sha256" + "encoding/hex" "math" "os" "path/filepath" @@ -171,7 +174,7 @@ func TestBuildSidebarProjection(t *testing.T) { } } -func TestGenerateViewerSeedsOverviewMap(t *testing.T) { +func TestGenerateViewerSeedsOverviewWithEmptyMarkers(t *testing.T) { dir := exampleCopy(t) m := manifest.LoadManifest(dir).Manifest if _, err := GenerateViewer(dir, m); err != nil { @@ -187,34 +190,53 @@ func TestGenerateViewerSeedsOverviewMap(t *testing.T) { t.Fatalf("expected the overview titled with the layer name, got: %s", s) } if !strings.Contains(s, "") { - t.Fatal("expected the regen markers") + t.Fatal("expected the map markers") } - if !strings.Contains(s, "```mermaid\nflowchart LR") { - t.Fatal("expected the map to be a mermaid flowchart") + // The map is derived from the index, so the seed carries the placement mark and + // one line saying where the map comes from, never a copy of the map itself. + between := strings.SplitN(s, "\n", 2)[1] + between = strings.SplitN(between, "\n", 2)[0] + if between != "" { + t.Fatalf("expected the markers to wrap exactly the rendering note, got: %q", between) } - if !strings.Contains(s, "boot --> cat_domain") { - t.Fatal("expected boot to link to the domain category") + if strings.Contains(s, "```mermaid\nflowchart LR") { + t.Fatal("expected no map written into the source file") } - if !strings.Contains(s, "cat_domain[\"📖 Domain · 1 doc\"]") { - t.Fatal("expected categories to carry counts, never per-doc nodes") - } - if strings.Contains(s, "n_glossary") { - t.Fatal("expected no per-document nodes (unreadable at scale)") + if !strings.Contains(s, "this file is never rewritten") { + t.Fatal("expected the seed to say so in its own prose") } } -func TestGenerateViewerOverviewSeededOnce(t *testing.T) { +func TestGenerateViewerRendersTheMapAndNeverWritesTheSource(t *testing.T) { dir := exampleCopy(t) m := manifest.LoadManifest(dir).Manifest if _, err := GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } overview := filepath.Join(dir, m.RootPath, "overview.md") - // The owner rewrites the prose but keeps the markers. + seeded, err := os.ReadFile(overview) + if err != nil { + t.Fatalf("read overview: %v", err) + } + // A second run over the same tree writes nothing: the seed happens once. + if _, err := GenerateViewer(dir, m); err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + again, err := os.ReadFile(overview) + if err != nil { + t.Fatalf("read overview: %v", err) + } + if !bytes.Equal(again, seeded) { + t.Fatal("expected a second run to leave the seeded page byte-identical") + } + // The owner rewrites the prose, keeps the markers, and leaves a stale map inside + // them. Adding a document changes the counts the map would show. edited := "# My own title\n\nHand-written intro.\n\n\nstale\n\n\nMore prose.\n" if err := os.WriteFile(overview, []byte(edited), 0o644); err != nil { t.Fatalf("write overview: %v", err) } + writeUnder(t, dir, "docs/domain/pricing.md", "# Pricing\n\nHow we price.\n") + before := sha256.Sum256([]byte(edited)) res, err := GenerateViewer(dir, m) if err != nil { t.Fatalf("GenerateViewer: %v", err) @@ -223,24 +245,34 @@ func TestGenerateViewerOverviewSeededOnce(t *testing.T) { if err != nil { t.Fatalf("read overview: %v", err) } - s := string(after) - if !strings.Contains(s, "# My own title") { - t.Fatal("expected owner prose preserved") - } - if !strings.Contains(s, "More prose.") { - t.Fatal("expected trailing prose preserved") - } - if !strings.Contains(s, "```mermaid\nflowchart LR") { - t.Fatal("expected the stale map block to be refreshed") - } - if strings.Contains(s, "\nstale\n") { - t.Fatal("expected old map content replaced") + if sha256.Sum256(after) != before { + t.Fatal("expected a reindex that changes the map to leave overview.md byte-identical") } for _, f := range res.Findings { if f.Rule == "overview-markers-missing" { t.Fatal("expected no warning when the markers are intact") } } + // The map exists at render time, from the same entries the run projected. + rendered, markersFound := RenderOverview(edited, m, res.IndexEntries) + if !markersFound { + t.Fatal("expected the markers to be the placement mark") + } + if !strings.Contains(rendered, "# My own title") { + t.Fatal("expected owner prose preserved around the map") + } + if !strings.Contains(rendered, "More prose.") { + t.Fatal("expected trailing prose preserved") + } + if strings.Contains(rendered, "\nstale\n") { + t.Fatal("expected the stale block ignored, not merged") + } + if !strings.Contains(rendered, "cat_domain[\"📖 Domain · 2 docs\"]") { + t.Fatal("expected the rendered counts to be the tree of today") + } + if !strings.Contains(rendered, "```mermaid\n"+buildLayerMap(m, res.IndexEntries)+"\n```") { + t.Fatal("expected the map block to be buildLayerMap between fences") + } } // Mirrors the Node test: homepage, favicon, and pins accept repo-relative and @@ -351,12 +383,17 @@ func TestGenerateViewerOverviewWithoutMarkersWarns(t *testing.T) { } warned := false for _, f := range res.Findings { - if f.Rule == "overview-markers-missing" && f.Severity == findings.Warning { + if f.Rule == "overview-markers-missing" && f.Severity == findings.Warning && + f.Message == "overview.md has no generated-map markers; the map is not rendered" { warned = true } } if !warned { - t.Fatal("expected a warning that the map was not refreshed") + t.Fatalf("expected a warning that the map has nowhere to render, got: %v", res.Findings) + } + // With nowhere to put it, the page renders as its own source bytes. + if text, markersFound := RenderOverview(custom, m, res.IndexEntries); text != custom || markersFound { + t.Fatalf("expected a marker-less page to render as its source, got %q / %v", text, markersFound) } } @@ -678,3 +715,80 @@ func TestMdLinkDest(t *testing.T) { } } } + +// --- the rendered page decodes the way the reference decodes --- +// The overview is rendered from a STRING, and the three SDKs must turn the same bytes +// into the same string or the rendered page diverges on any source that is not valid +// UTF-8. Node substitutes U+FFFD per maximal subpart; Go's own conversions do not. +// Every expectation below was captured from Node (`Buffer.from(bytes).toString('utf8')`) +// and is pinned here, so a change to DecodeUTF8 that drifts from the reference reddens. + +func TestDecodeUTF8MatchesNodeBufferToString(t *testing.T) { + cases := []struct { + name string + in []byte + want string // the exact bytes Node produced, as UTF-8 + nodeH string // and their hex, so a failure names the reference directly + }{ + {"valid ascii", []byte("hello"), "hello", "68656c6c6f"}, + {"valid emoji", []byte{0xF0, 0x9F, 0x98, 0x80}, "\U0001F600", "f09f9880"}, + {"lone FF between ascii", []byte{0x61, 0xFF, 0x62}, "a�b", "61efbfbd62"}, + {"lone continuation", []byte{0x80}, "�", "efbfbd"}, + {"truncated 3-byte at eof", []byte{0xE2, 0x82}, "�", "efbfbd"}, + {"truncated 3-byte then ascii", []byte{0xE2, 0x82, 0x41}, "�A", "efbfbd41"}, + {"overlong 2-byte", []byte{0xC0, 0x80}, "��", "efbfbdefbfbd"}, + {"surrogate", []byte{0xED, 0xA0, 0x80}, "���", "efbfbdefbfbdefbfbd"}, + {"past U+10FFFF", []byte{0xF4, 0x90, 0x80, 0x80}, "����", "efbfbdefbfbdefbfbdefbfbd"}, + {"overlong 4-byte", []byte{0xF0, 0x82, 0x82, 0xAC}, "����", "efbfbdefbfbdefbfbdefbfbd"}, + {"E0 80 80", []byte{0xE0, 0x80, 0x80}, "���", "efbfbdefbfbdefbfbd"}, + {"truncated 4-byte, two bytes", []byte{0xF0, 0x9F}, "�", "efbfbd"}, + {"truncated 4-byte, three bytes", []byte{0xF0, 0x9F, 0x98}, "�", "efbfbd"}, + {"FE FF", []byte{0xFE, 0xFF}, "��", "efbfbdefbfbd"}, + {"lead then ascii", []byte{0xC2, 0x41}, "�A", "efbfbd41"}, + } + for _, c := range cases { + got := DecodeUTF8(c.in) + if hex.EncodeToString([]byte(got)) != c.nodeH { + t.Errorf("%s: DecodeUTF8(% x) = %q (%s), Node gives %q (%s)", + c.name, c.in, got, hex.EncodeToString([]byte(got)), c.want, c.nodeH) + } + } +} + +func TestRenderOverviewOnInvalidUTF8IsByteIdenticalToTheReference(t *testing.T) { + // The whole rendered document, pinned against the reference. Source and expectation + // were captured by running the TypeScript SDK's own `renderOverview` over these + // bytes (packages/sdk/dist/index.js), with the invalid sequences deliberately placed + // OUTSIDE the marker span (a lone 0xFF before it and an overlong C0 80 after it), + // so what this pins is the decode and not the substitution. The truncated E2 82 + // inside the span is dropped with the span, as it is in every SDK. + const srcHex = "232054ff746c650a0a3c212d2d206c656a693a67656e6572617465642d6d61703a73" + + "7461727420" + "2d2d3e0a7374616c6520e282206d61700a3c212d2d206c656a693a67656e657261" + + "7465642d6d61703a656e64202d2d3e0a0a5461c080696c0a" + const wantHex = "232054efbfbd746c650a0a3c212d2d206c656a693a67656e6572617465642d6d61703a" + + "7374617274202d2d3e0a6060606d65726d6169640a666c6f776368617274204c520a2020626f6f" + + "745b22f09fa49620426f6f742070726f66696c65225d0a20206361745f646f6d61696e5b22f09f" + + "939620446f6d61696e20c2b7203120646f63225d0a2020626f6f74202d2d3e206361745f646f6d" + + "61696e0a6060600a3c212d2d206c656a693a67656e6572617465642d6d61703a656e64202d2d3e" + + "0a0a5461efbfbdefbfbd696c0a" + src, err := hex.DecodeString(srcHex) + if err != nil { + t.Fatalf("decode the pinned source: %v", err) + } + entries := []indexgen.IndexEntry{{ID: "a", Path: "docs/domain/a.md", Title: "A", Category: "domain"}} + got, markersFound := RenderOverview(DecodeUTF8(src), &manifest.Manifest{Name: "fixture"}, entries) + if !markersFound { + t.Fatal("the pinned source carries the marker pair") + } + if hex.EncodeToString([]byte(got)) != wantHex { + t.Fatalf("rendered bytes differ from the reference's\n got: %s\nwant: %s\n\ngot text:\n%s", + hex.EncodeToString([]byte(got)), wantHex, got) + } + // The invalid bytes are gone and the replacement stands where the reference put it. + if bytes.ContainsRune([]byte(got), 0xFF) { + t.Fatal("no raw invalid byte may survive the render") + } + if !strings.Contains(got, "# T�tle") || !strings.Contains(got, "Ta��il") { + t.Fatalf("the replacements are not where the reference puts them:\n%s", got) + } +} diff --git a/packages/sdk-go/internal/conformancetest/badge_test.go b/packages/sdk-go/internal/conformancetest/badge_test.go index bb8f66d..1bbc6ca 100644 --- a/packages/sdk-go/internal/conformancetest/badge_test.go +++ b/packages/sdk-go/internal/conformancetest/badge_test.go @@ -300,7 +300,7 @@ func TestBadgeRunThatWritesNothingEstablishesNoDirectory(t *testing.T) { if err := os.WriteFile(filepath.Join(parent, "badge.svg"), []byte(foreign), 0o644); err != nil { t.Fatal(err) } - before := snapshot(t, dir) + before := snapshotTree(t, dir, dir) if got := mustRun(t, dir, "pub/badge.svg").Refusal; got != "pub/badge.svg exists and is not a leji badge; remove or rename it" { t.Fatalf("refusal %q", got) } @@ -311,7 +311,7 @@ func TestBadgeRunThatWritesNothingEstablishesNoDirectory(t *testing.T) { if string(body) != foreign { t.Fatal("the target is byte-untouched") } - if !equalStrings(snapshot(t, dir), before) { + if !equalStrings(snapshotTree(t, dir, dir), before) { t.Fatal("the tree is untouched") } } @@ -339,7 +339,7 @@ func TestBadgeRefusesAParentResolvingOutsideTheRepository(t *testing.T) { if err := os.Symlink(outside, filepath.Join(dir, "pub")); err != nil { t.Fatal(err) } - before := snapshot(t, dir) + before := snapshotTree(t, dir, dir) r := mustRun(t, dir, "pub/x.svg") if r.UsageError == "" && r.Refusal == "" { @@ -362,7 +362,7 @@ func TestBadgeRefusesAParentResolvingOutsideTheRepository(t *testing.T) { if len(entries) != 1 || entries[0].Name() != "x.svg" { t.Fatal("nothing was created outside the repository") } - if !equalStrings(snapshot(t, dir), before) { + if !equalStrings(snapshotTree(t, dir, dir), before) { t.Fatal("and nothing inside it") } } @@ -433,7 +433,7 @@ func TestBadgeRefusesADanglingTargetInsideTheRepository(t *testing.T) { if err := os.Symlink("missing-file.svg", filepath.Join(dir, badge.DefaultOut)); err != nil { t.Fatal(err) } - before := snapshot(t, dir) + before := snapshotTree(t, dir, dir) r := mustRun(t, dir, badge.DefaultOut) assertTargetRefusal(t, r, badge.DefaultOut) @@ -444,7 +444,7 @@ func TestBadgeRefusesADanglingTargetInsideTheRepository(t *testing.T) { if _, err := os.Lstat(filepath.Join(dir, "missing-file.svg")); err == nil { t.Fatal("the link destination was never created") } - if !equalStrings(snapshot(t, dir), before) { + if !equalStrings(snapshotTree(t, dir, dir), before) { t.Fatal("the tree is untouched") } } @@ -472,14 +472,14 @@ func TestBadgeRefusesAUnixSocketTargetAsADocumentNotACrash(t *testing.T) { if err != nil || info.Mode()&os.ModeSocket == 0 { t.Fatal("the target is a socket") } - before := snapshot(t, dir) + before := snapshotTree(t, dir, dir) assertTargetRefusal(t, mustRun(t, dir, badge.DefaultOut), badge.DefaultOut) info, err = os.Lstat(target) if err != nil || info.Mode()&os.ModeSocket == 0 { t.Fatal("the socket itself is left alone") } - if !equalStrings(snapshot(t, dir), before) { + if !equalStrings(snapshotTree(t, dir, dir), before) { t.Fatal("the tree is untouched") } @@ -503,7 +503,7 @@ func TestBadgeRefusesASymlinkToAUnixSocketAsADocumentToo(t *testing.T) { if err := os.Symlink("sock", target); err != nil { t.Fatal(err) } - before := snapshot(t, dir) + before := snapshotTree(t, dir, dir) assertTargetRefusal(t, mustRun(t, dir, badge.DefaultOut), badge.DefaultOut) info, err := os.Lstat(target) @@ -514,7 +514,7 @@ func TestBadgeRefusesASymlinkToAUnixSocketAsADocumentToo(t *testing.T) { if err != nil || info.Mode()&os.ModeSocket == 0 { t.Fatal("and so is the socket it points at") } - if !equalStrings(snapshot(t, dir), before) { + if !equalStrings(snapshotTree(t, dir, dir), before) { t.Fatal("the tree is untouched") } @@ -836,7 +836,7 @@ func TestFixtureBadgeBlocks(t *testing.T) { } if block.Rerun != nil { - afterFirst := snapshot(t, dir) + afterFirst := snapshotTree(t, dir, dir) code, stdout := runCLI(t, append(append([]string{}, args...), "--root", dir, "--json")) if code != 0 { t.Fatalf("the steady state exits 0, got %d\n%s", code, stdout) @@ -847,7 +847,7 @@ func TestFixtureBadgeBlocks(t *testing.T) { steady.Action = &block.Rerun.Action steady.Preseed = nil assertBadgeDocument(t, stdout, &steady, targetRel, name+" (rerun)") - if block.Rerun.ByteIdentical && !equalStrings(snapshot(t, dir), afterFirst) { + if block.Rerun.ByteIdentical && !equalStrings(snapshotTree(t, dir, dir), afterFirst) { t.Fatal("a second run is a byte-level no-op across the whole working tree") } } diff --git a/packages/sdk-go/internal/conformancetest/canary_test.go b/packages/sdk-go/internal/conformancetest/canary_test.go index ffbe391..53fb4a4 100644 --- a/packages/sdk-go/internal/conformancetest/canary_test.go +++ b/packages/sdk-go/internal/conformancetest/canary_test.go @@ -11,8 +11,6 @@ package conformancetest // `--strict` variants) takes every other fixture. import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" @@ -21,7 +19,6 @@ import ( "os" "path" "path/filepath" - "sort" "strings" "testing" @@ -213,61 +210,6 @@ func cpTree(t *testing.T, src, dst string) { } } -// snapshot is every path under dir as `rel -> content digest` (directories as -// `rel/` -> ""), so a comparison covers appearance and disappearance as well as -// content. -// -// A `.git/` at the ROOT is the harness's own scaffolding and is excluded: no run -// under test can touch it, and git can. Background maintenance on a hosted runner -// rewrites a repository's object store on its own schedule, which reaches a -// comparison like this one two ways — a transient it opens and git removes -// mid-walk (`open .git/objects/maintenance.lock: no such file or directory`), and a -// pack that is simply not the pack the first snapshot saw. Both are the runner's -// git, never the subject, and both were seen on one rc run. Only the root is -// skipped: a `.git` deeper inside a fixture is content that fixture ships, and the -// TS and Python siblings of this helper draw the line in the same place. -func snapshot(t *testing.T, dir string) []string { - t.Helper() - var out []string - var walk func(rel string) - walk = func(rel string) { - abs := dir - if rel != "" { - abs = filepath.Join(dir, filepath.FromSlash(rel)) - } - entries, err := os.ReadDir(abs) - if err != nil { - t.Fatal(err) - } - for _, e := range entries { - if rel == "" && e.Name() == ".git" { - continue - } - childRel := e.Name() - if rel != "" { - childRel = rel + "/" + e.Name() - } - switch { - case e.IsDir(): - out = append(out, childRel+"/\x00") - walk(childRel) - case e.Type().IsRegular(): - body, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(childRel))) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(body) - out = append(out, childRel+"\x00"+hex.EncodeToString(sum[:])) - default: - out = append(out, childRel+"\x00non-regular") - } - } - } - walk("") - sort.Strings(out) - return out -} - // countToken counts recursive occurrences of the token under dir (an absent dir // counts as zero, which is what a run that wrote no tree leaves behind). func countToken(t *testing.T, dir string) (int, []string) { @@ -493,11 +435,11 @@ func TestLayoutFixturesCanaryAndIdempotency(t *testing.T) { // --- idempotency ------------------------------------------------------- if exp.Export.Rerun.ByteIdentical { - afterFirst := snapshot(t, dir) + afterFirst := snapshotTree(t, dir, dir) if _, err := export.BuildViewer(dir, m, "", export.Options{}); err != nil { t.Fatalf("second BuildViewer: %v", err) } - afterSecond := snapshot(t, dir) + afterSecond := snapshotTree(t, dir, dir) if !equalStrings(afterFirst, afterSecond) { t.Fatalf("a second run must be a byte-level no-op across the whole working tree\nfirst=%v\nsecond=%v", diffStrings(afterFirst, afterSecond), diffStrings(afterSecond, afterFirst)) @@ -720,7 +662,7 @@ func TestCheckBeforeActGenerationRefusesViewerAliasedIntoPrivateRole(t *testing. t.Fatal(err) } m := mustLoad(t, dir) - before := snapshot(t, aliased) + before := snapshotTree(t, aliased, dir) gen, err := viewer.GenerateViewer(dir, m) if err != nil { @@ -732,7 +674,7 @@ func TestCheckBeforeActGenerationRefusesViewerAliasedIntoPrivateRole(t *testing. if len(gen.Written) != 0 { t.Fatalf("generation must write nothing, got %v", gen.Written) } - if !equalStrings(before, snapshot(t, aliased)) { + if !equalStrings(before, snapshotTree(t, aliased, dir)) { t.Fatal("the aliased private role must be byte-identical") } @@ -745,7 +687,7 @@ func TestCheckBeforeActGenerationRefusesViewerAliasedIntoPrivateRole(t *testing. if !hasRefusal(built.Findings) { t.Fatalf("the export must inherit the refusal, got %v", built.Findings) } - if !equalStrings(before, snapshot(t, aliased)) { + if !equalStrings(before, snapshotTree(t, aliased, dir)) { t.Fatal("still untouched after BuildViewer") } if _, err := os.Stat(filepath.Join(dir, ".leji", "dist")); err == nil { @@ -769,12 +711,12 @@ func TestCheckBeforeActDefaultOutputRefusesDistIntoPrivateRole(t *testing.T) { t.Fatal(err) } m := mustLoad(t, dir) - before := snapshot(t, filepath.Join(dir, ".leji", "mounts")) + before := snapshotTree(t, filepath.Join(dir, ".leji", "mounts"), dir) _, err := export.BuildViewer(dir, m, "", export.Options{}) if err == nil || !strings.Contains(err.Error(), "reserved for the tool's own roles") { t.Fatalf("the default output must be refused, got %v", err) } - if !equalStrings(before, snapshot(t, filepath.Join(dir, ".leji", "mounts"))) { + if !equalStrings(before, snapshotTree(t, filepath.Join(dir, ".leji", "mounts"), dir)) { t.Fatal("nothing must be cleared or written in the private role") } body, rerr := os.ReadFile(filepath.Join(planted, "planted")) @@ -925,7 +867,7 @@ func TestCheckBeforeActOverviewSeedRefusedIntoPrivateRole(t *testing.T) { t.Fatal(err) } m := mustLoad(t, dir) - before := snapshot(t, roleDir) + before := snapshotTree(t, roleDir, dir) gen, err := viewer.GenerateViewer(dir, m) if err != nil { @@ -950,7 +892,7 @@ func TestCheckBeforeActOverviewSeedRefusedIntoPrivateRole(t *testing.T) { if _, err := os.Stat(filepath.Join(roleDir, "new.md")); err == nil { t.Fatal("nothing must be written through the alias") } - if !equalStrings(before, snapshot(t, roleDir)) { + if !equalStrings(before, snapshotTree(t, roleDir, dir)) { t.Fatalf("the aliased .leji/%s must be byte-identical", role) } } @@ -1092,7 +1034,7 @@ func TestExportRefusesNestedDanglingOutIntoPrivateRole(t *testing.T) { if err := os.Symlink(filepath.Join(".leji", "mounts", "ghost"), filepath.Join(dir, "redirect")); err != nil { t.Fatal(err) } - mountsBefore := snapshot(t, filepath.Join(dir, ".leji", "mounts")) + mountsBefore := snapshotTree(t, filepath.Join(dir, ".leji", "mounts"), dir) _, err := export.BuildViewer(dir, m, "redirect/export", export.Options{}) if err == nil || !strings.Contains(err.Error(), "reserved for the tool's own roles") { t.Fatalf("a nested dangling --out into a private role must be refused, got %v", err) @@ -1100,7 +1042,7 @@ func TestExportRefusesNestedDanglingOutIntoPrivateRole(t *testing.T) { if _, serr := os.Stat(filepath.Join(dir, ".leji", "mounts", "ghost")); serr == nil { t.Fatal("the dangling target must not be created by the build") } - if !equalStrings(mountsBefore, snapshot(t, filepath.Join(dir, ".leji", "mounts"))) { + if !equalStrings(mountsBefore, snapshotTree(t, filepath.Join(dir, ".leji", "mounts"), dir)) { t.Fatal("nothing must be cleared or written in the private role") } @@ -1129,12 +1071,12 @@ func TestExportRefusesChainedDanglingOutIntoPrivateRole(t *testing.T) { if err := os.Symlink(filepath.Join(".leji", "work", "ghost"), filepath.Join(dir, "hop")); err != nil { t.Fatal(err) } - workBefore := snapshot(t, filepath.Join(dir, ".leji", "work")) + workBefore := snapshotTree(t, filepath.Join(dir, ".leji", "work"), dir) _, err := export.BuildViewer(dir, m, "redirect/export", export.Options{}) if err == nil || !strings.Contains(err.Error(), "reserved for the tool's own roles") { t.Fatalf("a chained dangling --out into a private role must be refused, got %v", err) } - if !equalStrings(workBefore, snapshot(t, filepath.Join(dir, ".leji", "work"))) { + if !equalStrings(workBefore, snapshotTree(t, filepath.Join(dir, ".leji", "work"), dir)) { t.Fatal("nothing must be cleared or written in the private role") } } @@ -1184,7 +1126,7 @@ func TestExportRefusesADanglingOutputEntry(t *testing.T) { if err := os.Symlink("elsewhere", filepath.Join(dir, "published")); err != nil { t.Fatal(err) } - before := snapshot(t, dir) + before := snapshotTree(t, dir, dir) if _, err := export.BuildViewer(dir, m, "", export.Options{}); err == nil || !strings.Contains(err.Error(), "it is a dangling symlink") { @@ -1206,7 +1148,7 @@ func TestExportRefusesADanglingOutputEntry(t *testing.T) { t.Fatalf("the link destination %s was created", gone) } } - if !equalStrings(before, snapshot(t, dir)) { + if !equalStrings(before, snapshotTree(t, dir, dir)) { t.Fatal("the tree must be byte-identical") } } @@ -1242,7 +1184,7 @@ func TestCheckBeforeActRefusesCaseVariantAliasThroughNonEnumerableDirectory(t *t t.Fatal(err) } m := mustLoad(t, dir) - before := snapshot(t, aliased) + before := snapshotTree(t, aliased, dir) // Searchable and writable, but unlistable: the repository directory is the one that // holds the canonical spelling of `.leji`. if err := os.Chmod(dir, 0o311); err != nil { @@ -1260,7 +1202,7 @@ func TestCheckBeforeActRefusesCaseVariantAliasThroughNonEnumerableDirectory(t *t if len(gen.Written) != 0 { t.Fatalf("generation must write nothing, got %v", gen.Written) } - if !equalStrings(before, snapshot(t, aliased)) { + if !equalStrings(before, snapshotTree(t, aliased, dir)) { t.Fatal("the aliased private role must be byte-identical") } _ = os.Chmod(dir, 0o755) @@ -1276,7 +1218,7 @@ func TestCheckBeforeActRefusesCaseVariantAliasThroughNonEnumerableDirectory(t *t t.Fatal(err) } om := mustLoad(t, other) - mountsBefore := snapshot(t, filepath.Join(other, ".leji", "mounts")) + mountsBefore := snapshotTree(t, filepath.Join(other, ".leji", "mounts"), other) if err := os.Chmod(other, 0o311); err != nil { t.Fatal(err) } @@ -1286,7 +1228,7 @@ func TestCheckBeforeActRefusesCaseVariantAliasThroughNonEnumerableDirectory(t *t t.Fatalf("the default output must be refused as unresolvable, got %v", berr) } _ = os.Chmod(other, 0o755) - if !equalStrings(mountsBefore, snapshot(t, filepath.Join(other, ".leji", "mounts"))) { + if !equalStrings(mountsBefore, snapshotTree(t, filepath.Join(other, ".leji", "mounts"), other)) { t.Fatal("nothing must be cleared or written in the private role") } } diff --git a/packages/sdk-go/internal/conformancetest/lejiignore_test.go b/packages/sdk-go/internal/conformancetest/lejiignore_test.go new file mode 100644 index 0000000..d29cb76 --- /dev/null +++ b/packages/sdk-go/internal/conformancetest/lejiignore_test.go @@ -0,0 +1,500 @@ +package conformancetest + +// The self-managed `.leji/.gitignore`, driven from the shared fixtures: the tool +// ignores its own tree from inside, so a layer whose root `.gitignore` never received +// the `.leji/` line is clean after its first role-creating command. The fixtures own the +// scenario definitions (`lejiIgnore`), so all three SDKs answer the same six questions +// against the same trees; the unit tests below them pin what a fixture cannot construct +// without injecting a fault. +// +// Mirrors packages/sdk/test/leji-ignore.test.ts. + +import ( + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/cli" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/export" + initcmd "github.com/leji-org/leji/packages/sdk-go/internal/commands/init" + "github.com/leji-org/leji/packages/sdk-go/internal/fsx" + "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" + "github.com/leji-org/leji/packages/sdk-go/internal/mounts" +) + +var ignoreFixtures = []string{ + "valid-leji-ignore-fresh", + "valid-leji-ignore-existing", + "valid-leji-ignore-legacy", +} + +// plant is the one symlink a scenario has the harness create before the run: a fixture +// cannot commit a symlink, and two of the scenarios are about one. +type plant struct { + SymlinkAt string `json:"symlinkAt"` + SymlinkTo string `json:"symlinkTo"` + TargetKind string `json:"targetKind"` + TargetBytes string `json:"targetBytes"` +} + +type ignoreScenario struct { + ID string `json:"id"` + Note string `json:"note"` + Args []string `json:"args"` + Plant *plant `json:"plant"` + Exit int `json:"exit"` + IgnoreFile string `json:"ignoreFile"` + Bytes *string `json:"bytes"` + Notices int `json:"notices"` + UntrackedUnderLeji *[]string `json:"untrackedUnderLeji"` + Preserved []string `json:"preserved"` + JSONParses bool `json:"jsonParses"` +} + +type ignoreExpectation struct { + Seeds []seed `json:"seeds"` + LejiIgnore *struct { + Scenarios []ignoreScenario `json:"scenarios"` + } `json:"lejiIgnore"` +} + +// gitFixture is a pristine working copy of the fixture with every declared seed +// materialized, committed to its own git repository: `git status --porcelain` is one +// half of what these scenarios assert, and it answers nothing useful over an +// uncommitted tree. +func gitFixture(t *testing.T, name string, seeds []seed) string { + t.Helper() + dir := materialize(t, name, seeds) + git := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + git("init", "-q", "-b", "main") + git("config", "user.email", "fixtures@leji.org") + git("config", "user.name", "Leji Fixtures") + git("config", "commit.gpgsign", "false") + git("add", "-A") + git("commit", "-qm", "fixture") + return dir +} + +// plantSymlink creates the declared symlink and whatever it points at. A link out of the +// repository is spelled `outside`: it resolves to a directory the harness makes beside +// the working copy, which is the only shape a fixture cannot commit and cannot express +// as a contained relative path. +func plantSymlink(t *testing.T, dir string, declaration *plant) { + t.Helper() + at := fixtureAbs(dir, fixtureRel(t, declaration.SymlinkAt, "plant.symlinkAt")) + var target string + if declaration.SymlinkTo == "outside" { + target = filepath.Join(t.TempDir(), "outside") + } else { + target = fixtureAbs(dir, fixtureRel(t, declaration.SymlinkTo, "plant.symlinkTo")) + } + if declaration.TargetKind == "dir" { + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(target, []byte(declaration.TargetBytes), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(at), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, at); err != nil { + t.Fatal(err) + } +} + +// captureCLI runs the CLI with stdout and stderr redirected to pipes. The notice is a +// stderr line under every output mode, so counting it is what the `notices` field pins, +// and the `--json` document must be readable from stdout alone. +func captureCLI(t *testing.T, argv []string) (int, string, string) { + t.Helper() + origOut, origErr := os.Stdout, os.Stderr + rOut, wOut, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + rErr, wErr, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout, os.Stderr = wOut, wErr + outDone := make(chan string, 1) + errDone := make(chan string, 1) + go func() { body, _ := io.ReadAll(rOut); outDone <- string(body) }() + go func() { body, _ := io.ReadAll(rErr); errDone <- string(body) }() + code := cli.Run(argv) + _ = wOut.Close() + _ = wErr.Close() + os.Stdout, os.Stderr = origOut, origErr + out, errText := <-outDone, <-errDone + _ = rOut.Close() + _ = rErr.Close() + return code, out, errText +} + +// untrackedUnderLeji is every `git status --porcelain` entry whose path lies under the +// root `.leji/`. +func untrackedUnderLeji(t *testing.T, dir string) []string { + t.Helper() + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = dir + raw, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + out := []string{} + for _, line := range strings.Split(string(raw), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + rel := strings.Trim(line[3:], `"`) + if rel == layout.LejiDir || strings.HasPrefix(rel, layout.LejiDir+"/") { + out = append(out, rel) + } + } + sort.Strings(out) + return out +} + +func TestLejiIgnoreFixtureScenarios(t *testing.T) { + for _, name := range ignoreFixtures { + raw, err := os.ReadFile(filepath.Join(fixturesDir(t), name, "expected.json")) + if err != nil { + t.Fatal(err) + } + var expected ignoreExpectation + if err := json.Unmarshal(raw, &expected); err != nil { + t.Fatal(err) + } + if expected.LejiIgnore == nil { + t.Fatalf("%s declares a lejiIgnore block", name) + } + for _, scenario := range expected.LejiIgnore.Scenarios { + t.Run(name+"/"+scenario.ID, func(t *testing.T) { + dir := gitFixture(t, name, expected.Seeds) + if scenario.Plant != nil { + plantSymlink(t, dir, scenario.Plant) + } + before := map[string][]byte{} + for _, rel := range scenario.Preserved { + abs := fixtureAbs(dir, fixtureRel(t, rel, "preserved entry")) + body, rerr := os.ReadFile(abs) + if rerr != nil { + t.Fatalf("preserved path exists before the run: %s: %v", rel, rerr) + } + before[rel] = body + } + + code, stdout, stderr := captureCLI(t, append(append([]string{}, scenario.Args...), "--root", dir)) + if code != scenario.Exit { + t.Fatalf("exit %d, want %d (stderr: %s)", code, scenario.Exit, stderr) + } + + // The one file, judged on its ORIGINAL entry: a symlink standing there + // was refused, never followed, so lstat is what decides its kind. + ignoreAbs := layout.Abs(dir, layout.LejiIgnoreRel) + entry, lerr := os.Lstat(ignoreAbs) + switch scenario.IgnoreFile { + case "absent": + if lerr == nil { + t.Fatalf("%s must not exist", layout.LejiIgnoreRel) + } + case "symlink": + if lerr != nil || entry.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s is still the planted symlink", layout.LejiIgnoreRel) + } + default: + if lerr != nil || !entry.Mode().IsRegular() { + t.Fatalf("%s is a regular file", layout.LejiIgnoreRel) + } + body, rerr := os.ReadFile(ignoreAbs) + if rerr != nil { + t.Fatal(rerr) + } + if scenario.Bytes == nil || string(body) != *scenario.Bytes { + t.Fatalf("%s bytes = %q", layout.LejiIgnoreRel, string(body)) + } + } + + if notices := strings.Count(stderr, lejiignore.Notice); notices != scenario.Notices { + t.Fatalf("notice count %d, want %d (stderr: %s)", notices, scenario.Notices, stderr) + } + + if scenario.JSONParses { + var document map[string]any + if jerr := json.Unmarshal([]byte(stdout), &document); jerr != nil { + t.Fatalf("--json stdout parses as one document: %v (%s)", jerr, stdout) + } + if strings.Contains(stdout, "was left as is") { + t.Fatal("the notice is stderr only, never inside the JSON document") + } + } + + if scenario.UntrackedUnderLeji != nil { + got := untrackedUnderLeji(t, dir) + want := *scenario.UntrackedUnderLeji + if len(got) != len(want) { + t.Fatalf("git status under %s/ = %v, want %v", layout.LejiDir, got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("git status under %s/ = %v, want %v", layout.LejiDir, got, want) + } + } + } + + for rel, bytes := range before { + body, rerr := os.ReadFile(fixtureAbs(dir, rel)) + if rerr != nil || string(body) != string(bytes) { + t.Fatalf("preserved byte-identical: %s", rel) + } + } + }) + } + } +} + +// --- unit level: what a fixture cannot prepare without injecting a fault ---------- + +// freshIgnoreCopy is the smallest layer these unit tests drive, copied out of the +// fixture family. +func freshIgnoreCopy(t *testing.T) string { + t.Helper() + dir := materialize(t, "valid-leji-ignore-fresh", nil) + if err := os.Remove(filepath.Join(dir, "expected.json")); err != nil { + t.Fatal(err) + } + return dir +} + +func TestLejiIgnoreNoticeIsFrozenAndSaidOncePerContext(t *testing.T) { + if want := "leji: .leji/.gitignore exists and was left as is (expected content: *)"; lejiignore.Notice != want { + t.Fatalf("notice = %q, want %q", lejiignore.Notice, want) + } + if lejiignore.Content != "*\n" { + t.Fatalf("content = %q", lejiignore.Content) + } + + dir := freshIgnoreCopy(t) + if err := os.MkdirAll(layout.Abs(dir, layout.LejiDir), 0o755); err != nil { + t.Fatal(err) + } + ignoreAbs := layout.Abs(dir, layout.LejiIgnoreRel) + if err := os.WriteFile(ignoreAbs, []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + text := captureStderr(t, func() { + ctx := lejiignore.NewContext() + for i := 0; i < 3; i++ { + outcome, err := lejiignore.EnsureFile(dir, ctx) + if err != nil || outcome != lejiignore.LeftAsIs { + t.Fatalf("outcome %q err %v", outcome, err) + } + } + }) + if text != lejiignore.Notice+"\n" { + t.Fatalf("one notice per invocation context: %q", text) + } + body, err := os.ReadFile(ignoreAbs) + if err != nil || string(body) != "mine\n" { + t.Fatalf("bytes untouched: %q", string(body)) + } +} + +func TestLejiIgnoreContextIsPerInvocationNeverPerProcess(t *testing.T) { + first, second := freshIgnoreCopy(t), freshIgnoreCopy(t) + for _, dir := range []string{first, second} { + if err := os.MkdirAll(layout.Abs(dir, layout.LejiDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.Abs(dir, layout.LejiIgnoreRel), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + } + text := captureStderr(t, func() { + for _, dir := range []string{first, second} { + if _, err := lejiignore.EnsureFile(dir, lejiignore.NewContext()); err != nil { + t.Fatal(err) + } + } + }) + if got := strings.Count(text, lejiignore.Notice); got != 2 { + t.Fatalf("each invocation says it for itself: %d notices in %q", got, text) + } +} + +func TestLejiIgnoreNeverWritesThroughAnEntryStandingAtTheTarget(t *testing.T) { + // The check/use gap at the one file this exception allows, from both sides. The READ + // side first: a symlink into ordinary content standing at the target is refused, so + // the helper writes nothing and `decoy.txt` is untouched. Then the WRITE side, which + // is what closes the window a Go test cannot open by interception: the very guarded + // create the helper makes is asked to run against that same standing entry, and + // O_EXCL is what makes it report the entry rather than follow it. Mutation that + // reddens the second half: drop Exclusive from the guarded write in EnsureFile. + dir := freshIgnoreCopy(t) + if err := os.MkdirAll(layout.Abs(dir, layout.LejiDir), 0o755); err != nil { + t.Fatal(err) + } + decoy := filepath.Join(dir, "decoy.txt") + if err := os.WriteFile(decoy, []byte("not the ignore file\n"), 0o644); err != nil { + t.Fatal(err) + } + ignoreAbs := layout.Abs(dir, layout.LejiIgnoreRel) + if err := os.Symlink(decoy, ignoreAbs); err != nil { + t.Fatal(err) + } + outcome, err := lejiignore.EnsureFile(dir, nil) + if err != nil { + t.Fatal(err) + } + if outcome == lejiignore.Created { + t.Fatal("nothing was created through the planted link") + } + body, rerr := os.ReadFile(decoy) + if rerr != nil || string(body) != "not the ignore file\n" { + t.Fatalf("the link target is untouched: %q", string(body)) + } + entry, lerr := os.Lstat(ignoreAbs) + if lerr != nil || entry.Mode()&os.ModeSymlink == 0 { + t.Fatal("the planted link is still the planted link") + } + + // The write side, on the same standing entry: the exclusive create reports it + // instead of following it into `decoy.txt`. + verdict, werr := fsx.WriteFileGuarded(fsx.GuardRoot(dir), ignoreAbs, "", + []byte(lejiignore.Content), fsx.WriteOptions{Exclusive: true}) + if werr != nil { + t.Fatal(werr) + } + if !verdict.Exists { + t.Fatalf("the exclusive create must report the standing entry, got %+v", verdict) + } + body, rerr = os.ReadFile(decoy) + if rerr != nil || string(body) != "not the ignore file\n" { + t.Fatalf("the link target is untouched by the create: %q", string(body)) + } +} + +func TestLejiIgnoreGenerationAloneEstablishesARole(t *testing.T) { + // The fixture scenarios drive `viewer build` and `export`, which are one command; + // this is the other role establisher on the viewer side, reached by its own name. + dir := freshIgnoreCopy(t) + code, _, stderr := captureCLI(t, []string{"viewer", "--root", dir}) + if code != 0 { + t.Fatalf("viewer exited %d (stderr: %s)", code, stderr) + } + body, err := os.ReadFile(layout.Abs(dir, layout.LejiIgnoreRel)) + if err != nil || string(body) != lejiignore.Content { + t.Fatalf("ignore file = %q (%v)", string(body), err) + } +} + +func TestLejiIgnoreOnboardingGuardEstablishesTheWorkRole(t *testing.T) { + dir := freshIgnoreCopy(t) + action, err := initcmd.EnsureApprovalGuard(dir, "docs/") + if err != nil || action != "installed" { + t.Fatalf("guard action %q err %v", action, err) + } + body, rerr := os.ReadFile(layout.Abs(dir, layout.LejiIgnoreRel)) + if rerr != nil || string(body) != lejiignore.Content { + t.Fatalf("ignore file = %q (%v)", string(body), rerr) + } +} + +func TestLejiIgnoreDirectSDKCallNoticesAtMostOncePerCall(t *testing.T) { + dir := freshIgnoreCopy(t) + if err := os.MkdirAll(layout.Abs(dir, layout.LejiDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.Abs(dir, layout.LejiIgnoreRel), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + load := manifest.LoadManifest(dir) + if load.Manifest == nil { + t.Fatal("the fixture manifest loads") + } + // BuildViewer nests GenerateViewer and establishes two roles of its own. + text := captureStderr(t, func() { + if _, err := export.BuildViewer(dir, load.Manifest, "", export.Options{}); err != nil { + t.Fatal(err) + } + }) + if got := strings.Count(text, lejiignore.Notice); got != 1 { + t.Fatalf("one notice for the whole call: %d in %q", got, text) + } +} + +func TestLejiIgnoreMountsEstablishmentThreadsOneContext(t *testing.T) { + // `conformance --federation verify` probes reachability PER DECLARED MOUNT and + // `mounts update-pin --fetch` retains twice (the current pin, then the target); each + // establishes the managed store through the same helper, so each would say the frozen + // line again if the invocation's notice state were not threaded all the way down. + // + // The declared source is routed to a local empty repository the way the mounts suite + // routes its own (`insteadOf` is git's own redirection), so the retention establishes + // the store and then fails locally: nothing here reaches the network. + dir := freshIgnoreCopy(t) + if err := os.MkdirAll(layout.Abs(dir, layout.LejiDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.Abs(dir, layout.LejiIgnoreRel), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + routed := filepath.Join(t.TempDir(), "routed.git") + if out, err := exec.Command("git", "init", "--bare", "-q", routed).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v: %s", err, out) + } + const source = "https://github.com/acme/one" + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "url."+routed+".insteadOf") + t.Setenv("GIT_CONFIG_VALUE_0", source) + + decl := mounts.MountDecl{Name: "one", Source: source, Pin: strings.Repeat("0", 40), TrackingRef: "refs/heads/main"} + ctx := lejiignore.NewContext() + text := captureStderr(t, func() { + for _, oid := range []string{strings.Repeat("0", 40), strings.Repeat("1", 40)} { + if _, _, err := mounts.RetainPinInStore(dir, decl, "acme/one", oid, ctx); err != nil { + t.Fatal(err) + } + } + }) + // The guard against a test that passes for the wrong reason: the managed store really + // was established, so a notice was genuinely available to be said each time. + entries, err := os.ReadDir(filepath.Join(layout.Abs(dir, layout.MountsRel), "store")) + if err != nil || len(entries) == 0 { + t.Fatalf("the managed store was established: %v", err) + } + if got := strings.Count(text, lejiignore.Notice); got != 1 { + t.Fatalf("one notice for the invocation: %d in %q", got, text) + } + body, rerr := os.ReadFile(layout.Abs(dir, layout.LejiIgnoreRel)) + if rerr != nil || string(body) != "mine\n" { + t.Fatalf("bytes untouched: %q", string(body)) + } +} + +func TestLejiIgnoreIsNeverCreatedByAReadOnlyCommand(t *testing.T) { + // Nothing read-only creates the file: the bootstrap requirement is that it appears at + // the first ROLE creation, and `validate` creates none. + dir := freshIgnoreCopy(t) + code, _, stderr := captureCLI(t, []string{"validate", "--root", dir}) + if code != 0 { + t.Fatalf("validate exited %d (stderr: %s)", code, stderr) + } + if _, err := os.Lstat(layout.Abs(dir, layout.LejiDir)); err == nil { + t.Fatalf("%s/ must not exist after a read-only command", layout.LejiDir) + } +} diff --git a/packages/sdk-go/internal/conformancetest/snapshot_audit_test.go b/packages/sdk-go/internal/conformancetest/snapshot_audit_test.go new file mode 100644 index 0000000..3e53c1f --- /dev/null +++ b/packages/sdk-go/internal/conformancetest/snapshot_audit_test.go @@ -0,0 +1,243 @@ +package conformancetest + +// The structural half of the snapshot contract: the badge and canary suites hold one +// tree-snapshot helper between them, and a private walker must not be able to grow back +// beside it. A walker needs a directory-enumeration primitive, so this audit counts +// every reference to one in those two files and compares the counts against the named +// exceptions below. The claim is bounded and mechanical: it prevents a walker built on +// the primitives below, whatever it is named and whether it is a function or a closure. +// It claims nothing about a walker built on anything else; that wider closure is review +// of the call sites, not this scan. +// +// Mirrors packages/sdk/test/snapshot-audit.test.ts, whose shape this keeps: names, an +// exception table carrying a COUNT and a reason per context, and a scan over the parsed +// source, so a primitive named in a comment or in ordinary prose is not a hit (the +// parse takes no comments), and a name reached through a string is (the name has to be +// spelled somewhere for the primitive to be reached, as an identifier or as a string). + +import ( + "fmt" + "go/ast" + "go/parser" + gotoken "go/token" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// The names this audit is bounded to: Go's directory-enumeration calls, in every +// spelling: `os.ReadDir`, `filepath.Walk`, `filepath.WalkDir`, `ioutil.ReadDir`, and +// the `os.File` methods `ReadDir`, `Readdir` and `Readdirnames`. A walker built on any +// of them has to spell one of these selectors, whatever it calls itself. A walker built +// on something else (`os/fs.Glob`, a dependency, a shelled-out `find`) spells none of +// them and is outside the mechanical guarantee. +var snapshotAuditPrimitives = map[string]bool{ + "ReadDir": true, + "Walk": true, + "WalkDir": true, + "Readdir": true, + "Readdirnames": true, +} + +// The shared helper, as the two files must call it (Go has no import to check: the +// helper is package-level in snapshot_test.go, so the call is the whole evidence). +const snapshotAuditHelper = "snapshotTree" + +var snapshotAuditFiles = []string{"badge_test.go", "canary_test.go"} + +type snapshotAuditException struct { + count int + reason string +} + +// The exceptions, by `file#context` with the number of references each context is +// allowed. A count rather than a bare name, so a new reference fails even inside a +// context that already holds one; a context that no longer matches fails too, because a +// stale exception is an exception nobody is checking. `context` is the nearest named +// function, or the name the enclosing closure is bound to. +var snapshotAuditAllowed = map[string]snapshotAuditException{ + "badge_test.go#TestBadgeRefusesAParentResolvingOutsideTheRepository": { + count: 1, + reason: "lists an out-of-repository directory to prove nothing was created there; one level, no walk", + }, + "badge_test.go#TestBadgeRefusesAParentResolvingIntoLejiAtAnyDepth": { + count: 1, + reason: "asserts the private role is still empty; one level, no walk", + }, + "canary_test.go#copySeed": { + count: 1, + reason: "the seed materializer: copies a committed seed into its declared target", + }, + "canary_test.go#cpTree": { + count: 1, + reason: "the fixture copier: a working copy of a committed fixture, files and directories only", + }, + "canary_test.go#walk": { + count: 1, + reason: "the canary token scan inside countToken: reads bytes, records no tree", + }, + "canary_test.go#TestCheckBeforeActOutOfRepositoryViewerOrDistAliasIsRefused": { + count: 2, + reason: "asserts two out-of-tree destinations are empty; one level each, no walk", + }, +} + +func snapshotAuditParse(t *testing.T, name string) (*gotoken.FileSet, *ast.File) { + t.Helper() + fset := gotoken.NewFileSet() + // Mode 0: comments are not attached, so a primitive named in prose is not a hit. + file, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0) + if err != nil { + t.Fatal(err) + } + return fset, file +} + +// snapshotAuditContext is the nearest named function containing the node, or the name +// the enclosing closure is bound to, or the subtest it sits in: what a reviewer would +// cite when arguing the exception. `stack` is the node's ancestors, outermost first. +func snapshotAuditContext(stack []ast.Node) string { + for i := len(stack) - 1; i >= 0; i-- { + switch n := stack[i].(type) { + case *ast.FuncDecl: + return n.Name.Name + case *ast.FuncLit: + if i == 0 { + continue + } + switch parent := stack[i-1].(type) { + case *ast.AssignStmt: + for j, rhs := range parent.Rhs { + if lit, ok := rhs.(*ast.FuncLit); ok && lit == n && j < len(parent.Lhs) { + if id, ok := parent.Lhs[j].(*ast.Ident); ok { + return id.Name + } + } + } + case *ast.ValueSpec: + for j, value := range parent.Values { + if lit, ok := value.(*ast.FuncLit); ok && lit == n && j < len(parent.Names) { + return parent.Names[j].Name + } + } + } + case *ast.CallExpr: + // A subtest, named the way `t.Run` names it. + if sel, ok := n.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Run" && len(n.Args) > 0 { + if lit, ok := n.Args[0].(*ast.BasicLit); ok && lit.Kind == gotoken.STRING { + if title, err := strconv.Unquote(lit.Value); err == nil { + return "t.Run: " + title + } + } + } + } + } + return "(top level)" +} + +// snapshotAuditSpelled is how a node spells a name: an identifier (which is also how a +// selector's `.ReadDir` half arrives) or a string literal. +func snapshotAuditSpelled(n ast.Node) (string, bool) { + switch node := n.(type) { + case *ast.Ident: + return node.Name, true + case *ast.BasicLit: + if node.Kind != gotoken.STRING { + return "", false + } + value, err := strconv.Unquote(node.Value) + if err != nil { + return "", false + } + return value, true + } + return "", false +} + +// snapshotAuditReferences is every reference to an enumeration primitive in one file, as +// `file#context` keys with their counts, plus the line of each for the failure message. +func snapshotAuditReferences(t *testing.T, name string) (map[string]int, []string) { + t.Helper() + fset, file := snapshotAuditParse(t, name) + counts := map[string]int{} + var where []string + var stack []ast.Node + ast.Inspect(file, func(n ast.Node) bool { + if n == nil { + stack = stack[:len(stack)-1] + return false + } + if spelled, ok := snapshotAuditSpelled(n); ok && snapshotAuditPrimitives[spelled] { + key := name + "#" + snapshotAuditContext(stack) + counts[key]++ + where = append(where, fmt.Sprintf("%s:%d %s", name, fset.Position(n.Pos()).Line, key)) + } + stack = append(stack, n) + return true + }) + return counts, where +} + +func TestNoPrivateTreeWalkerInTheBadgeAndCanarySuites(t *testing.T) { + counts := map[string]int{} + var where []string + for _, name := range snapshotAuditFiles { + found, foundWhere := snapshotAuditReferences(t, name) + for key, count := range found { + counts[key] = count + } + where = append(where, foundWhere...) + } + + var keys []string + for key := range counts { + keys = append(keys, key) + } + for key := range snapshotAuditAllowed { + if _, seen := counts[key]; !seen { + keys = append(keys, key) + } + } + sort.Strings(keys) + + var problems []string + for _, key := range keys { + allowed := snapshotAuditAllowed[key] + if counts[key] == allowed.count { + continue + } + reason := allowed.reason + if reason == "" { + reason = "no exception names this context" + } + problems = append(problems, fmt.Sprintf(" %s: found %d, %d allowed (%s)", key, counts[key], allowed.count, reason)) + } + if len(problems) > 0 { + sort.Strings(where) + t.Fatalf("a directory-enumeration primitive appeared where no exception allows it, "+ + "or an exception no longer matches:\n%s\nEvery reference found:\n%s", + strings.Join(problems, "\n"), strings.Join(where, "\n")) + } +} + +func TestBadgeAndCanarySuitesTakeTheirSnapshotsFromTheSharedHelper(t *testing.T) { + for _, name := range snapshotAuditFiles { + _, file := snapshotAuditParse(t, name) + calls := 0 + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if id, ok := call.Fun.(*ast.Ident); ok && id.Name == snapshotAuditHelper { + calls++ + } + return true + }) + if calls == 0 { + t.Fatalf("%s must call %s", name, snapshotAuditHelper) + } + } +} diff --git a/packages/sdk-go/internal/conformancetest/snapshot_contract_test.go b/packages/sdk-go/internal/conformancetest/snapshot_contract_test.go new file mode 100644 index 0000000..561c607 --- /dev/null +++ b/packages/sdk-go/internal/conformancetest/snapshot_contract_test.go @@ -0,0 +1,230 @@ +package conformancetest + +// The shared fixture is the byte contract for the snapshot helper, and these goldens +// are the frozen bytes the Node reference (packages/sdk/test/snapshot-contract.test.ts) +// and the Python port assert against too. The walked payload is `payload/`; the seeds +// and the goldens live beside it, outside the walk, so a golden never has to contain +// its own digest. + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const snapshotFixture = "snapshot-contract" + +type snapshotSymlink struct { + At string `json:"at"` + To string `json:"to"` +} + +type snapshotGolden struct { + File string `json:"file"` + Walk string `json:"walk"` + RepoRoot string `json:"repoRoot"` +} + +// snapshotDeclaration is the fixture's `leji-test.json`: what a walk must find that git +// cannot carry (the two `.git` seeds, an empty directory, a symlink) and which golden +// each walk is compared to. +type snapshotDeclaration struct { + Seeds []seed `json:"seeds"` + Runtime struct { + Directories []string `json:"directories"` + Symlinks []snapshotSymlink `json:"symlinks"` + } `json:"runtime"` + Goldens map[string]snapshotGolden `json:"goldens"` +} + +func snapshotDecl(t *testing.T) snapshotDeclaration { + t.Helper() + b, err := os.ReadFile(filepath.Join(fixturesDir(t), snapshotFixture, "leji-test.json")) + if err != nil { + t.Fatal(err) + } + var decl snapshotDeclaration + if err := json.Unmarshal(b, &decl); err != nil { + t.Fatal(err) + } + if len(decl.Seeds) == 0 || len(decl.Goldens) == 0 { + t.Fatal("the fixture declaration must carry its seeds and its goldens") + } + return decl +} + +// snapshotWorkingCopy is a working copy of the fixture with everything the declaration +// says a walk must find: the declared seeds materialized as real `.git` directories by +// the harness's own seed materializer, then the entries git cannot track (an empty +// directory, a symlink) created here. +// +// Windows: creating a symlink needs SeCreateSymbolicLinkPrivilege (Developer Mode or an +// elevated shell), which an ordinary account does not hold, and `payload/link` is one of +// the entries the contract is about, and a walk without it is not this contract. The Go +// suite runs on ubuntu-latest in CI, so nothing is lost there; a Windows developer gets +// a documented skip rather than a failure about a privilege. Line endings are not a +// second Windows hazard: `.gitattributes` disables conversion for every path, so the +// committed bytes are the checked-out bytes and the digests hold. +func snapshotWorkingCopy(t *testing.T, decl snapshotDeclaration) string { + t.Helper() + dir := materialize(t, snapshotFixture, decl.Seeds) + for _, rel := range decl.Runtime.Directories { + if err := os.MkdirAll(fixtureAbs(dir, fixtureRel(t, rel, "runtime.directories entry")), 0o755); err != nil { + t.Fatal(err) + } + } + for _, link := range decl.Runtime.Symlinks { + at := fixtureAbs(dir, fixtureRel(t, link.At, "runtime.symlinks[].at")) + if err := os.Symlink(filepath.FromSlash(link.To), at); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("this platform refuses symlink creation without privilege, and %s is part of the contract: %v", link.At, err) + } + t.Fatal(err) + } + } + return dir +} + +// snapshotGoldenLines is the frozen bytes of one golden, as the lines a walk must +// produce. +func snapshotGoldenLines(t *testing.T, decl snapshotDeclaration, name string) []string { + t.Helper() + g, ok := decl.Goldens[name] + if !ok { + t.Fatalf("the declaration must name the %q golden", name) + } + b, err := os.ReadFile(filepath.Join(fixturesDir(t), snapshotFixture, g.File)) + if err != nil { + t.Fatal(err) + } + text := string(b) + if !strings.HasSuffix(text, "\n") { + t.Fatalf("%s: a golden ends with a newline", name) + } + return strings.Split(strings.TrimSuffix(text, "\n"), "\n") +} + +// snapshotWalk is the walked directory and repository root one golden declares, as +// absolute paths inside a working copy. +func snapshotWalk(t *testing.T, decl snapshotDeclaration, dir, name string) (string, string) { + t.Helper() + g := decl.Goldens[name] + return fixtureAbs(dir, fixtureRel(t, g.Walk, "goldens[].walk")), + fixtureAbs(dir, fixtureRel(t, g.RepoRoot, "goldens[].repoRoot")) +} + +func TestSnapshotContractWholeRepositoryWalk(t *testing.T) { + decl := snapshotDecl(t) + dir := snapshotWorkingCopy(t, decl) + walk, repoRoot := snapshotWalk(t, decl, dir, "repo") + lines := snapshotTree(t, walk, repoRoot) + + if want := snapshotGoldenLines(t, decl, "repo"); !equalStrings(lines, want) { + t.Fatalf("the walk must be the frozen golden, line for line:\n got=%v\nwant=%v", lines, want) + } + + // What the golden says, said again as claims, so a re-baked golden that lost one of + // them fails here rather than passing quietly. + for _, line := range lines { + if line == ".git/\tdir" || strings.HasPrefix(line, ".git/") { + t.Fatalf("the repository .git must be absent from the snapshot: %s", line) + } + } + if !containsLine(lines, "pkg/.git/\tdir") { + t.Fatal("the nested .git must be an entry of its own") + } + if !hasPrefixLine(lines, "pkg/.git/HEAD\tsha256:") { + t.Fatal("and its contents must be digested like any other file") + } + if !containsLine(lines, "empty/\tdir") { + t.Fatal("an empty directory must be recorded, so its creation is detectable") + } + if !containsLine(lines, "link\tnon-regular") { + t.Fatal("a symlink must be marked, never followed") + } + + // The ordering is bytewise over UTF-8. Go's own string comparison already is, so + // this vector is not the trap here that it is in the Node reference (where a default + // sort orders UTF-16 code units and puts these two the other way). It is the proof + // that this port's order IS the goldens' order: `z` (EF BD 9A) before `😀` + // (F0 9F 98 80). + wide := indexOfPrefix(lines, "z.txt\t") + grin := indexOfPrefix(lines, "😀.txt\t") + if wide < 0 || grin < 0 { + t.Fatal("both non-ASCII entries must be recorded") + } + if wide >= grin { + t.Fatal("the wide latin z must precede the emoji, which is UTF-8 byte order") + } + for i := 1; i < len(lines); i++ { + if lines[i-1] > lines[i] { + t.Fatalf("the lines must be sorted bytewise: %q before %q", lines[i-1], lines[i]) + } + } +} + +func TestSnapshotContractRepoRootDefaultsToTheWalkedDirectory(t *testing.T) { + decl := snapshotDecl(t) + dir := snapshotWorkingCopy(t, decl) + walk, _ := snapshotWalk(t, decl, dir, "repo") + if want := snapshotGoldenLines(t, decl, "repo"); !equalStrings(snapshotTree(t, walk, ""), want) { + t.Fatal("an unstated repoRoot is the whole-repository call, the same walk") + } +} + +func TestSnapshotContractSubtreeWalk(t *testing.T) { + decl := snapshotDecl(t) + dir := snapshotWorkingCopy(t, decl) + walk, repoRoot := snapshotWalk(t, decl, dir, "subtree") + golden := snapshotGoldenLines(t, decl, "subtree") + + // Root means the repository, not the call: `pkg/.git` is content, and the paths are + // relative to the walked directory. + if got := snapshotTree(t, walk, repoRoot); !equalStrings(got, golden) { + t.Fatalf("the frozen subtree golden:\n got=%v\nwant=%v", got, golden) + } + + // The same walk claiming the subtree as the repository excludes exactly the .git + // lines, and nothing else moves. + var own []string + for _, line := range golden { + if line == ".git/\tdir" || strings.HasPrefix(line, ".git/") { + continue + } + own = append(own, line) + } + if len(own) == len(golden) { + t.Fatal("the subtree golden must carry the .git lines this case removes") + } + if len(own) == 0 { + t.Fatal("and the walk must still record the rest of the subtree") + } + if got := snapshotTree(t, walk, walk); !equalStrings(got, own) { + t.Fatalf("its own .git is the one entry excluded:\n got=%v\nwant=%v", got, own) + } +} + +func containsLine(lines []string, want string) bool { + for _, line := range lines { + if line == want { + return true + } + } + return false +} + +func hasPrefixLine(lines []string, prefix string) bool { + return indexOfPrefix(lines, prefix) >= 0 +} + +func indexOfPrefix(lines []string, prefix string) int { + for i, line := range lines { + if strings.HasPrefix(line, prefix) { + return i + } + } + return -1 +} diff --git a/packages/sdk-go/internal/conformancetest/snapshot_test.go b/packages/sdk-go/internal/conformancetest/snapshot_test.go new file mode 100644 index 0000000..34f3b21 --- /dev/null +++ b/packages/sdk-go/internal/conformancetest/snapshot_test.go @@ -0,0 +1,106 @@ +package conformancetest + +// The one tree-snapshot contract the badge and canary suites share. Both ask the same +// question of a tree (is it byte-identical to what it was?) and both used to answer it +// with their own private walker, so a fix to one reached the other only by hand. The +// contract lives here, is pinned by `fixtures/snapshot-contract/` (asserted in +// snapshot_contract_test.go), and is the same contract the Node and Python suites hold: +// packages/sdk/test/helpers/snapshot.ts is the reference, and the goldens are frozen +// bytes all three walk to. + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sort" + "testing" +) + +// snapshotTree is every entry under dir as one line, so a comparison covers +// appearance, disappearance, content and entry kind: +// +// - regular file: `pathsha256:` +// - directory: `path/dir`, an entry of its own, so a created empty directory shows +// - symlink or any other non-regular entry: `pathnon-regular`, never followed +// +// Paths are POSIX and relative to dir itself, and the lines are sorted bytewise, +// which is what sort.Strings on Go strings already is, and what the goldens carry. +// +// Exactly one entry is excluded: `/.git`, when it lies inside dir. That one +// is the harness's own scaffolding, and git's background maintenance rewrites it under +// a running test: on a hosted runner that reached a comparison like this one two ways, +// a transient the walk opens and git removes mid-walk +// (`open .git/objects/maintenance.lock: no such file or directory`) and a pack that is +// simply not the pack the first snapshot saw. Both are the runner's git, never the +// subject, and both were seen on one rc run. Every other `.git` (a nested package, a +// mount, a work directory) is content and is walked like anything else. +// +// repoRoot means the repository, not the call. An empty repoRoot defaults to dir, the +// whole-repository call; a subtree call passes the repository root explicitly, so +// snapshotTree(t, pkg, repo) records `pkg/.git` as the content it is. +func snapshotTree(t *testing.T, dir, repoRoot string) []string { + t.Helper() + root, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + if repoRoot == "" { + repoRoot = dir + } + rootOfRepo, err := filepath.Abs(repoRoot) + if err != nil { + t.Fatal(err) + } + excluded := filepath.Join(rootOfRepo, ".git") + + var out []string + var walk func(rel string) + walk = func(rel string) { + abs := root + if rel != "" { + abs = filepath.Join(root, filepath.FromSlash(rel)) + } + entries, err := os.ReadDir(abs) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + childAbs := filepath.Join(abs, e.Name()) + if childAbs == excluded { + continue + } + childRel := e.Name() + if rel != "" { + childRel = rel + "/" + e.Name() + } + switch { + case e.IsDir(): + out = append(out, childRel+"/\tdir") + walk(childRel) + case e.Type().IsRegular(): + body, err := os.ReadFile(childAbs) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(body) + out = append(out, childRel+"\tsha256:"+hex.EncodeToString(sum[:])) + default: + out = append(out, childRel+"\tnon-regular") + } + } + } + walk("") + sort.Strings(out) + return out +} + +// snapshot is the whole-repository call under its historical name, kept for the render +// suite (render_test.go), whose Node counterparts (export.test.ts, renderlint.test.ts) +// keep private walkers of their own: the shared-helper rule covers the badge and canary +// suites, and widening it here would be a change nobody planned. It is the shared helper, +// not a second implementation: `snapshotTree(t, dir, dir)` and nothing else. +func snapshot(t *testing.T, dir string) []string { + t.Helper() + return snapshotTree(t, dir, dir) +} diff --git a/packages/sdk-go/internal/findings/findings.go b/packages/sdk-go/internal/findings/findings.go index 2d71d1e..abc74d1 100644 --- a/packages/sdk-go/internal/findings/findings.go +++ b/packages/sdk-go/internal/findings/findings.go @@ -25,6 +25,11 @@ type Finding struct { // outside the contract. Empty when the rule names none, and then omitted. Construct string Message string + // Detail is which act a rule with more than one failed at, and the resolver's + // own reason for it: `": "`. Serialized immediately after + // `message`, so the three SDKs emit the same bytes; empty for every rule that + // names no act, and then omitted. + Detail string // HasPath distinguishes "no path" from "empty-string path" so the emitted // JSON can omit the field, matching Node/Python. HasPath bool @@ -34,6 +39,14 @@ func New(rule string, severity Severity, message, path string) Finding { return Finding{Rule: rule, Severity: severity, Message: message, Path: path, HasPath: true} } +// NewWithDetail is New plus the act the rule failed at. An empty detail is the +// rule that names no act, and emits exactly what New would. +func NewWithDetail(rule string, severity Severity, message, path, detail string) Finding { + f := New(rule, severity, message, path) + f.Detail = detail + return f +} + func NewNoPath(rule string, severity Severity, message string) Finding { return Finding{Rule: rule, Severity: severity, Message: message} } diff --git a/packages/sdk-go/internal/fsx/fsx.go b/packages/sdk-go/internal/fsx/fsx.go index 476354c..1093cdf 100644 --- a/packages/sdk-go/internal/fsx/fsx.go +++ b/packages/sdk-go/internal/fsx/fsx.go @@ -305,14 +305,58 @@ func resolveLink(abs string) string { return filepath.Join(filepath.Dir(abs), target) } -// judgeTarget is one judged target: the verdict layout.WritableTarget returned for -// the resolved path, and that resolved path. resolved is "" (and ok false) only when -// the path could not be resolved at all. +// metadataFileVerdict is the ONE declared exception to the role rule, and the only +// place a MetadataFile verdict is constructed: `/.leji/.gitignore`, the ignore +// file the tool keeps for its own tree. It belongs to no role, so +// layout.WritableTarget refuses it and cannot be the judge here: the rule it needs +// is about the REQUESTED entry, which WritableTarget never sees. +// +// ok false means "not this path": every other target falls through to the rule +// unchanged. Otherwise the verdict is allowed on all three conditions, checked on the +// ORIGINAL directory entries so a link is caught rather than followed: +// +// 1. the requested path is exactly `/.leji/.gitignore`, and it resolves to +// itself (a `.LEJI/` spelling on a case-insensitive filesystem resolves to the +// name the filesystem holds and is not this path); +// 2. `/.leji` is a real directory, never a symlink; +// 3. the entry is absent or a regular file, never a symlink or anything else. +// +// When a condition fails the exception REFUSES rather than falling back to an +// allowance: today's verdict stands when it already refuses (a `.leji` symlinked out +// of the repository is OutsideRoot, exactly as it is now), and a redirect that +// happens to land on ordinary content is refused as the requested path's own role, +// never written through. The exception can only narrow, never widen. +func metadataFileVerdict(rootAbs, targetAbs, resolved, ownRoleRel string) (layout.TargetVerdict, bool) { + expected := layout.Abs(rootAbs, layout.LejiIgnoreRel) + requested, err := filepath.Abs(targetAbs) + if err != nil || requested != expected { + return layout.TargetVerdict{}, false + } + dir, derr := os.Lstat(layout.Abs(rootAbs, layout.LejiDir)) + entry, eerr := os.Lstat(expected) + entryAllowed := (eerr != nil && os.IsNotExist(eerr)) || (eerr == nil && entry.Mode().IsRegular()) + if resolved == expected && derr == nil && dir.IsDir() && entryAllowed { + return layout.TargetVerdict{OK: true, MetadataFile: true}, true + } + verdict := layout.WritableTarget(rootAbs, resolved, ownRoleRel) + if verdict.OK { + return layout.TargetVerdict{Role: layout.LejiRole(rootAbs, expected)}, true + } + return verdict, true +} + +// judgeTarget is one judged target: the verdict the rule returned for the resolved +// path, which is layout.WritableTarget's except at the one declared exception above, +// and that resolved path. resolved is "" (and ok false) only when the path could not +// be resolved at all. func judgeTarget(rootAbs, targetAbs, ownRoleRel string) (verdict layout.TargetVerdict, resolved string, ok bool) { real, ok := ResolvedPathUnder(rootAbs, targetAbs) if !ok { return layout.TargetVerdict{Unresolvable: true}, "", false } + if exception, isException := metadataFileVerdict(rootAbs, targetAbs, real, ownRoleRel); isException { + return exception, real, true + } return layout.WritableTarget(rootAbs, real, ownRoleRel), real, true } @@ -701,7 +745,13 @@ func VerifiedTargetRead(rootAbs, targetAbs, ownRoleRel string) (TargetRead, erro } var refusal RefusalReason src, err := openVerifiedSourceUnder(rootAbs, targetAbs, func(resolved string) bool { - verdict := layout.WritableTarget(rootAbs, resolved, ownRoleRel) + // The same rule the write will be judged by, the declared exception included: + // the read-then-act pair must agree, or the one target that belongs to no role + // could be read here and refused at the write (or the reverse). + verdict, isException := metadataFileVerdict(rootAbs, targetAbs, resolved, ownRoleRel) + if !isException { + verdict = layout.WritableTarget(rootAbs, resolved, ownRoleRel) + } if verdict.OK { return true } diff --git a/packages/sdk-go/internal/layout/layout.go b/packages/sdk-go/internal/layout/layout.go index 1ccd460..b6a390e 100644 --- a/packages/sdk-go/internal/layout/layout.go +++ b/packages/sdk-go/internal/layout/layout.go @@ -35,6 +35,14 @@ const WorkRel = LejiDir + "/work" // cache, staging. const MountsRel = LejiDir + "/mounts" +// LejiIgnoreRel is the one metadata file the tool keeps directly under root +// `.leji/`, outside every role: the ignore file that keeps the tool's own tree out +// of the repository even when the root `.gitignore` never received the `.leji/` +// line. It belongs to no role, so the role rule below refuses it; the single named +// exception that allows it lives in internal/fsx, where the REQUESTED entry is +// still visible. +const LejiIgnoreRel = LejiDir + "/.gitignore" + // Abs joins a repository-root-relative role path (POSIX, as the constants above // spell it) onto an absolute root, in the host's own separator. func Abs(rootAbs, rel string) string { @@ -81,12 +89,17 @@ func LejiRole(rootAbs, abs string) string { // repository, the private role it crossed into, that the path could not be resolved // at all (permission/I/O, not mere absence), or that an exclusive create found the // file already there. +// +// MetadataFile marks the one allowed target that belongs to no role, LejiIgnoreRel. +// It is never produced here: only the named exception in internal/fsx constructs it, +// on the requested entry, and a source-audit test pins that single constructor site. type TargetVerdict struct { OK bool Role string Unresolvable bool OutsideRoot bool Exists bool + MetadataFile bool } // WritableTarget is the check-before-act rule for a WRITE or CLEAR target, diff --git a/packages/sdk-go/internal/lejiignore/lejiignore.go b/packages/sdk-go/internal/lejiignore/lejiignore.go new file mode 100644 index 0000000..70389c1 --- /dev/null +++ b/packages/sdk-go/internal/lejiignore/lejiignore.go @@ -0,0 +1,159 @@ +// Package lejiignore keeps the tool's own directory out of the repository from +// inside. A layer whose root `.gitignore` never received the `.leji/` line (adopted +// before the unified layout, or written by hand) otherwise grows an untracked +// generated tree at every command; one file inside `.leji/` closes that without +// touching the repository's own ignore rules. +// +// The file is written the first time a command creates a role under `.leji/`, and +// only there: nothing read-only ever creates it. What stands at the target decides +// the act, read through the verified read rather than a pathname check, so a file +// swapped between the look and the write is never written through. +package lejiignore + +import ( + "fmt" + "os" + "sync" + + "github.com/leji-org/leji/packages/sdk-go/internal/fsx" + "github.com/leji-org/leji/packages/sdk-go/internal/layout" +) + +// Content is the whole file: ignore everything under `.leji/`, this file included. +// Nothing in that tree is committed by design, so the rule needs no exceptions and +// never grows any. A byte contract shared with the Node and Python SDKs. +const Content = "*\n" + +// Notice is what a run says, once, when it left an existing file alone. Frozen text, +// on stderr under every output mode: it is an advisory about the repository, never +// part of a `--json` document. +const Notice = "leji: " + layout.LejiIgnoreRel + " exists and was left as is (expected content: *)" + +// Context is one invocation's notice state. Created at the CLI command entry point +// and passed down every call path that can create a role, so one invocation says it +// once however many roles it establishes: `leji export` creates the viewer chrome and +// the export output and still notices once. A directly callable SDK function takes it +// as an optional trailing argument and passes it to whatever it nests; a direct caller +// that supplies none gets a context local to that call, so the documented behavior +// there is at most one notice per call. Deliberately not a package global: that would +// be process-scoped, and a long-lived host or a second repository in the same process +// would inherit a state that is not its own. +// +// The mutex is what makes one context safe to hand to concurrent establishers: the +// notice is said by whichever of them reaches it first, and once. +type Context struct { + mu sync.Mutex + noticed bool +} + +// NewContext is a fresh invocation context. +func NewContext() *Context { + return &Context{} +} + +// From is the context a variadic call site was handed, or nil when it was handed +// none: the Go spelling of the reference SDK's optional parameter, so a caller that +// has a context threads it and a direct caller that has none is left exactly as it +// was. EnsureFile reads nil as "a context local to this call". +func From(ctx ...*Context) *Context { + if len(ctx) > 0 { + return ctx[0] + } + return nil +} + +// say emits the frozen notice unless this context already did. +func (c *Context) say() { + c.mu.Lock() + defer c.mu.Unlock() + if c.noticed { + return + } + c.noticed = true + fmt.Fprintln(os.Stderr, Notice) +} + +// Outcome is what one EnsureFile call did. +type Outcome string + +const ( + // Created is nothing standing there and the file created exclusively. + Created Outcome = "created" + // Present is a regular file already holding exactly these bytes. + Present Outcome = "present" + // LeftAsIs is a regular file holding something else: it is untouched and the + // notice was emitted (once per context). + LeftAsIs Outcome = "left-as-is" + // Exists is an entry that appeared between the read and the exclusive create, so + // the create found it and wrote nothing. + Exists Outcome = "exists" + // Refused is the boundary refusing the target (a symlink at `.leji` or at the + // file, a non-regular entry, a containment failure); nothing was written. + Refused Outcome = "refused" +) + +// EnsureFile ensures `.leji/.gitignore` exists, at the one exception the write rule +// declares. +// +// Idempotent, and safe to call from every role establisher: the decision comes from +// fsx.VerifiedTargetRead (bytes read from the descriptor the rule cleared), and the +// create is exclusive through the guarded write path, so neither branch rests on a +// pathname that could change underneath it. A refusal is returned rather than raised; +// the calling command reports it the way it reports any refused write. An operational +// I/O failure travels out as an error, as a read by path always has. +// +// ctx nil means a context local to this call. +func EnsureFile(root string, ctx *Context) (Outcome, error) { + if ctx == nil { + ctx = NewContext() + } + rootReal := fsx.GuardRoot(root) + abs := layout.Abs(rootReal, layout.LejiIgnoreRel) + // Two looks at most. Another run creating this same file lands between the first + // look and its verification, and a standing entry that could not be verified is + // RefusedUnverifiable, which here is an ordinary concurrent create rather than a + // refusal, so it is looked at once more and read as what it now is. Anything this + // run genuinely cannot verify refuses on the second look exactly as on the first, + // and every other refusal (a symlink, a non-regular entry, a containment failure) + // is final at the first. + for look := 0; look < 2; look++ { + standing, err := fsx.VerifiedTargetRead(rootReal, abs, "") + if err != nil { + return Refused, err + } + if standing.Status == fsx.ReadRefused { + if standing.Reason == fsx.RefusedUnverifiable && look == 0 { + continue + } + return Refused, nil + } + if standing.Status == fsx.ReadRegular { + if string(standing.Bytes) == Content { + return Present, nil + } + // An empty file is the other half of that concurrent create: the winner has + // opened it exclusively and not yet written its two bytes. Looking again + // answers what it holds; a file that is genuinely empty answers the same + // thing twice and is left alone like any other content. + if len(standing.Bytes) == 0 && look == 0 { + continue + } + // Someone else's file: never merged, never rewritten. The run says so once + // and leaves the bytes exactly as they are. + ctx.say() + return LeftAsIs, nil + } + verdict, err := fsx.WriteFileGuarded(rootReal, abs, "", []byte(Content), fsx.WriteOptions{Exclusive: true}) + if err != nil { + return Refused, err + } + if verdict.Exists { + return Exists, nil + } + if verdict.OK { + return Created, nil + } + return Refused, nil + } + return Refused, nil +} diff --git a/packages/sdk-go/internal/mounts/mounts.go b/packages/sdk-go/internal/mounts/mounts.go index 7ad96bf..c725826 100644 --- a/packages/sdk-go/internal/mounts/mounts.go +++ b/packages/sdk-go/internal/mounts/mounts.go @@ -39,6 +39,7 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/fsx" "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/lejiignore" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/schemas" ) @@ -118,16 +119,25 @@ type HydrateOutcome struct { ProjectionFailed bool } -// HydrateResult carries the per-mount outcomes, or a fatal refusal. +// HydrateResult carries the per-mount outcomes, or a fatal refusal. Reasons is the +// resolver's own reason for the one `--fetch` act that failed per mount, keyed by +// mount name, for the caller's findings; it is a transport, never a document +// member, and never serialized. type HydrateResult struct { Outcomes []HydrateOutcome Fatal string + Reasons map[string]string `json:"-"` } // HydrateOptions mirror hydrateMounts' opts. type HydrateOptions struct { Fetch bool Names []string + // IgnoreContext is the invocation's notice state for the self-managed + // `.leji/.gitignore`. Hydration establishes a store and a staging directory per + // declared mount and they are all one invocation, so a caller that has a context + // threads it; nil means one context local to this call. + IgnoreContext *lejiignore.Context } // LocateResult mirrors the TS LocateResult (nil pointers = JSON null). @@ -250,7 +260,7 @@ func MountsDir(root string) string { // exception to the chokepoint, and it holds only because every one of its acts // happens under a root this function checked and returned — never under a path // re-joined from root. -func establishMountsDir(root, dirAbs string) (string, bool, error) { +func establishMountsDir(root, dirAbs string, ignoreContext *lejiignore.Context) (string, bool, error) { verdict, real, err := fsx.MkdirpGuarded(fsx.GuardRoot(root), dirAbs, layout.MountsRel) if err != nil { return "", false, err @@ -258,6 +268,16 @@ func establishMountsDir(root, dirAbs string) (string, bool, error) { if !verdict.OK { return "", false, nil } + // A role under `.leji/` now exists, so the tool's own ignore file is ensured here + // as it is at every other establisher. A refusal is this destination refusing: the + // caller reports it as it reports any destination it could not establish. + ignored, ierr := lejiignore.EnsureFile(root, ignoreContext) + if ierr != nil { + return "", false, ierr + } + if ignored == lejiignore.Refused { + return "", false, nil + } return real, true, nil } @@ -579,7 +599,7 @@ func retentionInjectedFailure(oid string) bool { // so the version of record and the version being moved to are equally safe from git // maintenance. repo "" means failure, with errMsg saying why (stable, Leji-authored // text: git stderr never reaches output). -func RetainPinInStore(root string, mount MountDecl, sourceIdentity, oid string) (repo string, errMsg string, err error) { +func RetainPinInStore(root string, mount MountDecl, sourceIdentity, oid string, ignoreContext ...*lejiignore.Context) (repo string, errMsg string, err error) { failed := func(msg string) (string, string, error) { return "", msg, nil } @@ -587,7 +607,7 @@ func RetainPinInStore(root string, mount MountDecl, sourceIdentity, oid string) if strings.HasPrefix(mount.Source, "-") { return failed(`the source locator may not begin with "-"`) } - store, ok, err := establishMountsDir(root, storeDir(root, sourceIdentity)) + store, ok, err := establishMountsDir(root, storeDir(root, sourceIdentity), lejiignore.From(ignoreContext...)) if err != nil { return "", "", err } @@ -631,21 +651,24 @@ func RetainPinInStore(root string, mount MountDecl, sourceIdentity, oid string) // fetches, so a mount whose pin a hint already resolves still needs its store // populated here. repo "" means failure, with errMsg saying why (stable, // Leji-authored text: git stderr never reaches output). -func FetchIntoStore(root string, mount MountDecl, sourceIdentity string) (repo string, witnessRefreshFailed bool, errMsg string, err error) { - store, errMsg, err := RetainPinInStore(root, mount, sourceIdentity, mount.Pin) +// witnessErrMsg carries the witness reason beside the flag, never in errMsg: that +// one is the store's own failure, and a mount whose store WAS established must not +// start reporting the witness reason as the reason nothing holds its pin. +func FetchIntoStore(root string, mount MountDecl, sourceIdentity string, ignoreContext ...*lejiignore.Context) (repo string, witnessRefreshFailed bool, errMsg, witnessErrMsg string, err error) { + store, errMsg, err := RetainPinInStore(root, mount, sourceIdentity, mount.Pin, lejiignore.From(ignoreContext...)) if err != nil || store == "" { - return "", false, errMsg, err + return "", false, errMsg, "", err } // The witness refresh is the second half of what `--fetch` was asked to do, so a // run that attempts it and does not publish says so on its own terms. Reported // only when it was actually attempted: a run that never got this far has already // reported the fetch failure that stopped it. if mount.TrackingRef != "" && ValidTrackingRef(mount.TrackingRef) { - if !RefreshWitness(store, mount, sourceIdentity) { - return store, true, "", nil + if ok, reason := RefreshWitness(store, mount, sourceIdentity); !ok { + return store, true, "", reason, nil } } - return store, false, "", nil + return store, false, "", "", nil } // RefreshWitness refreshes the managed witness ref: fetch the tracking ref to a @@ -654,7 +677,10 @@ func FetchIntoStore(root string, mount MountDecl, sourceIdentity string) (repo s // a non-fast-forward upstream move. No lock: git's ref update is atomic, a lost // swap means another writer published first (a valid outcome), and a failure // leaves the previous witness in place. -func RefreshWitness(store string, mount MountDecl, sourceIdentity string) bool { +// +// A refusal names which half did not happen, in stable Leji-authored text: the +// caller reports the witness act, and the act alone says nothing about why. +func RefreshWitness(store string, mount MountDecl, sourceIdentity string) (ok bool, errMsg string) { witnessRef := WitnessRefFor(sourceIdentity, mount.TrackingRef) tempRef := fmt.Sprintf("%s/tmp/%d-%s", WitnessRefNamespace, os.Getpid(), randomHex(8)) spec := "+" + mount.TrackingRef + ":" + tempRef @@ -689,7 +715,15 @@ func RefreshWitness(store string, mount MountDecl, sourceIdentity string) bool { // Cleanup is not part of the outcome: the canonical ref has already moved, and a // surviving temporary is inert (nothing reads the tmp namespace as a witness). RunGit([]string{"-C", store, "update-ref", "-d", tempRef}, "") - return published + if published { + return true, "" + } + // Two failure classes, and no third: the tracking ref never arrived, or it + // arrived and the canonical ref would not take it. + if tip == "" { + return false, "the tracking ref could not be fetched from the source" + } + return false, "the witness ref could not be published" } func randomHex(n int) string { @@ -1662,9 +1696,19 @@ func NowISO() string { // carries filesystem failures (TS exceptions); everything else is an outcome or // the fatal refusal. func HydrateMounts(root string, m *manifest.Manifest, opts HydrateOptions) (HydrateResult, error) { + // One context for the whole run: hydration establishes a store and a staging + // directory per declared mount, and they are all one invocation. + ignoreContext := opts.IgnoreContext + if ignoreContext == nil { + ignoreContext = lejiignore.NewContext() + } + reasons := map[string]string{} tracked := TrackedCacheFiles(root) if len(tracked) > 0 { - return HydrateResult{Fatal: fmt.Sprintf("git-tracked files under .leji/mounts/ (%s); the cache is never committed", tracked[0])}, nil + return HydrateResult{ + Fatal: fmt.Sprintf("git-tracked files under .leji/mounts/ (%s); the cache is never committed", tracked[0]), + Reasons: reasons, + }, nil } var mounts []MountDecl for _, mount := range declaredMounts(m) { @@ -1701,13 +1745,22 @@ func HydrateMounts(root string, m *manifest.Manifest, opts HydrateOptions) (Hydr witnessRefreshFailed := false var storeFetched *bool if opts.Fetch { - repo, refreshFailed, msg, ferr := FetchIntoStore(root, mount, identity) + repo, refreshFailed, msg, witnessMsg, ferr := FetchIntoStore(root, mount, identity, ignoreContext) if ferr != nil { return HydrateResult{}, ferr } witnessRefreshFailed, fetchedErr = refreshFailed, msg established := repo != "" storeFetched = &established + // At most one act can fail: a store that was not established is never asked + // to refresh a witness, so one reason per mount is the whole vocabulary here. + failedAct := witnessMsg + if !established { + failedAct = msg + } + if failedAct != "" { + reasons[mount.Name] = failedAct + } } // A requested fetch that did not establish the store is reported on its own // terms, whatever the projection then manages from a hint or the cache. @@ -1747,7 +1800,7 @@ func HydrateMounts(root string, m *manifest.Manifest, opts HydrateOptions) (Hydr // filesystem, and under a per-process name, so no two producers collide. The // staging directory is established through the chokepoint and every act below // works from the RESOLVED path it returned, the cache entry included. - staging, staged, err := establishMountsDir(root, filepath.Join(cacheDir, ".staging-"+stagingToken())) + staging, staged, err := establishMountsDir(root, filepath.Join(cacheDir, ".staging-"+stagingToken()), ignoreContext) if err != nil { return HydrateResult{}, err } @@ -1832,7 +1885,7 @@ func HydrateMounts(root string, m *manifest.Manifest, opts HydrateOptions) (Hydr // Nothing is recorded: a mount's cache key is derivable from its declaration, and // whether it is hydrated is the marker on disk. A state file would only be a second // copy of both, and one that two concurrent partial runs can each drop entries from. - return HydrateResult{Outcomes: outcomes}, nil + return HydrateResult{Outcomes: outcomes, Reasons: reasons}, nil } func contains(list []string, s string) bool { @@ -2360,7 +2413,7 @@ var headSymrefRe = regexp.MustCompile(`(?m)^ref:\s+(\S+)\s+HEAD`) // the witness ref into the resolver store. Any failure to reach the source // reports `unknown`, never a guess. The error return carries filesystem // failures (TS exceptions). -func CheckPinReachability(root string, mount MountDecl) (ReachabilityResult, error) { +func CheckPinReachability(root string, mount MountDecl, ignoreContext ...*lejiignore.Context) (ReachabilityResult, error) { identity, idOK := NormalizeSource(mount.Source) if !idOK { return ReachabilityResult{State: "unknown", Detail: "source is not a normalizable locator"}, nil @@ -2389,7 +2442,7 @@ func CheckPinReachability(root string, mount MountDecl) (ReachabilityResult, err tip := strings.Split(line, "\t")[0] // Establish ancestry in the resolver store: fetch the witness ref (full history, // no promisor state), then ask whether the pin is an ancestor of its tip. - store, established, err := establishMountsDir(root, filepath.Join(MountsDir(root), "store", Sha256Hex(identity))) + store, established, err := establishMountsDir(root, filepath.Join(MountsDir(root), "store", Sha256Hex(identity)), lejiignore.From(ignoreContext...)) if err != nil { return ReachabilityResult{}, err } diff --git a/packages/sdk-go/internal/mounts/mounts_test.go b/packages/sdk-go/internal/mounts/mounts_test.go index 6321073..7deeb5e 100644 --- a/packages/sdk-go/internal/mounts/mounts_test.go +++ b/packages/sdk-go/internal/mounts/mounts_test.go @@ -497,6 +497,98 @@ func withSourceRewrite(t *testing.T, sibling string, fn func() mounts.Reachabili return fn() } +// witnessTransactionHook installs a `reference-transaction` hook in the managed +// store, firing only on the canonical witness ref. "abort" fails the swap the way +// a lock, a permission error or a full disk does. "publish" writes oid into the ref +// and then fails, which is the state a run finds when another writer published +// between its read of and its own swap; the interleaving itself is not +// reachable in a single process, so the fixture reproduces what it leaves behind. +func witnessTransactionHook(t *testing.T, store, witnessRef, mode, oid string) { + t.Helper() + hooks := filepath.Join(store, "hooks") + if err := os.MkdirAll(hooks, 0o755); err != nil { + t.Fatal(err) + } + publish := "" + if mode == "publish" { + publish = fmt.Sprintf("mkdir -p \"$(dirname \"%s/%s\")\"\nprintf '%%s\\n' '%s' > \"%s/%s\"\n", + store, witnessRef, oid, store, witnessRef) + } + // Each stdin line is " "; every other ref (the fetched temporary, + // the pin ref) passes through untouched. + script := fmt.Sprintf("#!/bin/sh\n[ \"$1\" = prepared ] || exit 0\ngrep -q \" %s$\" || exit 0\n%sexit 1\n", + witnessRef, publish) + if err := os.WriteFile(filepath.Join(hooks, "reference-transaction"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } +} + +// TestMountsLostCompareAndSwapIsAConfirmedMismatch mirrors the TS reference's +// reference-transaction test. It is also the only reachable path to the witness +// act's SECOND frozen failure class: a tracking ref that arrived and a canonical +// ref that would not take it, whose reason travels into `Reasons` and from there +// into the finding's `detail`. +func TestMountsLostCompareAndSwapIsAConfirmedMismatch(t *testing.T) { + host, sibling, pin := mountedPair(t) + git(t, sibling, "config", "uploadpack.allowAnySHA1InWant", "true") + m := loadHost(t, host) + identity, ok := mounts.NormalizeSource(acmeSource) + if !ok { + t.Fatal("identity failed to normalize") + } + sum := sha256.Sum256([]byte(identity)) + store := filepath.Join(host, ".leji", "mounts", "store", hex.EncodeToString(sum[:])) + witnessRef := mounts.WitnessRefFor(identity, "refs/heads/main") + // The witness ref does not exist yet, so this run swaps against "must not exist", + // and finds another writer's commit there instead. That is a race it lost, not + // a failure: the published witness stands and nothing is reported. + if err := os.MkdirAll(store, 0o755); err != nil { + t.Fatal(err) + } + git(t, host, "init", "--bare", "-q", store) + witnessTransactionHook(t, store, witnessRef, "publish", pin) + var r mounts.HydrateResult + hydrate := func() { + t.Helper() + withSourceRewrite(t, sibling, func() mounts.ReachabilityResult { + var err error + if r, err = mounts.HydrateMounts(host, m, mounts.HydrateOptions{Fetch: true}); err != nil { + t.Fatalf("hydrate: %v", err) + } + return mounts.ReachabilityResult{} + }) + } + hydrate() + if r.Outcomes[0].WitnessRefreshFailed { + t.Fatal("another writer publishing is a valid outcome") + } + if got := git(t, store, "rev-parse", witnessRef); got != pin { + t.Fatalf("the other writer's witness stands, got %s", got) + } + if reason, carried := r.Reasons["acme-product-context"]; carried { + t.Fatalf("a valid outcome names no failed act, got %q", reason) + } + // The same failed swap, with the ref holding exactly what this run expected: no + // one published, so this is the disk, the permissions or a lock, and it may not + // pass as a refresh that happened. + witnessTransactionHook(t, store, witnessRef, "abort", "") + hydrate() + if r.Outcomes[0].StoreFetched == nil || !*r.Outcomes[0].StoreFetched { + t.Fatal("the store was established; only the swap failed") + } + if !r.Outcomes[0].WitnessRefreshFailed { + t.Fatal("an operational failure never reads as success") + } + if got := git(t, store, "rev-parse", witnessRef); got != pin { + t.Fatalf("the previous witness stays in place, got %s", got) + } + // The witness act's second failure class, which is not the first one: a ref that + // arrived and would not publish, never a ref that never arrived. + if got := r.Reasons["acme-product-context"]; got != "the witness ref could not be published" { + t.Fatalf("reason = %q", got) + } +} + func TestMountsPinReachabilityReachableUnreachableOffHistoryUnknownOffline(t *testing.T) { host, sibling, pin := mountedPair(t) mount := mounts.MountDecl{ diff --git a/packages/sdk-go/internal/schemas/schemas.go b/packages/sdk-go/internal/schemas/schemas.go index d1d3681..51fb55f 100644 --- a/packages/sdk-go/internal/schemas/schemas.go +++ b/packages/sdk-go/internal/schemas/schemas.go @@ -25,7 +25,7 @@ import ( var SupportedLines = []string{"1.0"} // SDKVersion is overridable via ldflags; defaults to match Node/Python. -var SDKVersion = "1.4.0" +var SDKVersion = "1.4.1" type CliOption struct { Flags string `json:"flags"` diff --git a/packages/sdk-go/internal/sourceaudit/role_test.go b/packages/sdk-go/internal/sourceaudit/role_test.go new file mode 100644 index 0000000..4df4c42 --- /dev/null +++ b/packages/sdk-go/internal/sourceaudit/role_test.go @@ -0,0 +1,509 @@ +package sourceaudit + +// The declared exception to the ROLE rule, pinned the way the write allow-list is +// pinned: the role rule allows exactly one target that belongs to no role (the +// self-managed `.leji/.gitignore`), and exactly one symbol may say so, by putting the +// MetadataFile field on a layout.TargetVerdict. docs/practice/trust-boundary.md mirrors +// this list. The write allow-list says which symbols may touch the filesystem raw; this +// one says which may declare a target writable that the role rule refuses, and it +// exists for the same reason: an exception nobody can find is an exception nobody is +// checking. +// +// Mirrors packages/sdk/test/source-audit.test.ts (the role-exception pin) and +// packages/sdk-py/tests/test_source_audit_role.py. + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// allowedRoleExceptions is the exception list, by `file#symbol`: the one place a +// MetadataFile verdict may be constructed. +var allowedRoleExceptions = map[string]string{ + "internal/fsx/fsx.go#metadataFileVerdict": "the self-managed .leji/.gitignore: judged on the requested entry, with a real .leji directory and a non-symlink entry, refused otherwise", +} + +// roleExceptionField is the field that carries the exception. +const roleExceptionField = "MetadataFile" + +// roleVerdictType is the type the field lives on. An UNKEYED composite literal sets +// every field by ORDER and spells no field name at all, so a name-based reader cannot +// see it: any positional literal of this type is therefore a constructor site, whatever +// its arity. Go has no keyword-only struct literal, so the audit is the whole of the +// prevention here, which is why the rule counts positions rather than arguments: the +// sixth is the exception today, and an audit tied to that number would be one field +// away from being wrong. +const roleVerdictType = "TargetVerdict" + +// reflectiveSetters are the members that put a field on a value from a NAME given as an +// argument rather than from a field written into a composite literal: the shape +// nothing else in this scan would see. They belong to no other API this repository +// uses, so the member name alone counts and an aliased or embedded receiver is caught +// with it. +var reflectiveSetters = map[string]bool{"FieldByName": true, "FieldByNameFunc": true} + +// literalString is the value of a string literal, or "" and false for anything else. +// A name assembled at runtime is the residual stated below, not something to guess at. +func literalString(node ast.Expr) (string, bool) { + lit, ok := node.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return "", false + } + value, err := strconv.Unquote(lit.Value) + if err != nil { + return "", false + } + return value, true +} + +// jsonSpellsField reports whether a string is a JSON DOCUMENT carrying the field as a +// key, at any depth. The text is parsed rather than substring-matched, deliberately: a +// literal that merely NAMES the field (an error message, a comment, this audit's own +// constant) creates nothing and must not be flagged, while `{"MetadataFile":true}` +// handed to a decoder creates exactly the thing this pin is about. +func jsonSpellsField(text string) bool { + var parsed any + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + return false + } + var walk func(any) bool + walk = func(value any) bool { + switch v := value.(type) { + case map[string]any: + if _, ok := v[roleExceptionField]; ok { + return true + } + for _, member := range v { + if walk(member) { + return true + } + } + case []any: + for _, member := range v { + if walk(member) { + return true + } + } + } + return false + } + return walk(parsed) +} + +// roleExceptions is every CONSTRUCTION of the metadata-file verdict in one file, keyed +// `file#symbol`. +// +// A field can be put on a value in a bounded number of statically named ways, and all +// of them count: written into a composite literal as a keyed field; assigned onto a +// value afterwards by selector, in any form of assignment; spelled as a map literal's +// key; handed to a reflective setter as a field NAME; or carried as a key inside a JSON +// document literal a decoder is given. +// +// And one way that spells no name at all: an UNKEYED composite literal of the verdict +// type, `layout.TargetVerdict{true, "", false, false, false, true}`, which sets fields +// by ORDER. Go offers no keyword-only struct, so this audit is the whole of the +// prevention: EVERY positional literal of the type is recorded as a constructor site, +// whatever its arity and whatever the sixth element happens to be. That is deliberately +// blunt (the alternative, reading the element at the exception's index, would silently +// stop working the day a field is inserted above it), and it costs nothing, because a +// keyed literal is the only form this SDK writes. +// +// The NAME is what decides for the keyed forms, never the value's type: a verdict built +// through an `any`, or on a shape no type check can relate to layout.TargetVerdict, must +// fail this audit rather than slip through it. Reflective forms compile where a direct +// assignment would not (an unexported or shadowed field), which is exactly why they are +// audited here rather than left to the compiler. +// +// READING the field is not constructing it, so a plain `verdict.MetadataFile` test is +// deliberately not a hit; only positions that create it are. +// +// THE RESIDUAL, stated exactly. One class remains outside, and only one: a field name +// ASSEMBLED AT RUNTIME, so that no single string can be read for it statically: a +// concatenation ("Metadata" + "File"), a formatted string, a variable this reader +// cannot narrow to one literal, a value read from data. Every such site is invisible to +// any static audit, this one included; its closure is human, through +// docs/practice/trust-boundary.md and the diff review. Positional construction was once +// in this list and is not any more: the unkeyed-literal rule below flags it. +func roleExceptions(rel string, file *ast.File, fset *token.FileSet) []hit { + var hits []hit + seen := map[token.Pos]bool{} + record := func(node ast.Node, spelling string) { + if seen[node.Pos()] { + return + } + seen[node.Pos()] = true + hits = append(hits, hit{ + key: rel + "#" + enclosingSymbol(file, node), + line: fset.Position(node.Pos()).Line, + name: spelling, + }) + } + ast.Inspect(file, func(n ast.Node) bool { + switch node := n.(type) { + // Written into a composite literal: `TargetVerdict{MetadataFile: true}`, or + // spelled as a map literal's key: `{"MetadataFile": true}`. + case *ast.KeyValueExpr: + if ident, ok := node.Key.(*ast.Ident); ok && ident.Name == roleExceptionField { + record(node, "field") + } + if text, ok := literalString(node.Key); ok && text == roleExceptionField { + record(node, "map key") + } + // Assigned onto a value afterwards, in every form of assignment operator. + case *ast.AssignStmt: + for _, target := range node.Lhs { + sel, ok := target.(*ast.SelectorExpr) + if ok && sel.Sel.Name == roleExceptionField { + record(node, "assignment") + } + } + // Handed to a reflective setter as a field NAME, or carried as a key inside a + // JSON document literal. + case *ast.CallExpr: + if sel, ok := node.Fun.(*ast.SelectorExpr); ok && reflectiveSetters[sel.Sel.Name] { + if len(node.Args) > 0 { + if text, ok := literalString(node.Args[0]); ok && text == roleExceptionField { + record(node, "reflect."+sel.Sel.Name) + } + } + } + case *ast.BasicLit: + if text, ok := literalString(node); ok && jsonSpellsField(text) { + record(node, "JSON document") + } + // Built positionally, with no field name anywhere: an unkeyed literal of the + // verdict type. An empty `TargetVerdict{}` names nothing and sets nothing, so it + // is not one; anything with an element that is not a key/value pair is. Inside a + // container of verdicts the element literals may elide their type entirely + // (`[]layout.TargetVerdict{{…}}`), so the container hands its element type down. + case *ast.CompositeLit: + if structLitName(node.Type) == roleVerdictType && positional(node) { + record(node, "positional literal") + } + if elementTypeName(node.Type) == roleVerdictType { + for _, element := range node.Elts { + if keyed, ok := element.(*ast.KeyValueExpr); ok { + element = keyed.Value + } + elided, ok := element.(*ast.CompositeLit) + if ok && elided.Type == nil && positional(elided) { + record(elided, "positional literal") + } + } + } + } + return true + }) + return hits +} + +// positional reports whether a composite literal sets any field by ORDER rather than by +// name. An empty literal sets nothing and is not positional. +func positional(node *ast.CompositeLit) bool { + for _, element := range node.Elts { + if _, keyed := element.(*ast.KeyValueExpr); !keyed { + return true + } + } + return false +} + +// structLitName is the STRUCT type a literal names: `TargetVerdict` for both the bare +// and the qualified `layout.TargetVerdict` spelling (`&T{…}` reaches here too, since the +// address-of wraps the literal rather than its type). "" for a nil type or a container. +func structLitName(expr ast.Expr) string { + switch typed := expr.(type) { + case *ast.Ident: + return typed.Name + case *ast.SelectorExpr: + return typed.Sel.Name + } + return "" +} + +// elementTypeName is the struct type a CONTAINER literal holds (the element of a slice +// or array, the value of a map, through a pointer element), so a literal that elides its +// own type can still be attributed to it. "" when the type is not a container of structs. +func elementTypeName(expr ast.Expr) string { + switch typed := expr.(type) { + case *ast.ArrayType: + return elementOrStruct(typed.Elt) + case *ast.MapType: + return elementOrStruct(typed.Value) + } + return "" +} + +func elementOrStruct(expr ast.Expr) string { + if star, ok := expr.(*ast.StarExpr); ok { + return structLitName(star.X) + } + return structLitName(expr) +} + +// roleScan parses one directory's files and returns every metadata-file construction in +// them. No type check: unlike the write surface, which is about which PACKAGE a call +// belongs to, this one is about a field NAME, and a name is what the syntax already +// carries. +func roleScan(t *testing.T, moduleRoot string, files map[string]string) []hit { + t.Helper() + fset := token.NewFileSet() + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + var hits []hit + for _, name := range names { + file, err := parser.ParseFile(fset, name, files[name], parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + rel, rerr := filepath.Rel(moduleRoot, fset.Position(file.Package).Filename) + if rerr != nil { + t.Fatal(rerr) + } + hits = append(hits, roleExceptions(filepath.ToSlash(rel), file, fset)...) + } + return hits +} + +func roleAudit(t *testing.T) []hit { + t.Helper() + root := moduleRoot(t) + pkgs := productionPackages(t, root) + if len(pkgs) == 0 { + t.Fatal("the role audit loaded no source files") + } + dirs := make([]string, 0, len(pkgs)) + for dir := range pkgs { + dirs = append(dirs, dir) + } + sort.Strings(dirs) + var all []hit + for _, dir := range dirs { + all = append(all, roleScan(t, root, pkgs[dir])...) + } + return all +} + +func TestSourceAuditMetadataFileVerdictHasOneConstructorSite(t *testing.T) { + hits := roleAudit(t) + if outside := unexpected(hits, allowedRoleExceptions); len(outside) > 0 { + t.Fatalf("a metadata-file verdict is constructed outside the declared exception:\n %s\n"+ + "The role rule allows one target that belongs to no role; argue any other into the "+ + "list, or route the write through its own role.", strings.Join(outside, "\n ")) + } + if dead := stale(hits, allowedRoleExceptions); len(dead) > 0 { + t.Fatalf("role-exception entries matching no symbol (delete them): %s", strings.Join(dead, ", ")) + } + if len(hits) != len(allowedRoleExceptions) { + t.Fatalf("one construction, not several at one site: %d hits for %d entries", len(hits), len(allowedRoleExceptions)) + } +} + +// The role-exception laundering corpus, permanent. Each probe is another way of putting +// MetadataFile on a verdict, and each must be flagged: the promise the single-constructor +// pin makes is that a SECOND exception cannot be added quietly, so every spelling a +// second one could take is asserted here rather than assumed. The last probe is the +// control: a longer, unrelated field that merely starts with the same letters, together +// with an ordinary READ of the field, which must never be flagged. +var roleProbes = []struct { + name string + source string + flagged bool + symbol string // when set, the symbol the hit must be attributed to + comment string +}{ + { + name: "composite_literal.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +func launderCompositeLiteral() layout.TargetVerdict { + return layout.TargetVerdict{OK: true, MetadataFile: true} +} +`, + flagged: true, + symbol: "launderCompositeLiteral", + comment: "the field written straight into the struct literal", + }, + { + name: "positional_literal.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +func launderPositional() layout.TargetVerdict { + return layout.TargetVerdict{true, "", false, false, false, true} +} +`, + flagged: true, + symbol: "launderPositional", + comment: "the review's own probe: every field set by order, the exception's name spelled nowhere", + }, + { + name: "positional_literal_addressed.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +func launderPositionalPointer() *layout.TargetVerdict { + return &layout.TargetVerdict{true, "", false, false, false, true} +} +`, + flagged: true, + symbol: "launderPositionalPointer", + comment: "the same literal behind an address-of", + }, + { + name: "positional_literal_elided.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +func launderPositionalElided() layout.TargetVerdict { + all := []layout.TargetVerdict{{true, "", false, false, false, true}} + return all[0] +} +`, + flagged: true, + symbol: "launderPositionalElided", + comment: "an element literal inside a container of verdicts, eliding its own type", + }, + { + name: "closure_literal.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +func launderClosure() layout.TargetVerdict { + build := func() layout.TargetVerdict { return layout.TargetVerdict{MetadataFile: true} } + return build() +} +`, + flagged: true, + symbol: "launderClosure", + comment: "naming a closure does not move the construction out of the function that owns it", + }, + { + name: "assignment.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +func launderAssignment() layout.TargetVerdict { + v := layout.TargetVerdict{OK: true} + v.MetadataFile = true + return v +} +`, + flagged: true, + symbol: "launderAssignment", + comment: "the field assigned onto the value afterwards", + }, + { + name: "pointer_assignment.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +func launderPointer(v *layout.TargetVerdict) { + v.MetadataFile = true +} +`, + flagged: true, + symbol: "launderPointer", + comment: "the same assignment through a pointer receiver", + }, + { + name: "map_key.go", + source: `package probe +func launderMapKey() map[string]bool { + return map[string]bool{"OK": true, "MetadataFile": true} +} +`, + flagged: true, + symbol: "launderMapKey", + comment: "the field spelled as a map literal's key, for a decoder to apply", + }, + { + name: "reflect_set.go", + source: `package probe +import ( + "reflect" + + "github.com/leji-org/leji/packages/sdk-go/internal/layout" +) +func launderReflect() layout.TargetVerdict { + v := layout.TargetVerdict{OK: true} + reflect.ValueOf(&v).Elem().FieldByName("MetadataFile").SetBool(true) + return v +} +`, + flagged: true, + symbol: "launderReflect", + comment: "the field set reflectively, from a name no compiler check sees", + }, + { + name: "json_document.go", + source: `package probe +import ( + "encoding/json" + + "github.com/leji-org/leji/packages/sdk-go/internal/layout" +) +func launderJSON() layout.TargetVerdict { + var v layout.TargetVerdict + _ = json.Unmarshal([]byte("{\"OK\":true,\"MetadataFile\":true}"), &v) + return v +} +`, + flagged: true, + symbol: "launderJSON", + comment: "the field carried as a key inside a JSON document literal", + }, + { + name: "negative.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +type unrelated struct{ MetadataFileName string } +func notTheException(v layout.TargetVerdict) unrelated { + other := unrelated{MetadataFileName: "x"} + other.MetadataFileName = "y" + if v.MetadataFile { + other.MetadataFileName = "read" + } + message := "MetadataFile is the one exception the role rule allows" + if len(message) == 0 { + other.MetadataFileName = message + } + return other +} +`, + flagged: false, + comment: "a longer, unrelated field name, and an ordinary READ of the field, are not constructions", + }, + { + name: "negative_keyed_and_empty.go", + source: `package probe +import "github.com/leji-org/leji/packages/sdk-go/internal/layout" +type other struct{ A, B bool } +func ordinary() (layout.TargetVerdict, layout.TargetVerdict, other) { + return layout.TargetVerdict{OK: true}, layout.TargetVerdict{}, other{true, false} +} +`, + flagged: false, + comment: "keyed and empty verdict literals, and a positional literal of some OTHER type, are not constructions", + }, +} + +func TestSourceAuditMetadataFilePinSeesThroughEverySpelling(t *testing.T) { + root := moduleRoot(t) + dir := filepath.Join(root, "internal", "sourceaudit", "__role_probe") + for _, probe := range roleProbes { + hits := roleScan(t, root, map[string]string{filepath.Join(dir, probe.name): probe.source}) + got := len(hits) > 0 + if got != probe.flagged { + t.Fatalf("%s (%s): flagged = %v, want %v (%v)", probe.name, probe.comment, got, probe.flagged, hits) + } + if probe.symbol != "" && !strings.HasSuffix(hits[0].key, "#"+probe.symbol) { + t.Fatalf("%s (%s): attributed to %q, want the enclosing %q", + probe.name, probe.comment, hits[0].key, probe.symbol) + } + } +} diff --git a/packages/sdk-go/package.json b/packages/sdk-go/package.json index 0c949fc..dc96702 100644 --- a/packages/sdk-go/package.json +++ b/packages/sdk-go/package.json @@ -1,6 +1,6 @@ { "name": "@leji-internal/sdk-go", - "version": "1.4.0", + "version": "1.4.1", "private": true, "description": "npm workspace stub for the Go SDK; the real package metadata is go.mod. Lets `npm test --workspaces` run the Go test suite. Never published to npm.", "scripts": { diff --git a/packages/sdk-go/setup.sh b/packages/sdk-go/setup.sh index 4d2ca4c..8697285 100755 --- a/packages/sdk-go/setup.sh +++ b/packages/sdk-go/setup.sh @@ -7,7 +7,7 @@ set -eu if ! command -v go >/dev/null 2>&1; then echo "leji sdk-go: Go not found on PATH." >&2 - echo " Install Go 1.23+ from https://go.dev/dl/ (or 'brew install go'), then re-run 'npm run setup:go'." >&2 + echo " Install Go 1.27+ from https://go.dev/dl/ (or 'brew install go'), then re-run 'npm run setup:go'." >&2 exit 1 fi echo "leji sdk-go: using $(go version)" diff --git a/packages/sdk-py/package.json b/packages/sdk-py/package.json index 04bf63f..5e8db98 100644 --- a/packages/sdk-py/package.json +++ b/packages/sdk-py/package.json @@ -1,6 +1,6 @@ { "name": "@leji-internal/sdk-py", - "version": "1.4.0", + "version": "1.4.1", "private": true, "description": "npm workspace stub for the Python SDK; the real package metadata is pyproject.toml. Lets `npm test --workspaces` run the pytest suite. Never published to npm.", "scripts": { diff --git a/packages/sdk-py/pyproject.toml b/packages/sdk-py/pyproject.toml index 4bcf77f..2f441ae 100644 --- a/packages/sdk-py/pyproject.toml +++ b/packages/sdk-py/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.32.0"] build-backend = "hatchling.build" [project] name = "leji" -version = "1.4.0" +version = "1.4.1" description = "Reference SDK and CLI for Leji, the open specification for the shared context layer of AI-native teams: validate, index, changelog, freshness, conformance, status, route, federation mounts, viewer, view, init, adopt, detect, start, ci, and agent." readme = "README.md" requires-python = ">=3.10" @@ -33,11 +33,11 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=7,<9", + "pytest==8.4.2", "ruff==0.15.17", "mypy==1.20.2", - "coverage>=7,<8", - "build>=1,<2", + "coverage==7.15.4", + "build==1.5.0", ] [project.urls] diff --git a/packages/sdk-py/src/leji/__init__.py b/packages/sdk-py/src/leji/__init__.py index cd84b67..ac416ad 100644 --- a/packages/sdk-py/src/leji/__init__.py +++ b/packages/sdk-py/src/leji/__init__.py @@ -39,6 +39,7 @@ ViewerResult, build_sidebar, generate_viewer, + render_overview, resolve_viewer_port, ) from .findings import Finding, Severity, sort_findings, summarize @@ -57,6 +58,15 @@ handoff_offer, init_layer, ) +from .layout import LEJI_IGNORE_REL +from .leji_ignore import ( + LEJI_IGNORE_CONTENT, + LEJI_IGNORE_NOTICE, + LejiIgnoreContext, + LejiIgnoreOutcome, + ensure_leji_ignore_file, + new_leji_ignore_context, +) from .manifest import Manifest, claimed_level, load_manifest from .route import ( LIVE_STATUSES, @@ -92,7 +102,12 @@ "HOST_SPECS", "HostSpec", "InitResult", + "LEJI_IGNORE_CONTENT", + "LEJI_IGNORE_NOTICE", + "LEJI_IGNORE_REL", "LIVE_STATUSES", + "LejiIgnoreContext", + "LejiIgnoreOutcome", "Manifest", "OUT_RULE", "PlanEntry", @@ -126,6 +141,7 @@ "detect_ecosystem", "detect_layer", "ensure_ci_workflow", + "ensure_leji_ignore_file", "enter_layer", "entering_via_boot", "freshness_report", @@ -143,7 +159,9 @@ "generate_index", "init_layer", "load_manifest", + "new_leji_ignore_context", "open_browser", + "render_overview", "render_write_plan", "resolve_viewer_port", "serialize_changelog", diff --git a/packages/sdk-py/src/leji/_assets/assets-manifest.json b/packages/sdk-py/src/leji/_assets/assets-manifest.json index 8f441ca..069ab81 100644 --- a/packages/sdk-py/src/leji/_assets/assets-manifest.json +++ b/packages/sdk-py/src/leji/_assets/assets-manifest.json @@ -5,7 +5,7 @@ "schemas/agent-profile.schema.json": "sha256:9597a0ff39db7587daf210177fdc7ede41f9efeaab54596289534209826ba657", "schemas/context-changelog.schema.json": "sha256:616fd7bddd1f07638e2cbdc2cfa665166f4739283c5194eca34fbf923218ced4", "schemas/context-index.schema.json": "sha256:c3618e356622793326076a424d53843bfccf00511520cdba010c6946262ab440", - "schemas/context-manifest.schema.json": "sha256:dd24a91bb4938f6b6b986928140a997774d5c90bcb720bfc57ef5d7e332b56e2", + "schemas/context-manifest.schema.json": "sha256:3d8ea744f37f06ceabc277da05d852c89895eb2f47d5aece5eecea76345a7cd4", "schemas/decision-record.schema.json": "sha256:f5db3e68be8b2233b9029949d79109b4784ce43ef1a1cd26e44a0427c8915b07", "templates/README.md": "sha256:3fa28c144a26076cc75dc2a6d23014d61370abcb2073afa7d5bd3f26af4884c7", "templates/agent-profile.md": "sha256:fb1cf77aeaffc10718795231b936a7eab9b54c9221f4de072677e3afd3656545", @@ -42,9 +42,9 @@ "templates/viewer/assets/source-sans-pro-600-vietnamese.woff2": "sha256:615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "templates/viewer/assets/third-party-licenses.txt": "sha256:010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "templates/viewer/assets/viewer-boot.js": "sha256:39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "templates/viewer/assets/vue.css": "sha256:af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "templates/viewer/assets/vue.css": "sha256:61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", "templates/viewer/assets/zoom-image.min.js": "sha256:c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", - "templates/viewer/index.html": "sha256:127dadfdca91e9e93739b4e2898ab1ec33fe0b5658354288d960e3e2989f6998", + "templates/viewer/index.html": "sha256:fd6e43cde0d72678dd26c9ad38753f046ed3c5e3e550fbd211c0f4307edd8806", "templates/writing-style.md": "sha256:ee17bb1b97cbe87c4d8ef59b80b2e1d03d997d8839a98d3eb2080c540efa7b2c" } } diff --git a/packages/sdk-py/src/leji/_assets/schemas/context-manifest.schema.json b/packages/sdk-py/src/leji/_assets/schemas/context-manifest.schema.json index 585c59e..a6f1f0f 100644 --- a/packages/sdk-py/src/leji/_assets/schemas/context-manifest.schema.json +++ b/packages/sdk-py/src/leji/_assets/schemas/context-manifest.schema.json @@ -312,7 +312,7 @@ "properties": { "primary": { "type": "string", - "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives links, the active state, and diagram accents." + "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives the viewer chrome, active states, and diagram accents; body links and inline code use the fixed accessible tone." } } }, diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/vue.css b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/vue.css index 1fc6850..7836ffc 100644 --- a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/vue.css +++ b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/vue.css @@ -131,8 +131,8 @@ span.emoji { vertical-align: middle; } .progress { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); height: 2px; left: 0px; position: fixed; @@ -143,12 +143,12 @@ span.emoji { z-index: 999999; } .search a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .search .search-keyword { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-style: normal; font-weight: bold; } @@ -159,7 +159,7 @@ body { body { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; - color: #34495e; + color: var(--leji-text-body); font-family: 'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif; font-size: 15px; letter-spacing: 0; @@ -218,14 +218,14 @@ li input[type='checkbox'] { transition: color 0.3s; } .app-nav a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .app-nav a.active { - border-bottom: 2px solid #42b983; - border-bottom: 2px solid var(--theme-color, #42b983); - color: #42b983; - color: var(--theme-color, #42b983); + border-bottom: 2px solid #009f71; + border-bottom: 2px solid var(--theme-color, #009f71); + color: #009f71; + color: var(--theme-color, #009f71); } .app-nav li { display: inline-block; @@ -283,8 +283,8 @@ li input[type='checkbox'] { } .github-corner svg { color: #fff; - fill: #42b983; - fill: var(--theme-color, #42b983); + fill: #009f71; + fill: var(--theme-color, #009f71); height: 80px; width: 80px; } @@ -304,7 +304,7 @@ main.hidden { transition: all 0.3s; } .anchor span { - color: #34495e; + color: var(--leji-text); } .anchor:hover { text-decoration: underline; @@ -393,8 +393,8 @@ main.hidden { opacity: 0.4; } .sidebar-toggle span { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); display: block; margin-bottom: 4px; width: 16px; @@ -489,7 +489,7 @@ body.sticky .sidebar-toggle { background-color: #efefef; } .markdown-section p.tip em { - color: #34495e; + color: var(--leji-text-body); } .markdown-section p.warn { background: rgba(66,185,131,0.1); @@ -653,14 +653,14 @@ section.cover ul { padding: 0; } section.cover .cover-main > p:last-child a { - border-color: #42b983; - border-color: var(--theme-color, #42b983); + border-color: #009f71; + border-color: var(--theme-color, #009f71); border-radius: 2rem; border-style: solid; border-width: 1px; box-sizing: border-box; - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); display: inline-block; font-size: 1.05rem; letter-spacing: 0.1rem; @@ -670,8 +670,8 @@ section.cover .cover-main > p:last-child a { transition: all 0.15s ease; } section.cover .cover-main > p:last-child a:last-child { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); color: #fff; } section.cover .cover-main > p:last-child a:last-child:hover { @@ -682,13 +682,13 @@ section.cover .cover-main > p:last-child a:hover { color: inherit; } section.cover blockquote > p > a { - border-bottom: 2px solid #42b983; - border-bottom: 2px solid var(--theme-color, #42b983); + border-bottom: 2px solid #009f71; + border-bottom: 2px solid var(--theme-color, #009f71); transition: color 0.3s; } section.cover blockquote > p > a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } body { background-color: #fff; @@ -696,13 +696,13 @@ body { /* sidebar */ .sidebar { background-color: #fff; - color: #364149; + color: var(--leji-text-body); } .sidebar li { margin: 6px 0 6px 0; } .sidebar ul li a { - color: #505d6b; + color: var(--leji-text-body); font-size: 14px; font-weight: normal; overflow: hidden; @@ -718,8 +718,8 @@ body { } .sidebar ul li.active > a { border-right: 2px solid; - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-weight: 600; } .app-sub-sidebar li::before { @@ -733,12 +733,12 @@ body { .markdown-section h3, .markdown-section h4, .markdown-section strong { - color: #2c3e50; + color: var(--leji-text); font-weight: 600; } .markdown-section a { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-weight: 600; } .markdown-section h1 { @@ -760,7 +760,7 @@ body { font-size: 1rem; } .markdown-section h6 { - color: #777; + color: var(--leji-text); font-size: 1rem; } .markdown-section figure, @@ -778,9 +778,9 @@ body { padding-left: 1.5rem; } .markdown-section blockquote { - border-left: 4px solid #42b983; - border-left: 4px solid var(--theme-color, #42b983); - color: #858585; + border-left: 4px solid #009f71; + border-left: 4px solid var(--theme-color, #009f71); + color: var(--leji-text-body); margin: 2em 0; padding-left: 20px; } @@ -792,7 +792,7 @@ body { margin: 1em 0; } .markdown-section em { - color: #7f8c8d; + color: var(--leji-text-body); } .markdown-section code, .markdown-section pre, @@ -901,8 +901,8 @@ body { color: #2973b7; } .token.string { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .token.selector { color: #6679cc; @@ -920,8 +920,8 @@ body { .token.control, .token.directive, .token.unit { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .token.keyword, .token.function { @@ -972,6 +972,26 @@ code .token { group/active treatment. CDN-free: the theme's webfonts are vendored locally (see the @font-face rules at the top of this file). ========================================================================== */ +/* Every text color in this file and in the viewer shell belongs to exactly one of + four classes, and a new one has to be placed in a class before it is written: + + i. typography: the three tones below and nothing else. Headings and emphasis + take --leji-text, all normal-size copy --leji-text-body, and --leji-text-muted + is declared for large-text use only (3.99:1 on white, below AA at normal + size), so it currently has no consumer. + ii. links and active chrome: --leji-link (the fixed AA body-link tone), + --theme-color (the layer's accent, viewer.theme.primary), --leji-deep, + --leji-brand. + iii. inverse text on dark or accent grounds: the literal #fff on a colored + ground, which is not a typography tone. + iv. code and syntax: the fenced-code text, the Prism .token.* colors, and the + inline-code ground. + + Surfaces (--leji-paper, --leji-line, --leji-code-bg) and icons (--leji-caret, the + group triangles) are not text and belong to none of the four. + + The legacy neutrals the stock docsify theme shipped are denied outright, in a + unit test over this directory (packages/sdk/test/viewer-tones.test.ts). */ :root { --theme-color: #009f71; /* recolors every var(--theme-color) rule above */ --leji-brand: #009f71; /* the Leji mark green: brand moments, never small text */ @@ -979,17 +999,18 @@ code .token { --leji-deep: #164e42; --leji-accent: #78d7b5; --leji-paper: #f7f8f5; /* the brand's light canvas: sidebar, chips, panels */ - --leji-ink: #34495e; - --leji-ink-soft: #555555; + --leji-text: #183b32; /* headings and emphasis */ + --leji-text-body: #4d5b56; /* every normal-size run of copy */ + --leji-text-muted: #76827d; /* large text only: 3.99:1 on white */ --leji-line: #cde5d9; /* the brand's border tone, not a neutral gray */ --leji-code-bg: #e8f4ee; - --leji-caret: #aaaaaa; /* lighter than the ink for the group triangles */ + --leji-caret: #aaaaaa; /* an icon tone, lighter than the text, not typography */ color-scheme: light; } body { background: var(--leji-paper); - color: var(--leji-ink); + color: var(--leji-text-body); } /* --- sidebar shell --- */ @@ -1014,7 +1035,7 @@ body { border-top: 1px solid var(--leji-line); } .sidebar ul li a { - color: var(--leji-ink-soft); + color: var(--leji-text-body); transition: color 0.12s; } .sidebar ul li a:hover { @@ -1060,7 +1081,7 @@ body { .sidebar-nav > ul > li.folder { /* 600 is Source Sans Pro's bold face; 500 would resolve down to regular. */ font-weight: 600; - color: var(--leji-ink); + color: var(--leji-text); } .sidebar-nav > ul > li.folder .app-sub-sidebar { font-weight: 400; @@ -1077,7 +1098,7 @@ body { content: none !important; } .sidebar-nav .app-sub-sidebar li a { - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .sidebar-nav .app-sub-sidebar li.active > a, .sidebar-nav .app-sub-sidebar li a:hover { @@ -1092,10 +1113,10 @@ body { background: #fff; border: 1px solid var(--leji-line); border-radius: 6px; - color: var(--leji-ink); + color: var(--leji-text-body); } .search input::placeholder { - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .search input:focus { outline: none; @@ -1133,7 +1154,7 @@ body { font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .sidebar-toggle { background-color: var(--leji-paper) !important; @@ -1152,15 +1173,27 @@ body { font-weight: 600; } +/* h3, h4, h6 and strong take the heading tone from the rules above; h1, h2 and h5 + say it here rather than inheriting, so every heading level carries one tone by + declaration and a stock-theme change cannot quietly split them. */ +.markdown-section h1, +.markdown-section h2, +.markdown-section h5 { + color: var(--leji-text); +} + +/* Body links take the fixed accessible tone, never the layer's accent: AA on the + content ground holds for every value of viewer.theme.primary. The chrome above + keeps --theme-color, so a custom accent still colors the viewer. */ .markdown-section a { - color: var(--theme-color); + color: var(--leji-link); } /* brand-tinted inline code, replacing the stock orange. Scoped away from pre > code so fenced blocks keep the stock token colors. */ .markdown-section code, .markdown-section p code, .markdown-section li code { - color: var(--theme-color); + color: var(--leji-link); background: var(--leji-code-bg); } /* The fenced-code panel takes the same ground, replacing the stock neutral gray. */ @@ -1173,5 +1206,5 @@ body { } .markdown-section blockquote { border-left: 3px solid var(--leji-accent); - color: var(--leji-ink-soft); + color: var(--leji-text-body); } diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/index.html b/packages/sdk-py/src/leji/_assets/templates/viewer/index.html index 924ce01..5745eb6 100644 --- a/packages/sdk-py/src/leji/_assets/templates/viewer/index.html +++ b/packages/sdk-py/src/leji/_assets/templates/viewer/index.html @@ -18,12 +18,16 @@ Docsify sets element styles at runtime; that is not a script-execution path. frame-src 'none' closes the framing path: without it a governed document could frame a same-origin /content/ page, which inherits none of this. - The local server sends the same policy as a response header on every - response, so it also covers documents fetched outside this shell. + The directive naming who may frame this page is deliberately absent: a meta + policy cannot deliver it, so claiming it here would protect nothing and cost + an ignored-directive console error on every page. The local server sends + this policy as a response header on every response, with that directive + added, so the served viewer is covered including documents fetched outside + this shell; a static host serving an export sets it in its own headers. --> {{LEJI_NAME_HTML}} @@ -34,7 +38,8 @@ /* Viewer default styling: white content, the brand canvas on the sidebar and its chrome, tight nesting — the shape teams hand-build for internal dashboards. Accents ride --theme-color (set by Docsify from - viewer.theme.primary). */ + viewer.theme.primary); the text tones are the --leji-text* custom + properties assets/vue.css declares, which loads before this block. */ body { background-color: #ffffff; } @@ -48,7 +53,7 @@ z-index: 5; font-size: 0.7rem; letter-spacing: 0.02em; - color: #555555; + color: var(--leji-text-body); background: #f7f8f5; border: 1px solid #cde5d9; border-radius: 999px; @@ -80,7 +85,7 @@ width: max-content; text-align: center; font-size: 0.8rem; - color: #999999; + color: var(--leji-text-body); animation: lj-nav-pulse 1.2s ease-in-out infinite; } @keyframes lj-nav-pulse { @@ -94,7 +99,7 @@ content: none !important; } .sidebar-nav li > a { - color: #2c3e50; + color: var(--leji-text); padding: 6px 0; } .sidebar-nav li > a:hover { @@ -136,7 +141,7 @@ /* Group headers: the theme's default bold rendering, at full size. */ .sidebar-nav li > strong, .sidebar-nav li > p > strong { - color: #1a1a1a; + color: var(--leji-text); font-weight: 600; user-select: none; } @@ -178,7 +183,7 @@ } .sidebar-nav ul ul li > a { font-size: 14px; - color: #555; + color: var(--leji-text-body); padding: 2px 0; } .sidebar-nav ul ul li > a:hover { @@ -197,7 +202,7 @@ .search input { background-color: #ffffff; border: 1px solid #cde5d9; - color: #2c3e50; + color: var(--leji-text-body); } .search .results-panel { background-color: #ffffff; @@ -205,7 +210,7 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .search .results-panel .matching-post > a { - color: #2c3e50; + color: var(--leji-text); } .search .results-panel .matching-post > a:hover { color: var(--theme-color, #009f71); @@ -241,7 +246,7 @@ font-size: 11px; letter-spacing: normal; text-transform: none; - color: #9aa0a6; + color: var(--leji-text-body); z-index: 30; } .leji-powered a { diff --git a/packages/sdk-py/src/leji/cli.py b/packages/sdk-py/src/leji/cli.py index cd0cae6..5eaec1e 100644 --- a/packages/sdk-py/src/leji/cli.py +++ b/packages/sdk-py/src/leji/cli.py @@ -24,6 +24,7 @@ from .freshness import freshness_report from .indexgen import check_index, write_index from .layout import DIST_REL, VIEWER_REL +from .leji_ignore import LejiIgnoreContext, new_leji_ignore_context from .gitutil import git_origin_url from .dependency import dependency_add_failed, offer_dependency from .ecosystem import ( @@ -343,17 +344,25 @@ def _print_findings(findings: list[Finding]) -> None: # A rule that locates a line says so, so a reader can go to it. where = f" {f.path}{'' if f.line is None else f':{f.line}'}" if f.path else "" label = "error " if f.severity == "error" else "warning" - print(f"{label} {f.rule}{where}: {f.message}") + # A rule that names the act it failed at says which one, on the same line. + detail = "" if f.detail is None else f" (detail: {f.detail})" + print(f"{label} {f.rule}{where}: {f.message}{detail}") -def _mount_findings(rows: list[dict[str, object]]) -> list[Finding]: +def _mount_findings( + rows: list[dict[str, object]], reasons: dict[str, str] | None = None +) -> list[Finding]: """The mount findings ``mounts hydrate`` and ``mounts status`` emit: what this run observed and nothing beyond it. The row-level warnings describe the run that is happening, never a remembered one, and all are visibility rather than failure — ``hydrate`` stays best-effort, so none moves the exit code. Emitted in declaration order, as Node emits them; the human renderer sorts, the JSON - surface does not.""" + surface does not. + + ``reasons`` is the resolver's reason for the one ``--fetch`` act that failed, + per mount: the finding names the act, the reason says what the act ran into.""" out: list[Finding] = [] + reasons = reasons or {} for row in rows: # An unavailable mount whose pinned layer would not project: the outcome # alone is not the diagnostic interface JSON consumers read, so the failure @@ -369,6 +378,7 @@ def _mount_findings(rows: list[dict[str, object]]) -> list[Finding]: path=cast("str", row["name"]), ) ) + reason = reasons.get(cast("str", row["name"])) if row.get("storeFetched") is False: out.append( Finding( @@ -376,6 +386,7 @@ def _mount_findings(rows: list[dict[str, object]]) -> list[Finding]: severity="warning", message="the managed store could not be established by the requested fetch", path=cast("str", row["name"]), + detail=None if reason is None else f"current pin: {reason}", ) ) if row.get("witnessRefreshFailed"): @@ -385,6 +396,7 @@ def _mount_findings(rows: list[dict[str, object]]) -> list[Finding]: severity="warning", message="the managed witness ref could not be refreshed by the requested fetch", path=cast("str", row["name"]), + detail=None if reason is None else f"witness: {reason}", ) ) return out @@ -423,7 +435,7 @@ def _print_unindexed_nudge(count: int) -> None: print(f"{count} file(s) unindexed: add to a category index or leave as reference deliberately") -def _run_export(args: argparse.Namespace) -> int: +def _run_export(args: argparse.Namespace, ignore_context: LejiIgnoreContext) -> int: """The one export run, reached by both of its names: `leji export` (the front door) and `leji viewer build` (the viewer subsystem's name for the same operation, beside `viewer serve`). One code path, so the two are byte-identical @@ -441,7 +453,12 @@ def _run_export(args: argparse.Namespace) -> int: return _report_export( args, BuildResult(out=out if out is not None else DIST_REL, findings=load.findings) ) - return _report_export(args, build_viewer(args.root, load.manifest, out, strict=args.strict)) + return _report_export( + args, + build_viewer( + args.root, load.manifest, out, strict=args.strict, ignore_context=ignore_context + ), + ) def _report_export(args: argparse.Namespace, r: BuildResult) -> int: @@ -542,8 +559,16 @@ def _report_update_pin(args: argparse.Namespace, r: UpdatePinResult) -> int: f"Would update leji.json: {r.mount['name']} pin {from12} → {to12} (dry run){overridden}" ) elif r.action == "refused": + # The refusal's own finding is what the document carries, so the human line + # is read off it rather than looked up a second time: one sentence, and the + # act it failed at when the rule names one. reason = r.reason or "" - print(f"Refused: {MOUNT_UPDATE_PIN_REASONS.get(reason, reason)}") + refusal = next((f for f in findings if f.severity == "error"), None) + message = ( + MOUNT_UPDATE_PIN_REASONS.get(reason, reason) if refusal is None else refusal.message + ) + detail = "" if refusal is None or refusal.detail is None else f" (detail: {refusal.detail})" + print(f"Refused: {message}{detail}") return 0 if ok else 1 @@ -1455,6 +1480,13 @@ def main(argv: list[str] | None = None) -> int: print(USAGE) return 2 + # One notice state for this invocation, created at the command entry point and + # handed to every path that can create a `.leji/` role: whatever a command + # establishes, it says at most once that it left an existing `.leji/.gitignore` + # alone. A second repository, or a long-lived host calling the SDK directly, never + # inherits it. + ignore_context = new_leji_ignore_context() + try: if args.command == "validate": validate_result = validate_layer(args.root, content=args.content) @@ -1840,7 +1872,11 @@ def split_list(s: object) -> list[str]: print("leji: --federation on conformance takes only verify\n", file=sys.stderr) print(USAGE, file=sys.stderr) return 2 - conformance = conformance_report(args.root, federation=args.federation == "verify") + conformance = conformance_report( + args.root, + federation=args.federation == "verify", + ignore_context=ignore_context, + ) if not args.json: for check_item in conformance.items: mark = { @@ -1909,14 +1945,17 @@ def split_list(s: object) -> list[str]: allow_non_fast_forward=args.allow_non_fast_forward, fetch=args.fetch, dry_run=args.dry_run, + ignore_context=ignore_context, ), ) if subcommand == "hydrate": - hydrate = hydrate_mounts(args.root, load.manifest, fetch=args.fetch) + hydrate = hydrate_mounts( + args.root, load.manifest, fetch=args.fetch, ignore_context=ignore_context + ) if hydrate.fatal is not None: print(f"leji: {hydrate.fatal}", file=sys.stderr) return 1 - issues = _mount_findings(hydrate.outcomes) + issues = _mount_findings(hydrate.outcomes, hydrate.reasons) had_error = any(o["status"] == "error" for o in hydrate.outcomes) if args.json: print( @@ -2022,7 +2061,7 @@ def split_list(s: object) -> list[str]: if args.command == "export" or ( args.command == "viewer" and getattr(args, "subcommand", None) == "build" ): - return _run_export(args) + return _run_export(args, ignore_context) if args.command in ("viewer", "view"): # `view` aliases `viewer serve` and also opens the browser. @@ -2032,7 +2071,7 @@ def split_list(s: object) -> list[str]: load = load_manifest(args.root) if load.manifest is None: return _emit("viewer", load.findings, args.json) - viewer_result = generate_viewer(args.root, load.manifest) + viewer_result = generate_viewer(args.root, load.manifest, ignore_context) # Terse by design: findings when something needs attention, one status # line otherwise. The full write list lives in --json. all_findings = [*load.findings, *viewer_result.findings] @@ -2061,6 +2100,9 @@ def split_list(s: object) -> list[str]: resolve_viewer_port(load.manifest, args.port), load.manifest["rootPath"], log=None if args.json else lambda line: print(line, flush=True), + # The generation above just projected the index: the first layer map is + # that snapshot rather than a second generation of the same tree. + entries=viewer_result.index_entries, ) port = server.server_address[1] # Display localhost (nicer, still a secure context); server stays bound @@ -2105,6 +2147,7 @@ def split_list(s: object) -> list[str]: no_agents=args.no_agents, agent=args.agent, mode=args.mode, + ignore_context=ignore_context, ) # The repository's own dependency ecosystem, read once and reported by # every output mode: the human block, the JSON document, and the offer. @@ -2163,6 +2206,7 @@ def split_list(s: object) -> list[str]: detected=adopt_result.detected, interactive=interactive, agent=args.agent, + ignore_context=ignore_context, ) ) if not handoff_offer( @@ -2189,6 +2233,7 @@ def split_list(s: object) -> list[str]: no_agents=args.no_agents, agent=args.agent, mode=args.mode, + ignore_context=ignore_context, ) init_eco = detect_ecosystem(init_result.root) if init_result.dry_run: @@ -2225,6 +2270,7 @@ def split_list(s: object) -> list[str]: detected=init_result.detected, interactive=interactive, agent=args.agent, + ignore_context=ignore_context, ) ) if not handoff_offer( diff --git a/packages/sdk-py/src/leji/conformance.py b/packages/sdk-py/src/leji/conformance.py index e80fac9..66adc32 100644 --- a/packages/sdk-py/src/leji/conformance.py +++ b/packages/sdk-py/src/leji/conformance.py @@ -18,6 +18,7 @@ effective_changelog_path, load_manifest, ) +from .leji_ignore import LejiIgnoreContext from .mounts import MountDecl, check_pin_reachability, normalize_source from .validate import check_changelog_append_only, mount_surfacing_findings, validate_layer @@ -83,10 +84,20 @@ def _count_attested(items: list[ChecklistItem]) -> int: return sum(1 for it in items if it.id in _PROCESS_ATTESTED_IDS) -def conformance_report(root: str, federation: bool = False) -> ConformanceResult: +def conformance_report( + root: str, + federation: bool = False, + ignore_context: LejiIgnoreContext | None = None, +) -> ConformanceResult: """Machine-checkable items pass or fail; process items (review gate, CI, federation consumers) are reported as `manual` and never block a level. - A claim above the verified level is an error.""" + A claim above the verified level is an error. + + ``ignore_context`` is the invocation's notice state for the self-managed + ``.leji/.gitignore``. Only the ``--federation`` probe reaches a mounts operation + that can establish a role, and it reaches one PER DECLARED MOUNT, so the context is + threaded rather than left to each call: one invocation notices at most once, + however many mounts it probes.""" items: list[ChecklistItem] = [] findings: list[Finding] = [] manifest = load_manifest(root).manifest @@ -311,6 +322,7 @@ def _declaration_defect(m: dict) -> Optional[str]: pin=m["pin"], tracking_ref=m.get("trackingRef"), ), + ignore_context, ) if r.state != "reachable": bad = (m["name"], r.state, r.detail) diff --git a/packages/sdk-py/src/leji/export_cmd.py b/packages/sdk-py/src/leji/export_cmd.py index 300fa71..773616b 100644 --- a/packages/sdk-py/src/leji/export_cmd.py +++ b/packages/sdk-py/src/leji/export_cmd.py @@ -19,6 +19,11 @@ from typing import Optional from .findings import Finding, sort_findings +from .leji_ignore import ( + LejiIgnoreContext, + ensure_leji_ignore_file, + new_leji_ignore_context, +) from .fsx import ( mkdirp_guarded, open_verified_source, @@ -36,6 +41,7 @@ from .layout import ( DIST_REL, LEJI_DIR, + LEJI_IGNORE_REL, VIEWER_REL, TargetVerdict, leji_role, @@ -48,9 +54,11 @@ from .viewer_cmd import ( ACTIVE_EXTENSIONS, EXPORT_BASE, + OVERVIEW_REL, _build_index_html, _resolved_profile_pages, generate_viewer, + render_overview, ) # The protect-your-context warning shown by `leji export` and embedded in the @@ -174,6 +182,7 @@ def build_viewer( manifest: Manifest, out_rel: Optional[str] = None, strict: bool = False, + ignore_context: Optional[LejiIgnoreContext] = None, ) -> BuildResult: """Export a self-contained static viewer into out_rel with the same URL contract the local server serves (chrome at the web root, layer markdown under /content/), @@ -182,8 +191,15 @@ def build_viewer( clear and write the target — so a failing check leaves a pre-existing export byte-untouched. ``strict`` is the gate: a lint finding fails the run before the target is cleared, mirroring ``status --strict``. The exported index.html carries - the protect-your-context warning as a comment.""" - gen = generate_viewer(root, manifest) + the protect-your-context warning as a comment. ``ignore_context`` is the + invocation's notice state for the self-managed ``.leji/.gitignore``, passed through + to the generation pass this run nests so one invocation notices once; a direct SDK + call that omits it notices at most once for that call.""" + # One context for the whole run, whether the caller supplied it or not: this command + # establishes two roles (the chrome it regenerates and its own output), and a caller + # that passes none is still one call. + ignore_context = ignore_context or new_leji_ignore_context() + gen = generate_viewer(root, manifest, ignore_context) # Every path below is resolved, root included, so the path a check judges is the # path the write lands on: a symlinked component — or a case-variant spelling of a # reserved role on a case-insensitive filesystem — resolves to its real name here, @@ -451,6 +467,14 @@ def open_carried(child_rel: str) -> Optional[int]: if not cleared.ok: raise _refused_dest(root_abs, out_abs, cleared) mkdir_dest(out_content) + # The output role exists: ensure the tool's own ignore file, as every role + # establisher does. The generation pass above shares this run's context, so an + # existing file is noticed once for the whole invocation rather than per role. + if ensure_leji_ignore_file(str(root_abs), ignore_context) == "refused": + raise RuntimeError( + f'refusing to write "{LEJI_IGNORE_REL}": it does not resolve to a regular ' + f"file inside {LEJI_DIR}/; remove the symlink" + ) for item in carried: dest = out_content / item.rel if item.is_dir: @@ -459,9 +483,23 @@ def open_carried(child_rel: str) -> Optional[int]: # Markdown was read once already: the exported file is that snapshot, so what # the lint judged is what the export carries. A document the re-check dropped # has no snapshot and is not exported. + # + # The overview homepage is the one path whose exported copy is not its source: + # the layer map is substituted between its markers here, after the lint has + # judged the source bytes, from the entries the generation above already + # projected. The layer's own file is not touched, and the map an export carries + # is the map the local server renders from the same function. if item.rel.lower().endswith(".md"): snapshot = linted.get(item.rel) if snapshot is not None: + if item.rel == OVERVIEW_REL: + # `errors="replace"` mirrors Node's Buffer.toString('utf8'), which + # substitutes rather than throwing on invalid bytes. + rendered = render_overview( + snapshot.decode("utf-8", errors="replace"), manifest, gen.index_entries + ) + if rendered.markers_found: + snapshot = rendered.text.encode("utf-8") write_dest(dest, snapshot) continue fd = open_carried(item.rel) diff --git a/packages/sdk-py/src/leji/findings.py b/packages/sdk-py/src/leji/findings.py index 3c41edf..9144fdb 100644 --- a/packages/sdk-py/src/leji/findings.py +++ b/packages/sdk-py/src/leji/findings.py @@ -21,6 +21,11 @@ class Finding: #: three SDKs compare on for ``render-unsupported``, message text being #: outside the contract. None when the rule names none, and then omitted. construct: Optional[str] = None + #: Which act a rule with more than one failed at, and the resolver's own + #: reason for it: ``": "``. Serialized immediately after + #: ``message``, so the three SDKs emit the same bytes; None for every rule + #: that names no act, and then omitted. + detail: Optional[str] = None def to_dict(self) -> dict: out: dict = {"rule": self.rule, "severity": self.severity} @@ -31,6 +36,8 @@ def to_dict(self) -> dict: if self.construct is not None: out["construct"] = self.construct out["message"] = self.message + if self.detail is not None: + out["detail"] = self.detail return out diff --git a/packages/sdk-py/src/leji/fsx.py b/packages/sdk-py/src/leji/fsx.py index 071d9cf..ad81251 100644 --- a/packages/sdk-py/src/leji/fsx.py +++ b/packages/sdk-py/src/leji/fsx.py @@ -9,7 +9,14 @@ from pathlib import Path from typing import Callable, Literal, Optional, Union -from .layout import TargetVerdict, writable_target +from .layout import ( + LEJI_DIR, + LEJI_IGNORE_REL, + TargetVerdict, + leji_role, + role_abs, + writable_target, +) def to_posix(p: str) -> str: @@ -216,14 +223,68 @@ def resolved_within_root(root: str, candidate: Path) -> bool: return real == real_root or real.startswith(real_root + os.sep) +def _metadata_file_verdict( + root_abs: str, target_abs: str, resolved: str, own_role_rel: Optional[str] +) -> Optional[TargetVerdict]: + """The ONE declared exception to the role rule, and the only place a + ``metadata_file`` verdict is constructed: ``/.leji/.gitignore``, the ignore + file the tool keeps for its own tree. It belongs to no role, so + :func:`~leji.layout.writable_target` refuses it and cannot be the judge here: the + rule it needs is about the REQUESTED entry, which ``writable_target`` never sees. + + None means "not this path": every other target falls through to the rule + unchanged. Otherwise the verdict is allowed on all three conditions, checked on the + ORIGINAL directory entries so a link is caught rather than followed: + + 1. the requested path is exactly ``/.leji/.gitignore``, and it resolves to + itself (a ``.LEJI/`` spelling on a case-insensitive filesystem resolves to the + name the filesystem holds and is not this path); + 2. ``/.leji`` is a real directory, never a symlink; + 3. the entry is absent or a regular file, never a symlink or anything else. + + When a condition fails the exception REFUSES rather than falling back to an + allowance: today's verdict stands when it already refuses (a ``.leji`` symlinked out + of the repository is ``outside_root``, exactly as it is now), and a redirect that + happens to land on ordinary content is refused as the requested path's own role, + never written through. The exception can only narrow, never widen.""" + expected = role_abs(root_abs, LEJI_IGNORE_REL) + if os.path.abspath(target_abs) != expected: + return None + try: + directory: Optional[os.stat_result] = os.lstat(role_abs(root_abs, LEJI_DIR)) + except OSError: + directory = None + entry_allowed = False + try: + entry = os.lstat(expected) + entry_allowed = stat.S_ISREG(entry.st_mode) + except FileNotFoundError: + entry_allowed = True + except OSError: + entry_allowed = False + if ( + resolved == expected + and directory is not None + and stat.S_ISDIR(directory.st_mode) + and entry_allowed + ): + return TargetVerdict(ok=True, metadata_file=True) + verdict = writable_target(root_abs, resolved, own_role_rel) + return TargetVerdict(role=leji_role(root_abs, expected)) if verdict.ok else verdict + + def _judge_target( root_abs: str, target_abs: str, own_role_rel: Optional[str] ) -> tuple[TargetVerdict, Optional[str]]: - """One judged target: the verdict :func:`~leji.layout.writable_target` returns - for its RESOLVED path, and that path — None only when it could not be resolved.""" + """One judged target: the verdict the rule returns for its RESOLVED path, which is + :func:`~leji.layout.writable_target`'s except at the one declared exception above, + and that path, None only when it could not be resolved.""" resolved = resolved_path_under(root_abs, target_abs) if resolved is None: return TargetVerdict(unresolvable=True), None + exception = _metadata_file_verdict(root_abs, target_abs, resolved, own_role_rel) + if exception is not None: + return exception, resolved return writable_target(root_abs, resolved, own_role_rel), resolved @@ -599,7 +660,15 @@ def verified_target_read( def allow(resolved: str) -> bool: nonlocal refusal - verdict = writable_target(root_abs, resolved, own_role_rel) + # The same rule the write will be judged by, the declared exception included: + # the read-then-act pair must agree, or the one target that belongs to no role + # could be read here and refused at the write (or the reverse). + exception = _metadata_file_verdict(root_abs, target_abs, resolved, own_role_rel) + verdict = ( + exception + if exception is not None + else writable_target(root_abs, resolved, own_role_rel) + ) if verdict.ok: return True refusal = "outside-root" if verdict.outside_root else "other-role" diff --git a/packages/sdk-py/src/leji/init_cmd.py b/packages/sdk-py/src/leji/init_cmd.py index f33d709..c723155 100644 --- a/packages/sdk-py/src/leji/init_cmd.py +++ b/packages/sdk-py/src/leji/init_cmd.py @@ -54,7 +54,8 @@ from .findings import Finding, has_errors from .gitutil import tracked_under, working_tree_clean from .indexgen import write_index -from .layout import LEJI_DIR, WORK_REL, TargetVerdict +from .layout import LEJI_DIR, LEJI_IGNORE_REL, WORK_REL, TargetVerdict +from .leji_ignore import LejiIgnoreContext, ensure_leji_ignore_file from .manifest import ( Manifest, bind_agent_in_manifest_text, @@ -379,6 +380,18 @@ def _assert_no_symlink_escape(root: Path, abs_path: Path, rel: str) -> None: raise InitPathError(f'refusing to write through a symlink that escapes the target: "{rel}"') +def _ensure_leji_ignore_or_refuse( + root: str, ignore_context: Optional[LejiIgnoreContext] = None +) -> None: + """The transient onboarding workspace is a ``.leji/`` role, so these commands ensure + the tool's own ignore file exactly as every other role establisher does. A refusal is + the refusal this command has always raised for an escaping target.""" + if ensure_leji_ignore_file(root, ignore_context) == "refused": + raise InitPathError( + f'refusing to write through a symlink that escapes the target: "{LEJI_IGNORE_REL}"' + ) + + def _init_role(rel: str) -> Optional[str]: """The ``.leji/`` role an init or adopt write legitimately lands in: the transient onboarding workspace is the tool's own ``work`` role, and everything else these @@ -1463,6 +1476,10 @@ def init_layer( # Skip generating the portable `AGENTS.md` pointer (written by default when # absent; an existing file is never touched). no_agents: bool = False, + # The invocation's notice state for the self-managed `.leji/.gitignore`, which this + # command ensures when it creates the onboarding workspace. None means a context + # local to this call. + ignore_context: Optional[LejiIgnoreContext] = None, ) -> InitResult: """Bootstrap a context layer. Interactive unless ``yes``. Refuses to run when leji.json already exists; never overwrites existing files.""" @@ -1597,6 +1614,10 @@ def init_layer( if w.rel == changelog_rel: continue _write_file_once(root, w.rel, w.content, written) + # The onboarding workspace is a `.leji/` role and now exists, so the tool ignores its + # own tree from inside: the nested counterpart to the root `.gitignore` line above, + # and the one that covers a layer whose root file never received it. + _ensure_leji_ignore_or_refuse(str(root), ignore_context) # The whole of the `leji index` rule, not half of it: write_index declines to # write on a hard generation finding, so the file is not claimed, the dependent @@ -1749,6 +1770,10 @@ def adopt_layer( # Skip generating the portable `AGENTS.md` pointer (written by default when # absent; an existing file keeps the migrate/--wire-adapters flow). no_agents: bool = False, + # The invocation's notice state for the self-managed `.leji/.gitignore`, which this + # command ensures when it creates the onboarding workspace. None means a context + # local to this call. + ignore_context: Optional[LejiIgnoreContext] = None, ) -> AdoptResult: """Bring Leji into an existing repository: reuse an existing docs root, migrate any vendor entrypoints into the layer (originals untouched), and seed the scaffold. @@ -1964,6 +1989,10 @@ def adopt_layer( written.append(w.rel) else: _write_file_once(root, w.rel, w.content, written) + # The onboarding workspace is a `.leji/` role and now exists, so the tool ignores its + # own tree from inside: the nested counterpart to the root `.gitignore` line above, + # and the one that covers a layer whose root file never received it. + _ensure_leji_ignore_or_refuse(str(root), ignore_context) # Same rule as `leji index`: the file is claimed only when it was written, and # the findings travel out so the caller reports them and fails. @@ -2759,12 +2788,17 @@ def _approval_guard_script(leji_rel: str) -> str: GuardAction = str # "installed" | "unchanged" -def ensure_approval_guard(root: str, root_path: str) -> GuardAction: +def ensure_approval_guard( + root: str, root_path: str, ignore_context: Optional[LejiIgnoreContext] = None +) -> GuardAction: """Write the guard script under the onboarding workspace (`.leji/work/hooks/`) and merge its PreToolUse entry into .claude/settings.json (created if absent, other settings preserved). Idempotent: an existing guard entry is left untouched. ``root_path`` no longer selects the workspace — it is one root-relative tree — - and is kept only so the exported signature holds.""" + and is kept only so the exported signature holds. ``ignore_context`` is the + invocation's notice state for the self-managed `.leji/.gitignore`, which this + function ensures because it creates `.leji/work/hooks/`; None means a context local + to this call.""" del root_path root_abs = Path(root).resolve() leji_rel = WORK_REL @@ -2805,6 +2839,8 @@ def ensure_approval_guard(root: str, root_path: str) -> GuardAction: if isinstance(h, dict) ) _write_file_atomic(root_abs, script_abs, script_rel, _approval_guard_script(leji_rel)) + # `.leji/work/hooks/` now exists: this is a role establisher like any other. + _ensure_leji_ignore_or_refuse(str(root_abs), ignore_context) if present: return "unchanged" pre.append( @@ -2834,6 +2870,9 @@ class GuardOfferOptions: interactive: bool agent: Optional[str] = None io: Optional[HandoffIO] = None + #: The invocation's notice state for the self-managed ``.leji/.gitignore``, passed + #: through to the install this offer nests. + ignore_context: Optional[LejiIgnoreContext] = None def offer_approval_guard(opts: GuardOfferOptions) -> None: @@ -2864,7 +2903,7 @@ def offer_approval_guard(opts: GuardOfferOptions) -> None: ).lower() if answer not in ("", "y", "yes"): return - action = ensure_approval_guard(opts.root, opts.root_path) + action = ensure_approval_guard(opts.root, opts.root_path, opts.ignore_context) print( "Onboarding guard added (this repository only: .claude/settings.json hook + " ".leji/work/hooks/approval-guard.mjs; removed at finalize)." diff --git a/packages/sdk-py/src/leji/layout.py b/packages/sdk-py/src/leji/layout.py index de5099b..cba449c 100644 --- a/packages/sdk-py/src/leji/layout.py +++ b/packages/sdk-py/src/leji/layout.py @@ -33,6 +33,13 @@ #: The private federation domain: managed object stores, projection cache, staging. MOUNTS_REL = f"{LEJI_DIR}/mounts" +#: The one metadata file the tool keeps directly under root ``.leji/``, outside every +#: role: the ignore file that keeps the tool's own tree out of the repository even +#: when the root ``.gitignore`` never received the ``.leji/`` line. It belongs to no +#: role, so the role rule below refuses it; the single named exception that allows it +#: lives in ``fsx.py``, where the REQUESTED entry is still visible. +LEJI_IGNORE_REL = f"{LEJI_DIR}/.gitignore" + def role_abs(root_abs: str, rel: str) -> str: """Join a repository-root-relative role path (POSIX, as the constants above @@ -75,19 +82,32 @@ def leji_role(root_abs: str, abs_path: str) -> str: return "" if rest == "." else rest.split(os.sep)[0] -@dataclass +@dataclass(kw_only=True) class TargetVerdict: """The verdict of :func:`writable_target`: whether a tool-owned target may be written or cleared, and — when refused — that it landed outside the repository, the private role it crossed into, that the path could not be resolved at all (permission/I/O, not mere absence), or that an exclusive create found the file - already there.""" + already there. + + ``metadata_file`` marks the one allowed target that belongs to no role, + :data:`LEJI_IGNORE_REL`. It is never produced here: only the named exception in + ``fsx.py`` constructs it, on the requested entry, and a source-audit test pins that + single constructor site. + + KEYWORD-ONLY, deliberately. The pin that keeps ``metadata_file`` to one constructor + reads NAMES, so a positional ``TargetVerdict(True, "", False, False, False, True)`` + would set the field without ever spelling it. ``kw_only`` removes that spelling from + the language rather than leaving it to an audit to chase: a positional construction + is now a ``TypeError``, not a silent second exception. The audit flags it too, so the + refusal is visible at review time as well as at run time.""" ok: bool = False role: str = "" unresolvable: bool = False outside_root: bool = False exists: bool = False + metadata_file: bool = False def writable_target(root_abs: str, resolved_abs: str, own_role_rel: Optional[str]) -> TargetVerdict: diff --git a/packages/sdk-py/src/leji/leji_ignore.py b/packages/sdk-py/src/leji/leji_ignore.py new file mode 100644 index 0000000..15b1d37 --- /dev/null +++ b/packages/sdk-py/src/leji/leji_ignore.py @@ -0,0 +1,124 @@ +"""The self-managed ``.leji/.gitignore``: the tool ignores its own directory from +inside. + +A layer whose root ``.gitignore`` never received the ``.leji/`` line (adopted before +the unified layout, or written by hand) otherwise grows an untracked generated tree at +every command; one file inside ``.leji/`` closes that without touching the +repository's own ignore rules. + +The file is written the first time a command creates a role under ``.leji/``, and only +there: nothing read-only ever creates it. What stands at the target decides the act, +read through the verified read rather than a pathname check, so a file swapped between +the look and the write is never written through. + +Mirrors packages/sdk/src/lib/leji-ignore.ts. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Literal, Optional + +from .fsx import guard_root, verified_target_read, write_file_guarded +from .layout import LEJI_IGNORE_REL, role_abs + +#: The whole file: ignore everything under ``.leji/``, this file included. Nothing in +#: that tree is committed by design, so the rule needs no exceptions and never grows +#: any. A byte contract shared with the Node and Go SDKs. +LEJI_IGNORE_CONTENT = "*\n" + +#: What a run says, once, when it left an existing file alone. Frozen text, on stderr +#: under every output mode: it is an advisory about the repository, never part of a +#: ``--json`` document. +LEJI_IGNORE_NOTICE = f"leji: {LEJI_IGNORE_REL} exists and was left as is (expected content: *)" + + +@dataclass +class LejiIgnoreContext: + """One invocation's notice state. Created at the CLI command entry point and passed + down every call path that can create a role, so one invocation says it once however + many roles it establishes: ``leji export`` creates the viewer chrome and the export + output and still notices once. A directly callable SDK function takes it as an + optional parameter and passes it to whatever it nests; a direct caller that supplies + none gets a context local to that call, so the documented behavior there is at most + one notice per call. Deliberately not a module global: that would be process-scoped, + and a long-lived host or a second repository in the same process would inherit a + state that is not its own.""" + + noticed: bool = False + + def say(self) -> None: + """Emit the frozen notice unless this context already did.""" + if self.noticed: + return + self.noticed = True + print(LEJI_IGNORE_NOTICE, file=sys.stderr) + + +def new_leji_ignore_context() -> LejiIgnoreContext: + """A fresh invocation context.""" + return LejiIgnoreContext() + + +#: What one :func:`ensure_leji_ignore_file` call did: +#: +#: - ``created``: nothing stood there and the file was created exclusively; +#: - ``present``: a regular file already holds exactly these bytes; +#: - ``left-as-is``: a regular file holds something else; it is untouched and the +#: notice was emitted (once per context); +#: - ``exists``: an entry appeared between the read and the exclusive create, so the +#: create found it and wrote nothing; +#: - ``refused``: the boundary refused the target (a symlink at ``.leji`` or at the +#: file, a non-regular entry, a containment failure); nothing was written. +LejiIgnoreOutcome = Literal["created", "present", "left-as-is", "exists", "refused"] + + +def ensure_leji_ignore_file( + root: str, ctx: Optional[LejiIgnoreContext] = None +) -> LejiIgnoreOutcome: + """Ensure ``.leji/.gitignore`` exists, at the one exception the write rule declares. + + Idempotent, and safe to call from every role establisher: the decision comes from + :func:`~leji.fsx.verified_target_read` (bytes read from the descriptor the rule + cleared), and the create is exclusive through the guarded write path, so neither + branch rests on a pathname that could change underneath it. A refusal is returned + rather than raised; the calling command reports it the way it reports any refused + write. + + ``ctx`` None means a context local to this call.""" + if ctx is None: + ctx = new_leji_ignore_context() + root_real = guard_root(root) + abs_path = role_abs(root_real, LEJI_IGNORE_REL) + # Two looks at most. Another run creating this same file lands between the first + # look and its verification, and a standing entry that could not be verified is + # `unverifiable`, which here is an ordinary concurrent create rather than a + # refusal, so it is looked at once more and read as what it now is. Anything this + # run genuinely cannot verify refuses on the second look exactly as on the first, + # and every other refusal (a symlink, a non-regular entry, a containment failure) + # is final at the first. + for look in range(2): + standing = verified_target_read(root_real, abs_path, None) + if standing.status == "refused": + if standing.reason == "unverifiable" and look == 0: + continue + return "refused" + if standing.status == "regular": + if standing.data == LEJI_IGNORE_CONTENT.encode("utf-8"): + return "present" + # An empty file is the other half of that concurrent create: the winner has + # opened it exclusively and not yet written its two bytes. Looking again + # answers what it holds; a file that is genuinely empty answers the same + # thing twice and is left alone like any other content. + if len(standing.data) == 0 and look == 0: + continue + # Someone else's file: never merged, never rewritten. The run says so once + # and leaves the bytes exactly as they are. + ctx.say() + return "left-as-is" + verdict = write_file_guarded(root_real, abs_path, None, LEJI_IGNORE_CONTENT, exclusive=True) + if verdict.exists: + return "exists" + return "created" if verdict.ok else "refused" + return "refused" diff --git a/packages/sdk-py/src/leji/mounts.py b/packages/sdk-py/src/leji/mounts.py index ad2cd7f..9b3888c 100644 --- a/packages/sdk-py/src/leji/mounts.py +++ b/packages/sdk-py/src/leji/mounts.py @@ -37,6 +37,11 @@ from typing import Callable, Literal, cast from .fsx import guard_root, mkdirp_guarded, resolved_within_root +from .leji_ignore import ( + LejiIgnoreContext, + ensure_leji_ignore_file, + new_leji_ignore_context, +) from .layout import MOUNTS_REL from .manifest import Manifest, all_strings_scalar from .schemas import schema_errors @@ -117,6 +122,19 @@ class FetchResult: # The run reporting on itself, never a remembered observation. witness_refresh_failed: bool = False error: str | None = None + # The witness reason travels beside the flag, never in ``error``: that one is + # the store's own failure, and a mount whose store WAS established must not + # start reporting the witness reason as the reason nothing holds its pin. + witness_error: str | None = None + + +@dataclass +class WitnessRefresh: + """What one witness refresh did, and (when it did not publish) which half + did not happen, in stable Leji-authored text.""" + + ok: bool + error: str | None = None @dataclass @@ -137,6 +155,11 @@ class ProjectionResult: class HydrateResult: outcomes: list[dict[str, object]] fatal: str | None = None + #: The resolver's own reason for the one ``--fetch`` act that failed per + #: mount, keyed by mount name, for the caller's findings; it is a transport, + #: never a document member, and the writers of ``mounts hydrate --json`` pick + #: their fields explicitly so it is never serialized. + reasons: dict[str, str] = field(default_factory=dict) def read_text_within(root: str, abs_path: Path) -> str | None: @@ -207,7 +230,9 @@ def mounts_dir(root: str) -> str: return _join(root, *MOUNTS_REL.split("/")) -def _establish_mounts_dir(root: str, dir_abs: str) -> str | None: +def _establish_mounts_dir( + root: str, dir_abs: str, ignore_context: LejiIgnoreContext | None = None +) -> str | None: """Establish one mounts DESTINATION — a managed store, a cache entry, a staging directory — through the write chokepoint, and hand back the RESOLVED directory it was created at. None when the rule refuses it: a planted ``.leji/mounts`` symlink @@ -219,7 +244,14 @@ def _establish_mounts_dir(root: str, dir_abs: str) -> str | None: chokepoint, and it holds only because every one of its acts happens under a root this function checked and returned — never under a path re-joined from ``root``.""" established = mkdirp_guarded(guard_root(root), dir_abs, MOUNTS_REL) - return established.real if established.ok else None + if not established.ok: + return None + # A role under ``.leji/`` now exists, so the tool's own ignore file is ensured here + # as it is at every other establisher. A refusal is this destination refusing: the + # caller reports it as it reports any destination it could not establish. + if ensure_leji_ignore_file(root, ignore_context) == "refused": + return None + return established.real def read_hints(root: str) -> dict[str, str]: @@ -420,7 +452,13 @@ def _retention_injected_failure(oid: str) -> bool: return os.environ.get("LEJI_TEST_FAIL_PIN_REF") == oid -def retain_pin_in_store(root: str, mount: MountDecl, source_identity: str, oid: str) -> FetchResult: +def retain_pin_in_store( + root: str, + mount: MountDecl, + source_identity: str, + oid: str, + ignore_context: LejiIgnoreContext | None = None, +) -> FetchResult: """Establish the managed store and retain ONE commit in it: fetch the object by id from the declared source when the store does not already hold it, then keep it reachable under ``refs/leji-pin/v1/``. Nothing here refreshes a witness, so a @@ -438,7 +476,7 @@ def failed(error: str) -> FetchResult: # The locator becomes argv here: anything option-shaped is refused, never passed. if mount.source.startswith("-"): return failed('the source locator may not begin with "-"') - store = _establish_mounts_dir(root, _store_dir(root, source_identity)) + store = _establish_mounts_dir(root, _store_dir(root, source_identity), ignore_context) if store is None: return failed("the managed store could not be initialized") if not _is_git_repo(store): @@ -478,11 +516,16 @@ def failed(error: str) -> FetchResult: return FetchResult(repo=store) -def fetch_into_store(root: str, mount: MountDecl, source_identity: str) -> FetchResult: +def fetch_into_store( + root: str, + mount: MountDecl, + source_identity: str, + ignore_context: LejiIgnoreContext | None = None, +) -> FetchResult: """Fetch the pin and refresh the managed witness ref in the store. This is the only writer of the witness namespace: ``status`` never fetches, so a mount whose pin a hint already resolves still needs its store populated here.""" - retained = retain_pin_in_store(root, mount, source_identity, mount.pin) + retained = retain_pin_in_store(root, mount, source_identity, mount.pin, ignore_context) if retained.repo is None: return retained store = retained.repo @@ -491,18 +534,24 @@ def fetch_into_store(root: str, mount: MountDecl, source_identity: str) -> Fetch # only when it was actually attempted: a run that never got this far has already # reported the fetch failure that stopped it. if mount.tracking_ref is not None and valid_tracking_ref(mount.tracking_ref): - if not refresh_witness(store, mount, source_identity): - return FetchResult(repo=store, witness_refresh_failed=True) + refreshed = refresh_witness(store, mount, source_identity) + if not refreshed.ok: + return FetchResult( + repo=store, witness_refresh_failed=True, witness_error=refreshed.error + ) return FetchResult(repo=store) -def refresh_witness(store: str, mount: MountDecl, source_identity: str) -> bool: +def refresh_witness(store: str, mount: MountDecl, source_identity: str) -> WitnessRefresh: """Refresh the managed witness ref: fetch the tracking ref to a unique temporary ref, publish it onto the canonical witness with git's own compare-and-swap, then drop the temporary. Forced (``+``), so the witness follows a non-fast-forward upstream move. No lock: git's ref update is atomic, a lost swap means another writer published first (a valid outcome), - and a failure leaves the previous witness in place.""" + and a failure leaves the previous witness in place. + + A refusal names which half did not happen, in stable Leji-authored text: the + caller reports the witness act, and the act alone says nothing about why.""" tracking_ref = cast("str", mount.tracking_ref) witness_ref = witness_ref_for(source_identity, tracking_ref) temp_ref = f"{WITNESS_REF_NAMESPACE}/tmp/{os.getpid()}-{secrets.token_hex(8)}" @@ -542,7 +591,18 @@ def refresh_witness(store: str, mount: MountDecl, source_identity: str) -> bool: # Cleanup is not part of the outcome: the canonical ref has already moved, and # a surviving temporary is inert (nothing reads the tmp namespace as a witness). run_git(["-C", store, "update-ref", "-d", temp_ref]) - return published + if published: + return WitnessRefresh(ok=True) + # Two failure classes, and no third: the tracking ref never arrived, or it + # arrived and the canonical ref would not take it. + return WitnessRefresh( + ok=False, + error=( + "the tracking ref could not be fetched from the source" + if tip is None + else "the witness ref could not be published" + ), + ) @dataclass @@ -1225,7 +1285,11 @@ def hydrate_mounts( manifest: Manifest, fetch: bool = False, names: list[str] | None = None, + ignore_context: LejiIgnoreContext | None = None, ) -> HydrateResult: + # One context for the whole run: hydration establishes a store and a staging + # directory per declared mount, and they are all one invocation. + ignore_context = ignore_context or new_leji_ignore_context() tracked = tracked_cache_files(root) if tracked: return HydrateResult( @@ -1235,6 +1299,7 @@ def hydrate_mounts( ) mounts = [m for m in _declared_mounts(manifest) if names is None or m.name in names] outcomes: list[dict[str, object]] = [] + reasons: dict[str, str] = {} for mount in mounts: identity = normalize_source(mount.source) # Details never echo a declaration back: a source may be a local path, and @@ -1260,7 +1325,13 @@ def hydrate_mounts( # --fetch populates the store for every declared mount, cached or already # resolvable: the store is the only witness namespace the resolver owns, # and `status` never fetches. - fetched = fetch_into_store(root, mount, identity) if fetch else None + fetched = fetch_into_store(root, mount, identity, ignore_context) if fetch else None + # At most one act can fail: a store that was not established is never asked + # to refresh a witness, so one reason per mount is the whole vocabulary here. + if fetched is not None: + failed_act = fetched.error if fetched.repo is None else fetched.witness_error + if failed_act is not None: + reasons[mount.name] = failed_act # A requested fetch that did not establish the store is reported on its # own terms, whatever the projection then manages from a hint or the cache. @@ -1309,7 +1380,9 @@ def outcome( # filesystem, and under a per-process name, so no two producers collide. The # staging directory is established through the chokepoint and every act below # works from the RESOLVED path it returned, the cache entry included. - staging = _establish_mounts_dir(root, _join(cache_dir, f".staging-{_staging_token()}")) + staging = _establish_mounts_dir( + root, _join(cache_dir, f".staging-{_staging_token()}"), ignore_context + ) if staging is None: outcomes.append( outcome( @@ -1372,7 +1445,7 @@ def outcome( # whether it is hydrated is the marker on disk. A state file would only be a # second copy of both, and one that two concurrent partial runs can each drop # entries from. - return HydrateResult(outcomes=outcomes) + return HydrateResult(outcomes=outcomes, reasons=reasons) def verify_projection(root: str, mount: MountDecl) -> bool | None: @@ -1770,7 +1843,9 @@ class ReachabilityResult: detail: str | None = None -def check_pin_reachability(root: str, mount: MountDecl) -> ReachabilityResult: +def check_pin_reachability( + root: str, mount: MountDecl, ignore_context: LejiIgnoreContext | None = None +) -> ReachabilityResult: """The networked conformance probe: is the pin reachable from an advertised ref of ``source``? Advertisement comes from ``git ls-remote`` against the declared source (never a hint: hint-only resolution is availability, not @@ -1813,7 +1888,9 @@ def check_pin_reachability(root: str, mount: MountDecl) -> ReachabilityResult: tip = line.split("\t")[0] # Establish ancestry in the resolver store: fetch the witness ref (full history, # no promisor state), then ask whether the pin is an ancestor of its tip. - store = _establish_mounts_dir(root, _join(mounts_dir(root), "store", sha256_hex(identity))) + store = _establish_mounts_dir( + root, _join(mounts_dir(root), "store", sha256_hex(identity)), ignore_context + ) if store is None: return ReachabilityResult( state="unknown", diff --git a/packages/sdk-py/src/leji/serve_cmd.py b/packages/sdk-py/src/leji/serve_cmd.py index 8f8de41..a165319 100644 --- a/packages/sdk-py/src/leji/serve_cmd.py +++ b/packages/sdk-py/src/leji/serve_cmd.py @@ -16,19 +16,28 @@ from typing import Callable, Optional from .findings import Finding -from .fsx import resolved_within_root, strip_slash, walk_tree +from .fsx import ( + open_verified_source, + read_all, + resolved_path, + resolved_within_root, + strip_slash, + walk_tree, +) from .indexgen import generate_index, serialize_index -from .layout import VIEWER_REL, role_abs, servable_path -from .manifest import effective_index_path, load_manifest +from .layout import VIEWER_REL, role_abs, servable_path, writable_target +from .manifest import Manifest, effective_index_path, load_manifest # The chrome generation and the layer helpers the served responses compose, shared # with the export exactly as the reference's serve.ts shares them with export.ts. from .viewer_cmd import ( ACTIVE_EXTENSIONS, + OVERVIEW_REL, _assemble_sidebar, _declares_inherits, _relative_to_root, _unresolved_profile_page, + render_overview, resolved_profile_page, ) @@ -118,11 +127,19 @@ class _SafeViewerHandler(BaseHTTPRequestHandler): base: str = "" content_abs: str = "" viewer_abs: str = "" + # The content mount as it really is on disk: the boundary a resolved source is + # judged against has to be resolved itself, or a symlinked rootPath component would + # put every legitimate document outside its own mount. + content_real: str = "" access_log: Optional[Callable[[str], None]] = None # Live-sidebar cache shared across requests (class attribute on the bound # subclass), guarded by a lock: ThreadingHTTPServer handles concurrently. _cache: Optional[dict] = None _cache_lock: threading.Lock = threading.Lock() + # The layer map is process state, not a file: the last index that generated + # cleanly, so a tree caught mid-edit still shows the map it last had rather than a + # page with a hole in it. Guarded by the same lock. + _last_good: Optional[tuple[Manifest, list[dict]]] = None def log_message(self, *args): # type: ignore[override] pass @@ -238,7 +255,8 @@ def _serve_text(self, content_type: str, body: str) -> None: self.end_headers() self._write_body(body.encode("utf-8")) - def _tree_fingerprint(self) -> str: + @classmethod + def _tree_fingerprint(cls) -> str: """One stat pass over leji.json + every markdown file under the content root (paths, mtimes, sizes — no content reads). walk_tree skips dotdirs, so the viewer's own artifacts never invalidate the cache.""" @@ -246,36 +264,125 @@ def _tree_fingerprint(self) -> str: def add(rel: str) -> None: try: - st = os.stat(os.path.join(self.root_abs, rel)) + st = os.stat(os.path.join(cls.root_abs, rel)) parts.append(f"{rel}\x00{st.st_mtime_ns}\x00{st.st_size}") except OSError: parts.append(f"{rel}\x00gone") add("leji.json") - for rel in walk_tree(self.root_abs, self.base or "."): + for rel in walk_tree(cls.root_abs, cls.base or "."): add(rel) return "\n".join(parts) - def _refresh_cache(self, key: str) -> Optional[dict]: + @classmethod + def _refresh_cache(cls, key: str) -> Optional[dict]: """Rebuild the live cache for key; None when the manifest is missing or the tree will not index cleanly (callers then fall back to the generated - artifact).""" - load = load_manifest(self.root_abs) + artifact). The manifest and entries ride along, so the overview map is a + projection of this same generation rather than a second one.""" + load = load_manifest(cls.root_abs) if load.manifest is None: return None - idx = generate_index(self.root_abs, load.manifest) + idx = generate_index(cls.root_abs, load.manifest) if any(f.severity == "error" for f in idx.findings): return None entries = (idx.index or {}).get("entries", []) - body = _assemble_sidebar(self.root_abs, load.manifest, entries, []) + body = _assemble_sidebar(cls.root_abs, load.manifest, entries, []) cache = { "key": key, "body": body, "index_json": serialize_index(idx.index) if idx.index is not None else None, + "manifest": load.manifest, + "entries": entries, } - type(self)._cache = cache + cls._cache = cache return cache + @classmethod + def _live_index(cls) -> Optional[dict]: + """One live index generation behind every generated route, cached by the same + fingerprint: the sidebar, the served context index, and the overview map are + projections of ONE index per tree state, never of three. None when the layer + cannot be indexed right now (no manifest, an error finding, or a generator that + raised), which is each route's cue to fall back.""" + try: + with cls._cache_lock: + key = cls._tree_fingerprint() + cache = cls._cache + if cache is None or cache["key"] != key: + cache = cls._refresh_cache(key) + return cache + except Exception: # noqa: BLE001 - a tree that cannot be walked is a fallback + return None + + @classmethod + def _within_content(cls, resolved: str) -> bool: + """True when a RESOLVED path lies under the content mount.""" + return resolved.startswith(cls.content_real + os.sep) + + def _serve_overview(self) -> None: + """The overview homepage, rendered: see the route's comment for why every check + is bound to the verified target rather than to a path resolved beforehand.""" + abs_path = os.path.join(self.content_abs, OVERVIEW_REL) + # By name first, exactly as _serve_from does, before anything is resolved. + if not servable_path(self.root_abs, abs_path): + self.send_response(404) + self.end_headers() + self._write_body(b"not found") + return + source: Optional[bytes] = None + landed: Optional[str] = None + try: + src = open_verified_source( + abs_path, + lambda resolved: ( + self._within_content(resolved) + and servable_path(self.root_abs, resolved) + and writable_target(self.root_abs, resolved, None).ok + ), + ) + landed = src.real + if src.fd is not None: + try: + source = read_all(src.fd) + finally: + os.close(src.fd) + except OSError: + source = None + if source is None: + # The refusal names where the source resolves NOW: outside the content mount + # is the mount's own answer (403), and everything else (a private role, a + # directory, an absent or unresolvable entry) is a plain miss. + outside = landed is not None and not self._within_content(landed) + self.send_response(403 if outside else 404) + self.end_headers() + self._write_body(b"forbidden" if outside else b"not found") + return + # A live generation that fails outright (an unreadable content root, an invalid + # manifest, a document the walk cannot read) serves the last map that did + # generate; before the first one ever did, the source bytes as they are. A + # source without markers is served unchanged whatever the index says. + cache = self._live_index() + cls = type(self) + with cls._cache_lock: + if cache is not None: + cls._last_good = (cache["manifest"], cache["entries"]) + layer_map = cls._last_good + body = source + if layer_map is not None: + # `errors="replace"` because Node's Buffer.toString('utf8') substitutes + # rather than throwing: an overview.md that is not valid UTF-8 renders the + # same way in all three SDKs instead of failing the request here. + rendered = render_overview( + source.decode("utf-8", errors="replace"), layer_map[0], layer_map[1] + ) + if rendered.markers_found: + body = rendered.text.encode("utf-8") + self.send_response(200) + self.send_header("content-type", "text/markdown; charset=utf-8") + self.end_headers() + self._write_body(body) + def do_GET(self) -> None: # noqa: N802 from urllib.parse import unquote, urlsplit @@ -343,17 +450,10 @@ def _do_get_inner(self, unquote, urlsplit) -> None: # document. When the tree is mid-edit and will not index cleanly, fall back # to the last generated artifact rather than failing the dashboard. if rel == "content/_sidebar.md": - try: - with type(self)._cache_lock: - key = self._tree_fingerprint() - cache = type(self)._cache - if cache is None or cache["key"] != key: - cache = self._refresh_cache(key) - if cache is not None: - self._serve_text("text/markdown; charset=utf-8", cache["body"]) - return - except Exception: # noqa: BLE001 - fall through to the generated artifact - pass + cache = self._live_index() + if cache is not None: + self._serve_text("text/markdown; charset=utf-8", cache["body"]) + return self._serve_from(self.viewer_abs, "_sidebar.md") return # The stored context index is served live (same fingerprint cache as the @@ -373,16 +473,8 @@ def _do_get_inner(self, unquote, urlsplit) -> None: and idx_rel is not None and rel == f"content/{idx_rel}" ): - with type(self)._cache_lock: - key = self._tree_fingerprint() - cache = type(self)._cache - if cache is None or cache["key"] != key: - cache = self._refresh_cache(key) - if ( - cache is not None - and cache["key"] == key - and cache["index_json"] is not None - ): + cache = self._live_index() + if cache is not None and cache["index_json"] is not None: self._serve_text("application/json; charset=utf-8", cache["index_json"]) return except Exception: # noqa: BLE001 - fall through to the stored artifact @@ -393,6 +485,24 @@ def _do_get_inner(self, unquote, urlsplit) -> None: if rel == "content/_manifest.md": self._serve_from(self.viewer_abs, "_manifest.md") return + # The overview homepage is served RENDERED: the source bytes with the layer map + # substituted between the author's markers, so the counts a reader sees are the + # ones the tree has right now and the committed file is never rewritten to say + # so. The route is a content route first: it makes every check _serve_from makes + # on this path, with the same answers, plus the generation guards (repository + # containment, no private `.leji/` role), because this is the one content path + # the tool also writes. + # + # EVERY one of those checks is bound to the VERIFIED target, not to a path + # resolved beforehand: the guarded read judges the resolved location, opens it, + # proves the descriptor is that same regular file, and the bytes come from that + # descriptor. A pre-read realpath plus a separate read leaves the window this + # closes: a link swapped in between resolves somewhere else (inside the + # repository, outside the content mount) and the read follows it past a check + # that judged the old target. + if rel == f"content/{OVERVIEW_REL}": + self._serve_overview() + return if rel == "content" or rel.startswith("content/"): sub = "" if rel == "content" else rel[len("content/") :] # An agent profile that declares `inherits` is served resolved: the file @@ -456,6 +566,7 @@ def serve_viewer( port: int, root_rel: str = "", log: Optional[Callable[[str], None]] = None, + entries: Optional[list[dict]] = None, ) -> ThreadingHTTPServer: """Serve the viewer at the web root, bound to 127.0.0.1 (local preview, never hosting). Two virtual mounts and nothing else — the servable roots: chrome @@ -463,7 +574,11 @@ def serve_viewer( Everything else under `.leji/` is denied by name, so the private roles are unreachable however the request is spelled and whatever a symlink under the content root points at. `log`, when set, receives one terse access-log line per - request. Caller runs serve_forever() / shutdown().""" + request. Caller runs serve_forever() / shutdown(). + + `entries` is an index snapshot for the initial layer map: a caller that has just + generated the viewer hands over what it projected, and a caller that passes none + gets one live generation at startup instead.""" root_abs = os.path.realpath(str(Path(root).resolve())) base = strip_slash(root_rel) content_abs = os.path.join(root_abs, base) if base and base != "." else root_abs @@ -472,7 +587,7 @@ def serve_viewer( if not resolved_within_root(root_abs, Path(content_abs)): raise ValueError(f'viewer root "{root_rel}" escapes the layer root') viewer_abs = role_abs(root_abs, VIEWER_REL) - handler_cls = type( + handler_cls: type[_SafeViewerHandler] = type( "_BoundSafeViewerHandler", (_SafeViewerHandler,), { @@ -480,11 +595,23 @@ def serve_viewer( "base": base if base != "." else "", "content_abs": content_abs, "viewer_abs": viewer_abs, + "content_real": resolved_path(content_abs) or content_abs, "access_log": staticmethod(log) if log is not None else None, "_cache": None, "_cache_lock": threading.Lock(), + "_last_good": None, }, ) + # The initial layer map, by the same generation the sidebar route makes per fetch, + # unless the caller handed over the snapshot its own generation just produced. + if entries is None: + cache = handler_cls._live_index() + if cache is not None: + handler_cls._last_good = (cache["manifest"], cache["entries"]) + else: + load = load_manifest(root_abs) + if load.manifest is not None: + handler_cls._last_good = (load.manifest, entries) return ThreadingHTTPServer(("127.0.0.1", port), handler_cls) diff --git a/packages/sdk-py/src/leji/update_pin.py b/packages/sdk-py/src/leji/update_pin.py index 34e6459..d059afe 100644 --- a/packages/sdk-py/src/leji/update_pin.py +++ b/packages/sdk-py/src/leji/update_pin.py @@ -8,7 +8,8 @@ claim of freshness. ``--fetch`` observes the declared source — and nothing else — in three acts: retain the current pin, refresh the witness once, and (after the gate passes) retain the target. Any of them failing REFUSES the move; a pin move is not -best-effort, which is ``hydrate``'s model rather than this one. +best-effort, which is ``hydrate``'s model rather than this one. The reason names the +act. The manifest is rewritten by replacing the addressed pin's own byte span (:func:`~leji.manifest.replace_mount_pin_in_manifest_text`), never by reserializing, @@ -25,6 +26,7 @@ from .findings import Finding from .fsx import guard_root, verified_target_read, write_file_atomic_guarded +from .leji_ignore import LejiIgnoreContext from .manifest import ( MANIFEST_FILENAME, Manifest, @@ -72,6 +74,15 @@ "mount-store-fetch-failed": ( "the requested fetch could not retain the commit in the managed store" ), + # The current-pin act is the one an operator can route past: an upstream that + # rewrote its history no longer serves the commit this manifest pins, and the + # move is still available against a repository that does hold both operands. + "mount-store-fetch-failed: current pin": ( + "the requested fetch could not retain the commit in the managed store; " + "if a local hint holds the current pin and the target with complete ancestry, " + "run without `--fetch`; to move past a rewritten upstream, pass " + "`--to --allow-non-fast-forward` against such a hint" + ), "mount-witness-refresh-failed": ( "the requested fetch could not refresh the managed witness ref" ), @@ -89,6 +100,19 @@ } +def _update_pin_reason_prose(reason: str, detail: str | None = None) -> str: + """The sentence a refusal shows. A code whose acts have different routes forward + carries one entry per act, keyed ``: `` exactly as the finding's + ``detail`` spells it, so the table stays the single source of every string this + command prints; every other code answers for all of its acts at once.""" + if detail is not None: + act = detail[: max(detail.find(": "), 0)] + qualified = MOUNT_UPDATE_PIN_REASONS.get(f"{reason}: {act}") + if qualified is not None: + return qualified + return MOUNT_UPDATE_PIN_REASONS.get(reason, reason) + + @dataclass class UpdatePinResult: """What the run did. ``refused`` is a stated outcome, never a crash.""" @@ -155,9 +179,15 @@ def update_pin_run( # noqa: C901 fetch: bool = False, dry_run: bool = False, now: dt.datetime | None = None, + ignore_context: LejiIgnoreContext | None = None, ) -> UpdatePinResult: """Move one mount's pin. Every refusal is a stated ``reason`` code plus an error - finding, so the exit status, the human line and the JSON document always agree.""" + finding, so the exit status, the human line and the JSON document always agree. + + ``ignore_context`` is the invocation's notice state for the self-managed + ``.leji/.gitignore``. One ``--fetch`` run retains TWICE (the current pin, then the + target), and both establish the managed store, so the context is threaded rather + than left to each call: one invocation notices at most once.""" # One observation time for the whole run, as ``status`` takes one for its whole # execution. observed_at = _now_iso(now) @@ -177,6 +207,7 @@ def refuse( reason: str, partial: dict[str, object] | None = None, pin_report: dict[str, object] | None = None, + detail: str | None = None, ) -> UpdatePinResult: block: dict[str, object] = { "name": name, @@ -192,7 +223,15 @@ def refuse( action="refused", override=False, reason=reason, - findings=[Finding(reason, "error", MOUNT_UPDATE_PIN_REASONS.get(reason, reason), name)], + findings=[ + Finding( + reason, + "error", + _update_pin_reason_prose(reason, detail), + name, + detail=detail, + ) + ], ) if mount is None or entry is None: @@ -255,21 +294,24 @@ def degraded(reason: str, compared_ref: str | None) -> dict[str, object]: # the managed store holds both operands, then refresh the witness exactly once. # A failure here refuses the move — best-effort belongs to ``hydrate``. if fetch: - retained = retain_pin_in_store(root, mount, identity, mount.pin) + retained = retain_pin_in_store(root, mount, identity, mount.pin, ignore_context) if retained.repo is None: return refuse( "mount-store-fetch-failed", None, degraded("mount-store-fetch-failed", effective_ref), + f"current pin: {retained.error}", ) witness_mount = MountDecl( name=mount.name, source=mount.source, pin=mount.pin, tracking_ref=effective_ref ) - if not refresh_witness(retained.repo, witness_mount, identity): + refreshed = refresh_witness(retained.repo, witness_mount, identity) + if not refreshed.ok: return refuse( "mount-witness-refresh-failed", None, degraded("mount-witness-refresh-failed", effective_ref), + f"witness: {refreshed.error}", ) # (c) The comparison repository and the ONE witness snapshot this run uses for @@ -327,13 +369,22 @@ def settled(action: str, override: bool, findings: list[Finding]) -> UpdatePinRe # and carries whatever the run had already decided: an override exercised at the # gate is still reported by a run that then refused for another reason. def refuse_settled( - reason: str, override: bool = False, warnings: list[Finding] | None = None + reason: str, + override: bool = False, + warnings: list[Finding] | None = None, + detail: str | None = None, ) -> UpdatePinResult: result = settled( "refused", override, [ - Finding(reason, "error", MOUNT_UPDATE_PIN_REASONS.get(reason, reason), mount.name), + Finding( + reason, + "error", + _update_pin_reason_prose(reason, detail), + mount.name, + detail=detail, + ), *(warnings or []), ], ) @@ -372,9 +423,14 @@ def refuse_settled( # (b iii) The target is retained only once the gate has passed, so a refused run # never establishes a pin ref for a commit it declined to move to. if fetch: - retained_target = retain_pin_in_store(root, mount, identity, target) + retained_target = retain_pin_in_store(root, mount, identity, target, ignore_context) if retained_target.repo is None: - return refuse_settled("mount-store-fetch-failed", override, warnings) + return refuse_settled( + "mount-store-fetch-failed", + override, + warnings, + f"target: {retained_target.error}", + ) # (g) ``--dry-run`` stops here. The store and network acts ``--fetch`` was asked # for have already happened; only the manifest rewrite is suppressed. diff --git a/packages/sdk-py/src/leji/viewer_cmd.py b/packages/sdk-py/src/leji/viewer_cmd.py index f2cd9d3..8303596 100644 --- a/packages/sdk-py/src/leji/viewer_cmd.py +++ b/packages/sdk-py/src/leji/viewer_cmd.py @@ -18,7 +18,7 @@ import unicodedata from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import NamedTuple, Optional import re @@ -39,11 +39,13 @@ from .indexgen import generate_index from .layout import ( LEJI_DIR, + LEJI_IGNORE_REL, VIEWER_REL, role_abs, servable_path, writable_target, ) +from .leji_ignore import LejiIgnoreContext, ensure_leji_ignore_file from .layer import ( ScannedProfile, resolve_agent_profile, @@ -284,6 +286,10 @@ class ViewerResult: written: list[str] = field(default_factory=list) findings: list[Finding] = field(default_factory=list) entries: int = 0 + #: The index entries this run projected, so a caller that renders from the same + #: generation (the export's overview map) reads one snapshot rather than making a + #: second one. Empty when the run refused to project anything. + index_entries: list[dict] = field(default_factory=list) def _relative_to_root(rel_path: str, root_path: str) -> Optional[str]: @@ -693,6 +699,12 @@ def _sidebar_label(root: str, rel_path: str, root_rel: str) -> str: return _filename_label(root_rel) +# The overview homepage, named relative to the context root: the one content path the +# tool seeds, and the one whose read renders the layer map into it. Shared by +# generation, the local server's route, and the export's copy. +OVERVIEW_REL = "overview.md" + + def _reference_tree(root: str, manifest: Manifest, governed_paths: set[str]) -> list[TreeNode]: """The browse zone: every markdown file under rootPath that is NOT governed (in the index), NOT viewer/layer chrome (boot profile, agent profiles, the @@ -705,7 +717,7 @@ def _reference_tree(root: str, manifest: Manifest, governed_paths: set[str]) -> for cat in CATEGORY_IDS: for f in (manifest["categories"].get(cat) or {}).get("indexes", []): index_files.add(f) - overview_rel = "overview.md" if root_dir_rel == "." else f"{root_dir_rel}/overview.md" + overview_rel = OVERVIEW_REL if root_dir_rel == "." else f"{root_dir_rel}/{OVERVIEW_REL}" sidebar_rel = "_sidebar.md" if root_dir_rel == "." else f"{root_dir_rel}/_sidebar.md" manifest_page_rel = "_manifest.md" if root_dir_rel == "." else f"{root_dir_rel}/_manifest.md" nodes: list[TreeNode] = [] @@ -727,12 +739,18 @@ def _reference_tree(root: str, manifest: Manifest, governed_paths: set[str]) -> return nodes -# The overview homepage is seeded once and then user-owned. The layer map lives -# between these markers; `leji viewer` regenerates only the marked block, leaving -# the surrounding prose untouched. +# The overview homepage is seeded once and then user-owned. The markers are the +# author's placement mark for the layer map: the map is substituted between them at +# render time, by the viewer and by `leji export`, and the file itself is never +# rewritten. MAP_START = "" MAP_END = "" +# The line the seed leaves between the markers, so a reader of the source file knows +# why the span is empty. Whatever an author leaves there is ignored at render, this +# line included. +_MAP_PLACEHOLDER = "" + def build_layer_map(manifest: Manifest, entries: list[dict]) -> str: """A deterministic mermaid map of the layer: boot profile -> populated @@ -758,9 +776,37 @@ def _map_block(manifest: Manifest, entries: list[dict]) -> str: return MAP_START + "\n```mermaid\n" + build_layer_map(manifest, entries) + "\n```\n" + MAP_END -def _build_overview_seed(manifest: Manifest, entries: list[dict]) -> str: +class RenderedOverview(NamedTuple): + """The overview homepage as it is read: the rendered text, and whether the marker + pair was there to render into.""" + + text: str + markers_found: bool + + +def render_overview(source: str, manifest: Manifest, entries: list[dict]) -> RenderedOverview: + """The overview homepage as it is READ, never as it is stored: the source bytes + with the marked span replaced by the map this index projects. The one function + behind both consumers (the local server renders it per fetch, the export renders + the copy it writes), so the served and the exported page carry the same bytes. + + Whatever stands between the markers in source is ignored: the map is derived from + the index, so the file is never rewritten to hold it. Without the marker pair there + is nowhere to put the map, and the source is returned unchanged (markers_found + False) for the caller to warn about.""" + start = source.find(MAP_START) + end = source.find(MAP_END) + if start < 0 or end <= start: + return RenderedOverview(source, False) + return RenderedOverview( + source[:start] + _map_block(manifest, entries) + source[end + len(MAP_END) :], True + ) + + +def _build_overview_seed(manifest: Manifest) -> str: """The starter overview/home page: a short explainer the owner can edit freely, - plus the auto-generated layer map inside the regen markers.""" + plus the empty marker pair the layer map is rendered into. Written once, when no + overview.md stands at the content root, and never rewritten after that.""" name = manifest["name"] return ( f"# {name}\n" @@ -769,10 +815,12 @@ def _build_overview_seed(manifest: Manifest, entries: list[dict]) -> str: "people and coding agents read before working in this repository. Start with the boot\n" "profile, then browse the categories in the sidebar.\n" "\n" - "This page is yours to edit. The map below is regenerated by `leji viewer` between the\n" - "markers; the prose around it is left untouched.\n" + "This page is yours to edit. The map below is rendered between the markers by the viewer\n" + "and by `leji export`; this file is never rewritten.\n" "\n" - f"{_map_block(manifest, entries)}\n" + f"{MAP_START}\n" + f"{_MAP_PLACEHOLDER}\n" + f"{MAP_END}\n" "\n" "- Write a ```mermaid code block in any document and it renders as a diagram here.\n" "- Run `leji conformance` to see the level this layer claims and verifies.\n" @@ -1485,9 +1533,16 @@ def _build_index_html(root: str, manifest: Manifest, base: str, findings: list[F ) -def generate_viewer(root: str, manifest: Manifest) -> ViewerResult: +def generate_viewer( + root: str, manifest: Manifest, ignore_context: Optional[LejiIgnoreContext] = None +) -> ViewerResult: """Write the Docsify index.html (frontmatter-stripping hook included) and the - projected _sidebar.md into the root `.leji/viewer/` role.""" + projected _sidebar.md into the root `.leji/viewer/` role. + + ``ignore_context`` is the invocation's notice state for the self-managed + ``.leji/.gitignore`` (this run creates a role, so it ensures that file): a caller + that has one passes it through, and a direct SDK call that passes none notices at + most once for that call.""" result = generate_index(root, manifest) # Don't project a viewer from a tree that can't be indexed cleanly (a # category-conflict, a malformed or dangling index entry): surface the errors @@ -1561,6 +1616,20 @@ def write_viewer_file(rel: str, content: bytes | str) -> None: for name, content in (("index.html", doc_html), ("_sidebar.md", sidebar)): write_viewer_file(f"{viewer_dir}/{name}", content) + # The role now exists, so the tool ignores its own tree from inside: a layer whose + # root .gitignore never carried the `.leji/` line is clean after this run. A refusal + # is an error finding like any other refused write here. + if ensure_leji_ignore_file(resolved_root, ignore_context) == "refused": + findings.append( + Finding( + "viewer-target-refused", + "error", + f"refusing to write {LEJI_IGNORE_REL}: it does not resolve to a regular " + f"file inside {LEJI_DIR}/; remove the symlink", + LEJI_IGNORE_REL, + ) + ) + # Copy every vendored viewer asset (Docsify core, theme, the plugins, and the # webfonts) alongside index.html (no remote CDN). The provenance note is # documentation, never shipped. @@ -1575,16 +1644,19 @@ def write_viewer_file(rel: str, content: bytes | str) -> None: write_viewer_file(f"{assets_rel_dir}/{asset_path.name}", asset_path.read_bytes()) # The overview/home page is committed, user-owned content (not viewer chrome): - # seeded once and never overwritten. On regeneration, only the marked map block - # is refreshed; if the owner removed the markers, the page is left entirely alone. + # seeded once and never written again. The layer map is rendered between its + # markers when the page is read (by the local server and by the export), so a + # reindex that changes the document counts leaves this file exactly as its author + # last saved it. If the markers are gone there is nowhere to render the map, which + # is a warning. # # Check-before-act: overview.md is content — its target must resolve WITHIN # the layer root AND never into a private `.leji/` role. It is judged on the # RESOLVED path (own role None: content has no `.leji/` role) BEFORE anything is # read or written, so an overview.md symlinked into `.leji/work/` or - # `.leji/mounts/` is refused before the seed or the refresh writes through it — - # and the write itself then lands via the guarded-write chokepoint on that path. - overview_rel = "overview.md" if root_dir == "." else f"{root_dir}/overview.md" + # `.leji/mounts/` is refused before the seed writes through it or the page is read, + # and the seed itself then lands via the guarded-write chokepoint on that path. + overview_rel = OVERVIEW_REL if root_dir == "." else f"{root_dir}/{OVERVIEW_REL}" overview_abs = Path(root) / overview_rel overview_resolved = resolved_path_under(resolved_root, str(overview_abs)) overview_verdict = ( @@ -1614,7 +1686,7 @@ def write_viewer_file(rel: str, content: bytes | str) -> None: ) elif overview_read.status == "refused": # A standing entry that cannot be verified as a regular file inside the layer: - # the map is neither seeded through it nor refreshed from bytes read by path. + # the page is neither seeded through it nor read from a path that could redirect. findings.append( Finding( "viewer-target-refused", @@ -1625,30 +1697,30 @@ def write_viewer_file(rel: str, content: bytes | str) -> None: ) ) elif overview_read.status == "absent": - seed = _build_overview_seed(manifest, entries) + seed = _build_overview_seed(manifest) if write_file_guarded(resolved_root, str(overview_abs), None, seed).ok: written.append(overview_rel) - else: - # The refresh rewrites the page it just read, so those bytes come from the - # verified descriptor rather than from a second read by pathname. - existing = overview_read.text() - start = existing.find(MAP_START) - end = existing.find(MAP_END) - if start >= 0 and end > start: - updated = ( - existing[:start] + _map_block(manifest, entries) + existing[end + len(MAP_END) :] - ) - if updated != existing: - write_file_guarded(resolved_root, str(overview_abs), None, updated) - else: - findings.append( - Finding( - "overview-markers-missing", - "warning", - "overview.md has no generated-map markers; left as-is (map not refreshed)", - overview_rel, - ) + elif not render_overview( + # Decoded the way Node's Buffer.toString('utf8') decodes, which is what the + # reference hands render_overview here. `VerifiedTargetRead.text()` is strict and + # would raise on a page that is not valid UTF-8, where the reference reports the + # markers question and moves on; the page is not written either way. + overview_read.data.decode("utf-8", errors="replace"), + manifest, + entries, + ).markers_found: + # A standing page is READ and not written: the only thing generation decides + # here is whether the map has a place to be rendered into. The bytes come from + # the verified descriptor rather than from a second read by pathname, so the + # page the guards judged is the page the answer is about. + findings.append( + Finding( + "overview-markers-missing", + "warning", + "overview.md has no generated-map markers; the map is not rendered", + overview_rel, ) + ) # The Manifest page: generated chrome, exactly like _sidebar.md. Written into the # gitignored viewer dir under a reserved underscore name (collision-free with the @@ -1658,4 +1730,6 @@ def write_viewer_file(rel: str, content: bytes | str) -> None: f"{viewer_dir}/_manifest.md", build_manifest_page(manifest, mount_status(root, manifest)) ) - return ViewerResult(written=written, findings=findings, entries=len(entries)) + return ViewerResult( + written=written, findings=findings, entries=len(entries), index_entries=entries + ) diff --git a/packages/sdk-py/tests/helpers/snapshot.py b/packages/sdk-py/tests/helpers/snapshot.py new file mode 100644 index 0000000..96eb09f --- /dev/null +++ b/packages/sdk-py/tests/helpers/snapshot.py @@ -0,0 +1,63 @@ +"""The one tree-snapshot contract the badge and canary suites share. + +Both ask the same question of a tree (is it byte-identical to what it was?) and both +used to answer it with their own private walker, so a fix to one reached the other only +by hand. The contract lives here, is pinned by `fixtures/snapshot-contract/` (asserted +in tests/test_snapshot_contract.py), and is the same contract the Node and Go suites +hold: packages/sdk/test/helpers/snapshot.ts is the reference, and the goldens are frozen +bytes all three walk to. +""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path + + +def snapshot_tree(directory: Path, repo_root: Path | None = None) -> list[str]: + """Every entry under `directory` as one line, so a comparison covers appearance, + disappearance, content and entry kind: + + - regular file: ``pathsha256:`` + - directory: ``path/dir``, an entry of its own, so a created empty directory shows + - symlink or any other non-regular entry: ``pathnon-regular``, never followed + + Paths are POSIX and relative to `directory` itself, and the lines are sorted over + their UTF-8 BYTES, not over decoded code points, which is what Python's default + string order is, and not over UTF-16 code units, which is what the Node reference had + to sort away from. The three orderings have to be the one ordering the goldens carry. + + Exactly one entry is excluded: ``/.git``, when it lies inside `directory`. + That one is the harness's own scaffolding, and git's background maintenance rewrites + it under a running test. Every other `.git` (a nested package, a mount, a work + directory) is content and is walked like anything else. + + `repo_root` means the repository, not the call. `None` defaults to `directory`, the + whole-repository call; a subtree call passes the repository root explicitly, so + ``snapshot_tree(pkg, repo_root=repo)`` records ``pkg/.git`` as the content it is. + """ + # Lexical, like the Node reference's `path.resolve`: a symlinked ancestor is left + # alone, so the walked paths and the excluded path are absolute the same way. + root = Path(os.path.abspath(directory)) + excluded = Path(os.path.abspath(directory if repo_root is None else repo_root)) / ".git" + lines: list[str] = [] + + def walk(rel: str) -> None: + base = root if rel == "" else root / rel + for entry in base.iterdir(): + if entry == excluded: + continue + child = entry.name if rel == "" else f"{rel}/{entry.name}" + if entry.is_symlink(): + lines.append(f"{child}\tnon-regular") + elif entry.is_dir(): + lines.append(f"{child}/\tdir") + walk(child) + elif entry.is_file(): + lines.append(f"{child}\tsha256:{hashlib.sha256(entry.read_bytes()).hexdigest()}") + else: + lines.append(f"{child}\tnon-regular") + + walk("") + return sorted(lines, key=lambda line: line.encode("utf-8")) diff --git a/packages/sdk-py/tests/test_badge.py b/packages/sdk-py/tests/test_badge.py index 75b41a9..c91565a 100644 --- a/packages/sdk-py/tests/test_badge.py +++ b/packages/sdk-py/tests/test_badge.py @@ -8,7 +8,6 @@ from __future__ import annotations -import hashlib import json import re import shutil @@ -20,6 +19,7 @@ import pytest +from helpers.snapshot import snapshot_tree from leji import badge_markdown, badge_run, render_badge from leji.cli import main @@ -58,32 +58,6 @@ def _committed_fixture(name: str) -> Path: return directory -def _snapshot(directory: Path) -> list[tuple[str, str]]: - """Every path under `directory` as `rel -> content digest` (directories as `rel/` -> - ''), so a comparison covers appearance and disappearance as well as content. `.git/` - is the harness's own scaffolding and is excluded: a badge run cannot touch it.""" - acc: list[tuple[str, str]] = [] - - def walk(rel: str) -> None: - base = directory if rel == "" else directory / rel - for entry in sorted(base.iterdir(), key=lambda p: p.name): - if rel == "" and entry.name == ".git": - continue - child = entry.name if rel == "" else f"{rel}/{entry.name}" - if entry.is_symlink(): - acc.append((child, "non-regular")) - elif entry.is_dir(): - acc.append((child + "/", "")) - walk(child) - elif entry.is_file(): - acc.append((child, hashlib.sha256(entry.read_bytes()).hexdigest())) - else: - acc.append((child, "non-regular")) - - walk("") - return sorted(acc) - - def _run_cli(capsys, args: list[str]) -> tuple[int, str]: """The CLI entry point as the bin runs it: the exit code and what it wrote to stdout.""" @@ -285,13 +259,13 @@ def test_a_run_that_writes_nothing_establishes_no_directory_on_the_way_to_not_wr (parent / "sibling.txt").write_text("untouched\n", encoding="utf-8") foreign = "not a badge\n" (parent / "badge.svg").write_text(foreign, encoding="utf-8") - before = _snapshot(directory) + before = snapshot_tree(directory) r = badge_run(str(directory), "pub/badge.svg") assert r.refusal == "pub/badge.svg exists and is not a leji badge; remove or rename it" assert (parent / "badge.svg").read_text(encoding="utf-8") == foreign, ( "the target is byte-untouched" ) - assert _snapshot(directory) == before, "the tree is untouched" + assert snapshot_tree(directory) == before, "the tree is untouched" finally: shutil.rmtree(directory, ignore_errors=True) @@ -308,7 +282,7 @@ def test_an_out_whose_parent_resolves_outside_the_repository_is_refused_and_read planted = "somebody elses file\n" (outside / "x.svg").write_text(planted, encoding="utf-8") (directory / "pub").symlink_to(outside, target_is_directory=True) - before = _snapshot(directory) + before = snapshot_tree(directory) r = badge_run(str(directory), "pub/x.svg") assert r.usage_error is not None or r.refusal is not None, "the escape is refused" @@ -320,7 +294,7 @@ def test_an_out_whose_parent_resolves_outside_the_repository_is_refused_and_read assert sorted(p.name for p in outside.iterdir()) == ["x.svg"], ( "nothing was created outside the repository" ) - assert _snapshot(directory) == before, "and nothing inside it" + assert snapshot_tree(directory) == before, "and nothing inside it" finally: shutil.rmtree(directory, ignore_errors=True) shutil.rmtree(outside, ignore_errors=True) @@ -369,7 +343,7 @@ def test_an_out_that_is_a_dangling_symlink_inside_the_repository_is_refused() -> # follow the link and create the destination; a standing entry that could not be # verified as a badge is a refusal instead. (directory / "leji-badge.svg").symlink_to("missing-file.svg") - before = _snapshot(directory) + before = snapshot_tree(directory) r = badge_run(str(directory)) assert r.refusal == ( @@ -385,7 +359,7 @@ def test_an_out_that_is_a_dangling_symlink_inside_the_repository_is_refused() -> assert not (directory / "missing-file.svg").exists(), ( "the link destination was never created" ) - assert _snapshot(directory) == before, "the tree is untouched" + assert snapshot_tree(directory) == before, "the tree is untouched" finally: shutil.rmtree(directory, ignore_errors=True) @@ -399,7 +373,7 @@ def test_an_out_that_is_a_unix_socket_is_refused_as_a_document_not_as_a_crash(ca # directory, and opening it fails with something other than ENOENT. Binding one is # not portable, so a platform that cannot is skipped rather than failed. server = _bind_socket(target) - before = _snapshot(directory) + before = snapshot_tree(directory) r = badge_run(str(directory)) assert r.refusal == ( @@ -411,7 +385,7 @@ def test_an_out_that_is_a_unix_socket_is_refused_as_a_document_not_as_a_crash(ca assert _finding_keys(r.findings) == [ {"rule": "badge-target-refused", "severity": "error", "path": "leji-badge.svg"} ] - assert _snapshot(directory) == before, "the tree is untouched" + assert snapshot_tree(directory) == before, "the tree is untouched" # Through the CLI: the refusal is the ordinary badge document at exit 2, which is # exactly what an escaping error would deny this case. @@ -446,7 +420,7 @@ def test_an_out_symlinked_to_a_unix_socket_is_refused_as_a_document_too(capsys) server = _bind_socket(directory / "sock") target.symlink_to("sock") assert target.is_symlink(), "the target is a symlink" - before = _snapshot(directory) + before = snapshot_tree(directory) r = badge_run(str(directory)) assert r.refusal == ( @@ -459,7 +433,7 @@ def test_an_out_symlinked_to_a_unix_socket_is_refused_as_a_document_too(capsys) {"rule": "badge-target-refused", "severity": "error", "path": "leji-badge.svg"} ] assert target.is_symlink(), "the link itself is left alone" - assert _snapshot(directory) == before, "the tree is untouched" + assert snapshot_tree(directory) == before, "the tree is untouched" # Through the CLI: the ordinary badge document at exit 2, not a bare error. code, stdout = _run_cli(capsys, ["badge", "--root", str(directory), "--json"]) @@ -613,7 +587,7 @@ def test_fixture_badge_block(name: str, capsys) -> None: rerun = block.get("rerun") if rerun: - after_first = _snapshot(directory) + after_first = snapshot_tree(directory) code, stdout = _run_cli(capsys, [*args, "--root", str(directory), "--json"]) assert code == 0, "the steady state exits 0" # The whole document again, not just `action`: the steady state is the same @@ -622,7 +596,7 @@ def test_fixture_badge_block(name: str, capsys) -> None: stdout, {**block, "action": rerun["action"]}, target_rel, f"{name} (rerun)" ) if rerun["byteIdentical"]: - assert _snapshot(directory) == after_first, ( + assert snapshot_tree(directory) == after_first, ( "a second run is a byte-level no-op across the whole working tree" ) finally: diff --git a/packages/sdk-py/tests/test_canary.py b/packages/sdk-py/tests/test_canary.py index b58082c..43a96a3 100644 --- a/packages/sdk-py/tests/test_canary.py +++ b/packages/sdk-py/tests/test_canary.py @@ -23,6 +23,7 @@ import pytest +from helpers.snapshot import snapshot_tree from leji import build_viewer, generate_viewer, load_manifest from leji import export_cmd, fsx from leji.serve_cmd import serve_viewer @@ -101,30 +102,6 @@ def _materialize(factory: pytest.TempPathFactory, name: str, seeds: list[dict]) return directory -def _snapshot(directory: Path) -> list[tuple[str, str]]: - """Every path under `directory` as `rel -> content digest` (directories as - `rel/` -> ''), so a comparison covers appearance and disappearance as well as - content.""" - acc: list[tuple[str, str]] = [] - - def walk(rel: str) -> None: - base = directory if rel == "" else directory / rel - for entry in sorted(base.iterdir(), key=lambda p: p.name): - child = entry.name if rel == "" else f"{rel}/{entry.name}" - if entry.is_symlink(): - acc.append((child, "non-regular")) - elif entry.is_dir(): - acc.append((child + "/", "")) - walk(child) - elif entry.is_file(): - acc.append((child, hashlib.sha256(entry.read_bytes()).hexdigest())) - else: - acc.append((child, "non-regular")) - - walk("") - return sorted(acc) - - def _files_under(directory: Path) -> list[str]: """Every file under `directory`, as export-root-relative POSIX paths, sorted.""" return sorted( @@ -314,9 +291,9 @@ def test_layout_fixture_canary_and_idempotency( # --- idempotency --------------------------------------------------------- if (expected_export.get("rerun") or {}).get("byteIdentical"): - after_first = _snapshot(directory) + after_first = snapshot_tree(directory, repo_root=directory) build_viewer(str(directory), manifest) - assert _snapshot(directory) == after_first, ( + assert snapshot_tree(directory, repo_root=directory) == after_first, ( "a second run is a byte-level no-op across the whole working tree" ) @@ -514,14 +491,16 @@ def test_check_before_act_generation_refuses_viewer_aliased_into_a_private_role( (aliased / "assets" / "planted.txt").write_text(f"{TOKEN}\n", encoding="utf-8") (directory / ".leji" / "viewer").symlink_to(Path("work") / "chrome") manifest = _load(directory) - before = _snapshot(aliased) + before = snapshot_tree(aliased, repo_root=directory) gen = generate_viewer(str(directory), manifest) assert any(f.rule == "viewer-target-refused" and f.severity == "error" for f in gen.findings), ( "generation refuses with a hard error (non-zero exit)" ) assert gen.written == [], "and writes nothing" - assert _snapshot(aliased) == before, "the aliased private role is byte-identical" + assert snapshot_tree(aliased, repo_root=directory) == before, ( + "the aliased private role is byte-identical" + ) # build_viewer regenerates first, so it inherits the refusal and never reaches the # destructive clean/copy: no export is produced either. @@ -529,7 +508,9 @@ def test_check_before_act_generation_refuses_viewer_aliased_into_a_private_role( assert any(f.rule == "viewer-target-refused" for f in built.findings), ( "the export inherits the refusal" ) - assert _snapshot(aliased) == before, "still untouched after build_viewer" + assert snapshot_tree(aliased, repo_root=directory) == before, ( + "still untouched after build_viewer" + ) assert not (directory / ".leji" / "dist").exists(), "no export was written" @@ -545,10 +526,10 @@ def test_check_before_act_default_output_refuses_dist_into_a_private_role( (planted / "planted").write_text(f"{TOKEN}\n", encoding="utf-8") (directory / ".leji" / "dist").symlink_to(Path("mounts") / "store" / "x") manifest = _load(directory) - before = _snapshot(directory / ".leji" / "mounts") + before = snapshot_tree(directory / ".leji" / "mounts", repo_root=directory) with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): build_viewer(str(directory), manifest, None) - assert _snapshot(directory / ".leji" / "mounts") == before, ( + assert snapshot_tree(directory / ".leji" / "mounts", repo_root=directory) == before, ( "nothing was cleared or written in the private role" ) assert (planted / "planted").read_text(encoding="utf-8") == f"{TOKEN}\n" @@ -664,7 +645,7 @@ def test_check_before_act_generation_refuses_an_overview_seed_aliased_into_a_pri role_dir.mkdir(parents=True, exist_ok=True) (directory / "overview.md").symlink_to(Path(".leji") / role / "new.md") manifest = _load(directory) - before = _snapshot(role_dir) + before = snapshot_tree(role_dir, repo_root=directory) gen = generate_viewer(str(directory), manifest) assert any( @@ -676,7 +657,9 @@ def test_check_before_act_generation_refuses_an_overview_seed_aliased_into_a_pri ), f"generation refuses the overview.md seed into .leji/{role} with a hard error" assert "overview.md" not in gen.written, "overview.md is not reported written" assert not (role_dir / "new.md").exists(), "nothing was written through the alias" - assert _snapshot(role_dir) == before, f"the aliased .leji/{role} is byte-identical" + assert snapshot_tree(role_dir, repo_root=directory) == before, ( + f"the aliased .leji/{role} is byte-identical" + ) # Generation-side case variant: a `.LEJI/` spelling of a role folds to the role on # a case-insensitive volume, so the resolved target is judged, not the spelling. @@ -742,13 +725,13 @@ def test_export_refuses_a_nested_dangling_out_redirecting_into_a_private_role( manifest = _load(directory) # redirect -> .leji/mounts/ghost, and ghost does NOT exist: a dangling intermediate. (directory / "redirect").symlink_to(Path(".leji") / "mounts" / "ghost") - mounts_before = _snapshot(directory / ".leji" / "mounts") + mounts_before = snapshot_tree(directory / ".leji" / "mounts", repo_root=directory) with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): build_viewer(str(directory), manifest, "redirect/export") assert not (directory / ".leji" / "mounts" / "ghost").exists(), ( "the dangling target was not created by the build" ) - assert _snapshot(directory / ".leji" / "mounts") == mounts_before, ( + assert snapshot_tree(directory / ".leji" / "mounts", repo_root=directory) == mounts_before, ( "nothing was cleared or written in the private role" ) @@ -773,10 +756,10 @@ def test_export_refuses_a_chained_dangling_out_that_ends_in_a_private_role( manifest = _load(directory) (directory / "redirect").symlink_to("hop") (directory / "hop").symlink_to(Path(".leji") / "work" / "ghost") - work_before = _snapshot(directory / ".leji" / "work") + work_before = snapshot_tree(directory / ".leji" / "work", repo_root=directory) with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): build_viewer(str(directory), manifest, "redirect/export") - assert _snapshot(directory / ".leji" / "work") == work_before, ( + assert snapshot_tree(directory / ".leji" / "work", repo_root=directory) == work_before, ( "nothing was cleared or written in the private role" ) @@ -829,7 +812,7 @@ def test_check_before_act_refuses_a_case_variant_alias_through_a_non_enumerable_ (aliased / "assets" / "planted.txt").write_text(f"{TOKEN}\n", encoding="utf-8") (directory / ".leji" / "viewer").symlink_to(Path("..") / ".LEJI" / "work" / "chrome") manifest = _load(directory) - before = _snapshot(aliased) + before = snapshot_tree(aliased, repo_root=directory) # Searchable and writable, but unlistable: the repository directory is the one that # holds the canonical spelling of `.leji`. directory.chmod(0o311) @@ -839,7 +822,9 @@ def test_check_before_act_refuses_a_case_variant_alias_through_a_non_enumerable_ f.rule == "viewer-target-refused" and f.severity == "error" for f in gen.findings ), "generation refuses an unresolvable viewer target" assert gen.written == [], "and writes nothing" - assert _snapshot(aliased) == before, "the aliased private role is byte-identical" + assert snapshot_tree(aliased, repo_root=directory) == before, ( + "the aliased private role is byte-identical" + ) finally: directory.chmod(0o755) @@ -849,14 +834,14 @@ def test_check_before_act_refuses_a_case_variant_alias_through_a_non_enumerable_ (other / ".leji" / "mounts" / "store" / "empty").mkdir(parents=True) (other / ".leji" / "dist").symlink_to(Path("..") / ".LEJI" / "mounts" / "store" / "empty") other_manifest = _load(other) - mounts_before = _snapshot(other / ".leji" / "mounts") + mounts_before = snapshot_tree(other / ".leji" / "mounts", repo_root=other) other.chmod(0o311) try: with pytest.raises(RuntimeError, match=r"cannot be resolved \(permission or I/O error\)"): build_viewer(str(other), other_manifest, None) finally: other.chmod(0o755) - assert _snapshot(other / ".leji" / "mounts") == mounts_before, ( + assert snapshot_tree(other / ".leji" / "mounts", repo_root=other) == mounts_before, ( "nothing was cleared or written in the private role" ) @@ -1048,7 +1033,7 @@ def test_export_refuses_a_dangling_output_entry_and_creates_nothing( (directory / ".leji" / "dist").symlink_to(Path("..") / "site") (directory / "published").symlink_to("elsewhere") - before = _snapshot(directory) + before = snapshot_tree(directory, repo_root=directory) with pytest.raises(RuntimeError, match="it is a dangling symlink"): build_viewer(str(directory), manifest, None) @@ -1059,4 +1044,4 @@ def test_export_refuses_a_dangling_output_entry_and_creates_nothing( assert (directory / "published").is_symlink(), "the --out link is left in place" assert not (directory / "site").exists(), "the default link destination was never created" assert not (directory / "elsewhere").exists(), "the --out link destination was never created" - assert _snapshot(directory) == before, "and the tree is byte-identical" + assert snapshot_tree(directory, repo_root=directory) == before, "and the tree is byte-identical" diff --git a/packages/sdk-py/tests/test_coverage.py b/packages/sdk-py/tests/test_coverage.py index b743342..068bfc0 100644 --- a/packages/sdk-py/tests/test_coverage.py +++ b/packages/sdk-py/tests/test_coverage.py @@ -327,7 +327,9 @@ def shutdown(self) -> None: self.shutdown_called = True fake = FakeServer() - monkeypatch.setattr(cli, "serve_viewer", lambda _root, _port, _root_rel="", log=None: fake) + monkeypatch.setattr( + cli, "serve_viewer", lambda _root, _port, _root_rel="", log=None, entries=None: fake + ) code = cli.main(["viewer", "serve", "--root", str(layer)]) out = capsys.readouterr().out assert code == 0 @@ -350,7 +352,9 @@ def shutdown(self) -> None: pass monkeypatch.setattr( - cli, "serve_viewer", lambda _root, _port, _root_rel="", log=None: FakeServer() + cli, + "serve_viewer", + lambda _root, _port, _root_rel="", log=None, entries=None: FakeServer(), ) opened: list[str] = [] monkeypatch.setattr(cli, "open_browser", lambda url: opened.append(url)) diff --git a/packages/sdk-py/tests/test_export.py b/packages/sdk-py/tests/test_export.py index 0033c84..34a8c36 100644 --- a/packages/sdk-py/tests/test_export.py +++ b/packages/sdk-py/tests/test_export.py @@ -19,9 +19,11 @@ import shutil from pathlib import Path +from leji import generate_viewer, load_manifest, render_overview from leji.cli import main from leji.export_cmd import STRICT_LINT_RULES from leji.schemas import load_cli_spec +from leji.viewer_cmd import build_layer_map REPO_ROOT = Path(__file__).resolve().parents[3] EXAMPLE = REPO_ROOT / "examples" / "monorepo" @@ -428,3 +430,98 @@ def test_export_flavor_carries_no_root_absolute_url(tmp_path: Path, capsys) -> N exported_asset = (directory / ".leji" / "dist").joinpath(*rel.split("/")).read_bytes() served_asset = (directory / ".leji" / "viewer").joinpath(*rel.split("/")).read_bytes() assert exported_asset == served_asset, f"{rel} must be flavor-neutral" + + +# --- the exported overview carries the map; the lint reads the source ---------- + + +def test_exported_overview_carries_the_rendered_map_and_the_lint_judges_the_source( + tmp_path: Path, capsys +) -> None: + directory = _copy(FIXTURES / "valid-unified-leji-fresh", tmp_path / "layer") + # An author's page: prose around the markers, and inside them a stale hand-edit + # carrying an out-of-subset construct. The construct's line number is what proves + # which bytes the lint read, since the substitution below changes every line after + # the markers. + overview = directory / "docs" / "overview.md" + source = ( + "# The layer\n\nIntro prose.\n\n\n" + "A raw element left inside the markers.\n" + "\n\nClosing prose.\n" + ) + overview.write_text(source, encoding="utf-8") + + assert main(["export", "--root", str(directory), "--json"]) == 0 + doc = json.loads(capsys.readouterr().out) + assert any( + f["rule"] == "render-unsupported" and f["path"] == "docs/overview.md" and f["line"] == 6 + for f in doc["findings"] + ), f"the lint reported the construct at its line in the SOURCE: {doc['findings']}" + + # The source is the author's file: untouched by an export that renders from it. + assert overview.read_text(encoding="utf-8") == source + exported = (directory / ".leji" / "dist" / "content" / "overview.md").read_text( + encoding="utf-8" + ) + manifest = load_manifest(str(directory)).manifest + entries = generate_viewer(str(directory), manifest).index_entries + assert exported == render_overview(source, manifest, entries).text, ( + "the exported copy is the source with the marked span substituted" + ) + assert ("```mermaid\n" + build_layer_map(manifest, entries) + "\n```") in exported + assert "# The layer" in exported, "the prose around the markers rides along" + assert "Closing prose." in exported, "including what follows them" + assert "" not in exported, "and the stale hand-edit between them is gone" + + +def test_exported_overview_without_markers_is_the_source_byte_for_byte( + tmp_path: Path, capsys +) -> None: + directory = _copy(FIXTURES / "valid-unified-leji-fresh", tmp_path / "layer") + overview = directory / "docs" / "overview.md" + source = "# Fully custom\n\nNo markers here at all.\n" + overview.write_text(source, encoding="utf-8") + assert main(["export", "--root", str(directory), "--json"]) == 0 + capsys.readouterr() + assert overview.read_text(encoding="utf-8") == source, "the source is untouched" + assert (directory / ".leji" / "dist" / "content" / "overview.md").read_text( + encoding="utf-8" + ) == source, "with nowhere to render the map, the exported copy is the source" + + +def test_exported_overview_decodes_invalid_utf8_like_the_reference(tmp_path: Path, capsys) -> None: + # An authored page carrying invalid UTF-8 OUTSIDE the marker span. The exported copy + # is rendered, so it is decoded first, and it must be decoded the way Node decodes. + directory = _copy(FIXTURES / "valid-unified-leji-fresh", tmp_path / "layer") + overview = directory / "docs" / "overview.md" + source = ( + b"# T\xfftle\n\nIntro.\n\n\nstale\n" + b"\n\nTa\xc0\x80il\n" + ) + overview.write_bytes(source) + assert main(["export", "--root", str(directory), "--json"]) == 0 + capsys.readouterr() + assert overview.read_bytes() == source, "the source is untouched, invalid bytes included" + exported = (directory / ".leji" / "dist" / "content" / "overview.md").read_bytes() + manifest = load_manifest(str(directory)).manifest + entries = generate_viewer(str(directory), manifest).index_entries + want = render_overview(source.decode("utf-8", errors="replace"), manifest, entries) + assert want.markers_found + assert exported == want.text.encode("utf-8"), "the exported copy is the reference rendering" + text = exported.decode("utf-8") + assert "# T�tle" in text and "Ta��il" in text + assert b"\xff" not in exported, "no raw invalid byte reaches the export of a rendered page" + + +def test_exported_markerless_overview_keeps_its_raw_bytes(tmp_path: Path, capsys) -> None: + # The other half of the same rule: with no markers there is nothing to render, so the + # export copies the snapshot it linted, invalid byte and all. + directory = _copy(FIXTURES / "valid-unified-leji-fresh", tmp_path / "layer") + overview = directory / "docs" / "overview.md" + source = b"# Fully custom\n\nNo markers, and a raw \xff byte.\n" + overview.write_bytes(source) + assert main(["export", "--root", str(directory), "--json"]) == 0 + capsys.readouterr() + assert (directory / ".leji" / "dist" / "content" / "overview.md").read_bytes() == source, ( + "a markerless page exports as its raw bytes" + ) diff --git a/packages/sdk-py/tests/test_leji_ignore.py b/packages/sdk-py/tests/test_leji_ignore.py new file mode 100644 index 0000000..bc98552 --- /dev/null +++ b/packages/sdk-py/tests/test_leji_ignore.py @@ -0,0 +1,325 @@ +"""The self-managed ``.leji/.gitignore``, driven from the shared fixtures: the tool +ignores its own tree from inside, so a layer whose root ``.gitignore`` never received the +``.leji/`` line is clean after its first role-creating command. The fixtures own the +scenario definitions (``lejiIgnore``), so all three SDKs answer the same six questions +against the same trees; the unit tests below them pin what a fixture cannot construct +without injecting a fault. + +Mirrors packages/sdk/test/leji-ignore.test.ts. +""" + +from __future__ import annotations + +import json +import posixpath +import shutil +import subprocess +from pathlib import Path + +import pytest + +from leji import ( + LEJI_IGNORE_CONTENT, + LEJI_IGNORE_NOTICE, + LEJI_IGNORE_REL, + build_viewer, + ensure_leji_ignore_file, + load_manifest, + new_leji_ignore_context, +) +from leji.cli import main +from leji.fsx import guard_root, write_file_guarded +from leji.init_cmd import ensure_approval_guard +from leji.layout import LEJI_DIR, role_abs +from leji.mounts import MountDecl, retain_pin_in_store + +IGNORE_FIXTURES = [ + "valid-leji-ignore-fresh", + "valid-leji-ignore-existing", + "valid-leji-ignore-legacy", +] + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES = REPO_ROOT / "fixtures" + + +def _fixture_rel(value: str, what: str) -> str: + """A fixture-declared path, as the README fixes it: repository-root-relative POSIX, + normalized, no ``..`` segment, never absolute.""" + assert not posixpath.isabs(value), f"{what} must be relative: {value}" + normalized = posixpath.normpath(value).rstrip("/") + assert normalized == value.rstrip("/"), f"{what} must be normalized: {value}" + assert ".." not in normalized.split("/"), f"{what} must not escape the fixture: {value}" + return normalized + + +def _fixture_abs(directory: Path, rel: str) -> Path: + return directory.joinpath(*rel.split("/")) + + +def _copy_seed(src: Path, dest: Path) -> None: + """Copy a committed seed's CONTENTS into ``dest``, which the harness creates.""" + dest.mkdir(parents=True, exist_ok=True) + for entry in sorted(src.iterdir(), key=lambda p: p.name): + assert not entry.is_symlink(), f"seed carries a symlink: {entry}" + target = dest / entry.name + if entry.is_dir(): + _copy_seed(entry, target) + else: + shutil.copy2(entry, target) + + +def _git(directory: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(directory), *args], + capture_output=True, + text=True, + check=True, + ).stdout + + +def _git_fixture(directory: Path, name: str, seeds: list[dict]) -> Path: + """A pristine working copy of the fixture with every declared seed materialized, + committed to its own git repository: ``git status --porcelain`` is one half of what + these scenarios assert, and it answers nothing useful over an uncommitted tree.""" + shutil.copytree(FIXTURES / name, directory, dirs_exist_ok=True) + for seed in seeds: + to = _fixture_rel(seed["to"], "seed.to") + to_abs = _fixture_abs(directory, to) + assert not to_abs.exists(), f"seed target already exists: {to}" + _copy_seed(_fixture_abs(directory, _fixture_rel(seed["from"], "seed.from")), to_abs) + _git(directory, "init", "-q", "-b", "main") + _git(directory, "config", "user.email", "fixtures@leji.org") + _git(directory, "config", "user.name", "Leji Fixtures") + _git(directory, "config", "commit.gpgsign", "false") + _git(directory, "add", "-A") + _git(directory, "commit", "-qm", "fixture") + return directory + + +def _plant(directory: Path, outside: Path, declaration: dict) -> None: + """The declared symlink, and whatever it points at, planted before the run. A link + out of the repository is spelled ``outside``: it resolves to a directory the harness + makes beside the working copy, which is the only shape a fixture cannot commit and + cannot express as a contained relative path.""" + at = _fixture_abs(directory, _fixture_rel(declaration["symlinkAt"], "plant.symlinkAt")) + if declaration["symlinkTo"] == "outside": + target = outside + else: + target = _fixture_abs(directory, _fixture_rel(declaration["symlinkTo"], "plant.symlinkTo")) + if declaration["targetKind"] == "dir": + target.mkdir(parents=True, exist_ok=True) + else: + target.write_text(declaration.get("targetBytes", ""), encoding="utf-8") + at.parent.mkdir(parents=True, exist_ok=True) + at.symlink_to(target) + + +def _untracked_under_leji(directory: Path) -> list[str]: + """Every ``git status --porcelain`` entry whose path lies under the root ``.leji/``.""" + raw = _git(directory, "status", "--porcelain") + entries = [line[3:].strip('"') for line in raw.split("\n") if line.strip()] + return sorted(r for r in entries if r == LEJI_DIR or r.startswith(LEJI_DIR + "/")) + + +def _scenarios() -> list[tuple[str, list[dict], dict]]: + out: list[tuple[str, list[dict], dict]] = [] + for name in IGNORE_FIXTURES: + expected = json.loads((FIXTURES / name / "expected.json").read_text("utf-8")) + block = expected.get("lejiIgnore") + assert block is not None, f"{name} declares a lejiIgnore block" + for scenario in block["scenarios"]: + out.append((name, expected.get("seeds", []), scenario)) + return out + + +@pytest.mark.parametrize( + "name,seeds,scenario", + _scenarios(), + ids=[f"{n}-{s['id']}" for n, _, s in _scenarios()], +) +def test_leji_ignore_fixture_scenario( + name: str, seeds: list[dict], scenario: dict, capsys, tmp_path: Path +) -> None: + directory = _git_fixture(tmp_path / "repo", name, seeds) + if scenario.get("plant"): + _plant(directory, tmp_path / "outside", scenario["plant"]) + before = {} + for rel in scenario.get("preserved", []): + abs_path = _fixture_abs(directory, _fixture_rel(rel, "preserved entry")) + assert abs_path.exists(), f"preserved path exists before the run: {rel}" + before[rel] = abs_path.read_bytes() + + code = main([*scenario["args"], "--root", str(directory)]) + captured = capsys.readouterr() + assert code == scenario["exit"], f"exit code (stderr: {captured.err})" + + # The one file, judged on its ORIGINAL entry: a symlink standing there was refused, + # never followed, so lstat is what decides its kind. + ignore_abs = Path(role_abs(str(directory), LEJI_IGNORE_REL)) + if scenario["ignoreFile"] == "absent": + assert not ignore_abs.is_symlink() and not ignore_abs.exists(), ( + f"{LEJI_IGNORE_REL} must not exist" + ) + elif scenario["ignoreFile"] == "symlink": + assert ignore_abs.is_symlink(), f"{LEJI_IGNORE_REL} is still the planted symlink" + else: + assert ignore_abs.is_file() and not ignore_abs.is_symlink(), ( + f"{LEJI_IGNORE_REL} is a regular file" + ) + assert ignore_abs.read_text("utf-8") == scenario["bytes"], f"{LEJI_IGNORE_REL} bytes" + + assert captured.err.count(LEJI_IGNORE_NOTICE) == scenario["notices"], ( + f"notice count (stderr: {captured.err})" + ) + + if scenario.get("jsonParses"): + document = json.loads(captured.out) + assert isinstance(document, dict), "--json stdout parses as one document" + assert "was left as is" not in captured.out, ( + "the notice is stderr only, never inside the JSON document" + ) + + if scenario.get("untrackedUnderLeji") is not None: + assert _untracked_under_leji(directory) == scenario["untrackedUnderLeji"], ( + f"git status under {LEJI_DIR}/" + ) + + for rel, data in before.items(): + assert _fixture_abs(directory, rel).read_bytes() == data, f"preserved byte-identical: {rel}" + + +# --- unit level: what a fixture cannot prepare without injecting a fault ----------- + + +def _fresh_copy(directory: Path) -> Path: + """The smallest layer these unit tests drive, copied out of the fixture family.""" + shutil.copytree(FIXTURES / "valid-leji-ignore-fresh", directory, dirs_exist_ok=True) + (directory / "expected.json").unlink(missing_ok=True) + return directory + + +def _with_foreign_ignore(directory: Path) -> Path: + """The same layer with somebody else's ignore file already standing where the tool + would write its own: the state the notice is for.""" + Path(role_abs(str(directory), LEJI_DIR)).mkdir(parents=True, exist_ok=True) + Path(role_abs(str(directory), LEJI_IGNORE_REL)).write_text("mine\n", encoding="utf-8") + return directory + + +def test_the_notice_is_frozen_and_one_context_says_it_exactly_once(capsys, tmp_path) -> None: + assert LEJI_IGNORE_NOTICE == ( + "leji: .leji/.gitignore exists and was left as is (expected content: *)" + ) + assert LEJI_IGNORE_CONTENT == "*\n" + + directory = _with_foreign_ignore(_fresh_copy(tmp_path / "repo")) + ctx = new_leji_ignore_context() + for _ in range(3): + assert ensure_leji_ignore_file(str(directory), ctx) == "left-as-is" + captured = capsys.readouterr() + assert captured.err == LEJI_IGNORE_NOTICE + "\n", "one notice per invocation context" + assert Path(role_abs(str(directory), LEJI_IGNORE_REL)).read_text("utf-8") == "mine\n", ( + "bytes untouched" + ) + + +def test_a_context_is_per_invocation_never_per_process(capsys, tmp_path) -> None: + first = _with_foreign_ignore(_fresh_copy(tmp_path / "first")) + second = _with_foreign_ignore(_fresh_copy(tmp_path / "second")) + for directory in (first, second): + ensure_leji_ignore_file(str(directory), new_leji_ignore_context()) + captured = capsys.readouterr() + assert captured.err.count(LEJI_IGNORE_NOTICE) == 2, "each invocation says it for itself" + + +def test_nothing_is_written_through_an_entry_standing_at_the_target(tmp_path) -> None: + # The check/use gap at the one file this exception allows, from both sides. The READ + # side first: a symlink into ordinary content standing at the target is refused, so + # the helper writes nothing and `decoy.txt` is untouched. Then the WRITE side: the + # very guarded create the helper makes is asked to run against that same standing + # entry, and ``O_EXCL`` is what makes it report the entry rather than follow it. + # Mutation that reddens the second half: drop ``exclusive`` from the guarded write in + # ``ensure_leji_ignore_file``. + directory = _fresh_copy(tmp_path / "repo") + Path(role_abs(str(directory), LEJI_DIR)).mkdir(parents=True, exist_ok=True) + decoy = directory / "decoy.txt" + decoy.write_text("not the ignore file\n", encoding="utf-8") + ignore_abs = Path(role_abs(str(directory), LEJI_IGNORE_REL)) + ignore_abs.symlink_to(decoy) + + assert ensure_leji_ignore_file(str(directory)) != "created" + assert decoy.read_text("utf-8") == "not the ignore file\n", "the link target is untouched" + assert ignore_abs.is_symlink(), "the planted link is still the planted link" + + verdict = write_file_guarded( + guard_root(str(directory)), str(ignore_abs), None, LEJI_IGNORE_CONTENT, exclusive=True + ) + assert verdict.exists, "the exclusive create must report the standing entry" + assert decoy.read_text("utf-8") == "not the ignore file\n", ( + "the link target is untouched by the create" + ) + + +def test_generation_alone_establishes_a_role(capsys, tmp_path) -> None: + # The fixture scenarios drive `viewer build` and `export`, which are one command; + # this is the other role establisher on the viewer side, reached by its own name. + directory = _fresh_copy(tmp_path / "repo") + assert main(["viewer", "--root", str(directory)]) == 0, capsys.readouterr().err + assert Path(role_abs(str(directory), LEJI_IGNORE_REL)).read_text("utf-8") == LEJI_IGNORE_CONTENT + + +def test_the_onboarding_guard_establishes_the_work_role(tmp_path) -> None: + directory = _fresh_copy(tmp_path / "repo") + assert ensure_approval_guard(str(directory), "docs/") == "installed" + assert Path(role_abs(str(directory), LEJI_IGNORE_REL)).read_text("utf-8") == LEJI_IGNORE_CONTENT + + +def test_a_direct_sdk_call_with_no_context_notices_at_most_once(capsys, tmp_path) -> None: + directory = _with_foreign_ignore(_fresh_copy(tmp_path / "repo")) + manifest = load_manifest(str(directory)).manifest + assert manifest is not None, "the fixture manifest loads" + # build_viewer nests generate_viewer and establishes two roles of its own. + build_viewer(str(directory), manifest) + assert capsys.readouterr().err.count(LEJI_IGNORE_NOTICE) == 1, "one notice for the whole call" + + +def test_mounts_establishment_threads_one_context_across_several_retentions( + capsys, monkeypatch, tmp_path +) -> None: + # `conformance --federation verify` probes reachability PER DECLARED MOUNT and + # `mounts update-pin --fetch` retains twice (the current pin, then the target); each + # establishes the managed store through the same helper, so each would say the frozen + # line again if the invocation's notice state were not threaded all the way down. + # + # The declared source is routed to a local empty repository the way the update-pin + # suite routes its own (`insteadOf` is git's own redirection), so the retention + # establishes the store and then fails locally: nothing here reaches the network. + directory = _with_foreign_ignore(_fresh_copy(tmp_path / "repo")) + routed = tmp_path / "routed.git" + subprocess.run(["git", "init", "--bare", "-q", str(routed)], check=True) + source = "https://github.com/acme/one" + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", f"url.{routed}.insteadOf") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", source) + + mount = MountDecl(name="one", source=source, pin="0" * 40, tracking_ref="refs/heads/main") + ctx = new_leji_ignore_context() + for oid in ("0" * 40, "1" * 40): + retain_pin_in_store(str(directory), mount, "acme/one", oid, ctx) + # The guard against a test that passes for the wrong reason: the managed store really + # was established, so a notice was genuinely available to be said each time. + store = Path(role_abs(str(directory), f"{LEJI_DIR}/mounts")) / "store" + assert store.is_dir() and any(store.iterdir()), "the managed store was established" + assert capsys.readouterr().err.count(LEJI_IGNORE_NOTICE) == 1, "one notice for the invocation" + assert Path(role_abs(str(directory), LEJI_IGNORE_REL)).read_text("utf-8") == "mine\n", ( + "bytes untouched" + ) + + +def test_the_ignore_file_is_never_created_by_a_read_only_command(capsys, tmp_path) -> None: + # Nothing read-only creates the file: the bootstrap requirement is that it appears at + # the first ROLE creation, and `validate` creates none. + directory = _fresh_copy(tmp_path / "repo") + assert main(["validate", "--root", str(directory)]) == 0, capsys.readouterr().err + assert not Path(role_abs(str(directory), LEJI_DIR)).exists() diff --git a/packages/sdk-py/tests/test_mounts.py b/packages/sdk-py/tests/test_mounts.py index 25bbcef..6e49835 100644 --- a/packages/sdk-py/tests/test_mounts.py +++ b/packages/sdk-py/tests/test_mounts.py @@ -345,6 +345,95 @@ def test_fetch_retains_the_pin_by_a_resolver_owned_ref_and_writes_no_fetch_head( assert not (store / "FETCH_HEAD").exists(), "still none after a witness refresh" +def _witness_transaction_hook(store: Path, witness_ref: str, mode: str, oid: str = "") -> None: + """A ``reference-transaction`` hook in the managed store, firing only on the + canonical witness ref. ``abort`` fails the swap the way a lock, a permission + error or a full disk does. ``publish`` writes ``oid`` into the ref and then + fails, which is the state a run finds when another writer published between its + read of and its own swap; the interleaving itself is not reachable in + a single process, so the fixture reproduces what it leaves behind.""" + hooks = store / "hooks" + hooks.mkdir(parents=True, exist_ok=True) + publish = ( + f'mkdir -p "$(dirname "{store}/{witness_ref}")"\n' + f"printf '%s\\n' '{oid}' > \"{store}/{witness_ref}\"\n" + if mode == "publish" + else "" + ) + hook = hooks / "reference-transaction" + # Each stdin line is " "; every other ref (the fetched temporary, + # the pin ref) passes through untouched. + hook.write_text( + f'#!/bin/sh\n[ "$1" = prepared ] || exit 0\n' + f'grep -q " {witness_ref}$" || exit 0\n{publish}exit 1\n', + encoding="utf-8", + ) + hook.chmod(0o755) + + +def test_a_lost_compare_and_swap_is_a_mismatch_and_an_operational_failure_is_not( + tmp_path, capsys +) -> None: + """Mirrors the TS reference's reference-transaction test. It is also the only + reachable path to the witness act's SECOND frozen failure class: a tracking ref + that arrived and a canonical ref that would not take it, whose reason travels + into ``reasons`` and from there into the finding's ``detail``.""" + from leji.cli import main + + host, sibling, pin = mounted_pair(tmp_path) + git(sibling, "config", "uploadpack.allowAnySHA1InWant", "true") + manifest = load_manifest(host).manifest + assert manifest is not None + identity = normalize_source("https://github.com/acme/product-context") + assert identity is not None + store = Path(host) / ".leji" / "mounts" / "store" / sha256_hex(identity) + witness_ref = witness_ref_for(identity, "refs/heads/main") + # The witness ref does not exist yet, so this run swaps against "must not exist", + # and finds another writer's commit there instead. That is a race it lost, not + # a failure: the published witness stands and nothing is reported. + store.mkdir(parents=True, exist_ok=True) + git(Path(host), "init", "--bare", "-q", str(store)) + _witness_transaction_hook(store, witness_ref, "publish", pin) + with _source_rewrite(sibling): + r = hydrate_mounts(host, manifest, fetch=True) + assert "witnessRefreshFailed" not in r.outcomes[0], "another writer publishing is valid" + assert git(store, "rev-parse", witness_ref) == pin, "the other writer's witness stands" + assert "acme-product-context" not in r.reasons, "a valid outcome names no failed act" + # The same failed swap, with the ref holding exactly what this run expected: no + # one published, so this is the disk, the permissions or a lock, and it may not + # pass as a refresh that happened. + _witness_transaction_hook(store, witness_ref, "abort") + with _source_rewrite(sibling): + r = hydrate_mounts(host, manifest, fetch=True) + assert r.outcomes[0]["storeFetched"] is True, "the store was established, the swap failed" + assert r.outcomes[0]["witnessRefreshFailed"] is True + assert git(store, "rev-parse", witness_ref) == pin, "the previous witness stays in place" + # The witness act's second failure class, which is not the first one: a ref that + # arrived and would not publish, never a ref that never arrived. + assert r.reasons["acme-product-context"] == "the witness ref could not be published" + # And the same reason as the bytes `mounts hydrate --json` emits. + capsys.readouterr() + with _source_rewrite(sibling): + code = main(["mounts", "hydrate", "--fetch", "--json", "--root", host]) + payload = json.loads(capsys.readouterr().out) + assert code == 0, "best-effort: a witness that would not publish is a warning" + assert [(f["rule"], f["severity"]) for f in payload["findings"]] == [ + ("mount-witness-refresh-failed", "warning") + ] + assert payload["findings"][0]["detail"] == "witness: the witness ref could not be published" + assert list(payload["findings"][0]) == ["rule", "severity", "path", "message", "detail"] + # And the same act reaches a person, on the finding's own line. + with _source_rewrite(sibling): + assert main(["mounts", "hydrate", "--fetch", "--root", host]) == 0 + human = capsys.readouterr().out + assert ( + "warning mount-witness-refresh-failed acme-product-context: the managed witness ref " + "could not be refreshed by the requested fetch " + "(detail: witness: the witness ref could not be published)\n" + ) in human + assert " (detail: witness: the witness ref could not be published)" in human + + def test_conformance_pin_reachable_is_unknown_offline_and_never_awards_federated( tmp_path, ) -> None: diff --git a/packages/sdk-py/tests/test_snapshot_audit.py b/packages/sdk-py/tests/test_snapshot_audit.py new file mode 100644 index 0000000..e73cc78 --- /dev/null +++ b/packages/sdk-py/tests/test_snapshot_audit.py @@ -0,0 +1,184 @@ +"""The structural half of the snapshot contract: the badge and canary suites hold one +tree-snapshot helper between them, and a private walker must not be able to grow back +beside it. A walker needs a directory-enumeration primitive, so this audit counts every +reference to one in those two files and compares the counts against the named exceptions +below. The claim is bounded and mechanical: it prevents a walker built on the primitives +below, whatever it is named and whether it is a function, a method or a lambda. It claims +nothing about a walker built on anything else; that wider closure is review of the +imports and call sites, not this scan. + +Mirrors packages/sdk/test/snapshot-audit.test.ts, whose shape this keeps: names, an +exception table carrying a COUNT and a reason per context, and a scan over the parsed +source, so a primitive named in a comment is not a hit (comments are not in the tree), +a name inside a longer docstring is not a hit (the whole string has to be the name), and +a name reached through a string is (`monkeypatch.setattr(os, "scandir", ...)`): the name +has to be spelled somewhere for the primitive to be reached, as an identifier or as a +string. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent + +# The names this audit is bounded to, Python's directory-enumeration calls: +# `os.walk`, `os.scandir`, `os.listdir`, `Path.iterdir`, `Path.rglob` and `Path.glob`. A +# walker built on any of them has to spell one, whatever it calls itself. A walker built +# on something else (`glob.iglob`, `fnmatch` over a shelled-out `find`, a dependency) +# spells none of them and is outside the mechanical guarantee: that one is left to +# review of the imports and call sites. +PRIMITIVES = frozenset({"walk", "scandir", "listdir", "iterdir", "rglob", "glob"}) + +# The shared helper, as the two files must import and call it. +HELPER_MODULE = "helpers.snapshot" +HELPER_NAME = "snapshot_tree" + +FILES = ("test_badge.py", "test_canary.py") + +# The exceptions, by `file#context` with the number of references each context is +# allowed. A count rather than a bare name, so a new reference fails even inside a +# context that already holds one; a context that no longer matches fails too, because a +# stale exception is an exception nobody is checking. `context` is the nearest named +# function, or `(top level)` for module scope, which a comprehension keeps, since a +# comprehension is a scope but not a function anyone can name. +ALLOWED: dict[str, tuple[int, str]] = { + "test_badge.py#test_an_out_whose_parent_resolves_outside_the_repository_is_refused_and_reads_nothing": ( + 1, + "lists an out-of-repository directory to prove nothing was created there; " + "one level, no walk", + ), + "test_badge.py#test_an_out_whose_parent_resolves_into_leji_is_refused_at_any_depth": ( + 1, + "asserts the private role is still empty; one level, no walk", + ), + "test_badge.py#(top level)": ( + 1, + "enumerates the fixture directory to generate one test per fixture carrying a badge block", + ), + "test_canary.py#_copy_seed": ( + 1, + "the seed materializer: copies a committed seed into its declared target", + ), + "test_canary.py#_files_under": ( + 1, + "the export listing: files only, a different contract from the snapshot", + ), + "test_canary.py#_count_token": ( + 1, + "the canary token scan: reads bytes, records no tree", + ), + "test_canary.py#test_check_before_act_out_of_repository_viewer_or_dist_alias_is_refused": ( + 2, + "asserts two out-of-tree destinations are empty; one level each, no walk", + ), + "test_canary.py#test_check_before_act_ancestor_swapped_after_enumeration_is_never_followed": ( + 2, + "the interception spy: captures and replaces the builtin to swap a tree mid-walk", + ), +} + + +def _parse(name: str) -> ast.Module: + return ast.parse((TESTS_DIR / name).read_text(encoding="utf-8"), filename=name) + + +def _parents(tree: ast.Module) -> dict[ast.AST, ast.AST]: + """Every node's parent, since an ast node carries no link upward.""" + links: dict[ast.AST, ast.AST] = {} + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + links[child] = parent + return links + + +def _context(node: ast.AST, links: dict[ast.AST, ast.AST]) -> str: + """The nearest named function containing `node`, or the name a lambda is bound to: + what a reviewer would cite when arguing the exception.""" + current = links.get(node) + while current is not None: + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): + return current.name + if isinstance(current, ast.Lambda): + parent = links.get(current) + if isinstance(parent, ast.Assign): + for target in parent.targets: + if isinstance(target, ast.Name): + return target.id + current = links.get(current) + return "(top level)" + + +def _spellings(node: ast.AST) -> list[str]: + """How a node spells a name: an identifier in any of the places an identifier can + stand, or a string literal whose whole value is the name.""" + if isinstance(node, ast.Name): + return [node.id] + if isinstance(node, ast.Attribute): + return [node.attr] + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + return [node.name] + if isinstance(node, ast.arg): + return [node.arg] + if isinstance(node, ast.keyword): + return [node.arg] if node.arg is not None else [] + if isinstance(node, ast.alias): + return [node.name] + ([node.asname] if node.asname is not None else []) + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return [node.value] + return [] + + +def _references(name: str) -> tuple[dict[str, int], list[str]]: + """Every reference to an enumeration primitive in one file, as `file#context` keys + with their counts, plus the line of each for the failure message.""" + tree = _parse(name) + links = _parents(tree) + counts: dict[str, int] = {} + where: list[str] = [] + for node in ast.walk(tree): + for spelled in _spellings(node): + if spelled not in PRIMITIVES: + continue + key = f"{name}#{_context(node, links)}" + counts[key] = counts.get(key, 0) + 1 + where.append(f"{name}:{getattr(node, 'lineno', 0)} {key}") + return counts, where + + +def test_no_private_tree_walker_in_the_badge_and_canary_suites() -> None: + counts: dict[str, int] = {} + where: list[str] = [] + for name in FILES: + found, found_where = _references(name) + counts.update(found) + where.extend(found_where) + + actual = dict(sorted(counts.items())) + expected = dict(sorted((key, entry[0]) for key, entry in ALLOWED.items())) + assert actual == expected, ( + "a directory-enumeration primitive appeared where no exception allows it, or an " + "exception no longer matches. Every reference found:\n" + "\n".join(sorted(where)) + ) + + +def test_the_badge_and_canary_suites_take_their_snapshots_from_the_shared_helper() -> None: + for name in FILES: + tree = _parse(name) + imported = any( + isinstance(node, ast.ImportFrom) + and node.module == HELPER_MODULE + and any(alias.name == HELPER_NAME and alias.asname is None for alias in node.names) + for node in ast.walk(tree) + ) + assert imported, f"{name} imports {HELPER_NAME} from {HELPER_MODULE}" + + calls = sum( + 1 + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == HELPER_NAME + ) + assert calls > 0, f"{name} calls {HELPER_NAME}" diff --git a/packages/sdk-py/tests/test_snapshot_contract.py b/packages/sdk-py/tests/test_snapshot_contract.py new file mode 100644 index 0000000..77acd52 --- /dev/null +++ b/packages/sdk-py/tests/test_snapshot_contract.py @@ -0,0 +1,150 @@ +"""The shared fixture is the byte contract for the snapshot helper, and these goldens +are the frozen bytes the Node reference (packages/sdk/test/snapshot-contract.test.ts) +and the Go port assert against too. The walked payload is `payload/`; the seeds and the +goldens live beside it, outside the walk, so a golden never has to contain its own +digest. + +Mirrors packages/sdk/test/snapshot-contract.test.ts, including its own seed +materializer: the canary suite's is private to that module, and the reference keeps this +one local rather than reaching across suites for it. +""" + +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path + +import pytest + +from helpers.snapshot import snapshot_tree + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURE_DIR = REPO_ROOT / "fixtures" / "snapshot-contract" + +DECLARATION = json.loads((FIXTURE_DIR / "leji-test.json").read_text(encoding="utf-8")) + + +def _copy_seed(src: Path, dest: Path) -> None: + """Copy a committed seed's CONTENTS into `dest`, which this harness creates. Regular + files and directories only, exactly as the seed convention fixes it + (`fixtures/README.md`): a symlink anywhere inside a seed is a harness error.""" + dest.mkdir(parents=True, exist_ok=True) + for entry in sorted(src.iterdir(), key=lambda p: p.name): + assert not entry.is_symlink(), f"seed carries a symlink: {entry}" + target = dest / entry.name + if entry.is_dir(): + _copy_seed(entry, target) + else: + assert entry.is_file(), f"seed carries a non-regular file: {entry}" + shutil.copy2(entry, target) + + +def _materialize(factory: pytest.TempPathFactory) -> Path: + """A working copy of the fixture with everything the declaration says a walk must + find: the declared seeds materialized as real `.git` directories, then the entries + git cannot track (an empty directory, a symlink) created here. + + Windows: creating a symlink needs SeCreateSymbolicLinkPrivilege (Developer Mode or an + elevated shell), which an ordinary account does not hold, and `payload/link` is one of + the entries this contract is about, and a walk without it is not this contract. The + Python suite runs on ubuntu-latest in CI, so nothing is lost there; a Windows + developer gets a documented skip rather than a failure about a privilege. Line + endings are not a second Windows hazard: `.gitattributes` disables conversion for + every path, so the committed bytes are the checked-out bytes and the digests hold. + """ + directory = factory.mktemp("leji-snapshot") + shutil.copytree(FIXTURE_DIR, directory, dirs_exist_ok=True) + for seed in DECLARATION["seeds"]: + to_abs = directory.joinpath(*seed["to"].split("/")) + assert not to_abs.exists(), f"seed target already exists: {seed['to']}" + _copy_seed(directory.joinpath(*seed["from"].split("/")), to_abs) + for rel in DECLARATION["runtime"]["directories"]: + directory.joinpath(*rel.split("/")).mkdir(parents=True, exist_ok=True) + for link in DECLARATION["runtime"]["symlinks"]: + try: + os.symlink(link["to"], directory.joinpath(*link["at"].split("/"))) + except OSError as error: # pragma: no cover - platform-dependent + pytest.skip( + "this platform refuses symlink creation without privilege, and " + f"{link['at']} is part of the contract: {error}" + ) + return directory + + +def _golden(name: str) -> list[str]: + """The frozen bytes of one golden, as the lines a walk must produce.""" + text = (FIXTURE_DIR / DECLARATION["goldens"][name]["file"]).read_text(encoding="utf-8") + assert text.endswith("\n"), f"{name}: a golden ends with a newline" + return text[:-1].split("\n") + + +def _walk(directory: Path, name: str) -> tuple[Path, Path]: + """The walked directory and repository root one golden declares, inside a working + copy.""" + declared = DECLARATION["goldens"][name] + return ( + directory.joinpath(*declared["walk"].split("/")), + directory.joinpath(*declared["repoRoot"].split("/")), + ) + + +def test_the_whole_repository_walk_excludes_the_repository_git_and_keeps_nested_ones( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + directory = _materialize(tmp_path_factory) + payload, repo_root = _walk(directory, "repo") + lines = snapshot_tree(payload, repo_root=repo_root) + assert lines == _golden("repo"), "the walk is the frozen golden, line for line" + + # What the golden says, said again as claims, so a re-baked golden that lost one of + # them fails here rather than passing quietly. + assert not any(line == ".git/\tdir" or line.startswith(".git/") for line in lines), ( + "the repository .git is absent from the snapshot" + ) + assert "pkg/.git/\tdir" in lines, "the nested .git is an entry of its own" + assert any(line.startswith("pkg/.git/HEAD\tsha256:") for line in lines), ( + "and its contents are digested like any other file" + ) + assert "empty/\tdir" in lines, "an empty directory is recorded, so its creation is detectable" + assert "link\tnon-regular" in lines, "a symlink is marked, never followed" + + # The ordering is bytewise over UTF-8. Python's own string order is over code points, + # which UTF-8 preserves, so this vector is not the trap here that it is in the Node + # reference (where a default sort orders UTF-16 code units and puts these two the + # other way). It is the proof that this port's order IS the goldens' order: `z` + # (EF BD 9A) before `😀` (F0 9F 98 80). + wide = next(i for i, line in enumerate(lines) if line.startswith("z.txt\t")) + grin = next(i for i, line in enumerate(lines) if line.startswith("😀.txt\t")) + assert wide < grin, "the wide latin z precedes the emoji, which is UTF-8 byte order" + assert lines == sorted(lines, key=lambda line: line.encode("utf-8")), ( + "and every line is in that order" + ) + + +def test_repo_root_defaults_to_the_walked_directory( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + directory = _materialize(tmp_path_factory) + payload, _ = _walk(directory, "repo") + assert snapshot_tree(payload) == _golden("repo"), "the default is the same walk" + + +def test_a_subtree_walk_keeps_the_subtree_git_and_drops_it_only_when_it_is_the_repository( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + directory = _materialize(tmp_path_factory) + pkg, repo_root = _walk(directory, "subtree") + golden = _golden("subtree") + + # Root means the repository, not the call: `pkg/.git` is content, and the paths are + # relative to the walked directory. + assert snapshot_tree(pkg, repo_root=repo_root) == golden, "the frozen subtree golden" + + # The same walk claiming the subtree as the repository excludes exactly the .git + # lines, and nothing else moves. + own = [line for line in golden if line != ".git/\tdir" and not line.startswith(".git/")] + assert len(own) < len(golden), "the subtree golden carries the .git lines this case removes" + assert own, "and the walk still records the rest of the subtree" + assert snapshot_tree(pkg, repo_root=pkg) == own, "its own .git is the one entry excluded" diff --git a/packages/sdk-py/tests/test_source_audit_role.py b/packages/sdk-py/tests/test_source_audit_role.py new file mode 100644 index 0000000..243f93e --- /dev/null +++ b/packages/sdk-py/tests/test_source_audit_role.py @@ -0,0 +1,387 @@ +"""The declared exception to the ROLE rule, pinned the way the write allow-list is +pinned: the role rule allows exactly one target that belongs to no role (the +self-managed ``.leji/.gitignore``), and exactly one symbol may say so, by putting the +``metadata_file`` field on a :class:`~leji.layout.TargetVerdict`. +``docs/practice/trust-boundary.md`` mirrors this list. The write allow-list +(``test_source_audit.py``) says which symbols may touch the filesystem raw; this one +says which may declare a target writable that the role rule refuses, and it exists for +the same reason: an exception nobody can find is an exception nobody is checking. + +Mirrors packages/sdk/test/source-audit.test.ts (the role-exception pin) and +packages/sdk-go/internal/sourceaudit/role_test.go. +""" + +from __future__ import annotations + +import ast +import json +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from leji.layout import TargetVerdict + +SRC_DIR = Path(__file__).resolve().parents[1] / "src" / "leji" + +#: The exception list, by ``file#symbol``: the one place a ``metadata_file`` verdict may +#: be constructed. The symbol is the dotted name of the enclosing function (a lambda is +#: transparent, belonging to the function that owns it). +ALLOWED_ROLE_EXCEPTIONS: dict[str, str] = { + "fsx.py#_metadata_file_verdict": ( + "the self-managed .leji/.gitignore: judged on the requested entry, with a real " + ".leji directory and a non-symlink entry, refused otherwise" + ), +} + +#: The attribute that carries the exception. +ROLE_EXCEPTION_ATTRIBUTE = "metadata_file" + +#: The verdict type the attribute lives on. A POSITIONAL construction sets the field +#: without spelling its name, so it is a constructor site whatever the arguments look +#: like, and the only way to tell one from a keyword construction is the presence of a +#: positional argument. :class:`~leji.layout.TargetVerdict` is declared ``kw_only``, so +#: such a call also fails at run time; this flags it at review time. +ROLE_VERDICT_TYPE = "TargetVerdict" + +#: The calls that put an attribute on a value from a NAME given as an argument rather +#: than from a keyword written at the call: the shapes nothing else in this scan would +#: see. ``setattr`` and ``object.__setattr__`` belong to no other API this repository +#: uses, so the member name alone counts and an aliased binding is caught with it. +REFLECTIVE_SETTERS = frozenset({"setattr", "__setattr__"}) + + +@dataclass(frozen=True) +class Hit: + key: str + line: int + name: str + + +def _constant_string(node: ast.expr | None) -> str | None: + """The string a node IS, when it is written as a literal; None for anything else. + A name assembled at runtime is the residual stated below, not something to guess.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _json_spells_attribute(text: str) -> bool: + """True when a string is a JSON DOCUMENT carrying the attribute as a key, at any + depth. The text is parsed rather than substring-matched, deliberately: a literal that + merely NAMES the attribute (an error message, a docstring, this audit's own constant) + creates nothing and must not be flagged, while ``'{"metadata_file": true}'`` handed + to a decoder creates exactly the thing this pin is about.""" + try: + parsed = json.loads(text) + except ValueError: + return False + + def walk(value: object) -> bool: + if isinstance(value, dict): + return ROLE_EXCEPTION_ATTRIBUTE in value or any(walk(v) for v in value.values()) + if isinstance(value, list): + return any(walk(v) for v in value) + return False + + return walk(parsed) + + +class _RoleAnalyzer(ast.NodeVisitor): + """Every CONSTRUCTION of the metadata-file verdict in one module, keyed + ``file#symbol``. + + An attribute can be put on a value in a bounded number of statically named ways, and + all of them count: passed as a KEYWORD at a constructor or a ``replace``; assigned + onto a value afterwards by attribute; assigned into a mapping (``__dict__``, + ``vars()``, or an ordinary dict) under a literal key; written into a dict literal + such a mapping is updated from; handed to ``setattr`` or ``object.__setattr__`` as an + attribute NAME; or carried as a key inside a JSON document literal a decoder is given. + + And one way that spells no name at all: a POSITIONAL ``TargetVerdict(True, …)``, + which sets fields by ORDER. A name-based reader cannot see it, so it is recognized by + the callee and the presence of a positional argument instead, and every such call is + a constructor site whatever its arity: the sixth position is the exception, and an + audit that counted arguments would be one field rename away from being wrong. + ``TargetVerdict`` is also declared ``kw_only``, so the call fails at run time as well; + the two together are why positional construction is no longer a residual class. + + The NAME is what decides for the keyword forms, never the value's type: a verdict + built on a duck-typed stand-in, or on a shape no annotation relates to + ``TargetVerdict``, must fail this audit rather than slip through it. Reflective forms + work where a direct assignment would not (a frozen dataclass, ``__slots__``), which is + exactly why they are audited here rather than left to the type checker. + + READING the attribute is not constructing it, so a plain ``verdict.metadata_file`` + test is deliberately not a hit; only positions that create it are. The dataclass + FIELD DECLARATION in ``layout.py`` is not a hit either: declaring the field is what + the exception is about, not a second exception. + + THE RESIDUAL, stated exactly. One class remains outside, and only one: an attribute + name ASSEMBLED AT RUNTIME, so that no single string can be read for it statically: a + concatenation (``"metadata_" + "file"``), an f-string, a variable this reader cannot + narrow to one literal, a value read from data. Every such site is invisible to any + static audit, this one included; its closure is human, through + ``docs/practice/trust-boundary.md`` and the diff review. Positional construction was + once in this list and is not any more: it is prevented by ``kw_only`` and flagged + here.""" + + def __init__(self, rel: str) -> None: + self.rel = rel + self.hits: list[Hit] = [] + self.scope: list[str] = [] + self._seen: set[tuple[int, int]] = set() + + # -- scope ----------------------------------------------------------------- + + def _in_scope(self, name: str, node: ast.AST) -> None: + self.scope.append(name) + self.generic_visit(node) + self.scope.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._in_scope(node.name, node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._in_scope(node.name, node) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._in_scope(node.name, node) + + def _record(self, node: ast.AST, name: str) -> None: + where = (getattr(node, "lineno", 0), getattr(node, "col_offset", 0)) + if where in self._seen: + return + self._seen.add(where) + key = f"{self.rel}#{'.'.join(self.scope) or '(top level)'}" + self.hits.append(Hit(key=key, line=where[0], name=name)) + + # -- constructions --------------------------------------------------------- + + def visit_Assign(self, node: ast.Assign) -> None: + self._judge_targets(node, node.targets) + self.generic_visit(node) + + def visit_AugAssign(self, node: ast.AugAssign) -> None: + self._judge_targets(node, [node.target]) + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + # `metadata_file: bool = False` inside the dataclass DECLARES the field; only an + # attribute or subscript target constructs it on a value. + self._judge_targets(node, [node.target]) + self.generic_visit(node) + + def _judge_targets(self, node: ast.AST, targets: list[ast.expr]) -> None: + for target in targets: + if isinstance(target, ast.Attribute) and target.attr == ROLE_EXCEPTION_ATTRIBUTE: + self._record(node, "attribute assignment") + if ( + isinstance(target, ast.Subscript) + and _constant_string(target.slice) == ROLE_EXCEPTION_ATTRIBUTE + ): + self._record(node, "subscript assignment") + + def visit_Dict(self, node: ast.Dict) -> None: + for key in node.keys: + if _constant_string(key) == ROLE_EXCEPTION_ATTRIBUTE: + self._record(node, "dict literal") + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + for keyword in node.keywords: + if keyword.arg == ROLE_EXCEPTION_ATTRIBUTE: + self._record(node, "keyword argument") + member = ( + node.func.attr + if isinstance(node.func, ast.Attribute) + else node.func.id + if isinstance(node.func, ast.Name) + else None + ) + # Positional construction, which spells no field name at all: whatever the arity, + # the fields are set by ORDER and one of those positions is the exception. + if member == ROLE_VERDICT_TYPE and node.args: + self._record(node, "positional construction") + if member in REFLECTIVE_SETTERS and len(node.args) >= 2: + if _constant_string(node.args[1]) == ROLE_EXCEPTION_ATTRIBUTE: + self._record(node, f"{member}()") + self.generic_visit(node) + + def visit_Constant(self, node: ast.Constant) -> None: + if isinstance(node.value, str) and _json_spells_attribute(node.value): + self._record(node, "JSON document") + self.generic_visit(node) + + +def _analyze_role(rel: str, source: str) -> list[Hit]: + analyzer = _RoleAnalyzer(rel) + analyzer.visit(ast.parse(source)) + return analyzer.hits + + +def _role_audit_tree() -> list[Hit]: + """Every production source file of this SDK (there are no tests under ``src/``).""" + files = sorted(SRC_DIR.rglob("*.py")) + assert files, "the role audit loaded no source files" + hits: list[Hit] = [] + for path in files: + hits.extend(_analyze_role(path.relative_to(SRC_DIR).as_posix(), path.read_text("utf-8"))) + return hits + + +def _unexpected(hits: list[Hit], allowed: dict[str, str]) -> list[str]: + return [f"{h.key} ({h.name}) at line {h.line}" for h in hits if h.key not in allowed] + + +def _stale(hits: list[Hit], allowed: dict[str, str]) -> list[str]: + matched = {h.key for h in hits} + return [key for key in allowed if key not in matched] + + +def test_the_role_rule_has_exactly_one_declared_exception_at_one_named_site() -> None: + hits = _role_audit_tree() + outside = _unexpected(hits, ALLOWED_ROLE_EXCEPTIONS) + assert outside == [], ( + f"a metadata-file verdict is constructed outside the declared exception: {outside}\n" + "The role rule allows one target that belongs to no role; argue any other into the " + "list, or route the write through its own role." + ) + dead = _stale(hits, ALLOWED_ROLE_EXCEPTIONS) + assert dead == [], f"role-exception entries matching no symbol (delete them): {dead}" + assert len(hits) == len(ALLOWED_ROLE_EXCEPTIONS), ( + f"one construction, not several at one site: {[(h.key, h.line, h.name) for h in hits]}" + ) + + +# --- the role-exception laundering corpus, permanent ------------------------------- + +# Each probe is another way of putting `metadata_file` on a verdict, and each must be +# flagged: the promise the single-constructor pin makes is that a SECOND exception cannot +# be added quietly, so every spelling a second one could take is asserted here rather than +# assumed. The negatives at the end are the controls: a longer, unrelated attribute name, +# an ordinary READ of the attribute, prose that merely names it, and the dataclass field +# declaration itself, none of which is a second construction. + +ROLE_PROBES: list[tuple[str, str, bool]] = [ + ( + "keyword argument", + "def launder():\n return TargetVerdict(ok=True, metadata_file=True)\n", + True, + ), + ( + # The review's own probe: the field set by ORDER, its name spelled nowhere. + "positional construction", + "def launder():\n return TargetVerdict(True, '', False, False, False, True)\n", + True, + ), + ( + "positional construction through a qualified name", + "from leji import layout\ndef launder():\n" + " return layout.TargetVerdict(True, '', False, False, False, True)\n", + True, + ), + ( + "positional construction splatted from a sequence", + "def launder(values):\n return TargetVerdict(*values)\n", + True, + ), + ( + "keyword argument through dataclasses.replace", + "import dataclasses\ndef launder(v):\n" + " return dataclasses.replace(v, metadata_file=True)\n", + True, + ), + ( + "attribute assignment", + "def launder(v):\n v.metadata_file = True\n return v\n", + True, + ), + ( + "attribute assignment inside a lambda's owner", + "def launder(v):\n apply = lambda: setattr(v, 'metadata_file', True)\n" + " apply()\n return v\n", + True, + ), + ( + "setattr with a literal name", + "def launder(v):\n setattr(v, 'metadata_file', True)\n return v\n", + True, + ), + ( + "object.__setattr__ on a frozen value", + "def launder(v):\n object.__setattr__(v, 'metadata_file', True)\n return v\n", + True, + ), + ( + "__dict__ subscript", + "def launder(v):\n v.__dict__['metadata_file'] = True\n return v\n", + True, + ), + ( + "vars() update from a dict literal", + "def launder(v):\n vars(v).update({'metadata_file': True})\n return v\n", + True, + ), + ( + "dict literal a constructor is splatted from", + "def launder():\n fields = {'ok': True, 'metadata_file': True}\n" + " return TargetVerdict(**fields)\n", + True, + ), + ( + "JSON document literal", + 'import json\ndef launder():\n return json.loads(\'{"ok": true, "metadata_file": true}\')\n', + True, + ), + ( + "negative: a longer, unrelated attribute", + "def other(v):\n v.metadata_file_name = 'x'\n" + " setattr(v, 'metadata_file_name', 'y')\n return v\n", + False, + ), + ( + "negative: reading the attribute", + "def read(v):\n return v.metadata_file is True and v.ok\n", + False, + ), + ( + "negative: prose that merely names the attribute", + "MESSAGE = 'metadata_file is the one exception the role rule allows'\n", + False, + ), + ( + "negative: the dataclass field declaration itself", + "from dataclasses import dataclass\n@dataclass\nclass Verdict:\n" + " ok: bool = False\n metadata_file: bool = False\n", + False, + ), + ( + # The control for the positional rule: an ordinary keyword construction that does + # not name the exception is not a constructor site, or every verdict in the SDK + # would be one. + "negative: keyword construction that does not name the exception", + "def ordinary():\n return TargetVerdict(ok=True, role='viewer')\n", + False, + ), +] + + +def test_the_role_exception_pin_sees_through_every_spelling_a_second_exception_could_take() -> None: + for name, source, expected in ROLE_PROBES: + hits = _analyze_role("__probe.py", source) + assert bool(hits) == expected, ( + f"probe {name!r}: expected {'a hit' if expected else 'no hit'}, " + f"got {[(h.name, h.line) for h in hits]}" + ) + + +def test_the_verdict_cannot_be_constructed_positionally_at_all() -> None: + """The language half of the same pin: positional construction is not merely audited, + it does not run. Without ``kw_only`` on the dataclass this call succeeds and sets + ``metadata_file`` without ever spelling it, which is exactly the bypass the audit + above cannot see by name.""" + with pytest.raises(TypeError): + TargetVerdict(True, "", False, False, False, True) # type: ignore[misc] + # The keyword form is unaffected, so the constraint costs the SDK nothing. + assert TargetVerdict(ok=True, metadata_file=True).metadata_file is True diff --git a/packages/sdk-py/tests/test_units.py b/packages/sdk-py/tests/test_units.py index 947b272..eaa3ed5 100644 --- a/packages/sdk-py/tests/test_units.py +++ b/packages/sdk-py/tests/test_units.py @@ -1,7 +1,10 @@ """Unit tests mirroring packages/sdk/test/units.test.ts.""" +import binascii +import hashlib import http.client import json +import os import posixpath import re import shutil @@ -20,12 +23,15 @@ freshness_report, generate_viewer, load_manifest, + render_overview, route, + serve_viewer, status_report, validate_layer, write_index, ) from leji.fsx import under_path, walk_md +from leji.viewer_cmd import build_layer_map from leji.manifest import bind_agent_in_manifest_text from leji.layer import ( excluded_from_categories, @@ -748,7 +754,7 @@ def test_viewer_build_sidebar_skips_out_of_root_boot_and_renders_plain_entries( assert "Empty group" not in sidebar -def test_viewer_seeds_overview_with_layer_map(tmp_path: Path) -> None: +def test_viewer_seeds_overview_with_empty_markers(tmp_path: Path) -> None: layer = _copy(EXAMPLE, tmp_path) manifest = load_manifest(str(layer)).manifest generate_viewer(str(layer), manifest) @@ -757,31 +763,52 @@ def test_viewer_seeds_overview_with_layer_map(tmp_path: Path) -> None: text = overview.read_text() assert "# acme-billing-context" in text assert "" in text - assert "```mermaid\nflowchart LR" in text - assert "boot --> cat_domain" in text - # Categories carry counts, never per-doc nodes (unreadable at scale). - assert 'cat_domain["📖 Domain · 1 doc"]' in text - assert "n_glossary" not in text + # The map is derived from the index, so the seed carries the placement mark and one + # line saying where the map comes from, never a copy of the map itself. + between = text.split("\n")[1].split( + "\n" + )[0] + assert between == "" + assert "```mermaid\nflowchart LR" not in text + assert "this file is never rewritten" in text -def test_viewer_overview_seeded_once(tmp_path: Path) -> None: +def test_viewer_map_is_rendered_from_the_index_never_written_into_the_source( + tmp_path: Path, +) -> None: layer = _copy(EXAMPLE, tmp_path) manifest = load_manifest(str(layer)).manifest generate_viewer(str(layer), manifest) overview = layer / "docs" / "overview.md" + seeded = overview.read_bytes() + # A second run over the same tree writes nothing: the seed happens once. + generate_viewer(str(layer), manifest) + assert overview.read_bytes() == seeded, "a second run leaves the seeded page byte-identical" + # The owner rewrites the prose, keeps the markers, and leaves a stale map inside + # them. Adding a document changes the counts the map would show. edited = ( "# My own title\n\nHand-written intro.\n\n" "\nstale\n\n\n" "More prose.\n" ) overview.write_text(edited) + (layer / "docs" / "domain" / "pricing.md").write_text("# Pricing\n\nHow we price.\n") + before = hashlib.sha256(overview.read_bytes()).hexdigest() result = generate_viewer(str(layer), manifest) - after = overview.read_text() - assert "# My own title" in after - assert "More prose." in after - assert "```mermaid\nflowchart LR" in after - assert "\nstale\n" not in after + assert hashlib.sha256(overview.read_bytes()).hexdigest() == before, ( + "a reindex that changes the map leaves overview.md byte-identical" + ) assert not any(f.rule == "overview-markers-missing" for f in result.findings) + # The map exists at render time, from the same entries the run projected. + rendered = render_overview(edited, manifest, result.index_entries) + assert rendered.markers_found + assert "# My own title" in rendered.text + assert "More prose." in rendered.text + assert "\nstale\n" not in rendered.text + assert 'cat_domain["📖 Domain · 2 docs"]' in rendered.text + assert ( + "```mermaid\n" + build_layer_map(manifest, result.index_entries) + "\n```" + ) in rendered.text def test_viewer_overview_without_markers_warns(tmp_path: Path) -> None: @@ -794,8 +821,13 @@ def test_viewer_overview_without_markers_warns(tmp_path: Path) -> None: result = generate_viewer(str(layer), manifest) assert overview.read_text() == custom assert any( - f.rule == "overview-markers-missing" and f.severity == "warning" for f in result.findings + f.rule == "overview-markers-missing" + and f.severity == "warning" + and f.message == "overview.md has no generated-map markers; the map is not rendered" + for f in result.findings ) + # With nowhere to put it, the page renders as its own source bytes. + assert render_overview(custom, manifest, result.index_entries) == (custom, False) def test_viewer_mermaid_disabled(tmp_path: Path) -> None: @@ -1512,6 +1544,304 @@ def status(host: str) -> int: server.shutdown() +# --- the overview map is served, never stored --- +# The layer map used to be written into the committed overview.md on every run that +# changed a document count. It is now substituted between the author's markers when +# the page is read: these pin the served half (the export's is in test_export.py). + + +def _overview_layer(tmp_path: Path) -> tuple[Path, dict]: + """The example layer with its viewer generated, at its resolved path (symlink + targets and mount comparisons below are about real locations).""" + layer = Path(os.path.realpath(_copy(EXAMPLE, tmp_path))) + manifest = load_manifest(str(layer)).manifest + generate_viewer(str(layer), manifest) + return layer, manifest + + +def test_serve_renders_the_layer_map_into_the_overview_page(tmp_path: Path) -> None: + layer, manifest = _overview_layer(tmp_path) + overview = layer / "docs" / "overview.md" + # A document added after the seed: the served map counts the tree of right now. + (layer / "docs" / "domain" / "pricing.md").write_text("# Pricing\n\nHow we price.\n") + before = overview.read_bytes() + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + try: + status, body = _get(port, "/content/overview.md") + assert status == 200 + text = body.decode("utf-8") + entries = generate_viewer(str(layer), manifest).index_entries + assert ("```mermaid\n" + build_layer_map(manifest, entries) + "\n```") in text, ( + "the served map is exactly what build_layer_map produces for the live index" + ) + assert 'cat_domain["📖 Domain · 2 docs"]' in text + assert text == render_overview(before.decode("utf-8"), manifest, entries).text + assert overview.read_bytes() == before, "serving the page never writes it" + finally: + server.shutdown() + + +def test_serve_renders_the_overview_from_a_relative_root(tmp_path: Path, monkeypatch) -> None: + # What the CLI actually passes: `--root .`. serve_viewer absolutizes it, so the + # route's guards judge a resolved source against a resolved root. Judging one + # against a relative root reads as "outside the repository" and refuses every page, + # which no absolute-path test can catch (the Go port failed exactly this way). + layer, manifest = _overview_layer(tmp_path) + monkeypatch.chdir(layer) + server, port = _serve_on_free_port(Path("."), manifest["rootPath"]) + try: + status, body = _get(port, "/content/overview.md") + assert status == 200, "a relative root still serves the overview" + assert "```mermaid\nflowchart LR" in body.decode("utf-8") + finally: + server.shutdown() + + +def test_serve_keeps_the_last_good_map_when_the_layer_stops_indexing(tmp_path: Path) -> None: + layer, manifest = _overview_layer(tmp_path) + overview = layer / "docs" / "overview.md" + source = overview.read_text() + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + + def overview_text() -> str: + status, body = _get(port, "/content/overview.md") + assert status == 200 + return body.decode("utf-8") + + try: + good = overview_text() + assert "```mermaid\nflowchart LR" in good, "a healthy tree renders the fresh map" + # A genuine generation failure: the manifest no longer parses, so this fetch has + # no index at all. The page keeps the map it last had rather than losing it. + manifest_abs = layer / "leji.json" + manifest_text = manifest_abs.read_text() + manifest_abs.write_text("{ not json") + assert overview_text() == good, "the last good map is served while the tree cannot index" + # Repaired, with the tree moved on: the map is the one the tree has now. + manifest_abs.write_text(manifest_text) + (layer / "docs" / "domain" / "pricing.md").write_text("# Pricing\n\nHow we price.\n") + assert 'cat_domain["📖 Domain · 2 docs"]' in overview_text() + assert overview.read_text() == source, "none of it wrote the source file" + finally: + server.shutdown() + + +def test_serve_refuses_an_overview_that_resolves_out_of_the_content_root(tmp_path: Path) -> None: + layer, manifest = _overview_layer(tmp_path) + overview = layer / "docs" / "overview.md" + # An ordinary file elsewhere in the repository: the route is a content route first, + # and a content route serves nothing from outside its own mount. + (layer / "elsewhere.md").write_text("# Elsewhere\n") + overview.unlink() + overview.symlink_to(layer / "elsewhere.md") + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + try: + status, body = _get(port, "/content/overview.md") + assert status == 403, "a target outside the content mount is refused" + assert b"Elsewhere" not in body, "and nothing of it is served" + finally: + server.shutdown() + + +def test_serve_refuses_an_overview_symlinked_into_a_private_role(tmp_path: Path) -> None: + # The content root here IS the repository root, so the private role is inside the + # mount and the servable whitelist is the check that answers: refused as today. + layer, _ = _overview_layer(tmp_path) + private = layer / ".leji" / "work" / "private.md" + private.parent.mkdir(parents=True, exist_ok=True) + private.write_text("# Private notes\n") + (layer / "overview.md").symlink_to(private) + server, port = _serve_on_free_port(layer, ".") + try: + status, body = _get(port, "/content/overview.md") + assert status == 404, "a private role is not servable, however it is reached" + assert b"Private notes" not in body, "and nothing of it is served" + finally: + server.shutdown() + + +def test_check_before_act_overview_swapped_between_authorization_and_read_is_refused( + tmp_path: Path, monkeypatch +) -> None: + # The window the overview route's binding exists for: the link is retargeted AFTER + # the resolution that authorizes the source and BEFORE the bytes are taken, at a + # target inside the repository but outside the content mount, where the private-role + # and containment guards alone say yes. Deterministic, not a race: the patched + # resolver performs the swap inline, so the window is exercised on every run (the + # idiom the export canaries use). + # + # Mutation that reddens: give the route the pre-review shape, a resolved_path that + # authorizes the path followed by a read that resolves the path again + # (verified_target_read), and the swapped-in file's bytes are served with a 200. + from leji import fsx + + layer, manifest = _overview_layer(tmp_path) + overview = layer / "docs" / "overview.md" + # The legitimate target is a real page inside the content root; overview.md is the + # link, so the swap changes only where it points. + inside = layer / "docs" / "home.md" + overview.rename(inside) + outside = layer / "outside.md" + secret = "SECRET-OUTSIDE-THE-CONTENT-ROOT" + outside.write_text(f"# Outside\n\n{secret}\n") + overview.symlink_to(inside) + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + + state = {"swapped": False} + real_resolver = fsx.resolved_path_under + + def swapping_resolver(base: str, abs_path: str): + # Called with the answer the real resolver just produced: the caller is about to + # judge or open THAT path, and the entry it came from is retargeted first. + answer = real_resolver(base, abs_path) + if not state["swapped"] and os.path.abspath(abs_path) == str(overview): + state["swapped"] = True + overview.unlink() + overview.symlink_to(outside) + return answer + + monkeypatch.setattr(fsx, "resolved_path_under", swapping_resolver) + try: + status, body = _get(port, "/content/overview.md") + finally: + monkeypatch.undo() + server.shutdown() + + assert state["swapped"], "the link was retargeted inside the route, after the resolution" + assert os.readlink(overview) == str(outside), "and it still points outside the mount" + assert secret not in body.decode("utf-8"), "no byte from outside the content mount was served" + # The mapping the ordinary content route uses: the source resolves outside the + # mount, so the mount answers, and it answers before anything is read. + assert status == 403, "the swapped-in target is refused" + + +# --- the rendered page decodes the way the reference decodes --- +# The overview is rendered from a STRING, so the three SDKs must turn the same bytes into +# the same string or the rendered page diverges on any source that is not valid UTF-8. +# The expectations below were captured from the TypeScript SDK itself and are pinned. + +# Node's `Buffer.from(bytes).toString('utf8')`, one row per invalid shape: U+FFFD per +# maximal subpart, so `E2 82` at the end is one replacement and `C0 80` is two. +_NODE_DECODE_VECTORS = [ + ("valid ascii", "68656c6c6f", "68656c6c6f"), + ("valid emoji", "f09f9880", "f09f9880"), + ("lone FF between ascii", "61ff62", "61efbfbd62"), + ("lone continuation", "80", "efbfbd"), + ("truncated 3-byte at eof", "e282", "efbfbd"), + ("truncated 3-byte then ascii", "e28241", "efbfbd41"), + ("overlong 2-byte", "c080", "efbfbdefbfbd"), + ("surrogate", "eda080", "efbfbdefbfbdefbfbd"), + ("past U+10FFFF", "f4908080", "efbfbdefbfbdefbfbdefbfbd"), + ("overlong 4-byte", "f08282ac", "efbfbdefbfbdefbfbdefbfbd"), + ("E0 80 80", "e08080", "efbfbdefbfbdefbfbd"), + ("truncated 4-byte, two bytes", "f09f", "efbfbd"), + ("truncated 4-byte, three bytes", "f09f98", "efbfbd"), + ("FE FF", "feff", "efbfbdefbfbd"), + ("lead then ascii", "c241", "efbfbd41"), +] + +# One rendered document, whole, as the reference produced it: the invalid sequences sit +# OUTSIDE the marker span (a lone 0xFF before it, an overlong C0 80 after it), so what is +# pinned is the decode and not the substitution. The truncated E2 82 inside the span is +# dropped with the span, as it is in every SDK. +_REFERENCE_SOURCE_HEX = ( + "232054ff746c650a0a3c212d2d206c656a693a67656e6572617465642d6d61703a7374617274202d2d3e" + "0a7374616c6520e282206d61700a3c212d2d206c656a693a67656e6572617465642d6d61703a656e6420" + "2d2d3e0a0a5461c080696c0a" +) +_REFERENCE_RENDERED_HEX = ( + "232054efbfbd746c650a0a3c212d2d206c656a693a67656e6572617465642d6d61703a" + "7374617274202d2d3e0a6060606d65726d6169640a666c6f776368617274204c520a2020626f6f" + "745b22f09fa49620426f6f742070726f66696c65225d0a20206361745f646f6d61696e5b22f09f" + "939620446f6d61696e20c2b7203120646f63225d0a2020626f6f74202d2d3e206361745f646f6d" + "61696e0a6060600a3c212d2d206c656a693a67656e6572617465642d6d61703a656e64202d2d3e" + "0a0a5461efbfbdefbfbd696c0a" +) + + +def test_utf8_decode_matches_node_buffer_to_string() -> None: + for name, src_hex, want_hex in _NODE_DECODE_VECTORS: + decoded = binascii.unhexlify(src_hex).decode("utf-8", errors="replace") + assert decoded.encode("utf-8").hex() == want_hex, name + + +def test_render_overview_on_invalid_utf8_is_byte_identical_to_the_reference() -> None: + source = binascii.unhexlify(_REFERENCE_SOURCE_HEX) + entries = [{"id": "a", "path": "docs/domain/a.md", "title": "A", "category": "domain"}] + rendered = render_overview( + source.decode("utf-8", errors="replace"), {"name": "fixture"}, entries + ) + assert rendered.markers_found + assert rendered.text.encode("utf-8").hex() == _REFERENCE_RENDERED_HEX + assert "# T�tle" in rendered.text and "Ta��il" in rendered.text + + +def test_serve_decodes_invalid_utf8_like_the_reference(tmp_path: Path) -> None: + # The served half of the decode rule: a rendered page is decoded the way Node decodes, + # and a markerless one is served as its raw bytes. + layer, manifest = _overview_layer(tmp_path) + overview = layer / "docs" / "overview.md" + rendered_source = ( + b"# T\xfftle\n\n\nstale\n" + b"\n\nTa\xc0\x80il\n" + ) + overview.write_bytes(rendered_source) + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + try: + status, body = _get(port, "/content/overview.md") + assert status == 200 + entries = generate_viewer(str(layer), manifest).index_entries + want = render_overview(rendered_source.decode("utf-8", errors="replace"), manifest, entries) + assert want.markers_found + assert body == want.text.encode("utf-8"), "the served page is the reference rendering" + assert "# T�tle" in body.decode("utf-8") + assert "Ta��il" in body.decode("utf-8") + assert b"\xff" not in body, "no raw invalid byte is served from a rendered page" + # Markerless: nothing to render, so the source bytes go out exactly as they stand. + markerless = b"# Custom\n\nA raw \xff byte.\n" + overview.write_bytes(markerless) + status, body = _get(port, "/content/overview.md") + assert status == 200 + assert body == markerless, "a markerless page is served as its raw bytes" + finally: + server.shutdown() + + +def test_zero_entry_snapshot_suppresses_the_startup_generation(tmp_path: Path, monkeypatch) -> None: + # A successful generation over a layer that governs nothing projects ZERO entries, not + # "no snapshot". `None` is the only "no snapshot" spelling here, and an empty list is a + # snapshot that suppresses the startup generation exactly as a full one does: the same + # class the Go port failed on with a nil slice. + from leji import serve_cmd + + layer, manifest = _overview_layer(tmp_path) + result = generate_viewer(str(layer), manifest) + assert result.index_entries is not None, "a successful generation always carries a snapshot" + + generations = 0 + real_generate_index = serve_cmd.generate_index + + def counting_generate_index(*args, **kwargs): + nonlocal generations + generations += 1 + return real_generate_index(*args, **kwargs) + + monkeypatch.setattr(serve_cmd, "generate_index", counting_generate_index) + + # An empty snapshot is still a snapshot: nothing is generated at startup. + server = serve_viewer(str(layer), 0, manifest["rootPath"], entries=[]) + try: + assert generations == 0, "the supplied snapshot must suppress the startup generation" + finally: + server.server_close() + # Not vacuous: with no snapshot at all the startup generation does run. + server = serve_viewer(str(layer), 0, manifest["rootPath"]) + try: + assert generations >= 1, "without a snapshot the server generates at startup" + finally: + server.server_close() + + def test_viewer_port_precedence(tmp_path: Path) -> None: from leji import resolve_viewer_port diff --git a/packages/sdk-py/tests/test_update_pin.py b/packages/sdk-py/tests/test_update_pin.py index bfca861..72899a1 100644 --- a/packages/sdk-py/tests/test_update_pin.py +++ b/packages/sdk-py/tests/test_update_pin.py @@ -172,6 +172,43 @@ def build_acme_sibling(directory: Path) -> None: git(directory, "config", "uploadpack.allowAnySHA1InWant", "true") +def build_orphan_target_repo(directory: Path) -> tuple[str, str]: + """The rewritten upstream before anything is pruned: ``a`` on its own branch + ``old``, and an unrelated orphan on ``main``. A repository in this shape holds + BOTH commits, which is what the refusal's route calls a local hint; pruning it is + what makes a source that can no longer serve the pin.""" + directory.mkdir(parents=True, exist_ok=True) + git(directory, "init", "-q", "-b", "old", ".") + pin = _commit(directory, "a.md") + git(directory, "checkout", "-q", "--orphan", "main") + git(directory, "rm", "-q", "-rf", ".") + target = _commit(directory, "o.md") + return pin, target + + +def build_orphan_target_source(directory: Path) -> tuple[str, str]: + """The orphan-target scaffold: a source that no longer serves the commit the host + pins, while still advertising a target unrelated to it: an upstream that rewrote + its history, which is the case the ``current pin`` act exists to name. Both halves + are asserted here, because a scaffold that quietly kept the pin reachable (which + is what pruning a branch the target still reaches would do) would prove nothing on + any platform's git.""" + pin, target = build_orphan_target_repo(directory) + git(directory, "branch", "-D", "old") + git(directory, "reflog", "expire", "--expire=now", "--all") + git(directory, "gc", "-q", "--prune=now") + # Fetching a commit by id is how the resolver retains a pin: the source serves one + # the way a real host does, so the refusal is the missing object and never a + # server declining to serve an unadvertised id. + git(directory, "config", "uploadpack.allowAnySHA1InWant", "true") + with pytest.raises(subprocess.CalledProcessError): + git(directory, "cat-file", "-e", pin) + assert re.search(rf"{target}\s+refs/heads/main", git(directory, "ls-remote", str(directory))), ( + "the target is advertised" + ) + return pin, target + + def _store_path(host: Path) -> Path: key = hashlib.sha256(ACME_IDENTITY.encode("utf-8")).hexdigest() return host / ".leji" / "mounts" / "store" / key @@ -443,6 +480,12 @@ def test_fixture_update_pin_block(fixture: str, block: dict, case: dict, tmp_pat {"rule": f["rule"], "severity": f["severity"], "path": f.get("path")} for f in doc["findings"] ] == expected_findings, f"{case['id']}: the exact findings" + # Only a rule with more than one act names one: every other refusal is the + # document it was before the act detail existed. + for f in doc["findings"]: + if f["rule"] in ("mount-store-fetch-failed", "mount-witness-refresh-failed"): + continue + assert "detail" not in f, f"{case['id']}: {f['rule']} carries no detail" if "comparisonRepository" in case: assert doc["pinReport"]["comparisonRepository"] == case["comparisonRepository"] if "comparedRef" in case: @@ -582,12 +625,205 @@ def test_a_target_that_cannot_be_retained_under_fetch_refuses_the_move(tmp_path) assert doc["reason"] == "mount-store-fetch-failed" assert doc["mount"]["to"] == OID_B, "the target it declined to retain is still reported" assert [f["rule"] for f in doc["findings"]] == ["mount-store-fetch-failed"] + # The act is named, and it is the TARGET's: the same rule id, from the other side + # of the gate, is a different act with no route past it. + assert ( + doc["findings"][0]["detail"] + == "target: the pin could not be retained by a ref in the managed store" + ) + assert ( + doc["findings"][0]["message"] + == "the requested fetch could not retain the commit in the managed store" + ) assert (host / "leji.json").read_bytes() == before, "leji.json is byte-untouched" # The refusal leaves the CURRENT pin retained: fetched objects and refs stay, # which is exactly what the help text says a failed --fetch may leave behind. assert git(_store_path(host), "rev-parse", pin_ref_for(ACME_IDENTITY, OID_A)) == OID_A +# --- which act failed, and the route past the one that has one ---------------- + +ROUTE_SENTENCE = ( + "the requested fetch could not retain the commit in the managed store; " + "if a local hint holds the current pin and the target with complete ancestry, " + "run without `--fetch`; to move past a rewritten upstream, pass " + "`--to --allow-non-fast-forward` against such a hint" +) + + +def test_a_current_pin_the_source_no_longer_serves_refuses_at_that_act(tmp_path) -> None: + source = tmp_path / "source" + host = tmp_path / "host" + pin, target = build_orphan_target_source(source) + shutil.copytree(FIXTURES / "warn-update-pin", host) + repin(host, pin, "keep") + before = (host / "leji.json").read_bytes() + argv = ["mounts", "update-pin", "product-context", "--fetch", "--root", str(host)] + env = _routed_env(source) + code, stdout = run_cli_proc([*argv, "--json"], env) + assert code == 1, stdout + doc = json.loads(stdout) + assert doc["reason"] == "mount-store-fetch-failed" + assert doc["mount"]["to"] is None, "the run refused before it had a target" + assert ( + doc["findings"][0]["detail"] == "current pin: the pin could not be fetched from the source" + ) + # `detail` sits immediately after `message`: the key order the three SDKs freeze. + assert list(doc["findings"][0]) == ["rule", "severity", "path", "message", "detail"] + assert doc["findings"][0]["message"] == ROUTE_SENTENCE + # The same sentence reaches a person, with the act on the same line. + human_code, human = run_cli_proc(argv, env) + assert human_code == 1, human + assert human.strip() == ( + f"Refused: {ROUTE_SENTENCE} " + "(detail: current pin: the pin could not be fetched from the source)" + ) + assert (host / "leji.json").read_bytes() == before, "leji.json is byte-untouched" + assert target != pin, "the advertised target is not the commit the host pins" + + +def test_the_same_injected_retention_failure_aimed_at_the_current_pin(tmp_path) -> None: + sibling = tmp_path / "sibling" + host = tmp_path / "host" + build_acme_sibling(sibling) + shutil.copytree(FIXTURES / "warn-update-pin", host) + repin(host, OID_A, "keep") + # One hook, two acts: it names a commit, and each act retains its own, so the + # pin's id aims it at the act before the gate rather than the one after it. + env = _routed_env(sibling) + env["LEJI_TEST_FAIL_PIN_REF"] = OID_A + code, stdout = run_cli_proc( + ["mounts", "update-pin", "product-context", "--fetch", "--root", str(host), "--json"], env + ) + assert code == 1, stdout + doc = json.loads(stdout) + assert doc["reason"] == "mount-store-fetch-failed" + assert ( + doc["findings"][0]["detail"] + == "current pin: the pin could not be retained by a ref in the managed store" + ) + assert doc["findings"][0]["message"] == ROUTE_SENTENCE + + +def test_a_tracking_ref_the_source_does_not_advertise_refuses_at_the_witness_act(tmp_path) -> None: + sibling = tmp_path / "sibling" + host = tmp_path / "host" + build_acme_sibling(sibling) + shutil.copytree(FIXTURES / "warn-update-pin", host) + repin(host, OID_A, "keep") + # The store already holds the pin, so its retention needs no network at all and + # the witness refresh is the only act left that can fail. + build_store( + host, sibling, {"pin": OID_A, "witnessRef": None, "witnessOid": None, "depth": None} + ) + mp = host / "leji.json" + mp.write_text( + mp.read_text(encoding="utf-8").replace("refs/heads/main", "refs/heads/release", 1), + encoding="utf-8", + ) + code, stdout = run_cli_proc( + ["mounts", "update-pin", "product-context", "--fetch", "--root", str(host), "--json"], + _routed_env(sibling), + ) + assert code == 1, stdout + doc = json.loads(stdout) + assert doc["reason"] == "mount-witness-refresh-failed" + assert ( + doc["findings"][0]["detail"] + == "witness: the tracking ref could not be fetched from the source" + ) + # The witness act has no route of its own: the rule's own sentence stands. + assert ( + doc["findings"][0]["message"] + == "the requested fetch could not refresh the managed witness ref" + ) + + +def test_a_hydrate_whose_source_no_longer_serves_the_pin_names_the_act(tmp_path) -> None: + source = tmp_path / "source" + host = tmp_path / "host" + pin, _ = build_orphan_target_source(source) + shutil.copytree(FIXTURES / "warn-update-pin", host) + repin(host, pin, "keep") + env = _routed_env(source) + code, stdout = run_cli_proc( + ["mounts", "hydrate", "--fetch", "--json", "--root", str(host)], env + ) + # Best-effort, as ever: the mount stays unavailable and the run does not fail. + assert code == 0, stdout + payload = json.loads(stdout) + assert payload["outcomes"][0]["status"] == "unavailable" + # The outcome row is the row it has always been: the detail it already carried. + assert payload["outcomes"][0]["detail"] == "the pin could not be fetched from the source" + assert list(payload["findings"][0]) == ["rule", "severity", "path", "message", "detail"] + assert payload["findings"][0]["rule"] == "mount-store-fetch-failed" + assert ( + payload["findings"][0]["detail"] + == "current pin: the pin could not be fetched from the source" + ) + # The reasons the findings were built from are a transport, never a member of the + # document: the writer picks its fields, and this is not one of them. + assert "reasons" not in stdout + _, human = run_cli_proc(["mounts", "hydrate", "--fetch", "--root", str(host)], env) + assert ( + "warning mount-store-fetch-failed product-context: the managed store could not be " + "established by the requested fetch " + "(detail: current pin: the pin could not be fetched from the source)\n" + ) in human + + +def test_the_route_the_current_pin_refusal_advertises(tmp_path) -> None: + source = tmp_path / "source" + hint = tmp_path / "hint" + host = tmp_path / "host" + # One recipe, two repositories: the source is pruned, the hint keeps `old`, so it + # holds the current pin and the orphan target with complete ancestry. + pin, target = build_orphan_target_source(source) + assert build_orphan_target_repo(hint) == (pin, target), "the hint holds the same two commits" + shutil.copytree(FIXTURES / "warn-update-pin", host) + repin(host, pin, "keep") + (host / ".leji").mkdir(parents=True, exist_ok=True) + (host / ".leji" / "mounts.local.json").write_text( + json.dumps({"mounts": {"product-context": {"repo": str(hint)}}}) + "\n", encoding="utf-8" + ) + before = (host / "leji.json").read_bytes() + move = ["mounts", "update-pin", "product-context", "--to", target, "--allow-non-fast-forward"] + # With `--fetch` the source is asked for the current pin first, and that act is the + # one that fails: the hint holding both operands does not save the run. + code, stdout = run_cli_proc( + [*move, "--fetch", "--root", str(host), "--json"], _routed_env(source) + ) + assert code == 1, stdout + refused = json.loads(stdout) + assert refused["reason"] == "mount-store-fetch-failed" + assert ( + refused["findings"][0]["detail"] + == "current pin: the pin could not be fetched from the source" + ) + assert (host / "leji.json").read_bytes() == before, "leji.json is byte-untouched" + # The store that refused run established holds nothing, so the move below is the + # hint's answer and no leftover managed operand. + with pytest.raises(subprocess.CalledProcessError): + git(_store_path(host), "cat-file", "-e", pin) + # Without `--fetch`, exactly as the refusal says: the hint answers, the override + # carries the move past the rewritten history, and the pin moves. + moved_code, moved_out = run_cli_proc([*move, "--root", str(host), "--json"], _plain_env()) + assert moved_code == 0, moved_out + moved = json.loads(moved_out) + assert moved["action"] == "updated" + assert moved["override"] is True + assert moved["pinReport"]["comparisonRepository"] == "hint" + assert moved["pinReport"]["witnessProvenance"] == "unmanaged" + assert moved["pinReport"]["ancestryComplete"] is True, "the hint answers the range" + assert moved["mount"]["from"] == pin + assert moved["mount"]["to"] == target + assert [(f["rule"], f["severity"]) for f in moved["findings"]] == [ + ("mount-pin-non-fast-forward-override", "warning") + ] + after = (host / "leji.json").read_text(encoding="utf-8") + assert after == before.decode("utf-8").replace(pin, target, 1), "exactly the pin span moved" + + def test_a_target_the_manifest_no_longer_pins_from_is_refused_by_the_scanner(tmp_path) -> None: sibling = tmp_path / "sibling" host = tmp_path / "host" diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 3dc247b..ec989ae 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -58,6 +58,36 @@ import { validateLayer, writeIndex, conformanceReport } from '@leji-org/leji'; const { findings } = validateLayer('.'); ``` +## Generated files + +The CLI keeps everything it generates under one `.leji/` directory at the +repository root, in four roles: `mounts/` (materialized federation mounts), +`viewer/` (generated viewer chrome), `dist/` (exported viewer builds), and +`work/` (the transient onboarding workspace). All of it is machine-local, and +none of it is committed. The first time a command creates one of those roles, +the CLI writes `.leji/.gitignore` containing `*`, so the directory ignores +itself; an existing `.leji/.gitignore` is left as it is, with a notice on +stderr. `leji init` and `leji adopt` also add a bare `.leji/` line to the +repository's root `.gitignore`. + +`.leji/mounts.local.json` is a per-machine hints file: it points the resolver +at local checkouts of the context layers a federation mounts. The CLI reads it +and never writes it. Do not commit it; a path on one machine is not a path on +another. + +Migrating from an earlier version: + +- If `.leji/mounts.local.json` was committed, untrack it with + `git rm --cached .leji/mounts.local.json`, and keep the bare `.leji/` line in + the root `.gitignore`. Onboarding refuses to run while anything under + `.leji/` is tracked, and the nested `.leji/.gitignore` takes precedence over + any negation written at the root. +- A `docs/.leji/` tree left by 1.3.x is unused in 1.4.x and can be deleted. + +Because the hints file stays uncommitted, a fresh clone hydrates its mounts +through the resolver store or the manifest's remote URLs, so a pinned commit has +to be reachable on its remote. + - Specification: https://leji.org - Source: https://github.com/leji-org/leji (`packages/sdk`) - License: Apache-2.0 diff --git a/packages/sdk/assets-manifest.json b/packages/sdk/assets-manifest.json index 8f441ca..069ab81 100644 --- a/packages/sdk/assets-manifest.json +++ b/packages/sdk/assets-manifest.json @@ -5,7 +5,7 @@ "schemas/agent-profile.schema.json": "sha256:9597a0ff39db7587daf210177fdc7ede41f9efeaab54596289534209826ba657", "schemas/context-changelog.schema.json": "sha256:616fd7bddd1f07638e2cbdc2cfa665166f4739283c5194eca34fbf923218ced4", "schemas/context-index.schema.json": "sha256:c3618e356622793326076a424d53843bfccf00511520cdba010c6946262ab440", - "schemas/context-manifest.schema.json": "sha256:dd24a91bb4938f6b6b986928140a997774d5c90bcb720bfc57ef5d7e332b56e2", + "schemas/context-manifest.schema.json": "sha256:3d8ea744f37f06ceabc277da05d852c89895eb2f47d5aece5eecea76345a7cd4", "schemas/decision-record.schema.json": "sha256:f5db3e68be8b2233b9029949d79109b4784ce43ef1a1cd26e44a0427c8915b07", "templates/README.md": "sha256:3fa28c144a26076cc75dc2a6d23014d61370abcb2073afa7d5bd3f26af4884c7", "templates/agent-profile.md": "sha256:fb1cf77aeaffc10718795231b936a7eab9b54c9221f4de072677e3afd3656545", @@ -42,9 +42,9 @@ "templates/viewer/assets/source-sans-pro-600-vietnamese.woff2": "sha256:615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "templates/viewer/assets/third-party-licenses.txt": "sha256:010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "templates/viewer/assets/viewer-boot.js": "sha256:39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "templates/viewer/assets/vue.css": "sha256:af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "templates/viewer/assets/vue.css": "sha256:61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", "templates/viewer/assets/zoom-image.min.js": "sha256:c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", - "templates/viewer/index.html": "sha256:127dadfdca91e9e93739b4e2898ab1ec33fe0b5658354288d960e3e2989f6998", + "templates/viewer/index.html": "sha256:fd6e43cde0d72678dd26c9ad38753f046ed3c5e3e550fbd211c0f4307edd8806", "templates/writing-style.md": "sha256:ee17bb1b97cbe87c4d8ef59b80b2e1d03d997d8839a98d3eb2080c540efa7b2c" } } diff --git a/packages/sdk/jsr.json b/packages/sdk/jsr.json index 4f0a9de..41d72c1 100644 --- a/packages/sdk/jsr.json +++ b/packages/sdk/jsr.json @@ -1,6 +1,6 @@ { "name": "@leji-org/leji", - "version": "1.4.0", + "version": "1.4.1", "license": "Apache-2.0", "exports": "./src/index.ts", "publish": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index eadbbac..db65e48 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@leji-org/leji", - "version": "1.4.0", + "version": "1.4.1", "description": "Reference SDK and CLI for Leji, the open specification for the shared context layer of AI-native teams: validate, index, changelog, freshness, conformance, status, route, federation mounts, viewer, view, init, adopt, detect, start, ci, and agent.", "keywords": [ "leji", diff --git a/packages/sdk/schemas/context-manifest.schema.json b/packages/sdk/schemas/context-manifest.schema.json index 585c59e..a6f1f0f 100644 --- a/packages/sdk/schemas/context-manifest.schema.json +++ b/packages/sdk/schemas/context-manifest.schema.json @@ -312,7 +312,7 @@ "properties": { "primary": { "type": "string", - "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives links, the active state, and diagram accents." + "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives the viewer chrome, active states, and diagram accents; body links and inline code use the fixed accessible tone." } } }, diff --git a/packages/sdk/src/commands/conformance.ts b/packages/sdk/src/commands/conformance.ts index 1e2b2d6..4da57dc 100644 --- a/packages/sdk/src/commands/conformance.ts +++ b/packages/sdk/src/commands/conformance.ts @@ -14,6 +14,7 @@ import { import { checkIndex } from './indexgen.js'; import { checkChangelogAppendOnly, mountSurfacingFindings, validateLayer } from './validate.js'; import { freshnessReport } from './freshness.js'; +import { type LejiIgnoreContext } from '../lib/leji-ignore.js'; import { checkPinReachability, normalizeSource } from '../lib/mounts.js'; export type ItemStatus = 'pass' | 'fail' | 'manual' | 'unknown' | 'not-applicable'; @@ -73,8 +74,17 @@ function countProcessAttested(items: ChecklistItem[]): number { * Score the layer against the conformance checklists. Machine-checkable items * pass/fail; process items are `manual` and never block a level. A claim above * the verified level is an error. + * + * `ignoreContext` is the invocation's notice state for the self-managed + * `.leji/.gitignore`. Only the `--federation` probe reaches a mounts operation that + * can establish a role, and it reaches one PER DECLARED MOUNT, so the context is + * threaded rather than left to each call: one invocation notices at most once, + * however many mounts it probes. */ -export function conformanceReport(root: string, opts: { federation?: boolean } = {}): ConformanceResult { +export function conformanceReport( + root: string, + opts: { federation?: boolean; ignoreContext?: LejiIgnoreContext } = {}, +): ConformanceResult { const items: ChecklistItem[] = []; const findings: Finding[] = []; const { manifest } = loadManifest(root); @@ -295,12 +305,16 @@ export function conformanceReport(root: string, opts: { federation?: boolean } = if (opts.federation) { let bad: { name: string; state: string; detail?: string } | undefined; for (const m of mounts) { - const r = checkPinReachability(root, { - name: m.name, - source: m.source, - pin: m.pin, - trackingRef: m.trackingRef, - }); + const r = checkPinReachability( + root, + { + name: m.name, + source: m.source, + pin: m.pin, + trackingRef: m.trackingRef, + }, + opts.ignoreContext, + ); if (r.state !== 'reachable') { bad = { name: m.name, state: r.state, detail: r.detail }; break; diff --git a/packages/sdk/src/commands/export.ts b/packages/sdk/src/commands/export.ts index 245c335..0bc1152 100644 --- a/packages/sdk/src/commands/export.ts +++ b/packages/sdk/src/commands/export.ts @@ -16,14 +16,23 @@ import { type TargetVerdict, DIST_REL, LEJI_DIR, + LEJI_IGNORE_REL, VIEWER_REL, lejiRole, servablePath, writableTarget, } from '../lib/layout.js'; +import { type LejiIgnoreContext, ensureLejiIgnoreFile, newLejiIgnoreContext } from '../lib/leji-ignore.js'; import { type Manifest } from '../lib/manifest.js'; import { renderLintFindings } from '../lib/renderlint.js'; -import { ACTIVE_EXTENSIONS, buildIndexHtml, generateViewer, resolvedProfilePages } from './viewer.js'; +import { + ACTIVE_EXTENSIONS, + OVERVIEW_REL, + buildIndexHtml, + generateViewer, + renderOverview, + resolvedProfilePages, +} from './viewer.js'; /** * `leji export` (and `leji viewer build`, its co-equal name for the same @@ -78,9 +87,13 @@ export interface BuildResult { } /** How one export run is driven. `strict` is the gate: a lint finding fails the run - * before the target is cleared, mirroring `status --strict`. */ + * before the target is cleared, mirroring `status --strict`. `ignoreContext` is the + * invocation's notice state for the self-managed `.leji/.gitignore`, passed through + * to the generation pass this run nests so one invocation notices once; a direct + * SDK call that omits it notices at most once for that call. */ export interface BuildOptions { strict?: boolean; + ignoreContext?: LejiIgnoreContext; } /** @@ -102,7 +115,11 @@ export const STRICT_LINT_RULES: ReadonlySet = new Set(['render-unsupport * as a comment. */ export function buildViewer(root: string, manifest: Manifest, outRel?: string, opts: BuildOptions = {}): BuildResult { - const gen = generateViewer(root, manifest); + // One context for the whole run, whether the caller supplied it or not: this + // command establishes two roles (the chrome it regenerates and its own output), + // and a caller that passes none is still one call. + const ignoreContext = opts.ignoreContext ?? newLejiIgnoreContext(); + const gen = generateViewer(root, manifest, ignoreContext); // Every path below is resolved, root included, so the path a check judges is the // path the write lands on: a symlinked component — or a case-variant spelling of // a reserved role on a case-insensitive filesystem — resolves to its real name @@ -374,6 +391,14 @@ export function buildViewer(root: string, manifest: Manifest, outRel?: string, o const cleared = rmGuarded(rootAbs, outAbs, DIST_REL); if (!cleared.ok) throw refusedDest(outAbs, cleared); mkdirDest(outContent); + // The output role exists: ensure the tool's own ignore file, as every role + // establisher does. The generation pass above shares this run's context, so an + // existing file is noticed once for the whole invocation rather than per role. + if (ensureLejiIgnoreFile(rootAbs, ignoreContext) === 'refused') { + throw new Error( + `refusing to write "${LEJI_IGNORE_REL}": it does not resolve to a regular file inside ${LEJI_DIR}/; remove the symlink`, + ); + } for (const item of carried) { const dest = path.join(outContent, item.rel); if (item.dir) { @@ -383,9 +408,21 @@ export function buildViewer(root: string, manifest: Manifest, outRel?: string, o // Markdown was read once already: the exported file is that snapshot, so what // the lint judged is what the export carries. A document the re-check dropped // has no snapshot and is not exported. + // + // The overview homepage is the one path whose exported copy is not its source: + // the layer map is substituted between its markers here, after the lint has + // judged the source bytes, from the entries the generation above already + // projected. The layer's own file is not touched, and the map an export carries + // is the map the local server renders from the same function. if (path.extname(item.rel).toLowerCase() === '.md') { const bytes = linted.get(item.rel); - if (bytes !== undefined) writeDest(dest, bytes); + if (bytes === undefined) continue; + if (item.rel === OVERVIEW_REL) { + const rendered = renderOverview(bytes.toString('utf8'), manifest, gen.indexEntries); + writeDest(dest, rendered.markersFound ? rendered.text : bytes); + continue; + } + writeDest(dest, bytes); continue; } const fd = openCarried(item.rel); diff --git a/packages/sdk/src/commands/init.ts b/packages/sdk/src/commands/init.ts index 05d4bc6..afb39e5 100644 --- a/packages/sdk/src/commands/init.ts +++ b/packages/sdk/src/commands/init.ts @@ -26,7 +26,8 @@ import { writeFileAtomicGuarded, writeFileGuarded, } from '../lib/fsx.js'; -import { type TargetVerdict, LEJI_DIR, WORK_REL } from '../lib/layout.js'; +import { type TargetVerdict, LEJI_DIR, LEJI_IGNORE_REL, WORK_REL } from '../lib/layout.js'; +import { type LejiIgnoreContext, ensureLejiIgnoreFile } from '../lib/leji-ignore.js'; import { type PlanEntry, type PlannedWrite, buildWritePlan } from '../lib/writeplan.js'; import { type DetectedHost, @@ -67,6 +68,10 @@ export interface InitOptions { /** Skip generating the portable `AGENTS.md` pointer (written by default when * absent; an existing file is never touched). */ noAgents?: boolean; + /** The invocation's notice state for the self-managed `.leji/.gitignore`, which + * this command ensures when it creates the onboarding workspace. Omitted means a + * context local to this call. */ + ignoreContext?: LejiIgnoreContext; } /** The layer's working mode: a team of one (`solo`) or a team (`team`). */ @@ -429,6 +434,15 @@ function guardedOrRefuse(rel: string, verdict: TargetVerdict): void { } } +/** The transient onboarding workspace is a `.leji/` role, so these commands ensure + * the tool's own ignore file exactly as every other role establisher does. A + * refusal is the refusal this command has always raised for an escaping target. */ +function ensureLejiIgnoreOrRefuse(root: string, ignoreContext?: LejiIgnoreContext): void { + if (ensureLejiIgnoreFile(root, ignoreContext) === 'refused') { + throw new Error(`refusing to write through a symlink that escapes the target: "${LEJI_IGNORE_REL}"`); + } +} + /** * The present vendor entrypoints and their VERIFIED bytes, read once. The same bytes * decide whether an entrypoint is converted, are archived under `governance/`, and @@ -1934,6 +1948,10 @@ export async function initLayer(options: InitOptions): Promise { if (w.rel === changelogRel) continue; writeFileOnce(root, w.rel, w.content, written); } + // The onboarding workspace is a `.leji/` role and now exists, so the tool ignores + // its own tree from inside: the nested counterpart to the root `.gitignore` line + // above, and the one that covers a layer whose root file never received it. + ensureLejiIgnoreOrRefuse(root, options.ignoreContext); // The whole of the `leji index` rule, not half of it: writeIndex declines to // write on a hard generation finding, so the file is not claimed, the dependent // changelog is not seeded, and the findings travel out for the caller to report. @@ -2021,6 +2039,10 @@ export interface AdoptOptions { /** Skip generating the portable `AGENTS.md` pointer (written by default when * absent; an existing file keeps the migrate/--wire-adapters flow). */ noAgents?: boolean; + /** The invocation's notice state for the self-managed `.leji/.gitignore`, which + * this command ensures when it creates the onboarding workspace. Omitted means a + * context local to this call. */ + ignoreContext?: LejiIgnoreContext; } /** Result of `adoptLayer`: the init result plus what adoption found and did. */ @@ -2269,6 +2291,10 @@ export async function adoptLayer(options: AdoptOptions): Promise { writeFileOnce(root, w.rel, w.content, written); } } + // The onboarding workspace is a `.leji/` role and now exists, so the tool ignores + // its own tree from inside: the nested counterpart to the root `.gitignore` line + // above, and the one that covers a layer whose root file never received it. + ensureLejiIgnoreOrRefuse(root, options.ignoreContext); // Same rule as `leji index`: the file is claimed only when it was written, and // the findings travel out so the caller reports them and fails. const index = writeIndex(root, manifest); @@ -2879,8 +2905,11 @@ export type GuardAction = 'installed' | 'unchanged'; * merge its PreToolUse entry into .claude/settings.json (created if absent, other * settings preserved). Idempotent: an existing guard entry is left untouched. * `rootPath` no longer selects the workspace — it is one root-relative tree — and - * is kept only so the exported signature holds. */ -export function ensureApprovalGuard(root: string, rootPath: string): GuardAction { + * is kept only so the exported signature holds. `ignoreContext` is the invocation's + * notice state for the self-managed `.leji/.gitignore`, which this function ensures + * because it creates `.leji/work/hooks/`; omitted means a context local to this + * call. */ +export function ensureApprovalGuard(root: string, rootPath: string, ignoreContext?: LejiIgnoreContext): GuardAction { void rootPath; const rootAbs = path.resolve(root); const lejiRel = WORK_REL; @@ -2906,6 +2935,8 @@ export function ensureApprovalGuard(root: string, rootPath: string): GuardAction const pre = (hooks.PreToolUse ??= []) as { matcher?: string; hooks?: { command?: string }[] }[]; const present = pre.some((e) => (e.hooks ?? []).some((h) => (h.command ?? '').includes('approval-guard.mjs'))); writeFileAtomic(rootAbs, scriptAbs, scriptRel, approvalGuardScript(lejiRel)); + // `.leji/work/hooks/` now exists: this is a role establisher like any other. + ensureLejiIgnoreOrRefuse(rootAbs, ignoreContext); if (present) return 'unchanged'; pre.push({ matcher: 'AskUserQuestion', @@ -2925,6 +2956,9 @@ export interface GuardOfferOptions { interactive: boolean; agent?: string; io?: HandoffIo; + /** The invocation's notice state for the self-managed `.leji/.gitignore`, passed + * through to the install this offer nests. */ + ignoreContext?: LejiIgnoreContext; } /** Offer the onboarding approval guard for a Claude Code handoff. Silent when @@ -2949,7 +2983,7 @@ export async function offerApprovalGuard(opts: GuardOfferOptions): Promise ) ).toLowerCase(); if (!(answer === '' || answer === 'y' || answer === 'yes')) return; - const action = ensureApprovalGuard(opts.root, opts.rootPath); + const action = ensureApprovalGuard(opts.root, opts.rootPath, opts.ignoreContext); console.log( action === 'installed' ? 'Onboarding guard added (this repository only: .claude/settings.json hook + .leji/work/hooks/approval-guard.mjs; removed at finalize).' diff --git a/packages/sdk/src/commands/mounts-update-pin.ts b/packages/sdk/src/commands/mounts-update-pin.ts index c8ab850..cba8e0a 100644 --- a/packages/sdk/src/commands/mounts-update-pin.ts +++ b/packages/sdk/src/commands/mounts-update-pin.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import { type Finding, finding } from '../lib/findings.js'; import { guardRoot, verifiedTargetRead, writeFileAtomicGuarded } from '../lib/fsx.js'; +import { type LejiIgnoreContext } from '../lib/leji-ignore.js'; import { MANIFEST_FILENAME, type Manifest, replaceMountPinInManifestText } from '../lib/manifest.js'; import { type MountDecl, @@ -24,7 +25,8 @@ import { * claim of freshness. `--fetch` observes the declared source — and nothing else — * in three acts: retain the current pin, refresh the witness once, and (after the * gate passes) retain the target. Any of them failing REFUSES the move; a pin move - * is not best-effort, which is `hydrate`'s model rather than this one. + * is not best-effort, which is `hydrate`'s model rather than this one. The reason + * names the act. * * The manifest is rewritten by replacing the addressed pin's own byte span * (`replaceMountPinInManifestText`), never by reserializing, so the three SDKs @@ -64,6 +66,11 @@ export interface UpdatePinOptions { dryRun?: boolean; /** Injectable observation clock, so tests and fixtures are stable. */ now?: () => Date; + /** The invocation's notice state for the self-managed `.leji/.gitignore`. One + * `--fetch` run retains TWICE (the current pin, then the target), and both + * establish the managed store, so the context is threaded rather than left to + * each call: one invocation notices at most once. */ + ignoreContext?: LejiIgnoreContext; } /** A pin at the length every human-facing line uses. */ @@ -99,6 +106,7 @@ export function updatePinRun(root: string, manifest: Manifest, opts: UpdatePinOp reason: string, partial: Partial = {}, pinReport: StatusResult['pinReport'] | null = null, + detail?: string, ): UpdatePinResult => ({ mount: { name: opts.name, @@ -112,7 +120,7 @@ export function updatePinRun(root: string, manifest: Manifest, opts: UpdatePinOp action: 'refused', override: false, reason, - findings: [finding(reason, 'error', MOUNT_UPDATE_PIN_REASONS[reason] ?? reason, opts.name)], + findings: [finding(reason, 'error', updatePinReasonProse(reason, detail), opts.name, detail)], }); if (!mount) return refuse('mount-unknown'); @@ -167,13 +175,24 @@ export function updatePinRun(root: string, manifest: Manifest, opts: UpdatePinOp // the managed store holds both operands, then refresh the witness exactly once. // A failure here refuses the move — best-effort belongs to `hydrate`. if (opts.fetch) { - const retained = retainPinInStore(root, mount, identity, mount.pin); + const retained = retainPinInStore(root, mount, identity, mount.pin, opts.ignoreContext); if (retained.repo === null) { - return refuse('mount-store-fetch-failed', {}, degraded('mount-store-fetch-failed', effectiveRef)); + return refuse( + 'mount-store-fetch-failed', + {}, + degraded('mount-store-fetch-failed', effectiveRef), + `current pin: ${retained.error}`, + ); } const witnessMount: MountDecl = { ...mount, trackingRef: effectiveRef }; - if (!refreshWitness(retained.repo, witnessMount, identity)) { - return refuse('mount-witness-refresh-failed', {}, degraded('mount-witness-refresh-failed', effectiveRef)); + const refreshed = refreshWitness(retained.repo, witnessMount, identity); + if (!refreshed.ok) { + return refuse( + 'mount-witness-refresh-failed', + {}, + degraded('mount-witness-refresh-failed', effectiveRef), + `witness: ${refreshed.error}`, + ); } } @@ -230,9 +249,14 @@ export function updatePinRun(root: string, manifest: Manifest, opts: UpdatePinOp // A refusal after the comparison settled reports the comparison it refused on, // and carries whatever the run had already decided: an override exercised at the // gate is still reported by a run that then refused for another reason. - const refuseSettled = (reason: string, override = false, warnings: Finding[] = []): UpdatePinResult => ({ + const refuseSettled = ( + reason: string, + override = false, + warnings: Finding[] = [], + detail?: string, + ): UpdatePinResult => ({ ...settled('refused', override, [ - finding(reason, 'error', MOUNT_UPDATE_PIN_REASONS[reason] ?? reason, mount.name), + finding(reason, 'error', updatePinReasonProse(reason, detail), mount.name, detail), ...warnings, ]), reason, @@ -266,8 +290,10 @@ export function updatePinRun(root: string, manifest: Manifest, opts: UpdatePinOp // (b iii) The target is retained only once the gate has passed, so a refused run // never establishes a pin ref for a commit it declined to move to. if (opts.fetch) { - const retainedTarget = retainPinInStore(root, mount, identity, target); - if (retainedTarget.repo === null) return refuseSettled('mount-store-fetch-failed', override, warnings); + const retainedTarget = retainPinInStore(root, mount, identity, target, opts.ignoreContext); + if (retainedTarget.repo === null) { + return refuseSettled('mount-store-fetch-failed', override, warnings, `target: ${retainedTarget.error}`); + } } // (g) `--dry-run` stops here. The store and network acts `--fetch` was asked for @@ -332,6 +358,21 @@ function declarationUnchanged( ); } +/** + * The sentence a refusal shows. A code whose acts have different routes forward + * carries one entry per act, keyed `: ` exactly as the finding's + * `detail` spells it, so the table stays the single source of every string this + * command prints; every other code answers for all of its acts at once. + */ +function updatePinReasonProse(reason: string, detail?: string): string { + if (detail !== undefined) { + const act = detail.slice(0, Math.max(detail.indexOf(': '), 0)); + const qualified = MOUNT_UPDATE_PIN_REASONS[`${reason}: ${act}`]; + if (qualified !== undefined) return qualified; + } + return MOUNT_UPDATE_PIN_REASONS[reason] ?? reason; +} + /** Prose for this command's stable reason codes: `--json` emits the code, a person * reads the sentence. The codes above `mount-unknown` are shared with * `mounts status`, whose prose lives beside the status reasons. */ @@ -348,6 +389,13 @@ export const MOUNT_UPDATE_PIN_REASONS: Record = { 'more than one submodule matches the source; declare an explicit hint in .leji/mounts.local.json', 'mount-ancestry-incomplete': 'incomplete ancestry; the comparison repository cannot answer the range', 'mount-store-fetch-failed': 'the requested fetch could not retain the commit in the managed store', + // The current-pin act is the one an operator can route past: an upstream that + // rewrote its history no longer serves the commit this manifest pins, and the + // move is still available against a repository that does hold both operands. + 'mount-store-fetch-failed: current pin': + 'the requested fetch could not retain the commit in the managed store; if a local hint holds the current pin ' + + 'and the target with complete ancestry, run without `--fetch`; to move past a rewritten upstream, pass ' + + '`--to --allow-non-fast-forward` against such a hint', 'mount-witness-refresh-failed': 'the requested fetch could not refresh the managed witness ref', 'mount-target-unavailable': 'the requested target commit is not held by the comparison repository', 'mount-pin-not-fast-forward': diff --git a/packages/sdk/src/commands/serve.ts b/packages/sdk/src/commands/serve.ts index 18d0def..d22a84e 100644 --- a/packages/sdk/src/commands/serve.ts +++ b/packages/sdk/src/commands/serve.ts @@ -3,15 +3,18 @@ import * as fs from 'node:fs'; import * as http from 'node:http'; import * as path from 'node:path'; import { finding } from '../lib/findings.js'; -import { resolvedWithinRoot, stripSlash, walkTree } from '../lib/fsx.js'; -import { VIEWER_REL, servablePath } from '../lib/layout.js'; -import { effectiveIndexPath, loadManifest } from '../lib/manifest.js'; +import { openVerifiedSource, resolvedPath, resolvedWithinRoot, stripSlash, walkTree } from '../lib/fsx.js'; +import { VIEWER_REL, servablePath, writableTarget } from '../lib/layout.js'; +import { type Manifest, effectiveIndexPath, loadManifest } from '../lib/manifest.js'; import { generateIndex } from './indexgen.js'; import { + type IndexEntryLite, ACTIVE_EXTENSIONS, + OVERVIEW_REL, assembleSidebar, declaresInherits, relativeToRoot, + renderOverview, resolvedProfilePage, unresolvedProfilePage, } from './viewer.js'; @@ -94,12 +97,16 @@ export function urlPathToRel(urlPath: string): string { * under `.leji/` is denied by name, so the private roles are unreachable however * the request is spelled and whatever a symlink under the content root points at. * Returns the listening server; port 0 picks free. + * + * `opts.entries` is an index snapshot for the initial layer map: a caller that has + * just generated the viewer hands over what it projected, and a caller that passes + * none gets one live generation at startup instead. */ export function serveViewer( root: string, port: number, rootRel = '', - opts: { log?: (line: string) => void } = {}, + opts: { log?: (line: string) => void; entries?: IndexEntryLite[] } = {}, ): Promise { const rootAbs = fs.realpathSync(path.resolve(root)); const base = stripSlash(rootRel); @@ -110,6 +117,12 @@ export function serveViewer( throw new Error(`viewer root "${rootRel}" escapes the layer root`); } const viewerAbs = path.join(rootAbs, VIEWER_REL); + // The content mount as it really is on disk: the boundary a resolved source is + // judged against has to be resolved itself, or a symlinked `rootPath` component + // would put every legitimate document outside its own mount. + const contentReal = resolvedPath(contentAbs) ?? contentAbs; + /** True when a RESOLVED path lies under the content mount. */ + const withinContent = (resolved: string): boolean => resolved.startsWith(contentReal + path.sep); // Serve `sub` (a clean relative path) from under `mountRoot`; '' -> index.html. // realpath-contains the resolved target under its mount so a symlink can't escape. @@ -159,7 +172,13 @@ export function serveViewer( // cached string at stat cost; any create, delete, or edit still lands on the // very next fetch. walkTree skips dotdirs, so the viewer's own artifacts // never invalidate the cache. - let sidebarCache: { key: string; body: string; indexJson: string | null } | null = null; + let sidebarCache: { + key: string; + body: string; + indexJson: string | null; + manifest: Manifest; + entries: IndexEntryLite[]; + } | null = null; const treeFingerprint = (): string => { const parts: string[] = []; const add = (rel: string): void => { @@ -175,6 +194,56 @@ export function serveViewer( return parts.join('\n'); }; + /** One live index generation behind every generated route, cached by the same + * fingerprint: the sidebar, the served context index, and the overview map are + * projections of ONE index per tree state, never of three. Null when the layer + * cannot be indexed right now (no manifest, an error finding, or a generator that + * threw), which is each route's cue to fall back. */ + const liveIndex = (cachedKey?: string): { manifest: Manifest; entries: IndexEntryLite[] } | null => { + try { + // Inside the guard, never in a default argument: a fingerprint pass over an + // unreadable tree throws like anything else here, and that is a fallback. + const key = cachedKey ?? treeFingerprint(); + if (sidebarCache !== null && sidebarCache.key === key) { + return { manifest: sidebarCache.manifest, entries: sidebarCache.entries }; + } + const { manifest } = loadManifest(rootAbs); + if (manifest === null) return null; + const idx = generateIndex(rootAbs, manifest); + if (idx.findings.some((f) => f.severity === 'error')) return null; + const entries = idx.index?.entries ?? []; + sidebarCache = { + key, + body: assembleSidebar(rootAbs, manifest, entries, []), + indexJson: idx.index ? JSON.stringify(idx.index, null, 2) + '\n' : null, + manifest, + entries, + }; + return { manifest, entries }; + } catch { + return null; + } + }; + + // The layer map is process state, not a file. The overview route renders it into + // the page's markers per fetch, from the live index above; the last index that + // generated cleanly is kept, so a tree caught mid-edit still shows the map it last + // had rather than a page with a hole in it. The initial one is computed here, by + // the same generation the sidebar route makes per fetch, unless the caller handed + // over the snapshot its own generation just produced. + let lastGoodMap: { manifest: Manifest; entries: IndexEntryLite[] } | null = ((): { + manifest: Manifest; + entries: IndexEntryLite[]; + } | null => { + if (opts.entries === undefined) return liveIndex(); + try { + const { manifest } = loadManifest(rootAbs); + return manifest === null ? null : { manifest, entries: opts.entries }; + } catch { + return null; + } + })(); + const server = http.createServer((req, res) => { // Access log: one terse line per request, after the status is known. if (opts.log) { @@ -221,27 +290,10 @@ export function serveViewer( // document. When the tree is mid-edit and will not index cleanly, fall back // to the last generated artifact rather than failing the dashboard. if (rel === 'content/_sidebar.md') { - try { - const key = treeFingerprint(); - if (sidebarCache !== null && sidebarCache.key === key) { - res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); - res.end(sidebarCache.body); - return; - } - const { manifest } = loadManifest(rootAbs); - if (manifest) { - const idx = generateIndex(rootAbs, manifest); - if (!idx.findings.some((f) => f.severity === 'error')) { - const sidebar = assembleSidebar(rootAbs, manifest, idx.index?.entries ?? [], []); - const indexJson = idx.index ? JSON.stringify(idx.index, null, 2) + '\n' : null; - sidebarCache = { key, body: sidebar, indexJson }; - res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); - res.end(sidebar); - return; - } - } - } catch { - // fall through to the generated artifact + if (liveIndex() !== null && sidebarCache !== null) { + res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); + res.end(sidebarCache.body); + return; } serveFrom(res, viewerAbs, '_sidebar.md'); return; @@ -254,16 +306,7 @@ export function serveViewer( const idxRel = manifest ? relativeToRoot(effectiveIndexPath(manifest), manifest.rootPath) : null; if (manifest && idxRel !== null && rel === `content/${idxRel}`) { const key = treeFingerprint(); - if (sidebarCache === null || sidebarCache.key !== key) { - const idx = generateIndex(rootAbs, manifest); - if (!idx.findings.some((f) => f.severity === 'error')) { - sidebarCache = { - key, - body: assembleSidebar(rootAbs, manifest, idx.index?.entries ?? [], []), - indexJson: idx.index ? JSON.stringify(idx.index, null, 2) + '\n' : null, - }; - } - } + liveIndex(key); if (sidebarCache !== null && sidebarCache.key === key && sidebarCache.indexJson !== null) { res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); res.end(sidebarCache.indexJson); @@ -281,6 +324,71 @@ export function serveViewer( serveFrom(res, viewerAbs, '_manifest.md'); return; } + // The overview homepage is served RENDERED: the source bytes with the layer map + // substituted between the author's markers, so the counts a reader sees are the + // ones the tree has right now and the committed file is never rewritten to say + // so. The route is a content route first: it makes every check `serveFrom` makes + // on this path, with the same answers, plus the generation guards (repository + // containment, no private `.leji/` role), because this is the one content path + // the tool also writes. + // + // EVERY one of those checks is bound to the VERIFIED target, not to a path + // resolved beforehand: the guarded read judges the resolved location, opens it, + // proves the descriptor is that same regular file, and the bytes come from that + // descriptor. A pre-read `realpath` plus a separate read leaves the window this + // closes: a link swapped in between resolves somewhere else (inside the + // repository, outside the content mount) and the read follows it past a check + // that judged the old target. + if (rel === `content/${OVERVIEW_REL}`) { + const abs = path.join(contentAbs, OVERVIEW_REL); + // By name first, exactly as `serveFrom` does, before anything is resolved. + if (!servablePath(rootAbs, abs)) { + res.writeHead(404).end('not found'); + return; + } + const resolvedRoot = resolvedPath(rootAbs) ?? rootAbs; + let source: Buffer | null = null; + let landed: string | null = null; + try { + const { fd, real } = openVerifiedSource( + abs, + (resolved) => + withinContent(resolved) && + servablePath(rootAbs, resolved) && + writableTarget(resolvedRoot, resolved, null).ok, + ); + landed = real; + if (fd !== null) { + try { + source = fs.readFileSync(fd); + } finally { + fs.closeSync(fd); + } + } + } catch { + source = null; + } + if (source === null) { + // The refusal names where the source resolves NOW: outside the content + // mount is the mount's own answer (403), and everything else (a private + // role, a directory, an absent or unresolvable entry) is a plain miss. + res.writeHead(landed !== null && !withinContent(landed) ? 403 : 404).end( + landed !== null && !withinContent(landed) ? 'forbidden' : 'not found', + ); + return; + } + // A live generation that fails outright (an unreadable content root, an + // invalid manifest, a document the walk cannot read) serves the last map that + // did generate; before the first one ever did, the source bytes as they are. + // A source without markers is served unchanged whatever the index says. + const index = liveIndex(); + if (index !== null) lastGoodMap = index; + const map = index ?? lastGoodMap; + const rendered = map === null ? null : renderOverview(source.toString('utf8'), map.manifest, map.entries); + res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); + res.end(rendered !== null && rendered.markersFound ? rendered.text : source); + return; + } if (rel === 'content' || rel.startsWith('content/')) { const sub = rel === 'content' ? '' : rel.slice('content/'.length); // An agent profile that declares `inherits` is served resolved: the file diff --git a/packages/sdk/src/commands/viewer.ts b/packages/sdk/src/commands/viewer.ts index 38d813e..ac781fc 100644 --- a/packages/sdk/src/commands/viewer.ts +++ b/packages/sdk/src/commands/viewer.ts @@ -15,7 +15,8 @@ import { writeFileGuarded, } from '../lib/fsx.js'; import { parseFrontmatter } from '../lib/frontmatter.js'; -import { LEJI_DIR, VIEWER_REL, servablePath, writableTarget } from '../lib/layout.js'; +import { LEJI_DIR, LEJI_IGNORE_REL, VIEWER_REL, servablePath, writableTarget } from '../lib/layout.js'; +import { type LejiIgnoreContext, ensureLejiIgnoreFile } from '../lib/leji-ignore.js'; import { type ScannedProfile, resolveAgentProfile, @@ -47,6 +48,10 @@ export interface ViewerResult { written: string[]; findings: Finding[]; entries: number; + /** The index entries this run projected, so a caller that renders from the same + * generation (the export's overview map) reads one snapshot rather than making a + * second one. Empty when the run refused to project anything. */ + indexEntries: IndexEntryLite[]; } const CATEGORY_LABELS: Record = { @@ -594,6 +599,11 @@ function sidebarLabel(root: string, relPath: string, rootRel: string): string { return filenameLabel(rootRel); } +/** The overview homepage, named relative to the context root: the one content path + * the tool seeds, and the one whose read renders the layer map into it. Shared by + * generation, the local server's route, and the export's copy. */ +export const OVERVIEW_REL = 'overview.md'; + /** * The browse zone: every markdown file under rootPath that is NOT governed (in the * index) and NOT viewer/layer chrome (boot profile, agent profiles, category index @@ -606,7 +616,7 @@ function referenceTree(root: string, manifest: Manifest, governedPaths: Set(); for (const cat of CATEGORY_IDS) for (const f of manifest.categories[cat]?.indexes ?? []) indexFiles.add(f); - const overviewRel = rootDirRel === '.' ? 'overview.md' : `${rootDirRel}/overview.md`; + const overviewRel = rootDirRel === '.' ? OVERVIEW_REL : `${rootDirRel}/${OVERVIEW_REL}`; const sidebarRel = rootDirRel === '.' ? '_sidebar.md' : `${rootDirRel}/_sidebar.md`; const manifestPageRel = rootDirRel === '.' ? '_manifest.md' : `${rootDirRel}/_manifest.md`; const nodes: TreeNode[] = []; @@ -623,11 +633,17 @@ function referenceTree(root: string, manifest: Manifest, governedPaths: Set populated categories * with document counts. Deliberately category-altitude: per-document nodes turn * unreadable past a handful of docs, so the map never lists documents (the @@ -653,7 +669,32 @@ function mapBlock(manifest: Manifest, entries: IndexEntryLite[]): string { return `${MAP_START}\n\`\`\`mermaid\n${buildLayerMap(manifest, entries)}\n\`\`\`\n${MAP_END}`; } -type IndexEntryLite = { id: string; path: string; title: string; category: string }; +/** + * The overview homepage as it is READ, never as it is stored: the source bytes with + * the marked span replaced by the map this index projects. The one function behind + * both consumers (the local server renders it per fetch, the export renders the copy + * it writes), so the served and the exported page carry the same bytes. + * + * Whatever stands between the markers in source is ignored: the map is derived from + * the index, so the file is never rewritten to hold it. Without the marker pair + * there is nowhere to put the map, and the source is returned unchanged + * (`markersFound` false) for the caller to warn about. + */ +export function renderOverview( + source: string, + manifest: Manifest, + entries: IndexEntryLite[], +): { text: string; markersFound: boolean } { + const start = source.indexOf(MAP_START); + const end = source.indexOf(MAP_END); + if (start < 0 || end <= start) return { text: source, markersFound: false }; + return { + text: source.slice(0, start) + mapBlock(manifest, entries) + source.slice(end + MAP_END.length), + markersFound: true, + }; +} + +export type IndexEntryLite = { id: string; path: string; title: string; category: string }; /** Normalize any value to one safe markdown-inline token: strip C0/C1/DEL control * characters, collapse ASCII whitespace runs to a single space, then neutralize the @@ -1065,18 +1106,21 @@ export function resolvedProfilePages(root: string, manifest: Manifest): { rel: s } /** The starter overview/home page: a short explainer the owner can edit freely, - * plus the auto-generated layer map inside the regen markers. */ -function buildOverviewSeed(manifest: Manifest, entries: IndexEntryLite[]): string { + * plus the empty marker pair the layer map is rendered into. Written once, when no + * overview.md stands at the content root, and never rewritten after that. */ +function buildOverviewSeed(manifest: Manifest): string { return `# ${manifest.name} This is the **Leji context layer** for \`${manifest.name}\`: the shared, validated context people and coding agents read before working in this repository. Start with the boot profile, then browse the categories in the sidebar. -This page is yours to edit. The map below is regenerated by \`leji viewer\` between the -markers; the prose around it is left untouched. +This page is yours to edit. The map below is rendered between the markers by the viewer +and by \`leji export\`; this file is never rewritten. -${mapBlock(manifest, entries)} +${MAP_START} +${MAP_PLACEHOLDER} +${MAP_END} - Write a \`\`\`mermaid code block in any document and it renders as a diagram here. - Run \`leji conformance\` to see the level this layer claims and verifies. @@ -1255,12 +1299,20 @@ export function buildIndexHtml(root: string, manifest: Manifest, base: ChromeBas .replace(/\{\{([A-Z_]+)\}\}/g, (whole, key: string) => substitutions[key] ?? whole); } -export function generateViewer(root: string, manifest: Manifest): ViewerResult { +/** + * Generate the viewer chrome into `.leji/viewer/`. + * + * `ignoreContext` is the invocation's notice state for the self-managed + * `.leji/.gitignore` (this run creates a role, so it ensures that file): a caller + * that has one passes it through, and a direct SDK call that passes none notices at + * most once for that call. + */ +export function generateViewer(root: string, manifest: Manifest, ignoreContext?: LejiIgnoreContext): ViewerResult { const result = generateIndex(root, manifest); // Don't project a viewer from a tree that can't be indexed cleanly: surface the // errors and write nothing, the same refusal writeIndex makes. if (result.findings.some((f) => f.severity === 'error')) { - return { written: [], findings: result.findings, entries: 0 }; + return { written: [], findings: result.findings, entries: 0, indexEntries: [] }; } const entries = result.index?.entries ?? []; const findingsEarly: Finding[] = []; @@ -1297,7 +1349,7 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { VIEWER_REL, ), ); - return { written, findings, entries: 0 }; + return { written, findings, entries: 0, indexEntries: [] }; } // Every `.leji/viewer/` write goes back through the chokepoint with the viewer's @@ -1324,6 +1376,20 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { writeViewerFile(`${viewerDir}/${name}`, content); } + // The role now exists, so the tool ignores its own tree from inside: a layer + // whose root .gitignore never carried the `.leji/` line is clean after this run. + // A refusal is an error finding like any other refused write here. + if (ensureLejiIgnoreFile(resolvedRoot, ignoreContext) === 'refused') { + findings.push( + finding( + 'viewer-target-refused', + 'error', + `refusing to write ${LEJI_IGNORE_REL}: it does not resolve to a regular file inside ${LEJI_DIR}/; remove the symlink`, + LEJI_IGNORE_REL, + ), + ); + } + // Copy vendored viewer assets alongside the page so nothing loads from a remote // CDN. The provenance note is documentation, never shipped. const assetsSrc = path.join(templatesDir(), 'viewer', 'assets'); @@ -1338,16 +1404,18 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { } // The overview page is user-owned content (not chrome): seeded once, never - // overwritten. Regeneration refreshes only the marked map block; if the owner - // removed the markers, the page is left entirely alone. + // written again. The layer map is rendered between its markers when the page is + // read (by the local server and by the export), so a reindex that changes the + // document counts leaves this file exactly as its author last saved it. If the + // markers are gone there is nowhere to render the map, which is a warning. // // Check-before-act: overview.md is content — its target must resolve WITHIN // the layer root AND never into a private `.leji/` role. It is judged on the // RESOLVED path (ownRole `null`: content has no `.leji/` role) BEFORE anything is // read or written, so an overview.md symlinked into `.leji/work/` or - // `.leji/mounts/` is refused before the seed or the refresh writes through it — - // and the write itself then lands via the guarded-write chokepoint on that path. - const overviewRel = rootDir === '.' ? 'overview.md' : `${rootDir}/overview.md`; + // `.leji/mounts/` is refused before the seed writes through it or the page is read, + // and the seed itself then lands via the guarded-write chokepoint on that path. + const overviewRel = rootDir === '.' ? OVERVIEW_REL : `${rootDir}/${OVERVIEW_REL}`; const overviewAbs = path.join(root, overviewRel); const overviewResolved = resolvedPath(overviewAbs); const overviewVerdict = @@ -1368,7 +1436,7 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { ); } else if (overviewRead.status === 'refused') { // A standing entry that cannot be verified as a regular file inside the layer: - // the map is neither seeded through it nor refreshed from bytes read by path. + // the page is neither seeded through it nor read from a path that could redirect. findings.push( finding( 'viewer-target-refused', @@ -1378,25 +1446,19 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { ), ); } else if (overviewRead.status === 'absent') { - const seeded = writeFileGuarded(resolvedRoot, overviewAbs, null, buildOverviewSeed(manifest, entries)); + const seeded = writeFileGuarded(resolvedRoot, overviewAbs, null, buildOverviewSeed(manifest)); if (seeded.ok) written.push(overviewRel); } else { - // The refresh rewrites the page it just read, so those bytes come from the - // verified descriptor rather than from a second read by pathname. - const existing = overviewRead.bytes.toString('utf8'); - const start = existing.indexOf(MAP_START); - const end = existing.indexOf(MAP_END); - if (start >= 0 && end > start) { - const updated = existing.slice(0, start) + mapBlock(manifest, entries) + existing.slice(end + MAP_END.length); - if (updated !== existing) { - writeFileGuarded(resolvedRoot, overviewAbs, null, updated); - } - } else { + // A standing page is READ and not written: the only thing generation decides + // here is whether the map has a place to be rendered into. The bytes come from + // the verified descriptor rather than from a second read by pathname, so the + // page the guards judged is the page the answer is about. + if (!renderOverview(overviewRead.bytes.toString('utf8'), manifest, entries).markersFound) { findings.push( finding( 'overview-markers-missing', 'warning', - 'overview.md has no generated-map markers; left as-is (map not refreshed)', + 'overview.md has no generated-map markers; the map is not rendered', overviewRel, ), ); @@ -1409,7 +1471,7 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { // file at the context root, so no diff churn). Regenerated every run; pinned. writeViewerFile(`${viewerDir}/_manifest.md`, buildManifestPage(manifest, mountStatus(root, manifest))); - return { written, findings, entries: entries.length }; + return { written, findings, entries: entries.length, indexEntries: entries }; } /** diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8efe00a..d178171 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,5 +1,6 @@ import { type Finding, finding, hasErrors, sortFindings, summarize } from './lib/findings.js'; import { DIST_REL, VIEWER_REL } from './lib/layout.js'; +import { type LejiIgnoreContext, newLejiIgnoreContext } from './lib/leji-ignore.js'; import { effectiveChangelogPath, effectiveIndexPath, loadManifest } from './lib/manifest.js'; import { type CliCommand, type CliSpec, SDK_VERSION, SUPPORTED_LINES, loadCliSpec } from './lib/schemas.js'; import { HELP_WIDTH, exitCodeColumn, helpRow, nameColumn, optionColumn, wrap } from './lib/text.js'; @@ -74,6 +75,7 @@ export { buildLayerMap, buildManifestPage, generateViewer, + renderOverview, resolveViewerPort, resolvedProfilePage, } from './commands/viewer.js'; @@ -104,6 +106,14 @@ export { type CiProvider, } from './commands/init.js'; export { detectLayer, renderDetect } from './commands/detect.js'; +export { + LEJI_IGNORE_CONTENT, + LEJI_IGNORE_NOTICE, + ensureLejiIgnoreFile, + newLejiIgnoreContext, +} from './lib/leji-ignore.js'; +export type { LejiIgnoreContext, LejiIgnoreOutcome } from './lib/leji-ignore.js'; +export { LEJI_IGNORE_REL } from './lib/layout.js'; export { buildWritePlan, renderWritePlan } from './lib/writeplan.js'; export { route, LIVE_STATUSES } from './lib/route.js'; export type { @@ -663,7 +673,9 @@ function printFindings(findings: Finding[]): void { for (const f of sortFindings(findings)) { // A rule that locates a line says so, so a reader can go to it. const where = f.path ? ` ${f.path}${f.line === undefined ? '' : `:${f.line}`}` : ''; - console.log(`${f.severity === 'error' ? 'error ' : 'warning'} ${f.rule}${where}: ${f.message}`); + // A rule that names the act it failed at says which one, on the same line. + const detail = f.detail === undefined ? '' : ` (detail: ${f.detail})`; + console.log(`${f.severity === 'error' ? 'error ' : 'warning'} ${f.rule}${where}: ${f.message}${detail}`); } } @@ -680,6 +692,9 @@ function mountFindings( witnessRefreshFailed?: boolean; projectionFailed?: boolean; }[], + /** The resolver's reason for the one `--fetch` act that failed, per mount: the + * finding names the act, the reason says what the act ran into. */ + reasons: Map = new Map(), ): Finding[] { const out: Finding[] = []; for (const o of rows) { @@ -696,6 +711,7 @@ function mountFindings( ), ); } + const reason = reasons.get(o.name); if (o.storeFetched === false) { out.push( finding( @@ -703,6 +719,7 @@ function mountFindings( 'warning', 'the managed store could not be established by the requested fetch', o.name, + reason === undefined ? undefined : `current pin: ${reason}`, ), ); } @@ -713,6 +730,7 @@ function mountFindings( 'warning', 'the managed witness ref could not be refreshed by the requested fetch', o.name, + reason === undefined ? undefined : `witness: ${reason}`, ), ); } @@ -804,13 +822,13 @@ function printUnindexedNudge(count: number): void { * `--strict`, a lint finding — with the target left byte-untouched, `2` a usage * error or a refusal (thrown, and rendered by the caller's catch). */ -function runExport(flags: Flags): number { +function runExport(flags: Flags, ignoreContext: LejiIgnoreContext): number { const { manifest, findings } = loadManifest(flags.root); // A failure before the pipeline can run (an unreadable manifest) reports in the // command's OWN document, never the generic one: a `--json` consumer parses one // shape under every outcome and either name. if (!manifest) return reportExport(flags, { out: flags.out ?? DIST_REL, findings, wrote: false }); - return reportExport(flags, buildViewer(flags.root, manifest, flags.out, { strict: flags.strict })); + return reportExport(flags, buildViewer(flags.root, manifest, flags.out, { strict: flags.strict, ignoreContext })); } /** The one export report, for every outcome the pipeline can reach. */ @@ -963,9 +981,15 @@ function reportUpdatePin(flags: Flags, r: UpdatePinResult): number { case 'dry-run': console.log(`Would update leji.json: ${r.mount.name} pin ${from12} → ${to12} (dry run)${overridden}`); break; - case 'refused': - console.log(`Refused: ${MOUNT_UPDATE_PIN_REASONS[r.reason ?? ''] ?? r.reason}`); + case 'refused': { + // The refusal's own finding is what the document carries, so the human line + // is read off it rather than looked up a second time: one sentence, and the + // act it failed at when the rule names one. + const refusal = findings.find((f) => f.severity === 'error'); + const message = refusal?.message ?? MOUNT_UPDATE_PIN_REASONS[r.reason ?? ''] ?? r.reason; + console.log(`Refused: ${message}${refusal?.detail === undefined ? '' : ` (detail: ${refusal.detail})`}`); break; + } } return ok ? 0 : 1; } @@ -1049,6 +1073,13 @@ export async function run(argv: string[]): Promise { } } + // One notice state for this invocation, created at the command entry point and + // handed to every path that can create a `.leji/` role: whatever a command + // establishes, it says at most once that it left an existing `.leji/.gitignore` + // alone. A second repository, or a long-lived host calling the SDK directly, + // never inherits it. + const ignoreContext = newLejiIgnoreContext(); + try { switch (command) { case 'validate': { @@ -1286,7 +1317,10 @@ export async function run(argv: string[]): Promise { console.error(USAGE); return 2; } - const result = conformanceReport(flags.root, { federation: flags.federation === 'verify' }); + const result = conformanceReport(flags.root, { + federation: flags.federation === 'verify', + ignoreContext, + }); if (!flags.json) { for (const item of result.items) { const mark = @@ -1351,16 +1385,17 @@ export async function run(argv: string[]): Promise { allowNonFastForward: flags.allowNonFastForward, fetch: flags.fetch, dryRun: flags.dryRun, + ignoreContext, }), ); } if (sub === 'hydrate') { - const r = hydrateMounts(flags.root, manifest, { fetch: flags.fetch }); + const r = hydrateMounts(flags.root, manifest, { fetch: flags.fetch, ignoreContext }); if (r.fatal) { console.error(`leji: ${r.fatal}`); return 1; } - const issues = mountFindings(r.outcomes); + const issues = mountFindings(r.outcomes, r.reasons); const hadError = r.outcomes.some((o) => o.status === 'error'); if (flags.json) { console.log( @@ -1443,7 +1478,7 @@ export async function run(argv: string[]): Promise { return reportBadge(flags, result); } case 'export': - return runExport(flags); + return runExport(flags, ignoreContext); case 'view': case 'viewer': { // `leji view` is an alias for `leji viewer serve` that also opens the @@ -1459,12 +1494,12 @@ export async function run(argv: string[]): Promise { console.error(USAGE); return 2; } - if (command === 'viewer' && sub === 'build') return runExport(flags); + if (command === 'viewer' && sub === 'build') return runExport(flags, ignoreContext); const wantServe = isAlias || sub === 'serve'; const wantOpen = flags.open || isAlias; const { manifest, findings } = loadManifest(flags.root); if (!manifest) return emit('viewer', findings, flags.json); - const result = generateViewer(flags.root, manifest); + const result = generateViewer(flags.root, manifest, ignoreContext); // Terse by design: findings when something needs attention, one status // line otherwise. The full write list lives in --json. const allFindings = [...findings, ...result.findings]; @@ -1485,6 +1520,9 @@ export async function run(argv: string[]): Promise { } const server = await serveViewer(flags.root, resolveViewerPort(manifest, flags.port), manifest.rootPath, { log: flags.json ? undefined : (line) => console.log(line), + // The generation above just projected the index: the first layer map is + // that snapshot rather than a second generation of the same tree. + entries: result.indexEntries, }); const address = server.address(); const port = @@ -1738,6 +1776,7 @@ export async function run(argv: string[]): Promise { case 'adopt': { const result = await adoptLayer({ dir: flags.dir === '.' && flags.root !== '.' ? flags.root : flags.dir, + ignoreContext, yes: flags.yes, name: flags.name, dryRun: flags.dryRun, @@ -1787,6 +1826,7 @@ export async function run(argv: string[]): Promise { detected: result.detected, interactive, agent: flags.agent, + ignoreContext, }); if ( !(await handoffOffer( @@ -1808,6 +1848,7 @@ export async function run(argv: string[]): Promise { case 'init': { const result = await initLayer({ dir: flags.dir === '.' && flags.root !== '.' ? flags.root : flags.dir, + ignoreContext, yes: flags.yes, name: flags.name, level: flags.level, @@ -1845,6 +1886,7 @@ export async function run(argv: string[]): Promise { detected: result.detected, interactive, agent: flags.agent, + ignoreContext, }); if ( !(await handoffOffer( diff --git a/packages/sdk/src/lib/findings.ts b/packages/sdk/src/lib/findings.ts index 0400292..bbe36f0 100644 --- a/packages/sdk/src/lib/findings.ts +++ b/packages/sdk/src/lib/findings.ts @@ -17,6 +17,10 @@ export interface Finding { * contract. */ construct?: string; message: string; + /** Which act a rule with more than one failed at, and the resolver's own reason + * for it: `": "`. Serialized immediately after `message`, so the + * three SDKs emit the same bytes; absent for every rule that names no act. */ + detail?: string; } export interface FindingSummary { @@ -24,8 +28,9 @@ export interface FindingSummary { warnings: number; } -export function finding(rule: string, severity: Severity, message: string, path?: string): Finding { - return path === undefined ? { rule, severity, message } : { rule, severity, path, message }; +export function finding(rule: string, severity: Severity, message: string, path?: string, detail?: string): Finding { + const base = path === undefined ? { rule, severity, message } : { rule, severity, path, message }; + return detail === undefined ? base : { ...base, detail }; } /** Findings in canonical order: (path, line, rule, construct), message last as the diff --git a/packages/sdk/src/lib/fsx.ts b/packages/sdk/src/lib/fsx.ts index 370f1cb..f18cf7e 100644 --- a/packages/sdk/src/lib/fsx.ts +++ b/packages/sdk/src/lib/fsx.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { type TargetVerdict, writableTarget } from './layout.js'; +import { type TargetVerdict, LEJI_DIR, LEJI_IGNORE_REL, lejiRole, writableTarget } from './layout.js'; export function toPosix(p: string): string { return p.split(path.sep).join('/'); @@ -144,8 +144,49 @@ export function resolvedWithinRoot(rootAbs: string, abs: string): boolean { return real === realRoot || real.startsWith(realRoot + path.sep); } -/** One judged target: the resolved path plus the verdict {@link writableTarget} - * returned for it. `resolved` is null only when the path could not be resolved. */ +/** + * The ONE declared exception to the role rule, and the only place a `metadataFile` + * verdict is constructed: `/.leji/.gitignore`, the ignore file the tool keeps + * for its own tree. It belongs to no role, so {@link writableTarget} refuses it and + * cannot be the judge here: the rule it needs is about the REQUESTED entry, which + * `writableTarget` never sees. + * + * Null means "not this path": every other target falls through to the rule + * unchanged. Otherwise the verdict is allowed on all three conditions, checked on + * the ORIGINAL directory entries so a link is caught rather than followed: + * + * 1. the requested path is exactly `/.leji/.gitignore`, and it resolves to + * itself (a `.LEJI/` spelling on a case-insensitive filesystem resolves to the + * name the filesystem holds and is not this path); + * 2. `/.leji` is a real directory, never a symlink; + * 3. the entry is absent or a regular file, never a symlink or anything else. + * + * When a condition fails the exception REFUSES rather than falling back to an + * allowance: today's verdict stands when it already refuses (a `.leji` symlinked + * out of the repository is `outsideRoot`, exactly as it is now), and a redirect + * that happens to land on ordinary content is refused as the requested path's own + * role, never written through. The exception can only narrow, never widen. + */ +function metadataFileVerdict( + rootAbs: string, + targetAbs: string, + resolved: string, + ownRoleRel: string | null, +): TargetVerdict | null { + const expected = path.join(rootAbs, LEJI_IGNORE_REL); + if (path.resolve(targetAbs) !== expected) return null; + const dir = fs.lstatSync(path.join(rootAbs, LEJI_DIR), { throwIfNoEntry: false }); + const entry = fs.lstatSync(expected, { throwIfNoEntry: false }); + if (resolved === expected && dir?.isDirectory() === true && (entry === undefined || entry.isFile())) { + return { ok: true, metadataFile: true }; + } + const verdict = writableTarget(rootAbs, resolved, ownRoleRel); + return verdict.ok ? { ok: false, role: lejiRole(rootAbs, expected) } : verdict; +} + +/** One judged target: the resolved path plus the verdict the rule returned for it, + * which is {@link writableTarget}'s except at the one declared exception above. + * `resolved` is null only when the path could not be resolved. */ function judgeTarget( rootAbs: string, targetAbs: string, @@ -153,7 +194,8 @@ function judgeTarget( ): { verdict: TargetVerdict; resolved: string | null } { const resolved = resolvedPath(targetAbs); if (resolved === null) return { verdict: { ok: false, unresolvable: true }, resolved: null }; - return { verdict: writableTarget(rootAbs, resolved, ownRoleRel), resolved }; + const exception = metadataFileVerdict(rootAbs, targetAbs, resolved, ownRoleRel); + return { verdict: exception ?? writableTarget(rootAbs, resolved, ownRoleRel), resolved }; } /** @@ -452,7 +494,11 @@ export function verifiedTargetRead(rootAbs: string, targetAbs: string, ownRoleRe } let refusal: 'outside-root' | 'other-role' | null = null; const { fd, real } = openVerifiedSource(targetAbs, (resolved) => { - const verdict = writableTarget(rootAbs, resolved, ownRoleRel); + // The same rule the write will be judged by, the declared exception included: + // the read-then-act pair must agree, or the one target that belongs to no role + // could be read here and refused at the write (or the reverse). + const verdict = + metadataFileVerdict(rootAbs, targetAbs, resolved, ownRoleRel) ?? writableTarget(rootAbs, resolved, ownRoleRel); if (verdict.ok) return true; refusal = verdict.outsideRoot === true ? 'outside-root' : 'other-role'; return false; diff --git a/packages/sdk/src/lib/layout.ts b/packages/sdk/src/lib/layout.ts index 8d0550b..5fd8ed9 100644 --- a/packages/sdk/src/lib/layout.ts +++ b/packages/sdk/src/lib/layout.ts @@ -28,6 +28,15 @@ export const WORK_REL = `${LEJI_DIR}/work`; /** The private federation domain: managed object stores, projection cache, staging. */ export const MOUNTS_REL = `${LEJI_DIR}/mounts`; +/** + * The one metadata file the tool keeps directly under root `.leji/`, outside every + * role: the ignore file that keeps the tool's own tree out of the repository even + * when the root `.gitignore` never received the `.leji/` line. It belongs to no + * role, so the role rule below refuses it; the single named exception that allows + * it lives in `lib/fsx.ts`, where the REQUESTED entry is still visible. + */ +export const LEJI_IGNORE_REL: string = `${LEJI_DIR}/.gitignore`; + /** True when `abs` is `dir` or sits underneath it. */ function under(dir: string, abs: string): boolean { return abs === dir || abs.startsWith(dir + path.sep); @@ -66,13 +75,19 @@ export function lejiRole(rootAbs: string, abs: string): string { * written or cleared, and — when refused — that it landed outside the repository, * the private role it crossed into, that the path could not be resolved at all * (permission/I/O, not mere absence), or that an exclusive create found the file - * already there. */ + * already there. + * + * `metadataFile` marks the one allowed target that belongs to no role, + * {@link LEJI_IGNORE_REL}. It is never produced here: only the named exception in + * `lib/fsx.ts` constructs it, on the requested entry, and a source-audit test + * pins that single constructor site. */ export interface TargetVerdict { ok: boolean; role?: string; unresolvable?: boolean; outsideRoot?: true; exists?: true; + metadataFile?: true; } /** diff --git a/packages/sdk/src/lib/leji-ignore.ts b/packages/sdk/src/lib/leji-ignore.ts new file mode 100644 index 0000000..ca51bde --- /dev/null +++ b/packages/sdk/src/lib/leji-ignore.ts @@ -0,0 +1,106 @@ +import * as path from 'node:path'; +import { guardRoot, verifiedTargetRead, writeFileGuarded } from './fsx.js'; +import { LEJI_IGNORE_REL } from './layout.js'; + +/** + * The tool ignores its own directory from inside. A layer whose root `.gitignore` + * never received the `.leji/` line (adopted before the unified layout, or written + * by hand) otherwise grows an untracked generated tree at every command; one file + * inside `.leji/` closes that without touching the repository's own ignore rules. + * + * The file is written the first time a command creates a role under `.leji/`, and + * only there: nothing read-only ever creates it. What stands at the target decides + * the act, read through the verified read rather than a pathname check, so a file + * swapped between the look and the write is never written through. + */ + +/** The whole file: ignore everything under `.leji/`, this file included. Nothing + * in that tree is committed by design, so the rule needs no exceptions and never + * grows any. A byte contract shared with the Go and Python SDKs. */ +export const LEJI_IGNORE_CONTENT = '*\n'; + +/** What a run says, once, when it left an existing file alone. Frozen text, on + * stderr under every output mode: it is an advisory about the repository, never + * part of a `--json` document. */ +export const LEJI_IGNORE_NOTICE: string = `leji: ${LEJI_IGNORE_REL} exists and was left as is (expected content: *)`; + +/** + * One invocation's notice state. Created at the CLI command entry point and passed + * down every call path that can create a role, so one invocation says it once + * however many roles it establishes: `leji export` creates the viewer chrome and + * the export output and still notices once. A directly callable SDK function takes + * it as an optional parameter and passes it to whatever it nests; a direct caller + * that supplies none gets a context local to that call, so the documented behavior + * there is at most one notice per call. Deliberately not a module global: that + * would be process-scoped, and a long-lived host or a second repository in the same + * process would inherit a state that is not its own. + */ +export interface LejiIgnoreContext { + noticed: boolean; +} + +/** A fresh invocation context. */ +export function newLejiIgnoreContext(): LejiIgnoreContext { + return { noticed: false }; +} + +/** + * What one call did: + * + * - `created`: nothing stood there and the file was created exclusively; + * - `present`: a regular file already holds exactly these bytes; + * - `left-as-is`: a regular file holds something else; it is untouched and the + * notice was emitted (once per context); + * - `exists`: an entry appeared between the read and the exclusive create, so the + * create found it and wrote nothing; + * - `refused`: the boundary refused the target (a symlink at `.leji` or at the + * file, a non-regular entry, a containment failure); nothing was written. + */ +export type LejiIgnoreOutcome = 'created' | 'present' | 'left-as-is' | 'exists' | 'refused'; + +/** + * Ensure `.leji/.gitignore` exists, at the one exception the write rule declares. + * + * Idempotent, and safe to call from every role establisher: the decision comes from + * {@link verifiedTargetRead} (bytes read from the descriptor the rule cleared), + * and the create is exclusive through the guarded write path, so neither branch + * rests on a pathname that could change underneath it. A refusal is returned rather + * than thrown; the calling command reports it the way it reports any refused write. + */ +export function ensureLejiIgnoreFile(root: string, ctx: LejiIgnoreContext = newLejiIgnoreContext()): LejiIgnoreOutcome { + const rootReal = guardRoot(root); + const abs = path.join(rootReal, LEJI_IGNORE_REL); + // Two looks at most. Another run creating this same file lands between the first + // look and its verification, and a standing entry that could not be verified is + // `unverifiable`, which here is an ordinary concurrent create rather than a + // refusal, so it is looked at once more and read as what it now is. Anything this + // run genuinely cannot verify refuses on the second look exactly as on the first, + // and every other refusal (a symlink, a non-regular entry, a containment failure) + // is final at the first. + for (let look = 0; look < 2; look += 1) { + const standing = verifiedTargetRead(rootReal, abs, null); + if (standing.status === 'refused') { + if (standing.reason === 'unverifiable' && look === 0) continue; + return 'refused'; + } + if (standing.status === 'regular') { + if (standing.bytes.toString('utf8') === LEJI_IGNORE_CONTENT) return 'present'; + // An empty file is the other half of that concurrent create: the winner has + // opened it exclusively and not yet written its two bytes. Looking again + // answers what it holds; a file that is genuinely empty answers the same + // thing twice and is left alone like any other content. + if (standing.bytes.length === 0 && look === 0) continue; + // Someone else's file: never merged, never rewritten. The run says so once + // and leaves the bytes exactly as they are. + if (!ctx.noticed) { + ctx.noticed = true; + process.stderr.write(`${LEJI_IGNORE_NOTICE}\n`); + } + return 'left-as-is'; + } + const verdict = writeFileGuarded(rootReal, abs, null, LEJI_IGNORE_CONTENT, { exclusive: true }); + if (verdict.exists === true) return 'exists'; + return verdict.ok ? 'created' : 'refused'; + } + return 'refused'; +} diff --git a/packages/sdk/src/lib/mounts.ts b/packages/sdk/src/lib/mounts.ts index 7c91e5c..9c890f7 100644 --- a/packages/sdk/src/lib/mounts.ts +++ b/packages/sdk/src/lib/mounts.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; import { exists, guardRoot, isDir, isFile, mkdirpGuarded, readTextWithin } from './fsx.js'; import { MOUNTS_REL } from './layout.js'; +import { type LejiIgnoreContext, ensureLejiIgnoreFile, newLejiIgnoreContext } from './leji-ignore.js'; import { type Manifest, allStringsScalar } from './manifest.js'; import { schemaErrors } from './schemas.js'; import { byteCompare } from './text.js'; @@ -182,9 +183,14 @@ export function mountsDir(root: string): string { * chokepoint, and it holds only because every one of its acts happens under a root * this function checked and returned — never under a path re-joined from `root`. */ -function establishMountsDir(root: string, dirAbs: string): string | null { +function establishMountsDir(root: string, dirAbs: string, ignoreContext?: LejiIgnoreContext): string | null { const established = mkdirpGuarded(guardRoot(root), dirAbs, MOUNTS_REL); - return established.ok ? established.real : null; + if (!established.ok) return null; + // A role under `.leji/` now exists, so the tool's own ignore file is ensured here + // as it is at every other establisher. A refusal is this destination refusing: + // the caller reports it as it reports any destination it could not establish. + if (ensureLejiIgnoreFile(root, ignoreContext) === 'refused') return null; + return established.real; } /** Machine-local resolution hints (never committed): .leji/mounts.local.json. */ @@ -393,12 +399,13 @@ export function retainPinInStore( mount: MountDecl, sourceIdentity: string, oid: string, + ignoreContext?: LejiIgnoreContext, ): { repo: string | null; error?: string } { // Details are stable, Leji-authored text: git stderr never reaches output. const failed = (error: string) => ({ repo: null, error }); // The locator becomes argv here: anything option-shaped is refused, never passed. if (mount.source.startsWith('-')) return failed('the source locator may not begin with "-"'); - const store = establishMountsDir(root, storeDir(root, sourceIdentity)); + const store = establishMountsDir(root, storeDir(root, sourceIdentity), ignoreContext); if (store === null) return failed('the managed store could not be initialized'); if (!isGitRepo(store)) { if (!runGit(['init', '--bare', '-q', store]).ok) return failed('the managed store could not be initialized'); @@ -442,8 +449,9 @@ export function fetchIntoStore( root: string, mount: MountDecl, sourceIdentity: string, -): { repo: string | null; witnessRefreshFailed?: boolean; error?: string } { - const retained = retainPinInStore(root, mount, sourceIdentity, mount.pin); + ignoreContext?: LejiIgnoreContext, +): { repo: string | null; witnessRefreshFailed?: boolean; error?: string; witnessError?: string } { + const retained = retainPinInStore(root, mount, sourceIdentity, mount.pin, ignoreContext); if (retained.repo === null) return retained; const store = retained.repo; // The witness refresh is the second half of what `--fetch` was asked to do, so a @@ -451,7 +459,11 @@ export function fetchIntoStore( // only when it was actually attempted: a run that never got this far has already // reported the fetch failure that stopped it. if (mount.trackingRef !== undefined && validTrackingRef(mount.trackingRef)) { - if (!refreshWitness(store, mount, sourceIdentity)) return { repo: store, witnessRefreshFailed: true }; + // The witness reason travels beside the flag, never in `error`: that one is + // the store's own failure, and a mount whose store WAS established must not + // start reporting the witness reason as the reason nothing holds its pin. + const refreshed = refreshWitness(store, mount, sourceIdentity); + if (!refreshed.ok) return { repo: store, witnessRefreshFailed: true, witnessError: refreshed.error }; } return { repo: store }; } @@ -469,8 +481,15 @@ export function refOid(repo: string, ref: string): string | null { * upstream move. No lock: git's ref update is atomic, a lost swap means another * writer published first (a valid outcome), and a failure leaves the previous * witness in place. + * + * A refusal names which half did not happen, in stable Leji-authored text: the + * caller reports the witness act, and the act alone says nothing about why. */ -export function refreshWitness(store: string, mount: MountDecl, sourceIdentity: string): boolean { +export function refreshWitness( + store: string, + mount: MountDecl, + sourceIdentity: string, +): { ok: boolean; error?: string } { const witnessRef = witnessRefFor(sourceIdentity, mount.trackingRef!); const tempRef = `${WITNESS_REF_NAMESPACE}/tmp/${process.pid}-${crypto.randomBytes(8).toString('hex')}`; const spec = `+${mount.trackingRef}:${tempRef}`; @@ -508,7 +527,16 @@ export function refreshWitness(store: string, mount: MountDecl, sourceIdentity: // Cleanup is not part of the outcome: the canonical ref has already moved, and a // surviving temporary is inert (nothing reads the tmp namespace as a witness). runGit(['-C', store, 'update-ref', '-d', tempRef]); - return published; + if (published) return { ok: true }; + // Two failure classes, and no third: the tracking ref never arrived, or it + // arrived and the canonical ref would not take it. + return { + ok: false, + error: + tip === null + ? 'the tracking ref could not be fetched from the source' + : 'the witness ref could not be published', + }; } interface TreeEntry { @@ -1159,16 +1187,27 @@ function declaredMounts(manifest: Manifest): MountDecl[] { })); } +/** + * Hydrate the declared mounts. `reasons` carries the resolver's own reason for the + * one `--fetch` act that failed per mount, keyed by mount name, for the caller's + * findings; it is a transport, never a document member, and the writers of + * `mounts hydrate --json` pick their fields explicitly so it is never serialized. + */ export function hydrateMounts( root: string, manifest: Manifest, - opts: { fetch?: boolean; names?: string[] } = {}, -): { outcomes: HydrateOutcome[]; fatal?: string } { + opts: { fetch?: boolean; names?: string[]; ignoreContext?: LejiIgnoreContext } = {}, +): { outcomes: HydrateOutcome[]; fatal?: string; reasons: Map } { + // One context for the whole run: hydration establishes a store and a staging + // directory per declared mount, and they are all one invocation. + const ignoreContext = opts.ignoreContext ?? newLejiIgnoreContext(); + const reasons = new Map(); const tracked = trackedCacheFiles(root); if (tracked.length > 0) { return { outcomes: [], fatal: `git-tracked files under .leji/mounts/ (${tracked[0]}); the cache is never committed`, + reasons, }; } const mounts = declaredMounts(manifest).filter((m) => !opts.names || opts.names.includes(m.name)); @@ -1196,7 +1235,11 @@ export function hydrateMounts( // --fetch populates the store for every declared mount, cached or already // resolvable: the store is the only witness namespace the resolver owns, // and `status` never fetches. - const fetched = opts.fetch ? fetchIntoStore(root, mount, identity) : null; + const fetched = opts.fetch ? fetchIntoStore(root, mount, identity, ignoreContext) : null; + // At most one act can fail: a store that was not established is never asked to + // refresh a witness, so one reason per mount is the whole vocabulary here. + const failedAct = fetched?.repo === null ? fetched.error : fetched?.witnessError; + if (failedAct !== undefined) reasons.set(mount.name, failedAct); // A requested fetch that did not establish the store is reported on its own // terms, whatever the projection then manages from a hint or the cache. const outcome = (o: HydrateOutcome): HydrateOutcome => ({ @@ -1236,7 +1279,7 @@ export function hydrateMounts( // filesystem, and under a per-process name, so no two producers collide. The // staging directory is established through the chokepoint and every act below // works from the RESOLVED path it returned, the cache entry included. - const staging = establishMountsDir(root, path.join(cacheDir, `.staging-${stagingToken()}`)); + const staging = establishMountsDir(root, path.join(cacheDir, `.staging-${stagingToken()}`), ignoreContext); if (staging === null) { outcomes.push( outcome({ @@ -1300,7 +1343,7 @@ export function hydrateMounts( // Nothing is recorded: a mount's cache key is derivable from its declaration, and // whether it is hydrated is the marker on disk. A state file would only be a second // copy of both, and one that two concurrent partial runs can each drop entries from. - return { outcomes }; + return { outcomes, reasons }; } /** @@ -1649,7 +1692,11 @@ export interface ReachabilityResult { * store. Any failure to reach the source reports `unknown`, never a guess, and * every detail is stable text: git stderr never reaches conformance output. */ -export function checkPinReachability(root: string, mount: MountDecl): ReachabilityResult { +export function checkPinReachability( + root: string, + mount: MountDecl, + ignoreContext?: LejiIgnoreContext, +): ReachabilityResult { const identity = normalizeSource(mount.source); if (identity === null) { return { state: 'unknown', witnessRef: null, detail: 'source is not a normalizable locator' }; @@ -1677,7 +1724,7 @@ export function checkPinReachability(root: string, mount: MountDecl): Reachabili const tip = line.split('\t')[0]; // Establish ancestry in the resolver store: fetch the witness ref (full history, // no promisor state), then ask whether the pin is an ancestor of its tip. - const store = establishMountsDir(root, path.join(mountsDir(root), 'store', sha256Hex(identity))); + const store = establishMountsDir(root, path.join(mountsDir(root), 'store', sha256Hex(identity)), ignoreContext); if (store === null) { return { state: 'unknown', witnessRef, detail: 'the managed store could not be initialized' }; } diff --git a/packages/sdk/templates/viewer/assets/vue.css b/packages/sdk/templates/viewer/assets/vue.css index 1fc6850..7836ffc 100644 --- a/packages/sdk/templates/viewer/assets/vue.css +++ b/packages/sdk/templates/viewer/assets/vue.css @@ -131,8 +131,8 @@ span.emoji { vertical-align: middle; } .progress { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); height: 2px; left: 0px; position: fixed; @@ -143,12 +143,12 @@ span.emoji { z-index: 999999; } .search a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .search .search-keyword { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-style: normal; font-weight: bold; } @@ -159,7 +159,7 @@ body { body { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; - color: #34495e; + color: var(--leji-text-body); font-family: 'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif; font-size: 15px; letter-spacing: 0; @@ -218,14 +218,14 @@ li input[type='checkbox'] { transition: color 0.3s; } .app-nav a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .app-nav a.active { - border-bottom: 2px solid #42b983; - border-bottom: 2px solid var(--theme-color, #42b983); - color: #42b983; - color: var(--theme-color, #42b983); + border-bottom: 2px solid #009f71; + border-bottom: 2px solid var(--theme-color, #009f71); + color: #009f71; + color: var(--theme-color, #009f71); } .app-nav li { display: inline-block; @@ -283,8 +283,8 @@ li input[type='checkbox'] { } .github-corner svg { color: #fff; - fill: #42b983; - fill: var(--theme-color, #42b983); + fill: #009f71; + fill: var(--theme-color, #009f71); height: 80px; width: 80px; } @@ -304,7 +304,7 @@ main.hidden { transition: all 0.3s; } .anchor span { - color: #34495e; + color: var(--leji-text); } .anchor:hover { text-decoration: underline; @@ -393,8 +393,8 @@ main.hidden { opacity: 0.4; } .sidebar-toggle span { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); display: block; margin-bottom: 4px; width: 16px; @@ -489,7 +489,7 @@ body.sticky .sidebar-toggle { background-color: #efefef; } .markdown-section p.tip em { - color: #34495e; + color: var(--leji-text-body); } .markdown-section p.warn { background: rgba(66,185,131,0.1); @@ -653,14 +653,14 @@ section.cover ul { padding: 0; } section.cover .cover-main > p:last-child a { - border-color: #42b983; - border-color: var(--theme-color, #42b983); + border-color: #009f71; + border-color: var(--theme-color, #009f71); border-radius: 2rem; border-style: solid; border-width: 1px; box-sizing: border-box; - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); display: inline-block; font-size: 1.05rem; letter-spacing: 0.1rem; @@ -670,8 +670,8 @@ section.cover .cover-main > p:last-child a { transition: all 0.15s ease; } section.cover .cover-main > p:last-child a:last-child { - background-color: #42b983; - background-color: var(--theme-color, #42b983); + background-color: #009f71; + background-color: var(--theme-color, #009f71); color: #fff; } section.cover .cover-main > p:last-child a:last-child:hover { @@ -682,13 +682,13 @@ section.cover .cover-main > p:last-child a:hover { color: inherit; } section.cover blockquote > p > a { - border-bottom: 2px solid #42b983; - border-bottom: 2px solid var(--theme-color, #42b983); + border-bottom: 2px solid #009f71; + border-bottom: 2px solid var(--theme-color, #009f71); transition: color 0.3s; } section.cover blockquote > p > a:hover { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } body { background-color: #fff; @@ -696,13 +696,13 @@ body { /* sidebar */ .sidebar { background-color: #fff; - color: #364149; + color: var(--leji-text-body); } .sidebar li { margin: 6px 0 6px 0; } .sidebar ul li a { - color: #505d6b; + color: var(--leji-text-body); font-size: 14px; font-weight: normal; overflow: hidden; @@ -718,8 +718,8 @@ body { } .sidebar ul li.active > a { border-right: 2px solid; - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-weight: 600; } .app-sub-sidebar li::before { @@ -733,12 +733,12 @@ body { .markdown-section h3, .markdown-section h4, .markdown-section strong { - color: #2c3e50; + color: var(--leji-text); font-weight: 600; } .markdown-section a { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); font-weight: 600; } .markdown-section h1 { @@ -760,7 +760,7 @@ body { font-size: 1rem; } .markdown-section h6 { - color: #777; + color: var(--leji-text); font-size: 1rem; } .markdown-section figure, @@ -778,9 +778,9 @@ body { padding-left: 1.5rem; } .markdown-section blockquote { - border-left: 4px solid #42b983; - border-left: 4px solid var(--theme-color, #42b983); - color: #858585; + border-left: 4px solid #009f71; + border-left: 4px solid var(--theme-color, #009f71); + color: var(--leji-text-body); margin: 2em 0; padding-left: 20px; } @@ -792,7 +792,7 @@ body { margin: 1em 0; } .markdown-section em { - color: #7f8c8d; + color: var(--leji-text-body); } .markdown-section code, .markdown-section pre, @@ -901,8 +901,8 @@ body { color: #2973b7; } .token.string { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .token.selector { color: #6679cc; @@ -920,8 +920,8 @@ body { .token.control, .token.directive, .token.unit { - color: #42b983; - color: var(--theme-color, #42b983); + color: #009f71; + color: var(--theme-color, #009f71); } .token.keyword, .token.function { @@ -972,6 +972,26 @@ code .token { group/active treatment. CDN-free: the theme's webfonts are vendored locally (see the @font-face rules at the top of this file). ========================================================================== */ +/* Every text color in this file and in the viewer shell belongs to exactly one of + four classes, and a new one has to be placed in a class before it is written: + + i. typography: the three tones below and nothing else. Headings and emphasis + take --leji-text, all normal-size copy --leji-text-body, and --leji-text-muted + is declared for large-text use only (3.99:1 on white, below AA at normal + size), so it currently has no consumer. + ii. links and active chrome: --leji-link (the fixed AA body-link tone), + --theme-color (the layer's accent, viewer.theme.primary), --leji-deep, + --leji-brand. + iii. inverse text on dark or accent grounds: the literal #fff on a colored + ground, which is not a typography tone. + iv. code and syntax: the fenced-code text, the Prism .token.* colors, and the + inline-code ground. + + Surfaces (--leji-paper, --leji-line, --leji-code-bg) and icons (--leji-caret, the + group triangles) are not text and belong to none of the four. + + The legacy neutrals the stock docsify theme shipped are denied outright, in a + unit test over this directory (packages/sdk/test/viewer-tones.test.ts). */ :root { --theme-color: #009f71; /* recolors every var(--theme-color) rule above */ --leji-brand: #009f71; /* the Leji mark green: brand moments, never small text */ @@ -979,17 +999,18 @@ code .token { --leji-deep: #164e42; --leji-accent: #78d7b5; --leji-paper: #f7f8f5; /* the brand's light canvas: sidebar, chips, panels */ - --leji-ink: #34495e; - --leji-ink-soft: #555555; + --leji-text: #183b32; /* headings and emphasis */ + --leji-text-body: #4d5b56; /* every normal-size run of copy */ + --leji-text-muted: #76827d; /* large text only: 3.99:1 on white */ --leji-line: #cde5d9; /* the brand's border tone, not a neutral gray */ --leji-code-bg: #e8f4ee; - --leji-caret: #aaaaaa; /* lighter than the ink for the group triangles */ + --leji-caret: #aaaaaa; /* an icon tone, lighter than the text, not typography */ color-scheme: light; } body { background: var(--leji-paper); - color: var(--leji-ink); + color: var(--leji-text-body); } /* --- sidebar shell --- */ @@ -1014,7 +1035,7 @@ body { border-top: 1px solid var(--leji-line); } .sidebar ul li a { - color: var(--leji-ink-soft); + color: var(--leji-text-body); transition: color 0.12s; } .sidebar ul li a:hover { @@ -1060,7 +1081,7 @@ body { .sidebar-nav > ul > li.folder { /* 600 is Source Sans Pro's bold face; 500 would resolve down to regular. */ font-weight: 600; - color: var(--leji-ink); + color: var(--leji-text); } .sidebar-nav > ul > li.folder .app-sub-sidebar { font-weight: 400; @@ -1077,7 +1098,7 @@ body { content: none !important; } .sidebar-nav .app-sub-sidebar li a { - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .sidebar-nav .app-sub-sidebar li.active > a, .sidebar-nav .app-sub-sidebar li a:hover { @@ -1092,10 +1113,10 @@ body { background: #fff; border: 1px solid var(--leji-line); border-radius: 6px; - color: var(--leji-ink); + color: var(--leji-text-body); } .search input::placeholder { - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .search input:focus { outline: none; @@ -1133,7 +1154,7 @@ body { font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--leji-ink-soft); + color: var(--leji-text-body); } .sidebar-toggle { background-color: var(--leji-paper) !important; @@ -1152,15 +1173,27 @@ body { font-weight: 600; } +/* h3, h4, h6 and strong take the heading tone from the rules above; h1, h2 and h5 + say it here rather than inheriting, so every heading level carries one tone by + declaration and a stock-theme change cannot quietly split them. */ +.markdown-section h1, +.markdown-section h2, +.markdown-section h5 { + color: var(--leji-text); +} + +/* Body links take the fixed accessible tone, never the layer's accent: AA on the + content ground holds for every value of viewer.theme.primary. The chrome above + keeps --theme-color, so a custom accent still colors the viewer. */ .markdown-section a { - color: var(--theme-color); + color: var(--leji-link); } /* brand-tinted inline code, replacing the stock orange. Scoped away from pre > code so fenced blocks keep the stock token colors. */ .markdown-section code, .markdown-section p code, .markdown-section li code { - color: var(--theme-color); + color: var(--leji-link); background: var(--leji-code-bg); } /* The fenced-code panel takes the same ground, replacing the stock neutral gray. */ @@ -1173,5 +1206,5 @@ body { } .markdown-section blockquote { border-left: 3px solid var(--leji-accent); - color: var(--leji-ink-soft); + color: var(--leji-text-body); } diff --git a/packages/sdk/templates/viewer/index.html b/packages/sdk/templates/viewer/index.html index 924ce01..5745eb6 100644 --- a/packages/sdk/templates/viewer/index.html +++ b/packages/sdk/templates/viewer/index.html @@ -18,12 +18,16 @@ Docsify sets element styles at runtime; that is not a script-execution path. frame-src 'none' closes the framing path: without it a governed document could frame a same-origin /content/ page, which inherits none of this. - The local server sends the same policy as a response header on every - response, so it also covers documents fetched outside this shell. + The directive naming who may frame this page is deliberately absent: a meta + policy cannot deliver it, so claiming it here would protect nothing and cost + an ignored-directive console error on every page. The local server sends + this policy as a response header on every response, with that directive + added, so the served viewer is covered including documents fetched outside + this shell; a static host serving an export sets it in its own headers. --> {{LEJI_NAME_HTML}} @@ -34,7 +38,8 @@ /* Viewer default styling: white content, the brand canvas on the sidebar and its chrome, tight nesting — the shape teams hand-build for internal dashboards. Accents ride --theme-color (set by Docsify from - viewer.theme.primary). */ + viewer.theme.primary); the text tones are the --leji-text* custom + properties assets/vue.css declares, which loads before this block. */ body { background-color: #ffffff; } @@ -48,7 +53,7 @@ z-index: 5; font-size: 0.7rem; letter-spacing: 0.02em; - color: #555555; + color: var(--leji-text-body); background: #f7f8f5; border: 1px solid #cde5d9; border-radius: 999px; @@ -80,7 +85,7 @@ width: max-content; text-align: center; font-size: 0.8rem; - color: #999999; + color: var(--leji-text-body); animation: lj-nav-pulse 1.2s ease-in-out infinite; } @keyframes lj-nav-pulse { @@ -94,7 +99,7 @@ content: none !important; } .sidebar-nav li > a { - color: #2c3e50; + color: var(--leji-text); padding: 6px 0; } .sidebar-nav li > a:hover { @@ -136,7 +141,7 @@ /* Group headers: the theme's default bold rendering, at full size. */ .sidebar-nav li > strong, .sidebar-nav li > p > strong { - color: #1a1a1a; + color: var(--leji-text); font-weight: 600; user-select: none; } @@ -178,7 +183,7 @@ } .sidebar-nav ul ul li > a { font-size: 14px; - color: #555; + color: var(--leji-text-body); padding: 2px 0; } .sidebar-nav ul ul li > a:hover { @@ -197,7 +202,7 @@ .search input { background-color: #ffffff; border: 1px solid #cde5d9; - color: #2c3e50; + color: var(--leji-text-body); } .search .results-panel { background-color: #ffffff; @@ -205,7 +210,7 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .search .results-panel .matching-post > a { - color: #2c3e50; + color: var(--leji-text); } .search .results-panel .matching-post > a:hover { color: var(--theme-color, #009f71); @@ -241,7 +246,7 @@ font-size: 11px; letter-spacing: normal; text-transform: none; - color: #9aa0a6; + color: var(--leji-text-body); z-index: 30; } .leji-powered a { diff --git a/packages/sdk/test/badge.test.ts b/packages/sdk/test/badge.test.ts index dade31f..1016d17 100644 --- a/packages/sdk/test/badge.test.ts +++ b/packages/sdk/test/badge.test.ts @@ -1,6 +1,5 @@ import { strict as assert } from 'node:assert'; import { execFile, execFileSync } from 'node:child_process'; -import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as net from 'node:net'; import * as os from 'node:os'; @@ -9,6 +8,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { type ConformanceLevel, badgeMarkdown, badgeRun, renderBadge } from '../dist/index.js'; +import { snapshotTree } from './helpers/snapshot.ts'; // Two halves of one contract. First the constants: every level rendered and // byte-compared against `fixtures/badge/`, the sole oracle, plus the `--out` @@ -210,11 +210,11 @@ test('a run that writes nothing establishes no directory on the way to not writi fs.writeFileSync(path.join(parent, 'sibling.txt'), 'untouched\n'); const foreign = 'not a badge\n'; fs.writeFileSync(path.join(parent, 'badge.svg'), foreign); - const before = snapshot(dir); + const before = snapshotTree(dir); const r = badgeRun(dir, 'pub/badge.svg'); assert.equal(r.refusal, 'pub/badge.svg exists and is not a leji badge; remove or rename it'); assert.equal(fs.readFileSync(path.join(parent, 'badge.svg'), 'utf8'), foreign, 'the target is byte-untouched'); - assert.deepEqual([...snapshot(dir).entries()].sort(), [...before.entries()].sort(), 'the tree is untouched'); + assert.deepEqual(snapshotTree(dir), before, 'the tree is untouched'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -231,7 +231,7 @@ test('a --out whose parent resolves outside the repository is refused, and reads const planted = 'somebody elses file\n'; fs.writeFileSync(path.join(outside, 'x.svg'), planted); fs.symlinkSync(outside, path.join(dir, 'pub'), 'dir'); - const before = snapshot(dir); + const before = snapshotTree(dir); const r = badgeRun(dir, 'pub/x.svg'); assert.ok(r.usageError !== undefined || r.refusal !== undefined, 'the escape is refused'); @@ -239,7 +239,7 @@ test('a --out whose parent resolves outside the repository is refused, and reads assert.equal(r.action, null); assert.equal(fs.readFileSync(path.join(outside, 'x.svg'), 'utf8'), planted, 'the outside file is untouched'); assert.deepEqual(fs.readdirSync(outside).sort(), ['x.svg'], 'nothing was created outside the repository'); - assert.deepEqual([...snapshot(dir).entries()].sort(), [...before.entries()].sort(), 'and nothing inside it'); + assert.deepEqual(snapshotTree(dir), before, 'and nothing inside it'); } finally { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(outside, { recursive: true, force: true }); @@ -289,7 +289,7 @@ test('a --out that is a dangling symlink inside the repository is refused, nothi // would follow the link and create the destination; a standing entry that // could not be verified as a badge is a refusal instead. fs.symlinkSync('missing-file.svg', path.join(dir, 'leji-badge.svg')); - const before = snapshot(dir); + const before = snapshotTree(dir); const r = badgeRun(dir); assert.equal( @@ -305,7 +305,7 @@ test('a --out that is a dangling symlink inside the repository is refused, nothi ); assert.ok(fs.lstatSync(path.join(dir, 'leji-badge.svg')).isSymbolicLink(), 'the link itself is left alone'); assert.ok(!fs.existsSync(path.join(dir, 'missing-file.svg')), 'the link destination was never created'); - assert.deepEqual([...snapshot(dir).entries()].sort(), [...before.entries()].sort(), 'the tree is untouched'); + assert.deepEqual(snapshotTree(dir), before, 'the tree is untouched'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -330,7 +330,7 @@ test('a --out that is a unix socket is refused as a document, not as a crash', a return; } assert.ok(fs.lstatSync(target).isSocket(), 'the target is a socket'); - const before = snapshot(dir); + const before = snapshotTree(dir); const r = badgeRun(dir); assert.equal( @@ -345,7 +345,7 @@ test('a --out that is a unix socket is refused as a document, not as a crash', a [{ rule: 'badge-target-refused', severity: 'error', path: 'leji-badge.svg' }], ); assert.ok(fs.lstatSync(target).isSocket(), 'the socket itself is left alone'); - assert.deepEqual([...snapshot(dir).entries()].sort(), [...before.entries()].sort(), 'the tree is untouched'); + assert.deepEqual(snapshotTree(dir), before, 'the tree is untouched'); // Through the real bin: the refusal is the ordinary badge document at exit 2, // which is exactly what the escaping error used to deny this case. @@ -392,7 +392,7 @@ test('a --out symlinked to a unix socket in the repository is refused as a docum fs.symlinkSync('sock', target); assert.ok(fs.lstatSync(target).isSymbolicLink(), 'the target is a symlink'); assert.ok(fs.statSync(target).isSocket(), 'and it resolves to the socket'); - const before = snapshot(dir); + const before = snapshotTree(dir); const r = badgeRun(dir); assert.equal( @@ -408,7 +408,7 @@ test('a --out symlinked to a unix socket in the repository is refused as a docum ); assert.ok(fs.lstatSync(target).isSymbolicLink(), 'the link itself is left alone'); assert.ok(fs.lstatSync(sock).isSocket(), 'and so is the socket it points at'); - assert.deepEqual([...snapshot(dir).entries()].sort(), [...before.entries()].sort(), 'the tree is untouched'); + assert.deepEqual(snapshotTree(dir), before, 'the tree is untouched'); // Through the real bin: the ordinary badge document at exit 2, not the generic // handler's bare error. @@ -465,32 +465,6 @@ async function runCliProc(args: string[]): Promise { } } -/** Every path under `dir` as `rel -> digest`, so a comparison covers appearance - * and disappearance as well as content. `.git/` is the harness's own scaffolding - * and is excluded: a second CLI run cannot touch it. */ -function snapshot(dir: string, rel = '', acc = new Map()): Map { - const abs = rel === '' ? dir : path.join(dir, rel); - for (const entry of fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) { - if (rel === '' && entry.name === '.git') continue; - const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`; - if (entry.isDirectory()) { - acc.set(childRel + '/', ''); - snapshot(dir, childRel, acc); - } else if (entry.isFile()) { - acc.set( - childRel, - crypto - .createHash('sha256') - .update(fs.readFileSync(path.join(dir, childRel))) - .digest('hex'), - ); - } else { - acc.set(childRel, 'non-regular'); - } - } - return acc; -} - /** Exactly the keys `--json` emits, under every outcome: a consumer parses one * document whether the run wrote a badge, refuted a claim, or refused a file. */ const DOCUMENT_KEYS = [ @@ -637,7 +611,7 @@ for (const name of fs.readdirSync(fixturesDir).sort()) { } if (block.rerun) { - const afterFirst = snapshot(dir); + const afterFirst = snapshotTree(dir); const second = await runCliProc([...args, '--root', dir, '--json']); assert.equal(second.code, 0, 'the steady state exits 0'); // The whole document again, not just `action`: the steady state is the @@ -650,8 +624,8 @@ for (const name of fs.readdirSync(fixturesDir).sort()) { ); if (block.rerun.byteIdentical) { assert.deepEqual( - [...snapshot(dir).entries()].sort(), - [...afterFirst.entries()].sort(), + snapshotTree(dir), + afterFirst, 'a second run is a byte-level no-op across the whole working tree', ); } diff --git a/packages/sdk/test/canary.test.ts b/packages/sdk/test/canary.test.ts index 3622363..170e091 100644 --- a/packages/sdk/test/canary.test.ts +++ b/packages/sdk/test/canary.test.ts @@ -9,6 +9,7 @@ import * as path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { buildViewer, generateViewer, loadManifest, serveViewer } from '../dist/index.js'; +import { snapshotTree } from './helpers/snapshot.ts'; // The trust-domain boundary, driven from the shared fixtures: nothing under `.leji/` // except `viewer/` is servable, and no export carries a byte of it. The fixtures own @@ -117,30 +118,6 @@ function materialize(name: string, seeds: Seed[]): string { return dir; } -/** Every path under `dir` as `rel -> content digest` (directories as `rel/` -> ''), - * so a comparison covers appearance and disappearance as well as content. */ -function snapshot(dir: string, rel = '', acc = new Map()): Map { - const abs = rel === '' ? dir : path.join(dir, rel); - for (const entry of fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) { - const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`; - if (entry.isDirectory()) { - acc.set(childRel + '/', ''); - snapshot(dir, childRel, acc); - } else if (entry.isFile()) { - acc.set( - childRel, - crypto - .createHash('sha256') - .update(fs.readFileSync(path.join(dir, childRel))) - .digest('hex'), - ); - } else { - acc.set(childRel, 'non-regular'); - } - } - return acc; -} - /** Files only, as export-root-relative POSIX paths. */ function filesUnder(dir: string, rel = '', acc: string[] = []): string[] { for (const entry of fs.readdirSync(rel === '' ? dir : path.join(dir, rel), { withFileTypes: true })) { @@ -353,14 +330,10 @@ for (const name of LAYOUT_FIXTURES) { // --- idempotency --------------------------------------------------------- if (expectedExport.rerun?.byteIdentical) { - const afterFirst = snapshot(dir); + const afterFirst = snapshotTree(dir, { repoRoot: dir }); buildViewer(dir, manifest); - const afterSecond = snapshot(dir); - assert.deepEqual( - [...afterSecond.entries()].sort(), - [...afterFirst.entries()].sort(), - 'a second run is a byte-level no-op across the whole working tree', - ); + const afterSecond = snapshotTree(dir, { repoRoot: dir }); + assert.deepEqual(afterSecond, afterFirst, 'a second run is a byte-level no-op across the whole working tree'); } // The planted bytes are still exactly as planted: the tool never read them @@ -547,7 +520,7 @@ test('check-before-act: generation refuses a .leji/viewer aliased into a private fs.symlinkSync(path.join('work', 'chrome'), path.join(dir, '.leji', 'viewer')); const { manifest } = loadManifest(dir); assert.ok(manifest); - const before = [...snapshot(aliased).entries()].sort(); + const before = snapshotTree(aliased, { repoRoot: dir }); const gen = generateViewer(dir, manifest); assert.ok( @@ -555,7 +528,7 @@ test('check-before-act: generation refuses a .leji/viewer aliased into a private 'generation refuses with a hard error (non-zero exit)', ); assert.equal(gen.written.length, 0, 'and writes nothing'); - assert.deepEqual([...snapshot(aliased).entries()].sort(), before, 'the aliased private role is byte-identical'); + assert.deepEqual(snapshotTree(aliased, { repoRoot: dir }), before, 'the aliased private role is byte-identical'); // buildViewer regenerates first, so it inherits the refusal and never reaches the // destructive clean/copy: no export is produced either. @@ -564,7 +537,7 @@ test('check-before-act: generation refuses a .leji/viewer aliased into a private built.findings.some((f) => f.rule === 'viewer-target-refused'), 'the export inherits the refusal', ); - assert.deepEqual([...snapshot(aliased).entries()].sort(), before, 'still untouched after buildViewer'); + assert.deepEqual(snapshotTree(aliased, { repoRoot: dir }), before, 'still untouched after buildViewer'); assert.ok(!fs.existsSync(path.join(dir, '.leji', 'dist')), 'no export was written'); fs.rmSync(dir, { recursive: true, force: true }); }); @@ -580,14 +553,14 @@ test('check-before-act: a DEFAULT-output build refuses when .leji/dist resolves fs.symlinkSync(path.join('mounts', 'store', 'x'), path.join(dir, '.leji', 'dist')); const { manifest } = loadManifest(dir); assert.ok(manifest); - const before = [...snapshot(path.join(dir, '.leji', 'mounts')).entries()].sort(); + const before = snapshotTree(path.join(dir, '.leji', 'mounts'), { repoRoot: dir }); assert.throws( () => buildViewer(dir, manifest, undefined), /reserved for the tool's own roles/, 'the default output is refused, not written', ); assert.deepEqual( - [...snapshot(path.join(dir, '.leji', 'mounts')).entries()].sort(), + snapshotTree(path.join(dir, '.leji', 'mounts'), { repoRoot: dir }), before, 'nothing was cleared or written in the private role', ); @@ -889,7 +862,7 @@ test('check-before-act: generation refuses an overview.md SEED aliased into a pr fs.symlinkSync(path.join('.leji', role, 'new.md'), path.join(dir, 'overview.md')); const { manifest } = loadManifest(dir); assert.ok(manifest); - const before = [...snapshot(roleDir).entries()].sort(); + const before = snapshotTree(roleDir, { repoRoot: dir }); const gen = generateViewer(dir, manifest); assert.ok( @@ -904,7 +877,7 @@ test('check-before-act: generation refuses an overview.md SEED aliased into a pr ); assert.ok(!gen.written.includes('overview.md'), 'overview.md is not reported written'); assert.ok(!fs.existsSync(path.join(roleDir, 'new.md')), 'nothing was written through the alias'); - assert.deepEqual([...snapshot(roleDir).entries()].sort(), before, `the aliased .leji/${role} is byte-identical`); + assert.deepEqual(snapshotTree(roleDir, { repoRoot: dir }), before, `the aliased .leji/${role} is byte-identical`); fs.rmSync(dir, { recursive: true, force: true }); } @@ -973,7 +946,7 @@ test('the export refuses a NESTED dangling --out whose intermediate component re assert.ok(manifest); // redirect -> .leji/mounts/ghost, and ghost does NOT exist: a dangling intermediate. fs.symlinkSync(path.join('.leji', 'mounts', 'ghost'), path.join(dir, 'redirect')); - const mountsBefore = [...snapshot(path.join(dir, '.leji', 'mounts')).entries()].sort(); + const mountsBefore = snapshotTree(path.join(dir, '.leji', 'mounts'), { repoRoot: dir }); assert.throws( () => buildViewer(dir, manifest, 'redirect/export'), /reserved for the tool's own roles/, @@ -984,7 +957,7 @@ test('the export refuses a NESTED dangling --out whose intermediate component re 'the dangling target was not created by the build', ); assert.deepEqual( - [...snapshot(path.join(dir, '.leji', 'mounts')).entries()].sort(), + snapshotTree(path.join(dir, '.leji', 'mounts'), { repoRoot: dir }), mountsBefore, 'nothing was cleared or written in the private role', ); @@ -1011,14 +984,14 @@ test('the export refuses a CHAINED dangling --out that ends in a private role', assert.ok(manifest); fs.symlinkSync('hop', path.join(dir, 'redirect')); fs.symlinkSync(path.join('.leji', 'work', 'ghost'), path.join(dir, 'hop')); - const workBefore = [...snapshot(path.join(dir, '.leji', 'work')).entries()].sort(); + const workBefore = snapshotTree(path.join(dir, '.leji', 'work'), { repoRoot: dir }); assert.throws( () => buildViewer(dir, manifest, 'redirect/export'), /reserved for the tool's own roles/, 'a chained dangling --out into a private role is refused', ); assert.deepEqual( - [...snapshot(path.join(dir, '.leji', 'work')).entries()].sort(), + snapshotTree(path.join(dir, '.leji', 'work'), { repoRoot: dir }), workBefore, 'nothing was cleared or written in the private role', ); @@ -1068,7 +1041,7 @@ test('the export refuses a DANGLING output entry, default or --out, and creates fs.symlinkSync(path.join('..', 'site'), path.join(dir, '.leji', 'dist')); fs.symlinkSync('elsewhere', path.join(dir, 'published')); - const before = [...snapshot(dir).entries()].sort(); + const before = snapshotTree(dir, { repoRoot: dir }); assert.throws( () => buildViewer(dir, manifest, undefined), @@ -1085,6 +1058,6 @@ test('the export refuses a DANGLING output entry, default or --out, and creates assert.ok(fs.lstatSync(path.join(dir, 'published')).isSymbolicLink(), 'the --out link is left in place'); assert.ok(!fs.existsSync(path.join(dir, 'site')), 'the default link destination was never created'); assert.ok(!fs.existsSync(path.join(dir, 'elsewhere')), 'the --out link destination was never created'); - assert.deepEqual([...snapshot(dir).entries()].sort(), before, 'and the tree is byte-identical'); + assert.deepEqual(snapshotTree(dir, { repoRoot: dir }), before, 'and the tree is byte-identical'); fs.rmSync(dir, { recursive: true, force: true }); }); diff --git a/packages/sdk/test/export.test.ts b/packages/sdk/test/export.test.ts index 51d2582..ec74fc7 100644 --- a/packages/sdk/test/export.test.ts +++ b/packages/sdk/test/export.test.ts @@ -8,7 +8,17 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { buildViewer, loadCliSpec, loadManifest, renderCommandHelp, run, serveViewer } from '../dist/index.js'; +import { + buildLayerMap, + buildViewer, + generateViewer, + loadCliSpec, + loadManifest, + renderCommandHelp, + renderOverview, + run, + serveViewer, +} from '../dist/index.js'; // The lint class is the command's own policy, not SDK surface: it stays inside its // module, which an in-repo test reads directly. import { STRICT_LINT_RULES } from '../dist/commands/export.js'; @@ -256,7 +266,10 @@ test('route-equivalence: every route the served layer names reads identically fr // The corpus: the two generated chrome pages served under the content root, every // link the sidebar names, and every document the stored index names. Enumerated // from the artifacts themselves, so a layer that grows a document grows the test. - const routes = new Set(['_sidebar.md', '_manifest.md']); + // `overview.md` is named explicitly: it is the homepage, so no sidebar entry + // points at it, and it is the one page whose bytes are rendered rather than + // copied. Served and exported must still be the same document. + const routes = new Set(['_sidebar.md', '_manifest.md', 'overview.md']); const sidebar = fs.readFileSync(path.join(outContent, '_sidebar.md'), 'utf8'); for (const m of sidebar.matchAll(/]\((\/[^)]+)\)/g)) routes.add(m[1].replace(/^\//, '')); const indexRel = manifest.machine?.indexPath ?? 'context-index.json'; @@ -506,3 +519,57 @@ test('canonical-json: a failure before the pipeline emits the export document, u assert.equal((JSON.parse(withOut.stdout) as { out: string }).out, 'site'); fs.rmSync(dir, { recursive: true, force: true }); }); + +// --- the exported overview carries the map; the lint reads the source ---------- + +test('overview: the exported copy carries the rendered map, and the lint judges the source bytes', async () => { + const dir = tmpCopy(path.join(fixturesDir, 'valid-unified-leji-fresh'), 'leji-export-overview-'); + // An author's page: prose around the markers, and inside them a stale hand-edit + // carrying an out-of-subset construct. The construct's line number is what proves + // which bytes the lint read, since the substitution below changes every line after + // the markers. + const overview = path.join(dir, 'docs', 'overview.md'); + const source = + '# The layer\n\nIntro prose.\n\n\nA raw element left inside the markers.\n\n\nClosing prose.\n'; + fs.writeFileSync(overview, source); + + const { value: exit, stdout } = await quiet(() => run(['export', '--root', dir, '--json'])); + assert.equal(exit, 0, `the export ran: ${stdout}`); + const doc = JSON.parse(stdout) as { findings: { rule: string; path?: string; line?: number }[] }; + assert.ok( + doc.findings.some((f) => f.rule === 'render-unsupported' && f.path === 'docs/overview.md' && f.line === 6), + `the lint reported the construct at its line in the SOURCE: ${JSON.stringify(doc.findings)}`, + ); + + // The source is the author's file: untouched by an export that renders from it. + assert.equal(fs.readFileSync(overview, 'utf8'), source, 'the export never writes the page it renders from'); + const exported = fs.readFileSync(path.join(dir, '.leji', 'dist', 'content', 'overview.md'), 'utf8'); + const { manifest } = loadManifest(dir); + const entries = generateViewer(dir, manifest!).indexEntries; + assert.equal( + exported, + renderOverview(source, manifest!, entries).text, + 'the exported copy is the source with the marked span substituted', + ); + assert.ok(exported.includes('```mermaid\n' + buildLayerMap(manifest!, entries) + '\n```'), 'the map is the map'); + assert.match(exported, /^# The layer$/m, 'the prose around the markers rides along'); + assert.match(exported, /Closing prose\./, 'including what follows them'); + assert.ok(!exported.includes(''), 'and the stale hand-edit between them is gone'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('overview: an exported page without markers is the source, byte for byte', async () => { + const dir = tmpCopy(path.join(fixturesDir, 'valid-unified-leji-fresh'), 'leji-export-nomarkers-'); + const overview = path.join(dir, 'docs', 'overview.md'); + const source = '# Fully custom\n\nNo markers here at all.\n'; + fs.writeFileSync(overview, source); + const { value: exit } = await quiet(() => run(['export', '--root', dir, '--json'])); + assert.equal(exit, 0); + assert.equal(fs.readFileSync(overview, 'utf8'), source, 'the source is untouched'); + assert.equal( + fs.readFileSync(path.join(dir, '.leji', 'dist', 'content', 'overview.md'), 'utf8'), + source, + 'with nowhere to render the map, the exported copy is the source', + ); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/packages/sdk/test/helpers/snapshot.ts b/packages/sdk/test/helpers/snapshot.ts new file mode 100644 index 0000000..1984025 --- /dev/null +++ b/packages/sdk/test/helpers/snapshot.ts @@ -0,0 +1,64 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// The one tree-snapshot contract the badge and canary suites share. Both ask the same +// question of a tree (is it byte-identical to what it was?) and both used to answer it +// with their own private walker, so a fix to one reached the other only by hand. The +// contract lives here, is pinned by `fixtures/snapshot-contract/`, and is the same +// contract the Go and Python suites hold. + +/** How a walk decides which `.git` is scaffolding. */ +export interface SnapshotOptions { + /** + * The repository root whose `.git` is the harness's own scaffolding. Defaults to + * `dir`, the whole-repository call; a subtree call passes the repository root + * explicitly, so `snapshotTree(pkg, { repoRoot: repo })` records `pkg/.git` as the + * content it is. + */ + repoRoot?: string; +} + +/** + * Every entry under `dir` as one line, so a comparison covers appearance, + * disappearance, content and entry kind: + * + * - regular file: `pathsha256:` + * - directory: `path/dir`, an entry of its own, so a created empty directory shows + * - symlink or any other non-regular entry: `pathnon-regular`, never followed + * + * Paths are POSIX and relative to `dir` itself, and the lines are sorted bytewise. + * + * Exactly one entry is excluded: `/.git`, when it lies inside `dir`. That one + * is the harness's scaffolding, and git's background maintenance rewrites it under a + * running test. Every other `.git` (a nested package, a mount, a work directory) is + * content and is walked like anything else. + */ +export function snapshotTree(dir: string, options: SnapshotOptions = {}): string[] { + const root = path.resolve(dir); + const excluded = path.join(path.resolve(options.repoRoot ?? dir), '.git'); + const lines: string[] = []; + + const walk = (rel: string): void => { + const abs = rel === '' ? root : path.join(root, rel); + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + const childAbs = path.join(abs, entry.name); + if (childAbs === excluded) continue; + const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`; + if (entry.isDirectory()) { + lines.push(`${childRel}/\tdir`); + walk(childRel); + } else if (entry.isFile()) { + const digest = crypto.createHash('sha256').update(fs.readFileSync(childAbs)).digest('hex'); + lines.push(`${childRel}\tsha256:${digest}`); + } else { + lines.push(`${childRel}\tnon-regular`); + } + } + }; + walk(''); + + // Bytewise, not by UTF-16 code unit: the Go and Python ports sort their own bytes, + // and the three orderings have to be the one ordering the goldens carry. + return lines.sort((a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))); +} diff --git a/packages/sdk/test/leji-ignore.test.ts b/packages/sdk/test/leji-ignore.test.ts new file mode 100644 index 0000000..1400279 --- /dev/null +++ b/packages/sdk/test/leji-ignore.test.ts @@ -0,0 +1,518 @@ +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as Module from 'node:module'; +import { createRequire } from 'node:module'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + LEJI_IGNORE_CONTENT, + LEJI_IGNORE_NOTICE, + ensureApprovalGuard, + ensureLejiIgnoreFile, + loadManifest, + newLejiIgnoreContext, + run, +} from '../dist/index.js'; + +// The self-managed `.leji/.gitignore`, driven from the shared fixtures: the tool +// ignores its own tree from inside, so a layer whose root `.gitignore` never +// received the `.leji/` line is clean after its first role-creating command. The +// fixtures own the scenario definitions (`lejiIgnore`), so all three SDKs answer the +// same six questions against the same trees; the unit tests below them pin what a +// fixture cannot construct without injecting a fault. + +const IGNORE_FIXTURES = ['valid-leji-ignore-fresh', 'valid-leji-ignore-existing', 'valid-leji-ignore-legacy']; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const fixturesDir = path.join(repoRoot, 'fixtures'); + +interface Seed { + from: string; + to: string; +} + +interface Plant { + symlinkAt: string; + symlinkTo: string; + targetKind: 'dir' | 'file'; + targetBytes?: string; +} + +interface Scenario { + id: string; + note: string; + args: string[]; + plant?: Plant; + exit: number; + ignoreFile: 'regular' | 'symlink' | 'absent'; + bytes: string | null; + notices: number; + untrackedUnderLeji?: string[]; + preserved?: string[]; + jsonParses?: boolean; +} + +/** A fixture-declared path, as the README fixes it: repository-root-relative POSIX, + * normalized, no `..` segment, never absolute. */ +function fixtureRel(value: string, what: string): string { + assert.ok(!path.posix.isAbsolute(value), `${what} must be relative: ${value}`); + const normalized = path.posix.normalize(value).replace(/\/+$/, ''); + assert.equal(normalized, value.replace(/\/+$/, ''), `${what} must be normalized: ${value}`); + assert.ok(!normalized.split('/').includes('..'), `${what} must not escape the fixture: ${value}`); + return normalized; +} + +/** Copy a committed seed's CONTENTS into `to`, which the harness creates. */ +function copySeed(from: string, to: string): void { + fs.mkdirSync(to, { recursive: true }); + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + assert.ok(!entry.isSymbolicLink(), `seed carries a symlink: ${path.join(from, entry.name)}`); + const src = path.join(from, entry.name); + const dest = path.join(to, entry.name); + if (entry.isDirectory()) copySeed(src, dest); + else fs.copyFileSync(src, dest); + } +} + +/** A pristine working copy of the fixture with every declared seed materialized, + * committed to its own git repository: `git status --porcelain` is one half of what + * these scenarios assert, and it answers nothing useful over an uncommitted tree. */ +function gitFixture(name: string, seeds: Seed[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-ignore-')); + fs.cpSync(path.join(fixturesDir, name), dir, { recursive: true }); + for (const seed of seeds) { + const toAbs = path.join(dir, ...fixtureRel(seed.to, 'seed.to').split('/')); + assert.ok(!fs.existsSync(toAbs), `seed target already exists: ${seed.to}`); + copySeed(path.join(dir, ...fixtureRel(seed.from, 'seed.from').split('/')), toAbs); + } + const git = (...args: string[]): void => { + execFileSync('git', ['-C', dir, ...args], { stdio: 'ignore' }); + }; + git('init', '-q', '-b', 'main'); + git('-c', 'user.email=fixtures@leji.org', '-c', 'user.name=Leji Fixtures', 'add', '-A'); + git( + '-c', + 'user.email=fixtures@leji.org', + '-c', + 'user.name=Leji Fixtures', + '-c', + 'commit.gpgsign=false', + 'commit', + '-q', + '-m', + 'fixture', + ); + return dir; +} + +/** The declared symlink, and whatever it points at, planted before the run. A link + * out of the repository is spelled `outside`: it resolves to a directory the harness + * makes beside the working copy, which is the only shape a fixture cannot commit and + * cannot express as a contained relative path. */ +function plant(dir: string, declaration: Plant): string { + const at = path.join(dir, ...fixtureRel(declaration.symlinkAt, 'plant.symlinkAt').split('/')); + let target: string; + if (declaration.symlinkTo === 'outside') { + target = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-ignore-outside-')); + } else { + target = path.join(dir, ...fixtureRel(declaration.symlinkTo, 'plant.symlinkTo').split('/')); + } + if (declaration.targetKind === 'dir') fs.mkdirSync(target, { recursive: true }); + else fs.writeFileSync(target, declaration.targetBytes ?? ''); + fs.mkdirSync(path.dirname(at), { recursive: true }); + fs.symlinkSync(target, at); + return target; +} + +/** stdout and stderr for the length of one run. The notice is a stderr line under + * every output mode, so counting it is what the `notices` field pins. */ +function capture(): { restore: () => void; out: () => string; err: () => string } { + const outChunks: string[] = []; + const errChunks: string[] = []; + const origErr = process.stderr.write.bind(process.stderr); + const origLog = console.log.bind(console); + (process.stderr as unknown as { write: unknown }).write = (s: unknown): boolean => { + errChunks.push(String(s)); + return true; + }; + console.log = (...args: unknown[]): void => { + outChunks.push(args.map(String).join(' ') + '\n'); + }; + return { + restore: () => { + (process.stderr as unknown as { write: unknown }).write = origErr; + console.log = origLog; + }, + out: () => outChunks.join(''), + err: () => errChunks.join(''), + }; +} + +/** `git status --porcelain` entries whose path lies under the root `.leji/`. */ +function untrackedUnderLeji(dir: string): string[] { + const raw = execFileSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' }); + return raw + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => line.slice(3).replace(/^"|"$/g, '')) + .filter((rel) => rel === '.leji' || rel.startsWith('.leji/')) + .sort(); +} + +for (const name of IGNORE_FIXTURES) { + const expected = JSON.parse(fs.readFileSync(path.join(fixturesDir, name, 'expected.json'), 'utf8')) as { + seeds?: Seed[]; + lejiIgnore?: { scenarios: Scenario[] }; + }; + const block = expected.lejiIgnore; + assert.ok(block, `${name} declares a lejiIgnore block`); + + for (const scenario of block.scenarios) { + test(`fixture ${name}: leji-ignore scenario ${scenario.id} (${scenario.note})`, async () => { + const dir = gitFixture(name, expected.seeds ?? []); + if (scenario.plant) plant(dir, scenario.plant); + const before = new Map(); + for (const rel of scenario.preserved ?? []) { + const abs = path.join(dir, ...fixtureRel(rel, 'preserved entry').split('/')); + assert.ok(fs.existsSync(abs), `preserved path exists before the run: ${rel}`); + before.set(rel, fs.readFileSync(abs)); + } + + const captured = capture(); + let code: number; + try { + code = await run([...scenario.args, '--root', dir]); + } finally { + captured.restore(); + } + assert.equal(code, scenario.exit, `exit code (stderr: ${captured.err()})`); + + // The one file, judged on its ORIGINAL entry: a symlink standing there was + // refused, never followed, so lstat is what decides its kind. + const ignoreAbs = path.join(dir, '.leji', '.gitignore'); + const entry = fs.lstatSync(ignoreAbs, { throwIfNoEntry: false }); + if (scenario.ignoreFile === 'absent') { + assert.equal(entry, undefined, '.leji/.gitignore must not exist'); + } else if (scenario.ignoreFile === 'symlink') { + assert.ok(entry?.isSymbolicLink() === true, '.leji/.gitignore is still the planted symlink'); + } else { + assert.ok(entry?.isFile() === true, '.leji/.gitignore is a regular file'); + assert.equal(fs.readFileSync(ignoreAbs, 'utf8'), scenario.bytes, '.leji/.gitignore bytes'); + } + + const notices = captured.err().split(LEJI_IGNORE_NOTICE).length - 1; + assert.equal(notices, scenario.notices, `notice count (stderr: ${captured.err()})`); + + if (scenario.jsonParses === true) { + const document = JSON.parse(captured.out()) as Record; + assert.ok(typeof document === 'object' && document !== null, '--json stdout parses as one document'); + assert.ok( + !JSON.stringify(document).includes('was left as is'), + 'the notice is stderr only, never inside the JSON document', + ); + } + + if (scenario.untrackedUnderLeji !== undefined) { + assert.deepEqual(untrackedUnderLeji(dir), scenario.untrackedUnderLeji, 'git status under .leji/'); + } + + for (const [rel, bytes] of before) { + const abs = path.join(dir, ...rel.split('/')); + assert.deepEqual(fs.readFileSync(abs), bytes, `preserved byte-identical: ${rel}`); + } + }); + } +} + +// --- unit level: what a fixture cannot prepare without injecting a fault -------- + +/** The smallest layer these unit tests drive, copied out of the fixture family. */ +function freshCopy(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-ignore-unit-')); + fs.cpSync(path.join(fixturesDir, 'valid-leji-ignore-fresh'), dir, { recursive: true }); + fs.rmSync(path.join(dir, 'expected.json'), { force: true }); + return dir; +} + +test('the notice text is frozen, and one context says it exactly once however many roles are established', () => { + assert.equal(LEJI_IGNORE_NOTICE, 'leji: .leji/.gitignore exists and was left as is (expected content: *)'); + assert.equal(LEJI_IGNORE_CONTENT, '*\n'); + + const dir = freshCopy(); + fs.mkdirSync(path.join(dir, '.leji'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji', '.gitignore'), 'mine\n'); + const captured = capture(); + let text: string; + try { + const ctx = newLejiIgnoreContext(); + assert.equal(ensureLejiIgnoreFile(dir, ctx), 'left-as-is'); + assert.equal(ensureLejiIgnoreFile(dir, ctx), 'left-as-is'); + assert.equal(ensureLejiIgnoreFile(dir, ctx), 'left-as-is'); + } finally { + captured.restore(); + text = captured.err(); + } + assert.equal(text, `${LEJI_IGNORE_NOTICE}\n`, 'one notice per invocation context'); + assert.equal(fs.readFileSync(path.join(dir, '.leji', '.gitignore'), 'utf8'), 'mine\n', 'bytes untouched'); +}); + +test('a context is per invocation, never per process: a second one notices for its own repository', () => { + const first = freshCopy(); + const second = freshCopy(); + for (const dir of [first, second]) { + fs.mkdirSync(path.join(dir, '.leji'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji', '.gitignore'), 'mine\n'); + } + const captured = capture(); + let text: string; + try { + ensureLejiIgnoreFile(first, newLejiIgnoreContext()); + ensureLejiIgnoreFile(second, newLejiIgnoreContext()); + } finally { + captured.restore(); + text = captured.err(); + } + assert.equal(text.split(LEJI_IGNORE_NOTICE).length - 1, 2, 'each invocation says it for itself'); +}); + +test('the create is exclusive: an entry that appears between the read and the write is never written through', () => { + // The check/use gap on the WRITE side, at the one file this exception allows. The + // verified read finds nothing standing at the target; a symlink into ordinary + // content is planted immediately after that last look, before the create runs. + // O_EXCL is what closes the window: a create that followed the link would rewrite + // `decoy.txt`. Mutation that reddens: drop `exclusive` from the guarded write. + const dir = fs.realpathSync(freshCopy()); + fs.mkdirSync(path.join(dir, '.leji'), { recursive: true }); + const decoy = path.join(dir, 'decoy.txt'); + fs.writeFileSync(decoy, 'not the ignore file\n'); + const ignoreAbs = path.join(dir, '.leji', '.gitignore'); + + // Builtin ESM bindings are snapshotted at link time, hence the CJS patch plus the + // resync (the idiom the canary suite's interception spies use). The read's own + // failed open arms the swap, so the plant lands after the read has decided and + // before the exclusive create asks. + const require = createRequire(import.meta.url); + const nodeFs = require('node:fs') as Record; + const openSync = nodeFs.openSync as (...args: unknown[]) => unknown; + const lstatSync = nodeFs.lstatSync as (...args: unknown[]) => unknown; + let armed = false; + let planted = false; + nodeFs.openSync = (...args: unknown[]): unknown => { + if (typeof args[0] === 'string' && path.resolve(args[0]) === ignoreAbs) armed = true; + return openSync(...args); + }; + nodeFs.lstatSync = (...args: unknown[]): unknown => { + const result = lstatSync(...args); + if (armed && !planted && typeof args[0] === 'string' && path.resolve(args[0]) === ignoreAbs) { + planted = true; + fs.symlinkSync(decoy, ignoreAbs); + } + return result; // what stood there BEFORE the plant: that is the race + }; + Module.syncBuiltinESMExports(); + let outcome: string; + try { + outcome = ensureLejiIgnoreFile(dir); + } finally { + nodeFs.openSync = openSync; + nodeFs.lstatSync = lstatSync; + Module.syncBuiltinESMExports(); + } + + assert.ok(planted, 'the swap landed between the read and the create'); + assert.notEqual(outcome, 'created', `nothing was created through the planted link (outcome: ${outcome})`); + assert.equal(fs.readFileSync(decoy, 'utf8'), 'not the ignore file\n', 'the link target is untouched'); + assert.ok(fs.lstatSync(ignoreAbs).isSymbolicLink(), 'the planted link is still the planted link'); +}); + +test('generation alone establishes a role: `leji viewer` writes the file without an export', async () => { + // The fixture scenarios drive `viewer build` and `export`, which are one command; + // this is the other role establisher on the viewer side, reached by its own name. + const dir = freshCopy(); + const captured = capture(); + let code: number; + try { + code = await run(['viewer', '--root', dir]); + } finally { + captured.restore(); + } + assert.equal(code, 0, `viewer exited cleanly (stderr: ${captured.err()})`); + assert.equal(fs.readFileSync(path.join(dir, '.leji', '.gitignore'), 'utf8'), LEJI_IGNORE_CONTENT); +}); + +test('the onboarding guard establishes .leji/work/hooks/, so installing it writes the file', () => { + const dir = freshCopy(); + assert.equal(ensureApprovalGuard(dir, 'docs/'), 'installed'); + assert.equal(fs.readFileSync(path.join(dir, '.leji', '.gitignore'), 'utf8'), LEJI_IGNORE_CONTENT); +}); + +test('a directly callable SDK function that is given no context still notices at most once per call', async () => { + const dir = freshCopy(); + fs.mkdirSync(path.join(dir, '.leji'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji', '.gitignore'), 'mine\n'); + const { manifest } = loadManifest(dir); + assert.ok(manifest, 'the fixture manifest loads'); + const { buildViewer } = await import('../dist/index.js'); + const captured = capture(); + let text: string; + try { + buildViewer(dir, manifest!); + } finally { + captured.restore(); + text = captured.err(); + } + // buildViewer nests generateViewer and establishes two roles of its own. + assert.equal(text.split(LEJI_IGNORE_NOTICE).length - 1, 1, 'one notice for the whole call'); +}); + +// --- the mounts commands: one invocation, several mounts operations, one notice --- +// +// Two CLI paths reach a role establisher more than once in a single run: +// `conformance --federation verify` probes reachability PER DECLARED MOUNT, and +// `mounts update-pin --fetch` retains twice (the current pin, then the target). +// Each establishes the managed store, so each would say the frozen line again if the +// invocation's notice state were not threaded all the way down. + +/** Git in one repository, with a fixed identity so nothing depends on the machine. */ +function git(cwd: string, ...args: string[]): string { + return execFileSync( + 'git', + [ + '-C', + cwd, + '-c', + 'user.email=fixtures@leji.org', + '-c', + 'user.name=Leji Fixtures', + '-c', + 'commit.gpgsign=false', + ...args, + ], + { encoding: 'utf8' }, + ).trim(); +} + +/** A sibling layer as a real git repository, with one commit per named file, and the + * commit ids in order. Small on purpose: what these tests need from it is a ref an + * `ls-remote` can advertise and a history an ancestry check can walk. */ +function siblingRepo(parent: string, name: string, files: string[]): { path: string; pins: string[] } { + const repo = path.join(parent, name); + fs.mkdirSync(repo, { recursive: true }); + execFileSync('git', ['-C', repo, 'init', '-q', '-b', 'main'], { stdio: 'ignore' }); + const pins: string[] = []; + for (const file of files) { + fs.writeFileSync(path.join(repo, file), `# ${file}\n`); + git(repo, 'add', '-A'); + git(repo, 'commit', '-q', '-m', file); + pins.push(git(repo, 'rev-parse', 'HEAD')); + } + return { path: repo, pins }; +} + +/** Route declared https sources to local repositories for the length of one call, the + * way the update-pin suite already does it: `insteadOf` is git's own redirection, so + * the SDK spells the source its manifest declares and nothing reaches the network. */ +async function withLocalSources(routes: { from: string; to: string }[], body: () => Promise): Promise { + const saved = { ...process.env }; + process.env.GIT_CONFIG_COUNT = String(routes.length); + routes.forEach((route, i) => { + process.env[`GIT_CONFIG_KEY_${i}`] = `url.${route.to}.insteadOf`; + process.env[`GIT_CONFIG_VALUE_${i}`] = route.from; + }); + try { + return await body(); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +} + +/** Every declared mount names an owner; the schema requires it. */ +const OWNER = { name: 'Fixture Owner' }; + +/** The layer with `federation.mounts` spliced in, plus somebody else's ignore file + * already standing where the tool would write its own: the state the notice is for. */ +function mountedLayer(mounts: Record[]): string { + const dir = fs.realpathSync(freshCopy()); + const manifestPath = path.join(dir, 'leji.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + manifest.federation = { mounts }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + fs.mkdirSync(path.join(dir, '.leji'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji', '.gitignore'), 'mine\n'); + return dir; +} + +/** The managed stores a run established, by directory name. */ +function stores(dir: string): string[] { + const storeDir = path.join(dir, '.leji', 'mounts', 'store'); + return fs.existsSync(storeDir) ? fs.readdirSync(storeDir).sort() : []; +} + +test('conformance --federation probes every declared mount and notices once for the invocation', async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-ignore-conformance-')); + const one = siblingRepo(parent, 'one', ['a.md']); + const two = siblingRepo(parent, 'two', ['b.md']); + const sources = { one: 'https://github.com/acme/one', two: 'https://github.com/acme/two' }; + const dir = mountedLayer([ + { name: 'one', source: sources.one, pin: one.pins[0], trackingRef: 'refs/heads/main', owner: OWNER }, + { name: 'two', source: sources.two, pin: two.pins[0], trackingRef: 'refs/heads/main', owner: OWNER }, + ]); + + const captured = capture(); + let text: string; + try { + await withLocalSources( + [ + { from: sources.one, to: one.path }, + { from: sources.two, to: two.path }, + ], + () => run(['conformance', '--federation', 'verify', '--root', dir]), + ); + } finally { + captured.restore(); + text = captured.err(); + } + + // The guard against a test that passes for the wrong reason: BOTH mounts really + // reached the establisher, so two notices were genuinely available to be said. + assert.equal(stores(dir).length, 2, `both mounts established a store (stderr: ${text})`); + assert.equal(text.split(LEJI_IGNORE_NOTICE).length - 1, 1, `one notice for the invocation (stderr: ${text})`); + assert.equal(fs.readFileSync(path.join(dir, '.leji', '.gitignore'), 'utf8'), 'mine\n', 'bytes untouched'); + fs.rmSync(parent, { recursive: true, force: true }); +}); + +test('mounts update-pin --fetch retains twice and notices once for the invocation', async () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-ignore-updatepin-')); + const source = 'https://github.com/acme/one'; + const sibling = siblingRepo(parent, 'one', ['a.md', 'b.md']); + const dir = mountedLayer([ + { name: 'one', source, pin: sibling.pins[0], trackingRef: 'refs/heads/main', owner: OWNER }, + ]); + + const captured = capture(); + let text: string; + let code: number; + try { + code = await withLocalSources([{ from: source, to: sibling.path }], () => + run(['mounts', 'update-pin', 'one', '--fetch', '--root', dir, '--json']), + ); + } finally { + captured.restore(); + text = captured.err(); + } + + assert.equal(code, 0, `the move succeeded (stdout: ${captured.out()}, stderr: ${text})`); + // The guard: both retentions really ran, so two notices were available to be said. + const store = path.join(dir, '.leji', 'mounts', 'store', stores(dir)[0]); + const pinRefs = git(store, 'for-each-ref', '--format=%(objectname)', 'refs/leji-pin').split('\n').sort(); + assert.deepEqual(pinRefs, [...sibling.pins].sort(), 'the current pin and the target were both retained'); + assert.equal(text.split(LEJI_IGNORE_NOTICE).length - 1, 1, `one notice for the invocation (stderr: ${text})`); + assert.equal(fs.readFileSync(path.join(dir, '.leji', '.gitignore'), 'utf8'), 'mine\n', 'bytes untouched'); + fs.rmSync(parent, { recursive: true, force: true }); +}); diff --git a/packages/sdk/test/mounts.test.ts b/packages/sdk/test/mounts.test.ts index 8f48ca1..ec315b9 100644 --- a/packages/sdk/test/mounts.test.ts +++ b/packages/sdk/test/mounts.test.ts @@ -505,6 +505,44 @@ test('mounts: a requested fetch that fails is visible, and stays availability ra assert.ok(!JSON.stringify(payload).includes('fatal:'), 'git stderr never reaches canonical output'); }); +test('mounts: a hydrate whose source no longer serves the pin names the act, row unchanged', async () => { + const { host, sibling, pin } = mountedPair(); + // The upstream rewrote its history: the branch the pin sat on is gone, its + // objects are pruned, and what the source advertises is unrelated to it. The + // hint is that same checkout, so nothing local holds the pin either. + git(sibling, 'branch', 'old'); + git(sibling, 'checkout', '-q', '--orphan', 'rewritten'); + git(sibling, 'rm', '-q', '-rf', '.'); + commitFile(sibling, 'rewritten.md'); + git(sibling, 'checkout', '-q', '-B', 'main'); + git(sibling, 'branch', '-D', 'old'); + git(sibling, 'branch', '-D', 'rewritten'); + git(sibling, 'reflog', 'expire', '--expire=now', '--all'); + git(sibling, 'gc', '-q', '--prune=now'); + git(sibling, 'config', 'uploadpack.allowAnySHA1InWant', 'true'); + assert.throws(() => git(sibling, 'cat-file', '-e', pin), 'the pin is unavailable in the source'); + const cli = await withSourceRewrite(sibling, () => + runCli(['mounts', 'hydrate', '--fetch', '--json', '--root', host]), + ); + const payload = JSON.parse(cli.stdout); + // Best-effort, as ever: the mount stays unavailable and the run does not fail. + assert.equal(cli.code, 0); + assert.equal(payload.outcomes[0].status, 'unavailable'); + // The outcome row is the row it has always been: the detail it already carried. + assert.equal(payload.outcomes[0].detail, 'the pin could not be fetched from the source'); + assert.deepEqual(Object.keys(payload.findings[0]), ['rule', 'severity', 'path', 'message', 'detail']); + assert.equal(payload.findings[0].rule, 'mount-store-fetch-failed'); + assert.equal(payload.findings[0].detail, 'current pin: the pin could not be fetched from the source'); + // The reasons the findings were built from are a transport, never a member of + // the document: the writer picks its fields, and this is not one of them. + assert.ok(!JSON.stringify(payload).includes('reasons')); + const human = await withSourceRewrite(sibling, () => runCli(['mounts', 'hydrate', '--fetch', '--root', host])); + assert.match( + human.stdout, + /^warning mount-store-fetch-failed acme-product-context: the managed store could not be established by the requested fetch \(detail: current pin: the pin could not be fetched from the source\)$/m, + ); +}); + test('mounts: a witness refresh that fails in this run is visible, and nothing about it is recorded', async () => { const { host, sibling } = mountedPair(); git(sibling, 'config', 'uploadpack.allowAnySHA1InWant', 'true'); @@ -527,6 +565,9 @@ test('mounts: a witness refresh that fails in this run is visible, and nothing a payload.findings.map((f: { rule: string; severity: string; path: string }) => [f.rule, f.severity, f.path]), [['mount-witness-refresh-failed', 'warning', 'acme-product-context']], ); + // Which act, and the resolver's own reason for it: the same encoding update-pin + // freezes, from the best-effort command that only warns. + assert.equal(payload.findings[0].detail, 'witness: the tracking ref could not be fetched from the source'); // Nothing about the failure persists, because nothing persists at all… assert.ok(!fs.existsSync(path.join(host, '.leji', 'mounts', 'state.json')), 'no state file is written'); // …so an offline status, which cannot know a witness is fresh, says nothing of it. @@ -1339,6 +1380,10 @@ test('mounts: a lost compare-and-swap is a confirmed mismatch; an operational fa JSON.parse(cli.stdout).findings.map((f: { rule: string; severity: string }) => [f.rule, f.severity]), [['mount-witness-refresh-failed', 'warning']], ); + // The witness act's second failure class, which is not the first one: a ref that + // arrived and would not publish, never a ref that never arrived. + assert.equal(r.reasons.get('acme-product-context'), 'the witness ref could not be published'); + assert.equal(JSON.parse(cli.stdout).findings[0].detail, 'witness: the witness ref could not be published'); }); // --- The canonical-schema gate, and the portability rules the closure holds ----- diff --git a/packages/sdk/test/preflight.test.ts b/packages/sdk/test/preflight.test.ts index db6c642..a151107 100644 --- a/packages/sdk/test/preflight.test.ts +++ b/packages/sdk/test/preflight.test.ts @@ -786,6 +786,12 @@ const OVERFLOW_TIMEOUT_MS = 30_000; * which mechanism ended the run, not how fast the machine is. */ const PROMPT_MS = 15_000; +/** + * The most a capped run can hold beyond its cap. Node reads a child's stdout in chunks + * of `SyncProcessOutputBuffer::kBufferSize` (65536, `src/spawn_sync.h`) and stops at the + * read that crossed the cap, so one whole chunk is the worst case. + */ +const PIPE_READ_MAX = 64 * 1024; /** * How long a stub holds stdout open after it has said its piece: longer than every * deadline in this file, so a run that ended early ended because leji ended it and @@ -812,29 +818,27 @@ test('capture replaces the environment rather than extending it', () => { } }); -test('capture kills a child that streams past the cap, well inside the timeout', () => { +test('capture kills a child that streams past the cap', () => { const dir = tmpdir('leji-capture-cap-'); + const cap = 4096; // 1 MiB in 1 KiB writes, far past the cap, then a slow tail: a run that did not cut - // the child off at the cap would still be waiting when the deadline arrives. + // the child off at the cap would still be holding the pipe when the deadline arrives, + // and the deadline is the harness safety net rather than the thing under test. const stub = captureStub( dir, 'flood', `i=0\nwhile [ $i -lt 1024 ]; do printf '%1024s' ''; i=$((i+1)); done\n${STUB_HOLD}`, ); - const started = Date.now(); - const res = capture(stub, dir, 4096, { PATH: '/usr/bin:/bin' }, OVERFLOW_TIMEOUT_MS); - const elapsed = Date.now() - started; - assert.ok(res.error, 'passing the cap is an error'); - // The child printed a megabyte; what is held is a bounded fraction of it. The - // runtime stops after the READ that crossed the cap, where the other two SDKs - // refuse the write that would cross it, so the bound here is the cap plus at most - // one pipe read rather than the cap exactly. Either way the overflow bytes are - // never parsed: a capped run is a failed probe. - assert.ok((res.stdout ?? '').length < 64 * 1024, `held ${(res.stdout ?? '').length} bytes`); - assert.ok( - elapsed < PROMPT_MS, - `the cap did not cut the child off: ${elapsed}ms, against a ${OVERFLOW_TIMEOUT_MS}ms deadline`, - ); + const res = capture(stub, dir, cap, { PATH: '/usr/bin:/bin' }, OVERFLOW_TIMEOUT_MS); + // What Node does at the cap: ENOBUFS, the child terminated by signal, and the bytes + // already read kept. The child printed a megabyte; what is held is bounded by the cap + // plus one pipe read, because the runtime stops after the READ that crossed the cap + // where the other two SDKs refuse the write that would cross it. Those overflow bytes + // are never parsed either way: a capped run is a failed probe. + assert.equal((res.error as NodeJS.ErrnoException | undefined)?.code, 'ENOBUFS'); + assert.notEqual(res.signal, null, 'the child is terminated, not left running'); + const held = Buffer.byteLength(res.stdout ?? ''); + assert.ok(held <= cap + PIPE_READ_MAX, `held ${held} bytes against a ${cap}-byte cap`); }); test('capture ends a sparse overflow promptly', () => { diff --git a/packages/sdk/test/site-hero.test.ts b/packages/sdk/test/site-hero.test.ts new file mode 100644 index 0000000..5224377 --- /dev/null +++ b/packages/sdk/test/site-hero.test.ts @@ -0,0 +1,173 @@ +import { strict as assert } from 'node:assert'; +import { execFileSync, spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// The homepage hero shows a real `leji` session: adopt, validate, conformance. It is +// a hand-captured constant (`heroSession` in packages/site/src/pages/index.astro), so +// nothing at build time notices when the CLI's output moves underneath it. The +// control was a comment asking for a manual re-capture, and it missed three drifts. +// This test is the mechanical replacement: scaffold the fixture that comment +// documents, run the same three commands against the built CLI, and compare. +// +// The comparison is a projection, not a byte match, because the hero renders a +// condensed transcript: +// cmd lines `leji ` plus the argv this test passes. +// adopt header the "Wrote N files (context root: X):" line, verbatim. +// adopt files the CLI prints one indented line per written file; the hero shows +// the first three paths in the CLI's order, three-space indented, +// joined by three spaces, then three spaces and a `…`. +// validate the last non-empty stdout line (the command's summary), verbatim. +// conformance the same rule: the last non-empty line, which is the summary the +// hero shows rather than the per-item report above it. +// gaps the blank band the hero puts between stanzas. +// Nothing else about the output is pinned here, and every line the hero does show is. +// +// The fixture's temporary directory is elided from every projected line before the +// comparison. It is machine-specific, so a line carrying it could never match the +// hero; the placeholder makes such a line fail as drift rather than as a stray path. +// +// Known limit of reading the constant with a regex, stated rather than papered over: +// it wants the array literal formatted the way Prettier formats it today, one entry +// per line, each value a single-quoted string with no escape inside it, and `mark` +// the only extra property. Anything else fails the parse loudly, with the offending +// line quoted, rather than silently checking less. Widen the two patterns below when +// the literal legitimately grows a shape they do not cover. + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.resolve(testDir, '..'); +const repoRoot = path.resolve(packageDir, '..', '..'); +const cli = path.join(packageDir, 'dist', 'cli.js'); +const HERO_REL = 'packages/site/src/pages/index.astro'; +const heroSource = path.join(repoRoot, ...HERO_REL.split('/')); + +/** One rendered transcript line: a typed command, an output line, or the blank band + * the hero puts between stanzas. */ +type Line = { kind: 'cmd' | 'out'; text: string } | { kind: 'gap' }; + +/** The array literal, from its declaration to the first line that closes it. */ +const HERO_BLOCK = /const heroSession = \[\n([\s\S]*?)\n\];/; +const HERO_GAP = /^\s*\{ gap: true \},?$/; +const HERO_ENTRY = /^\s*\{ (cmd|out): '([^'\\]*)'(?:, mark: '[a-z]+')? \},?$/; +/** The header `adopt` prints above its per-file lines, and one of those lines. */ +const WROTE = /^Wrote \d+ files \(context root: .*\):$/; +const WRITTEN_FILE = /^ {3}(\S.*)$/; + +function parseHeroSession(source: string): Line[] { + const block = HERO_BLOCK.exec(source); + assert.ok(block, `no heroSession array literal in ${HERO_REL}: this test cannot see the hero at all`); + return block[1].split('\n').map((line) => { + if (HERO_GAP.test(line)) return { kind: 'gap' } as Line; + const entry = HERO_ENTRY.exec(line); + assert.ok(entry, `heroSession entry is not a shape this test can read: ${line.trim()}`); + return { kind: entry[1] as 'cmd' | 'out', text: entry[2] } as Line; + }); +} + +/** + * The fixture the hero's re-capture comment documents: a repository that already has + * a docs/ tree and a non-empty AGENTS.md, committed. Committed for two reasons the + * SDK enforces: `adopt` refuses a dirty working tree, and the `core` conformance item + * asks the layer to live in a git repository. + */ +function fixture(): string { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-site-hero-'))); + fs.mkdirSync(path.join(dir, 'docs')); + fs.writeFileSync(path.join(dir, 'docs', 'overview.md'), '# Overview\n\nWhat this repository is for.\n'); + fs.writeFileSync(path.join(dir, 'docs', 'architecture.md'), '# Architecture\n\nHow the pieces fit together.\n'); + fs.writeFileSync(path.join(dir, 'AGENTS.md'), '# Agents\n\nHow agents work in this repository.\n'); + const git = (...a: string[]): void => { + execFileSync('git', a, { cwd: dir, env: { ...process.env, GIT_DIR: undefined }, stdio: 'ignore' }); + }; + git('init', '-q'); + git('add', '-A'); + git('-c', 'user.name=Hero Test', '-c', 'user.email=hero@example.com', 'commit', '-q', '-m', 'seed'); + return dir; +} + +/** The real bin, from the fixture directory, the way the re-capture comment runs it. */ +function runCli(dir: string, args: string[]): string[] { + const res = spawnSync('node', [cli, ...args], { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, GIT_DIR: undefined }, + }); + assert.equal(res.status, 0, `leji ${args.join(' ')} exited ${res.status}\n${res.stderr}`); + return res.stdout.split('\n').map((line) => line.split(dir).join('')); +} + +/** The one line the hero shows for validate and conformance. */ +function summary(out: string[], command: string): string { + const printed = out.filter((line) => line.trim() !== ''); + assert.ok(printed.length > 0, `leji ${command} printed nothing to summarize`); + return printed[printed.length - 1]; +} + +function projectedSession(dir: string): Line[] { + const adopt = ['adopt', '--yes', '--wire-adapters']; + const out = runCli(dir, adopt); + const header = out.findIndex((line) => WROTE.test(line)); + assert.notEqual(header, -1, `leji ${adopt.join(' ')} printed no "Wrote N files (context root: X):" line`); + const written: string[] = []; + for (const line of out.slice(header + 1)) { + const file = WRITTEN_FILE.exec(line); + if (!file) break; + written.push(file[1]); + } + assert.ok(written.length >= 3, `adopt listed ${written.length} written files; the hero line shows three and a "…"`); + return [ + { kind: 'cmd', text: `leji ${adopt.join(' ')}` }, + { kind: 'out', text: out[header] }, + { kind: 'out', text: ` ${written.slice(0, 3).join(' ')} …` }, + { kind: 'gap' }, + { kind: 'cmd', text: 'leji validate' }, + { kind: 'out', text: summary(runCli(dir, ['validate']), 'validate') }, + { kind: 'gap' }, + { kind: 'cmd', text: 'leji conformance' }, + { kind: 'out', text: summary(runCli(dir, ['conformance']), 'conformance') }, + ]; +} + +function render(session: Line[]): string { + return session.map((line) => (line.kind === 'gap' ? ' (gap)' : ` ${line.kind} | ${line.text}`)).join('\n'); +} + +let fixtureDir = ''; +let hero: Line[] = []; +let projected: Line[] = []; + +before(() => { + fixtureDir = fixture(); + projected = projectedSession(fixtureDir); + hero = parseHeroSession(fs.readFileSync(heroSource, 'utf8')); +}); + +after(() => { + if (fixtureDir) fs.rmSync(fixtureDir, { recursive: true, force: true }); +}); + +test('the homepage hero transcript is what the CLI prints today', () => { + assert.deepEqual( + hero, + projected, + `the hero transcript no longer matches the CLI.\nhero (${HERO_REL}):\n${render(hero)}\n` + + `projected (this run):\n${render(projected)}\n` + + 'Re-capture per the comment above heroSession, or fix what the CLI prints.', + ); +}); + +// The check above only earns its keep if it can fail. A green run over a parse that +// silently matched nothing, or over a comparison that compares nothing, would look +// identical. So: one character of one heroSession line, changed in memory, and the +// same comparison has to reject it. The file on disk is never touched. +test('a one-character edit to a heroSession line fails the comparison', () => { + const source = fs.readFileSync(heroSource, 'utf8'); + const mutated = source.replace(/(\{ out: 'Wrote )(\d)/, (_m, head: string, digit: string) => { + return `${head}${digit === '9' ? '8' : String(Number(digit) + 1)}`; + }); + assert.notEqual(mutated, source, 'the mutation changed nothing, so this negative check proves nothing'); + assert.throws(() => assert.deepEqual(parseHeroSession(mutated), projected), assert.AssertionError); +}); diff --git a/packages/sdk/test/snapshot-audit.test.ts b/packages/sdk/test/snapshot-audit.test.ts new file mode 100644 index 0000000..0479084 --- /dev/null +++ b/packages/sdk/test/snapshot-audit.test.ts @@ -0,0 +1,175 @@ +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +// The structural half of the snapshot contract: the badge and canary suites hold one +// tree-snapshot helper between them, and a private walker must not be able to grow back +// beside it. A walker needs a directory-enumeration primitive, so this audit counts +// every reference to one in those two files and compares the counts against the named +// exceptions below. The claim is bounded and mechanical: it prevents a walker built on +// the four primitives below, whatever it is named and whether it is a function, a method +// or a closure. It claims nothing about a walker built on anything else; that wider +// closure is review of the imports and call sites, not this scan. +// +// The scan is over the parsed source, so a primitive named in a comment or in ordinary +// prose is not a hit, and a computed access (`fs['readdirSync']`) is: the name has to be +// spelled somewhere for the primitive to be reached, as an identifier or as a string. + +const testDir = path.dirname(fileURLToPath(import.meta.url)); + +/** The four names this audit is bounded to: Node's own directory-enumeration calls, + * both spellings of each. A walker built on any of them has to spell one, whatever it + * calls itself. A walker built on something else (`fs.globSync`, a dependency, a + * shelled-out `find`) spells none of them and is outside the mechanical guarantee: that + * one is left to review of the imports and call sites. */ +const PRIMITIVES: ReadonlySet = new Set(['readdir', 'readdirSync', 'opendir', 'opendirSync']); + +/** The shared helper, as the two files must import it. */ +const HELPER_SPECIFIER = './helpers/snapshot.ts'; +const HELPER_EXPORT = 'snapshotTree'; + +/** + * The exceptions, by `file#context` with the number of references each context is + * allowed. A count rather than a bare name, so a new reference fails even inside a + * context that already holds one; a context that no longer matches fails too, because a + * stale exception is an exception nobody is checking. `context` is the nearest named + * function, or the title of the test the reference sits in. + */ +const ALLOWED: Readonly> = { + 'badge.test.ts#test: a --out whose parent resolves outside the repository is refused, and reads nothing': { + count: 1, + reason: 'lists an out-of-repository directory to prove nothing was created there; one level, no walk', + }, + 'badge.test.ts#test: a --out whose parent resolves into .leji/ is refused, at any depth': { + count: 1, + reason: 'asserts the private role is still empty; one level, no walk', + }, + 'badge.test.ts#(top level)': { + count: 1, + reason: 'enumerates the fixture directory to generate one test per fixture carrying a badge block', + }, + 'canary.test.ts#copySeed': { + count: 1, + reason: 'the seed materializer: copies a committed seed into its declared target', + }, + 'canary.test.ts#filesUnder': { + count: 1, + reason: 'the export listing: files only, a different contract from the snapshot', + }, + 'canary.test.ts#walk': { + count: 1, + reason: 'the canary token scan inside countToken: reads bytes, records no tree', + }, + 'canary.test.ts#test: check-before-act: an out-of-repository .leji/viewer or .leji/dist alias is REFUSED, and nothing is written outside': + { + count: 2, + reason: 'asserts two out-of-tree destinations are empty; one level each, no walk', + }, + 'canary.test.ts#test: check-before-act: an ancestor swapped to a symlink AFTER enumeration is never followed at use': + { + count: 6, + reason: 'the interception spy: captures, replaces and restores the builtin to swap a tree mid-walk', + }, +}; + +/** The nearest named function containing `node`, or the title of the test it sits in: + * what a reviewer would cite when arguing the exception. */ +function context(node: ts.Node): string { + for (let n: ts.Node | undefined = node.parent; n !== undefined; n = n.parent) { + if (ts.isFunctionLike(n)) { + if ((ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n)) && n.name && ts.isIdentifier(n.name)) { + return n.name.text; + } + const parent = n.parent; + if (parent !== undefined && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + return parent.name.text; + } + if (parent !== undefined && ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) { + return parent.name.text; + } + } + if (ts.isCallExpression(n) && n.arguments.length > 0) { + const callee = n.expression.getText(); + if ( + (callee === 'test' || callee === 'it' || callee === 'describe') && + ts.isStringLiteralLike(n.arguments[0]) + ) { + return `test: ${n.arguments[0].text}`; + } + } + } + return '(top level)'; +} + +function parse(name: string): ts.SourceFile { + const file = path.join(testDir, name); + return ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); +} + +/** Every reference to an enumeration primitive in one file, as `file#context` keys with + * their counts, plus the line of each for the failure message. */ +function references(name: string): { counts: Map; where: string[] } { + const source = parse(name); + const counts = new Map(); + const where: string[] = []; + const visit = (node: ts.Node): void => { + const spelled = ts.isIdentifier(node) ? node.text : ts.isStringLiteralLike(node) ? node.text : undefined; + if (spelled !== undefined && PRIMITIVES.has(spelled)) { + const key = `${name}#${context(node)}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + where.push(`${name}:${source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1} ${key}`); + } + ts.forEachChild(node, visit); + }; + visit(source); + return { counts, where }; +} + +test('no private tree walker in the badge and canary suites: every enumeration primitive is a named exception', () => { + const counts = new Map(); + const where: string[] = []; + for (const name of ['badge.test.ts', 'canary.test.ts']) { + const found = references(name); + for (const [key, count] of found.counts) counts.set(key, count); + where.push(...found.where); + } + + const actual = Object.fromEntries([...counts].sort(([a], [b]) => (a < b ? -1 : 1))); + const expected = Object.fromEntries( + Object.entries(ALLOWED) + .map(([key, entry]) => [key, entry.count] as const) + .sort(([a], [b]) => (a < b ? -1 : 1)), + ); + assert.deepEqual( + actual, + expected, + `a directory-enumeration primitive appeared where no exception allows it, or an exception no longer matches. Every reference found:\n${where.join('\n')}`, + ); +}); + +test('the badge and canary suites take their tree snapshots from the shared helper', () => { + for (const name of ['badge.test.ts', 'canary.test.ts']) { + const source = parse(name); + const imported = source.statements.some( + (statement) => + ts.isImportDeclaration(statement) && + ts.isStringLiteralLike(statement.moduleSpecifier) && + statement.moduleSpecifier.text === HELPER_SPECIFIER && + statement.importClause?.namedBindings !== undefined && + ts.isNamedImports(statement.importClause.namedBindings) && + statement.importClause.namedBindings.elements.some((element) => element.name.text === HELPER_EXPORT), + ); + assert.ok(imported, `${name} imports ${HELPER_EXPORT} from ${HELPER_SPECIFIER}`); + + let calls = 0; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && node.expression.getText() === HELPER_EXPORT) calls += 1; + ts.forEachChild(node, visit); + }; + visit(source); + assert.ok(calls > 0, `${name} calls ${HELPER_EXPORT}`); + } +}); diff --git a/packages/sdk/test/snapshot-contract.test.ts b/packages/sdk/test/snapshot-contract.test.ts new file mode 100644 index 0000000..ac18033 --- /dev/null +++ b/packages/sdk/test/snapshot-contract.test.ts @@ -0,0 +1,137 @@ +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { snapshotTree } from './helpers/snapshot.ts'; + +// The shared fixture is the byte contract for the snapshot helper, and these goldens +// are the frozen bytes the Go and Python ports assert against too. The walked payload +// is `payload/`; the seeds and the goldens live beside it, outside the walk, so a +// golden never has to contain its own digest. + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const fixtureDir = path.join(repoRoot, 'fixtures', 'snapshot-contract'); + +interface Seed { + from: string; + to: string; +} + +interface Declaration { + seeds: Seed[]; + runtime: { directories: string[]; symlinks: { at: string; to: string }[] }; + goldens: Record; +} + +const declaration = JSON.parse(fs.readFileSync(path.join(fixtureDir, 'leji-test.json'), 'utf8')) as Declaration; + +/** Copy a committed seed's CONTENTS into `to`, which this harness creates. Regular + * files and directories only, exactly as the seed convention fixes it + * (`fixtures/README.md`): a symlink anywhere inside a seed is a harness error. */ +function copySeed(from: string, to: string): void { + fs.mkdirSync(to, { recursive: true }); + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const src = path.join(from, entry.name); + const dest = path.join(to, entry.name); + assert.ok(!entry.isSymbolicLink(), `seed carries a symlink: ${src}`); + if (entry.isDirectory()) copySeed(src, dest); + else { + assert.ok(entry.isFile(), `seed carries a non-regular file: ${src}`); + fs.copyFileSync(src, dest); + } + } +} + +/** A working copy of the fixture with everything the declaration says a walk must + * find: the declared seeds materialized as real `.git` directories, then the entries + * git cannot track (an empty directory, a symlink) created here. */ +function materialize(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-snapshot-')); + fs.cpSync(fixtureDir, dir, { recursive: true }); + for (const seed of declaration.seeds) { + const toAbs = path.join(dir, ...seed.to.split('/')); + assert.ok(!fs.existsSync(toAbs), `seed target already exists: ${seed.to}`); + copySeed(path.join(dir, ...seed.from.split('/')), toAbs); + } + for (const rel of declaration.runtime.directories) { + fs.mkdirSync(path.join(dir, ...rel.split('/')), { recursive: true }); + } + for (const link of declaration.runtime.symlinks) { + fs.symlinkSync(link.to, path.join(dir, ...link.at.split('/'))); + } + return dir; +} + +/** The frozen bytes of one golden, as the lines a walk must produce. */ +function golden(name: string): string[] { + const text = fs.readFileSync(path.join(fixtureDir, declaration.goldens[name].file), 'utf8'); + assert.ok(text.endsWith('\n'), `${name}: a golden ends with a newline`); + return text.slice(0, -1).split('\n'); +} + +test('the whole-repository walk: the repository .git is excluded and every nested one is content', () => { + const dir = materialize(); + try { + const payload = path.join(dir, 'payload'); + const lines = snapshotTree(payload, { repoRoot: payload }); + assert.deepEqual(lines, golden('repo'), 'the walk is the frozen golden, line for line'); + + // What the golden says, said again as claims, so a re-baked golden that lost one + // of them fails here rather than passing quietly. + assert.ok( + !lines.some((line) => line === '.git/\tdir' || line.startsWith('.git/')), + 'the repository .git is absent from the snapshot', + ); + assert.ok(lines.includes('pkg/.git/\tdir'), 'the nested .git is an entry of its own'); + assert.ok( + lines.some((line) => line.startsWith('pkg/.git/HEAD\tsha256:')), + 'and its contents are digested like any other file', + ); + assert.ok(lines.includes('empty/\tdir'), 'an empty directory is recorded, so its creation is detectable'); + assert.ok(lines.includes('link\tnon-regular'), 'a symlink is marked, never followed'); + + // The ordering is bytewise over UTF-8, not over UTF-16 code units. The two + // non-ASCII payload entries are the vector that separates them: `z` (EF BD 9A) + // sorts before `😀` (F0 9F 98 80) by bytes and after it by code units, so this + // golden is only reachable one way. + const wide = lines.findIndex((line) => line.startsWith('z.txt\t')); + const grin = lines.findIndex((line) => line.startsWith('😀.txt\t')); + assert.ok(wide >= 0 && grin >= 0, 'both non-ASCII entries are recorded'); + assert.ok(wide < grin, 'the wide latin z precedes the emoji, which is UTF-8 byte order'); + assert.notDeepEqual([...lines].sort(), lines, 'and a UTF-16 code-unit sort would order them the other way'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('repoRoot defaults to the walked directory, which is the whole-repository call', () => { + const dir = materialize(); + try { + const payload = path.join(dir, 'payload'); + assert.deepEqual(snapshotTree(payload), golden('repo'), 'the default is the same walk'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a subtree walk keeps the subtree .git, and drops it only when the subtree IS the repository', () => { + const dir = materialize(); + try { + const payload = path.join(dir, 'payload'); + const pkg = path.join(payload, 'pkg'); + + // Root means the repository, not the call: `pkg/.git` is content, and the paths + // are relative to the walked directory. + assert.deepEqual(snapshotTree(pkg, { repoRoot: payload }), golden('subtree'), 'the frozen subtree golden'); + + // The same walk claiming the subtree as the repository excludes exactly the .git + // lines, and nothing else moves. + const own = golden('subtree').filter((line) => line !== '.git/\tdir' && !line.startsWith('.git/')); + assert.deepEqual(snapshotTree(pkg, { repoRoot: pkg }), own, 'its own .git is the one entry excluded'); + assert.ok(own.length > 0, 'and the walk still records the rest of the subtree'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/sdk/test/source-audit.test.ts b/packages/sdk/test/source-audit.test.ts index 167bc17..155cb03 100644 --- a/packages/sdk/test/source-audit.test.ts +++ b/packages/sdk/test/source-audit.test.ts @@ -348,13 +348,22 @@ function auditProgram(program: ts.Program, root: string): { writes: Hit[]; subpr return { writes, subprocesses }; } +let cachedProgram: ts.Program | null = null; + +/** The whole package as one type-checked program, built once and shared by every + * audit here: the checker is what makes a hit about the declaration a call or a + * property lands on rather than about how it was spelled. */ +function program(): ts.Program { + if (cachedProgram === null) { + cachedProgram = ts.createProgram({ rootNames: sourceFiles(srcDir), options: OPTIONS }); + } + return cachedProgram; +} + let cached: { writes: Hit[]; subprocesses: Hit[] } | null = null; function audit(): { writes: Hit[]; subprocesses: Hit[] } { - if (cached === null) { - const program = ts.createProgram({ rootNames: sourceFiles(srcDir), options: OPTIONS }); - cached = auditProgram(program, srcDir); - } + if (cached === null) cached = auditProgram(program(), srcDir); return cached; } @@ -404,6 +413,277 @@ test('every subprocess call is a named, reasoned exception', () => { assert.deepEqual(dead, [], `subprocess allow-list entries matching no symbol (delete them): ${dead.join(', ')}`); }); +/** + * The declared exceptions to the ROLE rule, by `file#symbol`: the one place a + * `metadataFile` verdict may be constructed. `docs/practice/trust-boundary.md` + * mirrors this list. The write allow-list above says which symbols may touch the + * filesystem raw; this one says which may declare a target writable that the role + * rule refuses, and it exists for the same reason: an exception nobody can find is + * an exception nobody is checking. + */ +const ALLOWED_ROLE_EXCEPTIONS: Readonly> = { + 'lib/fsx.ts#metadataFileVerdict': + 'the self-managed .leji/.gitignore: judged on the requested entry, with a real .leji directory and a non-symlink entry, refused otherwise', +}; + +/** The property that carries the exception. */ +const ROLE_EXCEPTION_PROPERTY = 'metadataFile'; + +/** + * The literal text of a property NAME, in whichever of its spellings it was + * written: `x:`, `'x':`, `"x":`, `` [`x`]: `` and `['x']:` are one name. Null when + * the name is computed from something that is not a literal: a case no purely + * syntactic audit can resolve, which the checker layer below is what covers. + */ +function propertyNameText(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text; + if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) return name.expression.text; + return null; +} + +/** + * The constant string an expression IS, when the checker can prove one: a literal + * written in place, or anything whose TYPE is a single string literal: a + * `const key = 'metadataFile'` used as a subscript, an imported constant, a value + * narrowed to one literal. That is what a name match alone cannot see, and it is + * why this audit resolves types instead of only reading spellings. + */ +function constantStringOf(node: ts.Expression, checker: ts.TypeChecker): string | null { + if (ts.isStringLiteralLike(node)) return node.text; + const type = checker.getTypeAtLocation(node); + return type.isStringLiteral() ? type.value : null; +} + +/** + * The member a call is spelled or RESOLVED as, with the holder it was reached + * through when one was written. Three spellings plus the checker's answer, so an + * `Object['defineProperty'](…)`, a destructured `const { defineProperty } = Object` + * and an `import { set } from` alias all report the same member as the plain call. + */ +function calleeMembers(node: ts.CallExpression, checker: ts.TypeChecker): { member: string; holder: string | null }[] { + const out: { member: string; holder: string | null }[] = []; + const callee = node.expression; + const holderOf = (expression: ts.Expression): string | null => + ts.isIdentifier(expression) ? expression.text : null; + if (ts.isPropertyAccessExpression(callee)) { + out.push({ member: callee.name.text, holder: holderOf(callee.expression) }); + } else if (ts.isElementAccessExpression(callee)) { + const member = constantStringOf(callee.argumentExpression, checker); + if (member !== null) out.push({ member, holder: holderOf(callee.expression) }); + } else if (ts.isIdentifier(callee)) { + out.push({ member: callee.text, holder: null }); + } + // Whatever the binding was (a namespace access, a destructured constant, an + // aliased import), the SIGNATURE the call resolves to is still the library's own + // declaration, and that declaration still sits inside the interface or namespace + // that names the holder. That is what recognizes `const { set } = Reflect` as + // `Reflect.set` while a map's `set` resolves to `Map` and is left alone. + const declaration = checker.getResolvedSignature(node)?.declaration; + const named = declaration === undefined ? undefined : (declaration as { name?: ts.Node }).name; + if (named !== undefined && ts.isIdentifier(named as ts.Node)) { + out.push({ member: (named as ts.Identifier).text, holder: declaredHolder(declaration!) }); + } + let symbol = checker.getSymbolAtLocation(callee); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol); + for (const declared of symbol?.declarations ?? []) { + out.push({ member: symbol!.getName(), holder: declaredHolder(declared) }); + } + return out; +} + +/** The interface or namespace a DECLARATION sits in (`ObjectConstructor`, + * `Reflect`, `Map`), which is the holder however the call reached it. */ +function declaredHolder(declaration: ts.Node): string | null { + for (let n: ts.Node | undefined = declaration.parent; n !== undefined; n = n.parent) { + if (ts.isInterfaceDeclaration(n)) return n.name.text; + if (ts.isModuleDeclaration(n) && ts.isIdentifier(n.name)) return n.name.text; + } + return null; +} + +/** True when an `Object.fromEntries` argument is an array literal carrying an entry + * whose first element statically spells the property. */ +function entriesSpellProperty(argument: ts.Expression | undefined, checker: ts.TypeChecker): boolean { + if (argument === undefined || !ts.isArrayLiteralExpression(argument)) return false; + return argument.elements.some( + (entry) => + ts.isArrayLiteralExpression(entry) && + entry.elements.length > 0 && + constantStringOf(entry.elements[0], checker) === ROLE_EXCEPTION_PROPERTY, + ); +} + +/** + * True when a source string literal is a JSON DOCUMENT carrying the property as a + * key, at any depth. The text is parsed rather than substring-matched, deliberately: + * a literal that merely NAMES the property (an error message, a comment, this + * audit's own constant) creates nothing and must not be flagged, while + * `'{"metadataFile":true}'` handed to a parser creates exactly the thing this pin is + * about. + */ +function jsonSpellsProperty(text: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return false; + } + const walk = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(walk); + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + return Object.keys(record).includes(ROLE_EXCEPTION_PROPERTY) || Object.values(record).some(walk); + }; + return walk(parsed); +} + +/** + * REFLECTIVE construction: the calls that create a property from a KEY ARGUMENT, or + * from a key spelled inside a document, rather than from a member written into an + * object literal: the shapes nothing else in this scan would see. Returns the + * spelling to record, or null. + * + * `Object.assign`, `Object.defineProperties`, `Object.create` and a spread are + * deliberately NOT here: each takes an object literal, so the literal rule already + * records the member at the literal itself, wherever that literal is written. The + * role-laundering corpus carries one probe per form so that coverage is asserted + * rather than assumed. + * + * `defineProperty` and `fromEntries` belong to no other API this repository uses, so + * the member name alone counts and an aliased binding is caught with it. `set` and + * `parse` are ordinary English words that a map, a cache, a schema and a date + * library all carry, so they count only when the holder says `Reflect` or `JSON`, + * spelled at the call, or resolved to the interface or namespace the declaration + * sits in. + */ +function reflectiveConstruction(node: ts.CallExpression, checker: ts.TypeChecker): string | null { + const keyAt = (index: number): boolean => + node.arguments.length > index && constantStringOf(node.arguments[index], checker) === ROLE_EXCEPTION_PROPERTY; + const first = node.arguments[0]; + for (const { member, holder } of calleeMembers(node, checker)) { + if (member === 'defineProperty' && keyAt(1)) return `${holder ?? 'aliased'}.defineProperty`; + if (member === 'set' && holder === 'Reflect' && keyAt(1)) return 'Reflect.set'; + if (member === 'fromEntries' && entriesSpellProperty(first, checker)) { + return `${holder ?? 'aliased'}.fromEntries`; + } + if (member === 'parse' && holder === 'JSON' && first !== undefined) { + const text = constantStringOf(first, checker); + if (text !== null && jsonSpellsProperty(text)) return 'JSON.parse'; + } + } + return null; +} + +/** + * Every CONSTRUCTION of the metadata-file verdict in one file, keyed `file#symbol`. + * + * A property can be put on an object in a bounded number of statically named ways, + * and all of them count: written into a literal as an identifier, a quoted key or a + * computed literal key; contributed by a shorthand; assigned onto a value afterwards + * by dot or by subscript; or created reflectively, where the key is an argument + * (`Object.defineProperty`, `Reflect.defineProperty`, `Reflect.set`, + * `Object.fromEntries`) or a member of a literal the call is handed + * (`Object.assign`, `Object.defineProperties`, `Object.create`, a spread). On top of + * the spellings, every key position is asked for the constant string it RESOLVES to, + * so a key held in a literal-typed constant is caught even though the property is + * spelled nowhere at the site. + * + * The NAME is what decides, never the object's type: a verdict built on an + * `any`-typed value, or on a shape the checker cannot relate to `TargetVerdict`, + * must fail this audit rather than slip through it. Reflective forms compile even + * where a direct assignment would not (a readonly or narrowed type), which is + * exactly why they are audited here rather than left to the compiler. + * + * READING the property is not constructing it, so a plain `verdict.metadataFile` + * test is deliberately not a hit; only key positions that create it are. + * + * THE RESIDUAL, stated exactly. One class remains outside, and only one: a key + * ASSEMBLED AT RUNTIME, so that no single string can be resolved for it statically: + * a concatenation (`'metadata' + 'File'`), a template with substitutions, a variable + * the checker cannot narrow to one literal, a value read from data. Every such site + * is invisible to any static audit, this one included; its closure is human, through + * `docs/practice/trust-boundary.md` and the diff review. What is NOT residual, and + * was once wrongly claimed to be: reflective construction with a statically spelled + * key, which the corpus below now flags in every form. + */ +function roleExceptions(rel: string, source: ts.SourceFile, checker: ts.TypeChecker): Hit[] { + const hits: Hit[] = []; + const seen = new Set(); + const record = (node: ts.Node, spelling: string): void => { + const start = node.getStart(source); + if (seen.has(start)) return; + seen.add(start); + hits.push({ + key: `${rel}#${enclosingSymbol(node)}`, + line: source.getLineAndCharacterOfPosition(start).line + 1, + name: spelling, + }); + }; + const visit = (node: ts.Node): void => { + // Written into an object literal: `x: v`, `'x': v`, `['x']: v`, `[key]: v`. + if (ts.isPropertyAssignment(node)) { + const spelled = propertyNameText(node.name); + const resolved = ts.isComputedPropertyName(node.name) ? constantStringOf(node.name.expression, checker) : null; + if (spelled === ROLE_EXCEPTION_PROPERTY || resolved === ROLE_EXCEPTION_PROPERTY) record(node, 'property'); + } + // Contributed by a shorthand: `{ metadataFile }`. + if (ts.isShorthandPropertyAssignment(node) && node.name.text === ROLE_EXCEPTION_PROPERTY) { + record(node, 'shorthand'); + } + // Assigned onto a value afterwards, in every form of assignment operator. + if (ts.isBinaryExpression(node) && ts.isAssignmentExpression(node, /*excludeCompoundAssignment*/ false)) { + const target = node.left; + if (ts.isPropertyAccessExpression(target) && target.name.text === ROLE_EXCEPTION_PROPERTY) { + record(node, 'assignment'); + } + if ( + ts.isElementAccessExpression(target) && + constantStringOf(target.argumentExpression, checker) === ROLE_EXCEPTION_PROPERTY + ) { + record(node, 'subscript'); + } + } + // Created reflectively, from a key argument no other rule here would see. + if (ts.isCallExpression(node)) { + const reflective = reflectiveConstruction(node, checker); + if (reflective !== null) record(node, reflective); + } + ts.forEachChild(node, visit); + }; + visit(source); + return hits; +} + +/** Every metadata-file construction in the production sources of one program. */ +function roleAudit(scanned: ts.Program, root: string): Hit[] { + const checker = scanned.getTypeChecker(); + const hits: Hit[] = []; + const files = scanned + .getSourceFiles() + .filter((f) => !f.isDeclarationFile && path.resolve(f.fileName).startsWith(root + path.sep)) + .sort((a, b) => a.fileName.localeCompare(b.fileName)); + assert.ok(files.length > 0, 'the role audit loaded no source files'); + for (const source of files) { + const rel = path.relative(root, path.resolve(source.fileName)).split(path.sep).join('/'); + hits.push(...roleExceptions(rel, source, checker)); + } + return hits; +} + +test('the role rule has exactly one declared exception, constructed at one named site', () => { + const hits = roleAudit(program(), srcDir); + const outside = unexpected(hits, ALLOWED_ROLE_EXCEPTIONS); + assert.deepEqual( + outside, + [], + `a metadata-file verdict is constructed outside the declared exception: ${outside.join(', ')}\n` + + 'The role rule allows one target that belongs to no role; argue any other into the list, or route the write through its own role.', + ); + const dead = stale(hits, ALLOWED_ROLE_EXCEPTIONS); + assert.deepEqual(dead, [], `role-exception entries matching no symbol (delete them): ${dead.join(', ')}`); + assert.equal(hits.length, Object.keys(ALLOWED_ROLE_EXCEPTIONS).length, 'one construction, not several at one site'); +}); + /** * The laundering corpus, permanent. Each file below is a way of reaching an fs mutator * that a binding-following scanner misses; they are compiled as production source @@ -471,11 +751,12 @@ const PROBES: ReadonlyArray<{ rel: string; source: string; flagged: boolean }> = }, ]; -/** The probe corpus as a program: the real compiler host with the synthetic files - * overlaid, so `node:fs` and a local re-export resolve exactly as they do in `src/`. */ -function probeProgram(): { program: ts.Program; root: string } { +/** A probe corpus as a program: the real compiler host with the synthetic files + * overlaid, so `node:fs`, a local re-export and `../lib/layout.js` all resolve + * exactly as they do in `src/`. */ +function probeProgram(probes: ReadonlyArray<{ rel: string; source: string }>): { program: ts.Program; root: string } { const root = path.join(srcDir, '__audit_probes'); - const files = new Map(PROBES.map((p) => [path.join(root, p.rel), p.source])); + const files = new Map(probes.map((p) => [path.join(root, p.rel), p.source])); const host = ts.createCompilerHost(OPTIONS, true); const readFile = host.readFile.bind(host); const getSourceFile = host.getSourceFile.bind(host); @@ -495,8 +776,8 @@ function probeProgram(): { program: ts.Program; root: string } { } test('the analyzer sees through every known laundering of an fs binding', () => { - const { program, root } = probeProgram(); - const { writes, subprocesses } = auditProgram(program, root); + const { program: probes, root } = probeProgram(PROBES); + const { writes, subprocesses } = auditProgram(probes, root); const flagged = new Set(writes.map((h) => h.key.split('#')[0])); for (const probe of PROBES.filter((p) => p.flagged)) { assert.ok( @@ -511,3 +792,291 @@ test('the analyzer sees through every known laundering of an fs binding', () => 'the negative control must raise no subprocess hit either', ); }); + +/** + * The role-exception laundering corpus, permanent. Each file is another way of + * putting `metadataFile` on a verdict, and each must be flagged: the promise the + * single-constructor pin makes is that a SECOND exception cannot be added quietly, + * so every spelling a second one could take is asserted here rather than assumed. + * The last file is the control: a longer, unrelated property that merely starts + * with the same letters, which must never be flagged. + */ +const ROLE_PROBES: ReadonlyArray<{ rel: string; source: string; flagged: boolean }> = [ + { + rel: '__probe_role_identifier.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderIdentifierKey(): TargetVerdict {', + ' return { ok: true, metadataFile: true };', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_string_key.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderStringKey(): TargetVerdict {', + " return { ok: true, 'metadataFile': true };", + '}', + ].join('\n'), + }, + { + rel: '__probe_role_computed_key.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderComputedKey(): TargetVerdict {', + " return { ok: true, ['metadataFile']: true };", + '}', + ].join('\n'), + }, + { + rel: '__probe_role_shorthand.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'const metadataFile = true as const;', + 'export function launderShorthand(): TargetVerdict {', + ' return { ok: true, metadataFile };', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_assignment.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderAssignment(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + ' verdict.metadataFile = true;', + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_subscript.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderSubscript(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + " verdict['metadataFile'] = true;", + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_aliased_key.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + "const key = 'metadataFile' as const;", + 'export function launderAliasedKey(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + ' verdict[key] = true;', + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_aliased_literal_key.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + "const literalKey = 'metadataFile' as const;", + 'export function launderAliasedLiteralKey(): TargetVerdict {', + ' return { ok: true, [literalKey]: true };', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_define_property.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderDefineProperty(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + " Object.defineProperty(verdict, 'metadataFile', { value: true });", + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_define_property_resolved_key.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + "const reflectiveKey = 'metadataFile' as const;", + 'export function launderDefinePropertyResolvedKey(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + ' Object.defineProperty(verdict, reflectiveKey, { value: true });', + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_define_property_aliased.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'const { defineProperty } = Object;', + 'export function launderAliasedDefineProperty(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + " defineProperty(verdict, 'metadataFile', { value: true });", + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_reflect_define_property.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderReflectDefineProperty(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + " Reflect.defineProperty(verdict, 'metadataFile', { value: true });", + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_reflect_set.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderReflectSet(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + " Reflect.set(verdict, 'metadataFile', true);", + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_reflect_set_aliased.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'const { set } = Reflect;', + 'export function launderAliasedReflectSet(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + " set(verdict, 'metadataFile', true);", + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_define_properties.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderDefineProperties(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + ' Object.defineProperties(verdict, { metadataFile: { value: true } });', + ' return verdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_object_create.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderObjectCreate(): TargetVerdict {', + ' return Object.create({ ok: true }, { metadataFile: { value: true } }) as TargetVerdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_object_assign.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderObjectAssign(): TargetVerdict {', + ' const verdict: TargetVerdict = { ok: true };', + ' return Object.assign(verdict, { metadataFile: true });', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_spread.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderSpread(): TargetVerdict {', + ' return { ok: true, ...{ metadataFile: true } };', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_from_entries.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderFromEntries(): TargetVerdict {', + " return Object.fromEntries([['ok', true], ['metadataFile', true]]) as unknown as TargetVerdict;", + '}', + ].join('\n'), + }, + { + rel: '__probe_role_json_parse.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'export function launderJsonParse(): TargetVerdict {', + ' return JSON.parse(\'{"ok":true,"metadataFile":true}\') as TargetVerdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_json_parse_const.ts', + flagged: true, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'const document = \'{"ok":true,"metadataFile":true}\' as const;', + 'export function launderJsonParseConst(): TargetVerdict {', + ' return JSON.parse(document) as TargetVerdict;', + '}', + ].join('\n'), + }, + { + rel: '__probe_role_negative.ts', + flagged: false, + source: [ + "import { type TargetVerdict } from '../lib/layout.js';", + 'interface Unrelated {', + ' metadataFileName: string;', + '}', + 'export function notTheException(verdict: TargetVerdict): Unrelated {', + " const other: Unrelated = { metadataFileName: 'x' };", + " other.metadataFileName = 'y';", + " Object.defineProperty(other, 'metadataFileName', { value: 'z' });", + " Reflect.set(other, 'metadataFileName', 'w');", + ' const cache = new Map();', + " cache.set('metadataFile', verdict.ok);", + ' JSON.parse(\'{"metadataFileName":"z"}\') as Unrelated;', + " const message = 'metadataFile is the one exception the role rule allows';", + ' if (message.length === 0) other.metadataFileName = message;', + " if (verdict.metadataFile === true) other.metadataFileName = 'read';", + ' return other;', + '}', + ].join('\n'), + }, +]; + +test('the role-exception pin sees through every spelling a second exception could take', () => { + const { program: probes, root } = probeProgram(ROLE_PROBES); + const flagged = new Set(roleAudit(probes, root).map((h) => h.key.split('#')[0])); + for (const probe of ROLE_PROBES.filter((p) => p.flagged)) { + assert.ok( + flagged.has(probe.rel), + `${probe.rel} laundered a metadata-file verdict past the pin (flagged: ${[...flagged].join(', ') || 'nothing'})`, + ); + } + assert.equal( + flagged.has('__probe_role_negative.ts'), + false, + 'a longer, unrelated property name must not be flagged', + ); +}); diff --git a/packages/sdk/test/units.test.ts b/packages/sdk/test/units.test.ts index a9e1b5a..580bbd3 100644 --- a/packages/sdk/test/units.test.ts +++ b/packages/sdk/test/units.test.ts @@ -1,7 +1,10 @@ import { strict as assert } from 'node:assert'; import { execFileSync } from 'node:child_process'; +import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as http from 'node:http'; +import * as Module from 'node:module'; +import { createRequire } from 'node:module'; import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -9,6 +12,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import * as vm from 'node:vm'; import { + buildLayerMap, buildSidebar, buildManifestPage, ciProviderFromRemote, @@ -22,6 +26,7 @@ import { generateViewer, loadManifest, pickDocsRoot, + renderOverview, seedChangelogIfMissing, serializeChangelog, statusReport, @@ -1213,7 +1218,7 @@ test('viewer: brand config (logo, primary color, title, favicon, pins) flows int ); }); -test('viewer: seeds an editable overview homepage with a generated layer map', () => { +test('viewer: seeds an editable overview homepage whose markers stand empty', () => { const dir = copyExample(); const { manifest } = loadManifest(dir); generateViewer(dir, manifest!); @@ -1221,31 +1226,56 @@ test('viewer: seeds an editable overview homepage with a generated layer map', ( assert.ok(fs.existsSync(overview), 'overview.md seeded at the content root'); const text = fs.readFileSync(overview, 'utf8'); assert.match(text, /^# acme-billing-context$/m, 'titled with the layer name'); - assert.match(text, //, 'carries the regen markers'); - assert.match(text, /```mermaid\nflowchart LR/, 'the map is a mermaid flowchart'); - assert.match(text, /boot --> cat_domain/, 'boot links to the domain category'); - assert.match(text, /cat_domain\["📖 Domain · 1 doc"\]/, 'categories carry counts, never per-doc nodes'); - assert.ok(!text.includes('n_glossary'), 'no per-document nodes (unreadable at scale)'); + assert.match(text, //, 'carries the map markers'); + // The map is derived from the index, so the seed carries the placement mark and + // one line saying where the map comes from, never a copy of the map itself. + assert.equal( + text.split('\n')[1].split('\n')[0], + '', + 'the markers wrap exactly the rendering note', + ); + assert.ok(!text.includes('```mermaid\nflowchart LR'), 'no map is written into the source file'); + assert.match(text, /this file is never rewritten/, 'the seed says so in its own prose'); }); -test('viewer: overview is seeded once; only the marked map block is regenerated', () => { +test('viewer: the layer map is rendered from the index, never written into the source', () => { const dir = copyExample(); const { manifest } = loadManifest(dir); generateViewer(dir, manifest!); const overview = path.join(dir, 'docs', 'overview.md'); - // The owner rewrites the prose but keeps the markers. + const seeded = fs.readFileSync(overview); + // A second run over the same tree writes nothing: the seed happens once. + generateViewer(dir, manifest!); + assert.deepEqual(fs.readFileSync(overview), seeded, 'a second run leaves the seeded page byte-identical'); + // The owner rewrites the prose, keeps the markers, and leaves a stale map inside + // them. Adding a document changes the counts the map would show. const edited = `# My own title\n\nHand-written intro.\n\n\nstale\n\n\nMore prose.\n`; fs.writeFileSync(overview, edited); + fs.writeFileSync(path.join(dir, 'docs', 'domain', 'pricing.md'), '# Pricing\n\nHow we price.\n'); + const before = crypto.createHash('sha256').update(fs.readFileSync(overview)).digest('hex'); const result = generateViewer(dir, manifest!); - const after = fs.readFileSync(overview, 'utf8'); - assert.match(after, /^# My own title$/m, 'owner prose preserved'); - assert.match(after, /More prose\./, 'trailing prose preserved'); - assert.match(after, /```mermaid\nflowchart LR/, 'the stale map block was refreshed'); - assert.ok(!after.includes('\nstale\n'), 'old map content replaced'); + assert.equal( + crypto.createHash('sha256').update(fs.readFileSync(overview)).digest('hex'), + before, + 'a reindex that changes the map leaves overview.md byte-identical', + ); assert.ok( !result.findings.some((f) => f.rule === 'overview-markers-missing'), 'no warning when the markers are intact', ); + // The map exists at render time, from the same entries the run projected. + const rendered = renderOverview(edited, manifest!, result.indexEntries); + assert.equal(rendered.markersFound, true, 'the markers are the placement mark'); + assert.match(rendered.text, /^# My own title$/m, 'owner prose preserved around the map'); + assert.match(rendered.text, /More prose\./, 'trailing prose preserved'); + assert.match(rendered.text, /```mermaid\nflowchart LR/, 'the rendered page carries the map'); + assert.ok(!rendered.text.includes('\nstale\n'), 'the stale block is ignored, not merged'); + assert.match(rendered.text, /cat_domain\["📖 Domain · 2 docs"\]/, 'the rendered counts are the tree of today'); + // The rendered map is exactly what buildLayerMap produces for those entries. + assert.ok( + rendered.text.includes('```mermaid\n' + buildLayerMap(manifest!, result.indexEntries) + '\n```'), + 'the map block is buildLayerMap between fences', + ); }); test('viewer: an overview without markers is left untouched and warns', () => { @@ -1258,9 +1288,16 @@ test('viewer: an overview without markers is left untouched and warns', () => { const result = generateViewer(dir, manifest!); assert.equal(fs.readFileSync(overview, 'utf8'), custom, 'a marker-less overview is never modified'); assert.ok( - result.findings.some((f) => f.rule === 'overview-markers-missing' && f.severity === 'warning'), - 'warns that the map was not refreshed', + result.findings.some( + (f) => + f.rule === 'overview-markers-missing' && + f.severity === 'warning' && + f.message === 'overview.md has no generated-map markers; the map is not rendered', + ), + 'warns that the map has nowhere to render', ); + // With nowhere to put it, the page renders as its own source bytes. + assert.deepEqual(renderOverview(custom, manifest!, result.indexEntries), { text: custom, markersFound: false }); }); test('viewer build: exports a self-contained static folder carrying the protect warning', async () => { @@ -1819,6 +1856,251 @@ test('viewer: serve serves the scaffold on localhost', async () => { } }); +// --- the overview map is served, never stored --- +// The layer map used to be written into the committed overview.md on every run that +// changed a document count. It is now substituted between the author's markers when +// the page is read: these pin the served half (the export's is in export.test.ts). + +test('viewer: serve renders the layer map into the overview page, never into the file', async () => { + const dir = copyExample(); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + const overview = path.join(dir, 'docs', 'overview.md'); + // A document added after the seed: the served map counts the tree of right now. + fs.writeFileSync(path.join(dir, 'docs', 'domain', 'pricing.md'), '# Pricing\n\nHow we price.\n'); + const before = fs.readFileSync(overview); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + try { + const res = await fetch(`http://127.0.0.1:${port}/content/overview.md`); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'text/markdown; charset=utf-8'); + const text = await res.text(); + const entries = generateViewer(dir, manifest!).indexEntries; + assert.ok( + text.includes('```mermaid\n' + buildLayerMap(manifest!, entries) + '\n```'), + 'the served map is exactly what buildLayerMap produces for the live index', + ); + assert.match(text, /cat_domain\["📖 Domain · 2 docs"\]/, 'the document added after the seed is counted'); + assert.equal( + text, + renderOverview(before.toString('utf8'), manifest!, entries).text, + 'the served page is the source with the marked span substituted', + ); + assert.deepEqual(fs.readFileSync(overview), before, 'serving the page never writes it'); + } finally { + server.close(); + } +}); + +test('viewer: serve keeps the last good map when the layer stops indexing, and recovers', async () => { + const dir = copyExample(); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + const overview = path.join(dir, 'docs', 'overview.md'); + const source = fs.readFileSync(overview, 'utf8'); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + const overviewText = async (): Promise => { + const res = await fetch(`http://127.0.0.1:${port}/content/overview.md`); + assert.equal(res.status, 200); + return res.text(); + }; + try { + const good = await overviewText(); + assert.match(good, /```mermaid\nflowchart LR/, 'a healthy tree renders the fresh map'); + // A genuine generation failure: the manifest no longer parses, so this fetch + // has no index at all. The page keeps the map it last had rather than losing it. + const manifestAbs = path.join(dir, 'leji.json'); + const manifestText = fs.readFileSync(manifestAbs, 'utf8'); + fs.writeFileSync(manifestAbs, '{ not json'); + assert.equal(await overviewText(), good, 'the last good map is served while the tree cannot be indexed'); + // Repaired, with the tree moved on: the map is the one the tree has now. + fs.writeFileSync(manifestAbs, manifestText); + fs.writeFileSync(path.join(dir, 'docs', 'domain', 'pricing.md'), '# Pricing\n\nHow we price.\n'); + assert.match(await overviewText(), /cat_domain\["📖 Domain · 2 docs"\]/, 'the repaired tree renders afresh'); + assert.equal(fs.readFileSync(overview, 'utf8'), source, 'none of it wrote the source file'); + } finally { + server.close(); + } +}); + +test('viewer: serve refuses an overview.md that resolves out of the content root', async () => { + const dir = fs.realpathSync(copyExample()); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + const overview = path.join(dir, 'docs', 'overview.md'); + // An ordinary file elsewhere in the repository: the route is a content route + // first, and a content route serves nothing from outside its own mount. + fs.writeFileSync(path.join(dir, 'elsewhere.md'), '# Elsewhere\n'); + fs.rmSync(overview); + fs.symlinkSync(path.join(dir, 'elsewhere.md'), overview); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + try { + const res = await fetch(`http://127.0.0.1:${port}/content/overview.md`); + assert.equal(res.status, 403, 'a target outside the content mount is refused'); + assert.ok(!(await res.text()).includes('Elsewhere'), 'and nothing of it is served'); + } finally { + server.close(); + } +}); + +test('viewer: an overview.md retargeted between requests is the page or an ordinary refusal (settled states)', async () => { + // SETTLED STATES ONLY. The retargeting here happens between requests, never inside + // one: the swapper and the request handler share this thread, and the route's + // resolve-check-read sequence is synchronous, so no swap can land inside it. What + // this holds is the state machine either side of a swap, over many alternations. + // The window itself is the deterministic canary below, which injects the swap + // inside the sequence and is the load-bearing test of the binding. + const dir = fs.realpathSync(copyExample()); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + const overview = path.join(dir, 'docs', 'overview.md'); + // The real page moves aside and overview.md becomes a link to it: the legitimate + // target is then a symlink too, so the swap changes only where it points. + const inside = path.join(dir, 'docs', 'home.md'); + fs.renameSync(overview, inside); + const outside = path.join(dir, 'outside.md'); + const secret = 'SECRET-OUTSIDE-THE-CONTENT-ROOT'; + fs.writeFileSync(outside, `# Outside\n\n${secret}\n`); + fs.symlinkSync(inside, overview); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + const point = (target: string): void => { + fs.rmSync(overview, { force: true }); + fs.symlinkSync(target, overview); + }; + try { + let swapping = true; + let swaps = 0; + const swapper = (async (): Promise => { + while (swapping) { + point(outside); + swaps++; + await new Promise((r) => setImmediate(r)); + point(inside); + swaps++; + await new Promise((r) => setImmediate(r)); + } + })(); + const statuses = new Set(); + for (let i = 0; i < 60; i++) { + const res = await fetch(`http://127.0.0.1:${port}/content/overview.md`); + statuses.add(res.status); + assert.ok(!(await res.text()).includes(secret), `response ${i} carried bytes from outside the mount`); + } + swapping = false; + await swapper; + assert.ok(swaps > 0, 'the swap actually ran against the live server'); + assert.ok( + [...statuses].every((s) => s === 200 || s === 403 || s === 404), + `only the page or an ordinary content-route refusal: ${[...statuses].join(', ')}`, + ); + // Not vacuous: requests really did land on the swapped-in target, and were + // refused rather than served. + assert.ok(statuses.has(403), `the swap was seen by the route: ${[...statuses].join(', ')}`); + // Settled, both ways, so the statuses above are not the whole claim. + point(outside); + const refused = await fetch(`http://127.0.0.1:${port}/content/overview.md`); + assert.equal(refused.status, 403, 'a target outside the content mount is refused'); + assert.ok(!(await refused.text()).includes(secret)); + point(inside); + const served = await fetch(`http://127.0.0.1:${port}/content/overview.md`); + assert.equal(served.status, 200, 'and the legitimate target still serves'); + assert.match(await served.text(), /```mermaid\nflowchart LR/, 'with the map rendered into it'); + } finally { + server.close(); + } +}); + +test('check-before-act: an overview.md swapped between the authorization and the read is refused, never served', async () => { + // The window the overview route's binding exists for: the link is retargeted AFTER + // the resolution that authorizes the source and BEFORE the bytes are taken, at a + // target inside the repository but outside the content mount, where the private-role + // and containment guards alone say yes. Deterministic, not a race: the patched + // resolver performs the swap inline, so the window is exercised on every run (the + // idiom the export canaries use). + // + // Mutation that reddens: give the route back its pre-review shape, a `realpathSync` + // that authorizes the path followed by a read that resolves the path again + // (`verifiedTargetRead`), and the swapped-in file's bytes are served with a 200. + const dir = fs.realpathSync(copyExample()); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + const overview = path.join(dir, 'docs', 'overview.md'); + // The legitimate target is a real page inside the content root; overview.md is the + // link, so the swap changes only where it points. + const inside = path.join(dir, 'docs', 'home.md'); + fs.renameSync(overview, inside); + const outside = path.join(dir, 'outside.md'); + const secret = 'SECRET-OUTSIDE-THE-CONTENT-ROOT'; + fs.writeFileSync(outside, `# Outside\n\n${secret}\n`); + fs.symlinkSync(inside, overview); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + + // Builtin ESM bindings are snapshotted at link time, hence the CJS patch plus the + // resync. BOTH spellings are patched: `resolvedPath` (and so the verified open) + // resolves with `realpathSync.native`, while a route that authorizes with the + // JavaScript `realpathSync` must fall into the same window, or the mutation above + // could not be observed. + const require = createRequire(import.meta.url); + const nodeFs = require('node:fs') as Record; + type Realpath = ((p: unknown, o?: unknown) => string) & { native: (p: unknown, o?: unknown) => string }; + const original = nodeFs.realpathSync as Realpath; + const originalNative = original.native; + let armed = false; + let swapped = false; + // Called with the answer the real resolver just produced: the caller is about to + // judge or open THAT path, and the entry it came from is retargeted first. + const swapAfter = (p: unknown, answer: string): string => { + if (armed && !swapped && typeof p === 'string' && path.resolve(p) === overview) { + swapped = true; + fs.rmSync(overview, { force: true }); + fs.symlinkSync(outside, overview); + } + return answer; + }; + const patched = ((p: unknown, o?: unknown) => swapAfter(p, original(p, o))) as Realpath; + patched.native = (p: unknown, o?: unknown): string => swapAfter(p, originalNative(p, o)); + nodeFs.realpathSync = patched; + Module.syncBuiltinESMExports(); + let res: Response; + let body: string; + try { + armed = true; + res = await fetch(`http://127.0.0.1:${port}/content/overview.md`); + body = await res.text(); + } finally { + armed = false; + nodeFs.realpathSync = original; + Module.syncBuiltinESMExports(); + server.close(); + } + + assert.ok(swapped, 'the link was retargeted inside the route, after the authorizing resolution'); + assert.equal(fs.readlinkSync(overview), outside, 'and it still points outside the content mount'); + assert.ok(!body.includes(secret), `no byte from outside the content mount was served: ${JSON.stringify(body)}`); + // The mapping the ordinary content route uses: the source resolves outside the + // mount, so the mount answers, and it answers before anything is read. + assert.equal(res.status, 403, `the swapped-in target is refused: ${res.status}`); +}); + +test('viewer: serve refuses an overview.md symlinked into a private .leji role', async () => { + // The content root here IS the repository root, so the private role is inside the + // mount and the servable whitelist is the check that answers: refused as today. + const dir = fs.realpathSync(copyExample()); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + fs.mkdirSync(path.join(dir, '.leji', 'work'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji', 'work', 'private.md'), '# Private notes\n'); + fs.symlinkSync(path.join(dir, '.leji', 'work', 'private.md'), path.join(dir, 'overview.md')); + const { server, port } = await serveOnFreePort(dir, '.'); + try { + const res = await fetch(`http://127.0.0.1:${port}/content/overview.md`); + assert.equal(res.status, 404, 'a private role is not servable, however it is reached'); + assert.ok(!(await res.text()).includes('Private notes'), 'and nothing of it is served'); + } finally { + server.close(); + } +}); + // --- link classes stay inside the router --- // A relative link on a nested page used to be resolved by the browser against the // server root, leaving the SPA for a URL the server has no route for. The fix has diff --git a/packages/sdk/test/update-pin.test.ts b/packages/sdk/test/update-pin.test.ts index 449bf7f..857f4b8 100644 --- a/packages/sdk/test/update-pin.test.ts +++ b/packages/sdk/test/update-pin.test.ts @@ -176,6 +176,44 @@ function buildAcmeSibling(dir: string): void { git(dir, 'config', 'uploadpack.allowAnySHA1InWant', 'true'); } +/** The rewritten upstream before anything is pruned: `a` on its own branch `old`, + * and an unrelated orphan on `main`. A repository in this shape holds BOTH commits, + * which is what the refusal's route calls a local hint; pruning it is what makes a + * source that can no longer serve the pin. */ +function buildOrphanTargetRepo(dir: string): { pin: string; target: string } { + fs.mkdirSync(dir, { recursive: true }); + git(dir, 'init', '-q', '-b', 'old', '.'); + const pin = commit(dir, 'a.md'); + git(dir, 'checkout', '-q', '--orphan', 'main'); + git(dir, 'rm', '-q', '-rf', '.'); + const target = commit(dir, 'o.md'); + return { pin, target }; +} + +/** + * The orphan-target scaffold: a source that no longer serves the commit the host + * pins, while still advertising a target unrelated to it: an upstream that rewrote + * its history, which is the case the `current pin` act exists to name. `a` is + * committed on `old`, `main` is rebuilt as an orphan, `old` is deleted, and the + * reflog and the object store are pruned, so nothing reaches `a` any more. Both + * halves are asserted here, because a scaffold that quietly kept the pin reachable + * (which is what pruning a branch the target still reaches would do) would prove + * nothing on any platform's git. + */ +function buildOrphanTargetSource(dir: string): { pin: string; target: string } { + const { pin, target } = buildOrphanTargetRepo(dir); + git(dir, 'branch', '-D', 'old'); + git(dir, 'reflog', 'expire', '--expire=now', '--all'); + git(dir, 'gc', '-q', '--prune=now'); + // Fetching a commit by id is how the resolver retains a pin: the source serves + // one the way a real host does, so the refusal is the missing object and never + // a server declining to serve an unadvertised id. + git(dir, 'config', 'uploadpack.allowAnySHA1InWant', 'true'); + assert.throws(() => git(dir, 'cat-file', '-e', pin), 'the pin is unavailable in the source'); + assert.match(git(dir, 'ls-remote', dir), new RegExp(`${target}\\s+refs/heads/main`), 'the target is advertised'); + return { pin, target }; +} + interface StoreSpec { pin: string | null; witnessRef: string | null; @@ -333,6 +371,18 @@ interface CliResult { stdout: string; } +/** The declared source is a locator no test may reach, so it is routed at git's own + * level to the repository the scaffold built. */ +function routedEnv(routed: string): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_DIR: undefined, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `url.${routed}.insteadOf`, + GIT_CONFIG_VALUE_0: ACME_SOURCE, + }; +} + async function runCliProc(args: string[], env: NodeJS.ProcessEnv): Promise { try { const { stdout } = await execFileAsync('node', [cli, ...args], { cwd: repoRoot, env }); @@ -346,7 +396,7 @@ async function runCliProc(args: string[], env: NodeJS.ProcessEnv): Promise (a.rule < b.rule ? -1 : 1)), `${c.id}: the exact findings`, ); + // Only a rule with more than one act names one: every other refusal is + // the document it was before the act detail existed. + for (const f of doc.findings) { + if (f.rule === 'mount-store-fetch-failed' || f.rule === 'mount-witness-refresh-failed') continue; + assert.ok(!('detail' in f), `${c.id}: ${f.rule} carries no detail`); + } if (c.comparisonRepository !== undefined) { assert.equal( doc.pinReport?.comparisonRepository, @@ -527,11 +583,7 @@ test('a target that cannot be retained under --fetch refuses the move, manifest // one reachable path. It names the TARGET, so retaining the current pin — the // act before the gate — still succeeds and the refusal is unambiguous. const r = await runCliProc(['mounts', 'update-pin', 'product-context', '--fetch', '--root', host, '--json'], { - ...process.env, - GIT_DIR: undefined, - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: `url.${sibling}.insteadOf`, - GIT_CONFIG_VALUE_0: ACME_SOURCE, + ...routedEnv(sibling), LEJI_TEST_FAIL_PIN_REF: OID.b, }); assert.equal(r.code, 1, r.stdout); @@ -543,6 +595,10 @@ test('a target that cannot be retained under --fetch refuses the move, manifest doc.findings.map((f) => f.rule), ['mount-store-fetch-failed'], ); + // The act is named, and it is the TARGET's: the same rule id, from the other + // side of the gate, is a different act with no route past it. + assert.equal(doc.findings[0].detail, 'target: the pin could not be retained by a ref in the managed store'); + assert.equal(doc.findings[0].message, 'the requested fetch could not retain the commit in the managed store'); assert.deepEqual(fs.readFileSync(path.join(host, 'leji.json')), before, 'leji.json is byte-untouched'); // The refusal leaves the CURRENT pin retained: fetched objects and refs stay, // which is exactly what the help text says a failed --fetch may leave behind. @@ -554,6 +610,158 @@ test('a target that cannot be retained under --fetch refuses the move, manifest } }); +test('the same injected retention failure, aimed at the CURRENT pin, names that act instead', async () => { + const dir = tmpdir('leji-updatepin-retain-current-'); + try { + const sibling = path.join(dir, 'sibling'); + const host = path.join(dir, 'host'); + buildAcmeSibling(sibling); + fs.cpSync(path.join(fixturesDir, 'warn-update-pin'), host, { recursive: true }); + repin(host, OID.a, 'keep'); + // One hook, two acts: it names a commit, and each act retains its own, so the + // pin's id aims it at the act before the gate rather than the one after it. + const r = await runCliProc(['mounts', 'update-pin', 'product-context', '--fetch', '--root', host, '--json'], { + ...routedEnv(sibling), + LEJI_TEST_FAIL_PIN_REF: OID.a, + }); + assert.equal(r.code, 1, r.stdout); + const doc = JSON.parse(r.stdout) as UpdatePinDocument; + assert.equal(doc.reason, 'mount-store-fetch-failed'); + assert.equal(doc.findings[0].detail, 'current pin: the pin could not be retained by a ref in the managed store'); + assert.match(doc.findings[0].message, /^the requested fetch could not retain the commit in the managed store;/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a current pin the source no longer serves refuses at that act, with the route past it', async () => { + const dir = tmpdir('leji-updatepin-orphan-'); + try { + const source = path.join(dir, 'source'); + const host = path.join(dir, 'host'); + const { pin, target } = buildOrphanTargetSource(source); + fs.cpSync(path.join(fixturesDir, 'warn-update-pin'), host, { recursive: true }); + repin(host, pin, 'keep'); + const before = fs.readFileSync(path.join(host, 'leji.json')); + const argv = ['mounts', 'update-pin', 'product-context', '--fetch', '--root', host]; + const r = await runCliProc([...argv, '--json'], routedEnv(source)); + assert.equal(r.code, 1, r.stdout); + const doc = JSON.parse(r.stdout) as UpdatePinDocument; + assert.equal(doc.reason, 'mount-store-fetch-failed'); + assert.equal(doc.mount.to, null, 'the run refused before it had a target'); + assert.equal(doc.findings[0].detail, 'current pin: the pin could not be fetched from the source'); + // `detail` sits immediately after `message`: the key order the ports freeze. + assert.deepEqual(Object.keys(doc.findings[0]), ['rule', 'severity', 'path', 'message', 'detail']); + assert.equal( + doc.findings[0].message, + 'the requested fetch could not retain the commit in the managed store; if a local hint holds the current ' + + 'pin and the target with complete ancestry, run without `--fetch`; to move past a rewritten upstream, ' + + 'pass `--to --allow-non-fast-forward` against such a hint', + ); + // The same sentence reaches a person, with the act on the same line. + const human = await runCliProc(argv, routedEnv(source)); + assert.equal(human.code, 1, human.stdout); + assert.equal( + human.stdout.trim(), + `Refused: ${doc.findings[0].message} (detail: current pin: the pin could not be fetched from the source)`, + ); + assert.deepEqual(fs.readFileSync(path.join(host, 'leji.json')), before, 'leji.json is byte-untouched'); + assert.notEqual(target, pin, 'the advertised target is not the commit the host pins'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the route the current-pin refusal advertises: the hint moves the pin, --fetch still refuses', async () => { + const dir = tmpdir('leji-updatepin-route-'); + try { + const source = path.join(dir, 'source'); + const hint = path.join(dir, 'hint'); + const host = path.join(dir, 'host'); + // One recipe, two repositories: the source is pruned, the hint keeps `old`, so + // it holds the current pin and the orphan target with complete ancestry. + const { pin, target } = buildOrphanTargetSource(source); + const held = buildOrphanTargetRepo(hint); + assert.deepEqual(held, { pin, target }, 'the hint holds the same two commits'); + fs.cpSync(path.join(fixturesDir, 'warn-update-pin'), host, { recursive: true }); + repin(host, pin, 'keep'); + fs.mkdirSync(path.join(host, '.leji'), { recursive: true }); + fs.writeFileSync( + path.join(host, '.leji', 'mounts.local.json'), + JSON.stringify({ mounts: { 'product-context': { repo: hint } } }) + '\n', + ); + const before = fs.readFileSync(path.join(host, 'leji.json')); + const move = ['mounts', 'update-pin', 'product-context', '--to', target, '--allow-non-fast-forward']; + // With `--fetch` the source is asked for the current pin first, and that act is + // the one that fails: the hint holding both operands does not save the run. + const refused = await runCliProc([...move, '--fetch', '--root', host, '--json'], routedEnv(source)); + assert.equal(refused.code, 1, refused.stdout); + const refusedDoc = JSON.parse(refused.stdout) as UpdatePinDocument; + assert.equal(refusedDoc.reason, 'mount-store-fetch-failed'); + assert.equal(refusedDoc.findings[0].detail, 'current pin: the pin could not be fetched from the source'); + assert.deepEqual(fs.readFileSync(path.join(host, 'leji.json')), before, 'leji.json is byte-untouched'); + // The store that refused run established holds nothing, so the move below is + // the hint's answer and no leftover managed operand. + const store = path.join( + host, + '.leji', + 'mounts', + 'store', + crypto.createHash('sha256').update(ACME_IDENTITY).digest('hex'), + ); + assert.throws(() => git(store, 'cat-file', '-e', pin), 'the managed store never got the pin'); + // Without `--fetch`, exactly as the refusal says: the hint answers, the + // override carries the move past the rewritten history, and the pin moves. + const moved = await runCliProc([...move, '--root', host, '--json'], { ...process.env, GIT_DIR: undefined }); + assert.equal(moved.code, 0, moved.stdout); + const movedDoc = JSON.parse(moved.stdout) as UpdatePinDocument; + assert.equal(movedDoc.action, 'updated'); + assert.equal(movedDoc.override, true); + assert.equal(movedDoc.pinReport?.comparisonRepository, 'hint'); + assert.equal(movedDoc.pinReport?.witnessProvenance, 'unmanaged'); + assert.equal(movedDoc.pinReport?.ancestryComplete, true, 'the hint answers the range'); + assert.equal(movedDoc.mount.from, pin); + assert.equal(movedDoc.mount.to, target); + assert.deepEqual( + movedDoc.findings.map((f) => [f.rule, f.severity]), + [['mount-pin-non-fast-forward-override', 'warning']], + ); + const after = fs.readFileSync(path.join(host, 'leji.json'), 'utf8'); + assert.ok(after.includes(`"pin": "${target}"`), 'the manifest now pins the target'); + assert.equal(after, before.toString('utf8').replace(pin, target), 'exactly the pin span moved'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a tracking ref the source does not advertise refuses at the witness act', async () => { + const dir = tmpdir('leji-updatepin-witness-'); + try { + const sibling = path.join(dir, 'sibling'); + const host = path.join(dir, 'host'); + buildAcmeSibling(sibling); + fs.cpSync(path.join(fixturesDir, 'warn-update-pin'), host, { recursive: true }); + repin(host, OID.a, 'keep'); + // The store already holds the pin, so its retention needs no network at all and + // the witness refresh is the only act left that can fail. + buildStore(host, sibling, { pin: OID.a, witnessRef: null, witnessOid: null, depth: null }); + const mp = path.join(host, 'leji.json'); + fs.writeFileSync(mp, fs.readFileSync(mp, 'utf8').replace('refs/heads/main', 'refs/heads/release')); + const r = await runCliProc( + ['mounts', 'update-pin', 'product-context', '--fetch', '--root', host, '--json'], + routedEnv(sibling), + ); + assert.equal(r.code, 1, r.stdout); + const doc = JSON.parse(r.stdout) as UpdatePinDocument; + assert.equal(doc.reason, 'mount-witness-refresh-failed'); + assert.equal(doc.findings[0].detail, 'witness: the tracking ref could not be fetched from the source'); + // The witness act has no route of its own: the rule's own sentence stands. + assert.equal(doc.findings[0].message, 'the requested fetch could not refresh the managed witness ref'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('a target the manifest no longer pins from is refused by the scanner, at exit 2', async () => { const dir = tmpdir('leji-updatepin-span-'); try { diff --git a/packages/sdk/test/viewer-contrast.test.ts b/packages/sdk/test/viewer-contrast.test.ts new file mode 100644 index 0000000..8137a30 --- /dev/null +++ b/packages/sdk/test/viewer-contrast.test.ts @@ -0,0 +1,80 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; + +// The viewer's text tones, judged by the contrast formula rather than by eye. +// +// `--leji-link` is fixed at #007D59 precisely so that a layer's own accent +// (`viewer.theme.primary`) can be anything and body links still meet AA. That +// guarantee is arithmetic, so it is asserted as arithmetic: the moment either the +// token or a ground moves, this file says so, instead of a reviewer noticing a +// slightly paler green two releases later. +// +// The formula is WCAG 2.x relative luminance and contrast ratio, spelled out here +// rather than pulled in: it is nine lines, it is frozen, and a dependency for it +// would be a larger surface than the thing it computes. + +/** sRGB channel, 0–255, linearized per WCAG 2.x. */ +function channel(value: number): number { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +/** WCAG relative luminance of a `#RRGGBB` color. */ +function luminance(hex: string): number { + const match = /^#([0-9a-fA-F]{6})$/.exec(hex); + assert.ok(match, `not a #RRGGBB color: ${hex}`); + const [r, g, b] = [0, 2, 4].map((i) => channel(Number.parseInt(match[1].slice(i, i + 2), 16))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** WCAG contrast ratio between two `#RRGGBB` colors, 1:1 to 21:1. */ +function contrast(a: string, b: string): number { + const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (high + 0.05) / (low + 0.05); +} + +// The brand's tones, by the role each one plays in `templates/viewer/`. +const LINK = '#007D59'; +const TEXT = '#183B32'; +const TEXT_BODY = '#4D5B56'; +const TEXT_MUTED = '#76827D'; +const WHITE = '#FFFFFF'; +const CODE_BG = '#E8F4EE'; + +/** Ratios are compared to two decimals: the expected values are the point of the + * test, and a tolerance wide enough to absorb a real tone change would defeat it. */ +const TOLERANCE = 0.01; + +test('the fixed link tone is AA on both grounds it lands on', () => { + // Body links: #007D59 on the content ground. + assert.ok( + Math.abs(contrast(LINK, WHITE) - 5.15) < TOLERANCE, + `${LINK} on ${WHITE} is ${contrast(LINK, WHITE).toFixed(2)}:1, expected 5.15:1`, + ); + // Inline code: the same tone on the brand's code ground, the narrower of the two. + assert.ok( + Math.abs(contrast(LINK, CODE_BG) - 4.56) < TOLERANCE, + `${LINK} on ${CODE_BG} is ${contrast(LINK, CODE_BG).toFixed(2)}:1, expected 4.56:1`, + ); + assert.ok(contrast(LINK, CODE_BG) >= 4.5, 'the inline-code pair meets AA for normal-size text'); +}); + +test('the typography tones carry their sizes on the content ground', () => { + // Headings, emphasis, and every normal-size run of copy: AA at normal size. + for (const [name, tone] of [ + ['--leji-text', TEXT], + ['--leji-text-body', TEXT_BODY], + ] as const) { + const ratio = contrast(tone, WHITE); + assert.ok(ratio >= 4.5, `${name} (${tone}) is ${ratio.toFixed(2)}:1 on white, below AA for normal-size text`); + } + + // The muted tone is 3.99:1, which is AA for large text (>=18.66px bold or + // >=24px) and nothing else. `templates/viewer/` declares it and uses it nowhere, + // so this bound is the rule a future consumer has to satisfy: large sizes only. + const muted = contrast(TEXT_MUTED, WHITE); + assert.ok( + muted >= 3, + `--leji-text-muted (${TEXT_MUTED}) is ${muted.toFixed(2)}:1 on white, below AA for large text`, + ); +}); diff --git a/packages/sdk/test/viewer-tones.test.ts b/packages/sdk/test/viewer-tones.test.ts new file mode 100644 index 0000000..63ca06e --- /dev/null +++ b/packages/sdk/test/viewer-tones.test.ts @@ -0,0 +1,61 @@ +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// The viewer's typography is three named tones, and this is the mechanism that +// keeps it that way. +// +// `templates/viewer/assets/vue.css` is a recolored copy of the stock docsify "vue" +// theme. Every neutral below is one the stock theme shipped and the Leji theme +// replaced: they came back once already, through a theme refresh nobody diffed by +// eye, and nothing failed. A blanket "no hex literals" rule cannot work here +// (inverse text on a colored ground, the Prism token palette, and the accent +// fallbacks are all literal by design), so the contract is the narrower one the +// token block states: these specific legacy values, and no others, are denied. +// +// A neutral that belongs on this list is one the theme no longer uses; adding a +// tone to the token block does not add it here. + +const LEGACY_NEUTRALS = [ + '#34495e', + '#2c3e50', + '#555', + '#555555', + '#999999', + '#1a1a1a', + '#9aa0a6', + '#364149', + '#505d6b', + '#777', + '#858585', + '#7f8c8d', + '#42b983', +] as const; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const viewerDir = path.join(repoRoot, 'templates', 'viewer'); + +/** The authored viewer sources: this theme's own files, never the vendored + * third-party bundles beside them, whose colors are their upstream's business. */ +const AUTHORED = ['index.html', path.join('assets', 'vue.css')]; + +for (const rel of AUTHORED) { + test(`templates/viewer/${rel.split(path.sep).join('/')} carries no legacy neutral`, () => { + const file = path.join(viewerDir, rel); + assert.ok(fs.existsSync(file), `the authored source exists: ${rel}`); + const lines = fs.readFileSync(file, 'utf8').split('\n'); + + const hits: string[] = []; + for (const [index, line] of lines.entries()) { + const lowered = line.toLowerCase(); + for (const neutral of LEGACY_NEUTRALS) { + if (lowered.includes(neutral)) hits.push(`${rel}:${index + 1}: ${neutral} in ${line.trim()}`); + } + } + // Every site is reported, not just the first: a refresh that brings the greys + // back brings several at once, and one failure per run would hide the rest. + assert.deepEqual(hits, [], `legacy neutrals are replaced by the named tones:\n${hits.join('\n')}`); + }); +} diff --git a/packages/site/public/_headers b/packages/site/public/_headers index 08766f6..a0d6bc2 100644 --- a/packages/site/public/_headers +++ b/packages/site/public/_headers @@ -8,9 +8,13 @@ # an equivalent fallback either way: X-Frame-Options and X-Content-Type-Options # cannot be set by meta at all. Clickjacking and nosniff protection therefore # only exist where these are served as response headers. +# +# The policy below is first-party only, matching a site built with no analytics. +# A deployment that turns analytics on adds that vendor's hosts to its own +# response headers, next to the build values that enable the script. /* X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin X-Frame-Options: DENY Link: ; rel="describedby"; type="text/markdown", ; rel="sitemap" - Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://cdn.usefathom.com; connect-src 'self' https://cdn.usefathom.com; font-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none' + Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none' diff --git a/packages/site/src/data/runtimes.ts b/packages/site/src/data/runtimes.ts index a62e79c..7696268 100644 --- a/packages/site/src/data/runtimes.ts +++ b/packages/site/src/data/runtimes.ts @@ -31,7 +31,7 @@ export const runtimes: Runtime[] = [ }, { name: 'Go', - label: 'Go 1.23+', + label: 'Go 1.27+', icon: goIcon, command: 'go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest', wide: true, diff --git a/packages/site/src/layouts/Base.astro b/packages/site/src/layouts/Base.astro index 3f84ef5..f2eee9f 100644 --- a/packages/site/src/layouts/Base.astro +++ b/packages/site/src/layouts/Base.astro @@ -14,12 +14,26 @@ const { const canonical = new URL(Astro.url.pathname, Astro.site); const ogImage = new URL('/og.png', Astro.site); +// Analytics are opt-in at build time and off by default: this source ships with none, so a +// plain build produces a site that loads nothing third-party. A deployment that wants +// analytics sets both values in the build environment, PUBLIC_ANALYTICS_SCRIPT being the +// full script URL and PUBLIC_ANALYTICS_SITE the site token that script expects. Either one +// alone does nothing, since a script without its token cannot report anyway. +const analyticsScript = (import.meta.env.PUBLIC_ANALYTICS_SCRIPT ?? '').trim(); +const analyticsSite = (import.meta.env.PUBLIC_ANALYTICS_SITE ?? '').trim(); +const analyticsEnabled = analyticsScript !== '' && analyticsSite !== ''; +// Only the origin reaches the policy: a CSP source matches by origin, and a bad URL here +// should fail the build loudly rather than ship a policy nobody can read. +const analyticsSource = analyticsEnabled ? ` ${new URL(analyticsScript).origin}` : ''; + // CSP, kept in lockstep with public/_headers except the directives that have no effect in a policy: // frame-ancestors and X-Content-Type-Options are response headers only, and live in public/_headers alone. +// The policy is first-party only unless the build supplies analytics, in which case that one +// origin joins the three directives its beacon needs and nothing else changes. const CSP = - "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " + - "script-src 'self' 'unsafe-inline' https://cdn.usefathom.com; " + - "connect-src 'self' https://cdn.usefathom.com; " + + `default-src 'self'; img-src 'self' data:${analyticsSource}; style-src 'self' 'unsafe-inline'; ` + + `script-src 'self' 'unsafe-inline'${analyticsSource}; ` + + `connect-src 'self'${analyticsSource}; ` + "font-src 'self'; base-uri 'self'; object-src 'none'"; // Sitewide structured data, public information only. @@ -74,7 +88,7 @@ const siteLd = { + {analyticsEnabled &&